金马的Blog

我喜欢折腾

一分钟讲清楚 Authentication 和 Authorization

我们在看文档经常会遇到这两个单词,但是很多人对这两个单词的理解有些模糊,所以我今天给大家简单介绍一下,以后读文档会更舒服一些。

Authentication 一般指身份验证,又称“验证”、“鉴权”,是指通过一定的手段,完成对用户身份的确认。 Authorization 一般是授权、委托的意思,向…授予职权或权力许可,批准等意思。

所以你可以看一下 Laravel 的文档

  1. Authentication ,主要讲的是和登录相关的内容。
  2. Authorization ,主要讲的是权限相关的内容(注意,Gates 和 Policies 都必须是在用户登录后才有意义,如果没有登录态,直接返回 false)。

讲完这个在说一说对应的 Http Status Code:

  1. Authentication:未登录的时候调用需要登录的接口,一般使用 401 Unauthorized。
  2. Authorization:登录后请求没有权限的接口,一般使用 403 Forbidden。

那么肯定有同学问了,为什么 401 是 Unauthorized 而不是 Unautheticated 呢?这不是坑人吗?事实上,参考 RFC,好像确实是弄错了。

我贴一篇解释,秒懂:原文链接

There’s a problem with 401 Unauthorized, the HTTP status code for authentication errors. And that’s just it: it’s for authentication, not authorization. Receiving a 401 response is the server telling you, “you aren’t authenticated–either not authenticated at all or authenticated incorrectly–but please reauthenticate and try again.” To help you out, it will always include a WWW-Authenticate header that describes how to authenticate.

This is a response generally returned by your web server, not your web application.

It’s also something very temporary; the server is asking you to try again.

So, for authorization I use the 403 Forbidden response. It’s permanent, it’s tied to my application logic, and it’s a more concrete response than a 401.

Receiving a 403 response is the server telling you, “I’m sorry. I know who you are–I believe who you say you are–but you just don’t have permission to access this resource. Maybe if you ask the system administrator nicely, you’ll get permission. But please don’t bother me again until your predicament changes.”

In summary, a 401 Unauthorized response should be used for missing or bad authentication, and a 403 Forbidden response should be used afterwards, when the user is authenticated but isn’t authorized to perform the requested operation on the given resource.

一个小坑提醒:某个 Class 或某个 Trait 突然找不到

“我 Mac 本地没问题啊!”

“但是为什么 Linux 服务器上报这个错啊???”

1
2
[Symfony\Component\Debug\Exception\FatalErrorException] 
Trait "App\Library\XXXX" not found

“太诡异了,这怎么查?”

同事中又有人被这个问题坑了,你可以搜到很多人遇到这样的问题,我先告诉大家如何解决,再解释原因。

如何解决

  1. 查看是否文件名是不是大小写弄错了?
  2. 查看 namespaceclass 的名字的大小写弄错了?

解决方案就这么简单,肯定是遇到了大小写的问题。

为什么 Mac 上没有问题,Linux 上有问题呢?

主要原因是 Mac 上的文件系统 HFS+ 默认是大小写不敏感(case-insensitive)的,当然可以修改为大小写敏感(case-sensitive)。 但是 Linux 系统却默认是大小写敏感的(case-sensitive),所以在 Mac 上能找到的文件,在 Linux 上却找不到。

当然为了了解这个问题,你还需要了解 PSR-4 的 autoload 方式。

为什么不把 Mac 直接格式化为大小写敏感(case-sensitive)?

因为历史的原因,如果你格式化为大小写敏感,很多软件都会有问题,比如大名鼎鼎的 Adobe 家的产品就有问题,刚才同事反馈,idea 家的软件也经常会莫名其妙崩溃(最终他不得不重新格式化为不敏感)。

在 Mac 上,如果发现文件名大小写有问题,怎么办?

你以为直接把文件名修改就可以解决问题了吗?你太年轻了,文件名大小写变化,git 根本没有任何察觉,还是那个原因,因为文件系统 HFS+ 默认是大小写不敏感的(case-insensitive),(谢谢 @NauxLiu 分享)你可以有这样几个选择:

方案一:

  1. 删除旧文件,提交代码。
  2. 添加新文件,提交代码。

方案二:修改配置

1
git config core.ignorecase false

方案三:

1
2
3
$ git mv test.txt tmp.txt
$ git mv tmp.txt Test.txt
$ git commit -m "Renamed test.txt to Test.txt"

希望对你有帮助。

使用 Laravel Queue 不得不明白的知识

背景

首先说一下我写这篇文章的初衷,在我们打算使用 Laravel Queue 的时候,你的首选应该是去看文档,但是无奈 Laravel 的文档很多地方写得太简单,有时候想了解一个深入的问题,不得不去看源码,但是看源码确实费一些时间。

所以我打算写一篇文章,把我在使用 Laravel Queue 过程中的方方面面都写一下,方便新手学习、老司机温习。

因为 Redis Queue 是比较简单也很常用的一种队列,所以以下内容我都基于 Redis Queue。

为什么使用队列?

虽然这个问题不是今天文章的重点,但是我还要说一下,一般来说使用队列是为了:

  1. 异步
  2. 重试

也许你还有其他的理由使用队列,但是这应该是最基本的两个原因。

什么情况使用队列?

了解了为什么使用队列,那么一般有这么几类任务使用队列:

  1. 耗时比较久的,比如上传一个文件后进行一些格式的转化等。
  2. 需要保证送达率的,比如发送短信,因为要调用别人的 api,总会有几率失败,那么为了保证送达,重试就必不可少了。

使用队列的时候一定要想明白一个问题,这个任务到底是不是可以异步,如果因为异步会导致问题,那么就要放弃使用队列。

一些小技巧

  1. 在开发环境我们想测试的时候,可以把 Queue driver 设置成为 sync,这样队列就变成了同步执行,方便调试队列里面的任务。
  2. Job 里面的 handle 方法是可以注入别的 class 的,就像在 Controller action 里面也可以注入一样。

问答

问:什么时候使用 queue:listen 什么时候使用 queue:work

答:Laravel 5.3 的文档已经不写 queue:listen这个指令怎么用了,所以你可以看出来可能官方已经不怎么建议使用 queue:listen了,但是在本地调试的时候要使用 queue:listen,因为 queue:work在启动后,代码修改,queue:work不会再 Load 上下文,但是 queue:listen仍然会重新 Load 新代码。

其余情况全部使用 queue:work吧,因为效率更高。

命令讲解

以下是常用的指令,我讲解一下

1
php artisan queue:work --daemon --quiet --queue=default --delay=3 --sleep=3 --tries=3

--daemon

The queue:work Artisan command includes a —daemon option for forcing the queue worker to continue processing jobs without ever re-booting the framework. This results in a significant reduction of CPU usage when compared to the queue:listen command

总体来说,在 supervisor 中一般要加这个 option,可以节省 CPU 使用。

--quiet

不输出任何内容

--delay=3

一个任务失败后,延迟多长时间后再重试,单位是秒。这个值的设定我个人建议不要太短,因为一个任务失败(比如网络原因),重试时间太短可能会出现连续失败的情况。

--sleep=3

去 Redis 中拿任务的时候,发现没有任务,休息多长时间,单位是秒。这个值的设定要看你的任务是否紧急,如果是那种非常紧急的任务,不能等待太长时间。

--tries=3

定义失败任务最多重试次数。这个值的设定根据任务的重要程度来确定,一般 3 次比较适合。

Redis 中发生了什么事情

1
dispatch(new ExampleJob());

如果一个任务进入 default 队列,会发生:

1
2
3
4
5
127.0.0.1:6379>monitor

"RPUSH"
"queues:default"
"{\"job\":\"Illuminate\\\\Queue\\\\CallQueuedHandler@call\",\"data\":{\"commandName\":\"App\\\\Jobs\\\\ExampleJob\",\"command\":\"O:19:\\\"App\\\\Jobs\\\\ExampleJob\\\":7:{s:17:\\\"\\u0000*\\u0000userIdentifier\\\";N;s:9:\\\"\\u0000*\\u0000realIp\\\";N;s:12:\\\"\\u0000*\\u0000requestId\\\";N;s:6:\\\"\\u0000*\\u0000job\\\";N;s:10:\\\"connection\\\";N;s:5:\\\"queue\\\";N;s:5:\\\"delay\\\";N;}\"},\"id\":\"bwA7ICPqnjYiM0ErjRBNwn0kVWF6KeAs\",\"attempts\":1}"

redis 中会出现如下内容:

1
2
127.0.0.1:6379> keys queue*
1) "queues:default"

如果执行命令:

1
php artisan queue:work --daemon --quiet --queue=default --delay=3 --sleep=3 --tries=3

Redis 会发生什么事情?

第一步:查看是否需要重启,如果 laravel:illuminate:queue:restart 存在,就重启队列(代码更新后,一定要重启队列,否则队列不会读取最新代码)。

1
2
"GET"
"laravel:illuminate:queue:restart"

第二步:查看zset queues:default:delayed ,注意这里的事务

1
2
3
4
5
6
7
8
9
"WATCH"
"queues:default:delayed"

"ZRANGEBYSCORE"
"queues:default:delayed"
"-inf"
"1485386782"

"UNWATCH"

第三步:查看 zset queues:default:reserved,注意这里的事务

1
2
3
4
5
6
7
8
9
"WATCH"
"queues:default:reserved"

"ZRANGEBYSCORE"
"queues:default:reserved"
"-inf"
"1485386782"

"UNWATCH"

第四步:从 queue:default list 中取任务,如果有任务,要把任务先暂存到 queues:default:reserved 中(过期时间60秒,Redis Queue 里面写一个任务最多执行60秒)。

任务执行结束会把 queues:default:reserved 中的任务删除,如果任务报错(Throw exception),也会把queues:default:reserved 中的任务删除,然后把任务扔进 queues:default:delay,delay 的秒数是 3 秒(因为我们上面参数配置的是 --delay=3)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
"LPOP"
"queues:default"

#取出任务后,先要放到 queues:default:reserved zset 中

"ZADD"
"queues:default:reserved"
"1485386842"
"{\"job\":\"Illuminate\\\\Queue\\\\CallQueuedHandler@call\",\"data\":{\"commandName\":\"App\\\\Jobs\\\\ExampleJob\",\"command\":\"O:19:\\\"App\\\\Jobs\\\\ExampleJob\\\":7:{s:17:\\\"\\u0000*\\u0000userIdentifier\\\";N;s:9:\\\"\\u0000*\\u0000realIp\\\";N;s:12:\\\"\\u0000*\\u0000requestId\\\";N;s:6:\\\"\\u0000*\\u0000job\\\";N;s:10:\\\"connection\\\";N;s:5:\\\"queue\\\";N;s:5:\\\"delay\\\";N;}\"},\"id\":\"bwA7ICPqnjYiM0ErjRBNwn0kVWF6KeAs\",\"attempts\":1}"

# 任务执行完毕后, 从 queues:default:reserved zset 中删除

"ZREM"
"queues:default:reserved"
"{\"job\":\"Illuminate\\\\Queue\\\\CallQueuedHandler@call\",\"data\":{\"commandName\":\"App\\\\Jobs\\\\ExampleJob\",\"command\":\"O:19:\\\"App\\\\Jobs\\\\ExampleJob\\\":7:{s:17:\\\"\\u0000*\\u0000userIdentifier\\\";N;s:9:\\\"\\u0000*\\u0000realIp\\\";N;s:12:\\\"\\u0000*\\u0000requestId\\\";N;s:6:\\\"\\u0000*\\u0000job\\\";N;s:10:\\\"connection\\\";N;s:5:\\\"queue\\\";N;s:5:\\\"delay\\\";N;}\"},\"id\":\"bwA7ICPqnjYiM0ErjRBNwn0kVWF6KeAs\",\"attempts\":1}"

# 如果任务失败,会放到 queue:default:delay zset 中

"ZADD"
"queues:default:delayed"
"1485386783"
"{\"job\":\"Illuminate\\\\Queue\\\\CallQueuedHandler@call\",\"data\":{\"commandName\":\"App\\\\Jobs\\\\ExampleJob\",\"command\":\"O:19:\\\"App\\\\Jobs\\\\ExampleJob\\\":7:{s:17:\\\"\\u0000*\\u0000userIdentifier\\\";N;s:9:\\\"\\u0000*\\u0000realIp\\\";N;s:12:\\\"\\u0000*\\u0000requestId\\\";N;s:6:\\\"\\u0000*\\u0000job\\\";N;s:10:\\\"connection\\\";N;s:5:\\\"queue\\\";N;s:5:\\\"delay\\\";N;}\"},\"id\":\"uuPBCq4QE9ocnw8UbkLhUl2Lh07yPm6M\",\"attempts\":1}"

Redis 中的数据结构和操作:

1. queue:default

数据结构: List

操作: LRANGE "queues:default" 0 -1 获取 List 里面的所有数据。

2. queue:default:reserved 和 queue:default:delay

数据结构: Zset,时间是 zset 的 score,通过 score 来排序。

操作: ZRANGE ”queues:default:reserved“ 0 -1 获取 zset 里面的所有数据。 ZRANGEBYSCORE queues:default:reserved -inf +inf 通过时间来排序获取所有数据。

注意

Redis 里面一个任务默认最多执行60秒,如果一个任务60秒没有执行完毕,会继续放回到队列中,循环执行,那酸爽(依稀记得那个加班的夜晚……..) file

【phpunit】这样跑测试,竟然节省了我们 90% 的时间

关于 phpunit 我会写一个系列,把我们项目中使用 phpunit 遇到的每一个问题分享给大家。

项目背景:

我们的微服务使用 lumen 搭建,所以这里的测试都是指的是 api 的测试,而且我们没有写任何的单元测试,直接写的是系统测试,我举一个例子你就明白了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
/**
 * @test
 */
public function itShouldReturnXXXList()
{
    factory(XXX::class, 3)->create();
    $this->be(new User());
    $headers = [
        //headers
    ];
    $this->get('/api/xxxs', $headers);
    $this->assertResponseOk();
    $this->seeJsonStructure([self::XXX_STRUCTURE]);
}

以上的例子是测试在登陆的情况下,获取 xxx 的列表的接口的返回数据情况。

测试背景

一般在测试的时候,因为每个测试数据库都要隔离,一般的解决方案有两种: 1. 第一种是使用 transaction,每次在一个测试开始的时候transaction begin,在断言结束后transaction rollback,这样一个数据库中实际没有写入任何数据,所以每次测试互不影响,trait 如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
trait DatabaseTransactions
{
    /**
     * Begin a database transaction.
     *
     * @return void
     */
    public function beginDatabaseTransaction()
    {
        $this->app->make('db')->beginTransaction();

        $this->beforeApplicationDestroyed(function () {
            $this->app->make('db')->rollBack();
        });
    }
}
  1. 第二种是使用migrate,每次在一个测试开始的时候migrate,在断言结束后migrate:rollback,这样数据库每次测试都建表,写数据,清空数据和表格,所以每次测试也互不影响。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
trait DatabaseMigrations
{
    /**
     * Run the database migrations for the application.
     *
     * @return void
     */
    public function runDatabaseMigrations()
    {
        $this->artisan('migrate');

        $this->beforeApplicationDestroyed(function () {
            $this->artisan('migrate:rollback');
        });
    }
}

我们的测试使用的是 sqlite :memory:,配置如下:

1
2
3
4
    'testing' => [
        'driver' => 'sqlite',
        'database' => ':memory:',
    ],

很遗憾,sqlite :memory: 不支持 transaction,只能使用 migrate的方式。

问题:

随着测试越来越多,问题来了,我们一个有一个核心项目的系统测试已经有 97 个了,导致的问题是每次本地测试全部跑一次需要 8 分钟左右(每个人电脑不同,有些许差别),GitLab 上 ci 需要跑两次(一次自定义分支提交会跑,一次合并到 master 后会跑),结果就是一个 commit 从提交到审核代码到最终合并上线,需要十几分钟,真是的是心好痛。

最大的问题是,很多人一起开发,这个时间是会浪费在每一个人的头上,所以我们一直想办法尝试解决,但是并没有很好的解决方案。

尝试解决方案:

尝试解决方案1(失败)

因为我们使用的是 migrate 的方式,我们猜想可能使用 transaction 的方式可能会更快一点,而且也确实看到别人使用 mysql transaction 的方式测试速度加快了很多,就试用了一下,但是结果并不理想,甚至更慢。

放弃。

尝试解决方案2(失败)

某天我看到 ruby 里面有比较成熟的并行测试方案,比如 parallel_tests ,觉得这是一个很好的思路,在测试使用多进程的方式来跑,就可以节省大量的时间,想想 php 不可能没有类似的工具 :),于是我找到了一个 Paratest,你可以通过 https://code.tutsplus.com/tutorials/parallel-testing-for-phpunit-with-paratest–net-32105Paratest 有一定了解。

但是经过试用后,发现出现在不同 php 版本下,Paratest 很不稳定的,偶尔还抽风,决定放弃试用。

期间还遇到了别的类似的工具,但在兼容性上都做的很差,略失望。

偶然发现:

偶然我观察到,在使用 phpunit 来跑全部测试的时候,比较慢的测试都在后面,这很奇怪,如果我使用 phpunit --filter XxxTest 的时候,事实上并没有那么慢,这是为什么?

既然单个文件跑的时候很快,那我试试一个文件一个文件来跑。如下:

1
for i in $(ls -R ./tests |grep php); do ./vendor/bin/phpunit --configuration phpunit.xml --filter $(echo $i|sed -e "s/.php//g"); done

以上代码的意思是从 tests 文件夹中找出所有 php 文件,最后单个使用 filter 的方式来跑测试,比如有一个文件是 ExampleTest.php,最终执行的是 ./vendor/bin/phpunit --configuration phpunit.xml --filter ExampleTest

结果跑下来太震惊了,时间从 8 分 08 秒减到了 47 秒(在我机器),节省超过了 90% 的时间,一个字:”吓死人“。

尝试解释原理:

难道是内存的原因吗?如果是内存的原因我在跑 phpunit 的时候,把 memory 调整到 limited,看看效果,

1
php -d memory_limit=-1 vendor/bin/phpunit --configuration phpunit.xml

事实上,内存确实调整到了无限,但是仍然没有解决问题。

那到底是为什么呢?为什么把测试拆到多个文件,一个一个文件来跑比全部一起跑会快这么多?

而且,每个文件保持测试在 10 个以下,效果更佳。

直到现在,我还不可以更好的解释原因,如果你有线索,我们可以聊聊。

最终解决方案:

因为我们的 GitLab ci 里面会跑 phpunit,下面我分享下 .gitlab-ci.yml 配置

1
2
3
  script:
    - composer install --quiet
    - ./phpunit.sh

phpunit.sh,此脚本是我们架构师 @Sin30 写的

1
2
3
4
5
6
7
8
#!/bin/sh

set -eo pipefail

for i in $(find tests -type f -name "*Test.php" | xargs -I {} basename {} .php)
do
    vendor/bin/phpunit --configuration phpunit.xml --filter $i
done

里面的 set -eo pipfail 解释以下:

set -e 表示一旦脚本中命令返回值不是 0,脚本立即退出;

set -o pipefail 表示在 pipe | 中,只要任何一个命令返回值不是 0(假设是 -1),整个 pipe 返回 -1,即使最后一个命令返回 0。

这样可以保证只要有一个 Test 出错,后面的就不用再跑了,节省时间。

总结

能节省程序员时间的事情是最重要的事情,怎么强调都不过分。

【phpunit】Laravel 测试的时候,如果有多个数据库怎么办?

我们系统重构的时候,数据库是有多个的,所以在 database.php 里面定义了两个 connections

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
'mysql' => [
    'driver'    => 'mysql',
    'host'      => env('MYSQL_HOSTNAME', 'localhost'),
    'port'      => env('MYSQL_PORT', 3306),
    'database'  => env('MYSQL_DATABASE', 'forge'),
    'username'  => env('MYSQL_USER', 'forge'),
    'password'  => env('MYSQL_PASSWORD', ''),
    'charset'   => env('MYSQL_CHARSET', 'utf8mb4'),
    'collation' => env('MYSQL_COLLATION', 'utf8mb4_unicode_ci'),
    'prefix'    => env('MYSQL_PREFIX', ''),
    'timezone'  => env('MYSQL_TIMEZONE', '+08:00'),
    'strict'    => env('MYSQL_STRICT_MODE', true),
],
'new_mysql' => [
    'driver'    => 'mysql',
    'host'      => env('NEW_NEW_MYSQL_HOSTNAME', 'localhost'),
    'port'      => env('NEW_MYSQL_PORT', 3306),
    'database'  => env('NEW_MYSQL_DATABASE', 'forge'),
    'username'  => env('NEW_MYSQL_USER', 'forge'),
    'password'  => env('NEW_MYSQL_PASSWORD', ''),
    'charset'   => env('NEW_MYSQL_CHARSET', 'utf8mb4'),
    'collation' => env('NEW_MYSQL_COLLATION', 'utf8mb4_unicode_ci'),
    'prefix'    => env('NEW_MYSQL_PREFIX', ''),
    'timezone'  => env('NEW_MYSQL_TIMEZONE', '+08:00'),
    'strict'    => env('NEW_MYSQL_STRICT_MODE', true),
]

很自然的,在 Model (表在 new_mysql 数据库)里面我们会覆盖 connection

1
protected $connection = 'new_mysql';

这个时候测试出问题了,因为我的测试配置是: database.php

1
2
3
4
'testing' => [
    'driver' => 'sqlite',
    'database' => ':memory:',
],

phpunit.xml

1
<env name="DB_CONNECTION" value="testing" />

因为 Model 里面 connection 写死了,所以在测试的时候会尝试去连 new_mysql 正确的做法应该是在 Model 里面通过覆盖 getConnectionName 来处理 testing 的数据库问题。

1
2
3
4
public function getConnectionName()
{
    return app()->environment('testing') ? config('database.default') : 'new_mysql';
}

重构系统不得不考虑的两个问题

先讲结论:

  1. 老系统是否可以精简?
  2. 如何重构才可以一步一步上线?

公司之前的项目是一个大杂烩,多个业务放在一个项目中,多个网站也是放在一起,因为总是有一些共用的数据,结果导致很多工程师同事在一个项目上开发,之前大家开开心心和和睦睦,平安无事,但是随着业务越来越复杂,问题出现了:

  1. 共用的东西很多,牵一发动全身,经常出现修改一些东西需要在多个业务里面测试,因为没有写单元测试,就只能手动测试。
  2. 每个人写的代码参差不齐,某天你想重构一些代码,但你看看要改那么多地方,你就只能苦笑,结果就是破罐子破摔,就像有洁癖的人,只能捏子鼻子闭着眼睛生活。

所以今年7月份,我们启动了核心项目的重构,整体来说就是拆服务,写 API,用 API。 今天我不讲整个过程是这么做的,我只分享重构前做的一些考虑,如果你也在做一些大的重构,希望这些考虑可以给你帮助。

一,老系统的业务是否可以精简或者删除?

项目重构可不单单只重构代码,这正是一个非常好的机会来重新梳理业务,可能你感觉业务跑的很顺,但是:

  1. 会不会有哪些业务已经是可有可无的状态?
  2. 会不会有哪个流程本来可以更简单?
  3. 会不会有一些遗留问题早就应该解决了,但是一直因为忙忙忙的缘故被拖后?

在重构的之前,技术人要完整的理解现有系统的结构,更重要的是要理解业务,尤其是要和第一线的业务人员进行沟通,了解他们的工作模式,了解他们的现有流程的一些困惑或难点。

了解清楚后,开始做减法,已经不需要的功能或流程要在老系统上进行精简或删除,可能你会问“我已经要重构了,为什么还需要在老系统上精简?“,因为你重构的系统还不知道什么时候上线呢,而且重构的系统再开发的时候需要参考老系统。

二,如何重构才能一步一步上线?

这一点是我考虑不足的地方,因为项目拉的比较长,时间预估方面也考虑不周,导致项目延期比较久。其中很重要的一个问题是:如何才能一步一步上线?

  1. 重构后的系统哪些部分可以先做完上线?让老系统与新系统的兼容工作分步做,而不是想着一步就全部替换掉,这不现实。
  2. 数据存储结构是否需要修改?如果修改,是否可以先洗一部分数据?

分步上线是把风险也分步,把风险分散开对一个成熟的业务非常关键。而且每一步都会给大家很多的信心,很清楚看到我们在前进,否则战线拉太长,大家都会觉得比较疲劳。

总体来说,这两点是在重构项目的时候大家都可以尝试考虑的问题,希望对你有帮助。

当 Laravel 5.3 RedisQueue 遇到阿里云 Redis 的时候

公司的微服务的 api 使用的是 Lumen 5.2 来做的,队列使用 Redis 跑得很好。

最近做了一个网站项目,使用了 Laravel 5.3,就在昨天我们使用了 Laravel 5.3 的队列,driver 使用的是 Redis,简单好用。

本地测试顺利,CI 跑完,开开心心的上线。

Bang…… 出错日志疯一样的出现,如下:

1
2
3
4
5
6
7
[2016-12-23 10:07:14] production.ERROR: Predis\Response\ServerException: ERR unknown command eval in /var/www/html/vendor/predis/predis/src/Client.php:370
Stack trace:
#0 /var/www/html/vendor/predis/predis/src/Client.php(335): Predis\Client->onErrorResponse(Object(Predis\Command\ServerEval), Object(Predis\Response\Error))
#1 /var/www/html/vendor/predis/predis/src/Client.php(314): Predis\Client->executeCommand(Object(Predis\Command\ServerEval))
#2 /var/www/html/vendor/laravel/framework/src/Illuminate/Queue/RedisQueue.php(187): Predis\Client->__call('eval', Array)
#3 /var/www/html/vendor/laravel/framework/src/Illuminate/Queue/RedisQueue.php(132): Illuminate\Queue\RedisQueue->migrateExpiredJobs('queues:default:...', 'queues:default')
#4 /var/www/html/vendor/laravel/framework/src/Illuminate/Queue/Worker.php(173): Illuminate\Queue\RedisQueue->pop('queues:default')

看一下 RedisQueue 代码就会发现,现在的实现已经大部分都使用 LuaScript 来进行操作,不使用原生方法来操作,使用 LuaScript 来操作好处很多,比如:

  • 高效性:减少网络开销及时延,多次redis服务器网络请求的操作,使用LUA脚本可以用一个请求完成
  • 数据可靠性:Redis会将整个脚本作为一个整体执行,中间不会被其他命令插入。
  • 复用性:LUA脚本执行后会永久存储在Redis服务器端,其他客户端可以直接复用
  • 便捷性:实现程序热更新
  • 可嵌入性:可嵌入JAVA,C#等多种编程语言,支持不同操作系统跨平台交互
  • 简单强大:小巧轻便,资源占用率低,支持过程化和对象化的编程语言
  • 免费开源:遵循MIT Licence协议,可免费商用化

这个时候我傻眼了,如果你是我,这个时候会怎么做?脑海里无数个想法:

  1. 是否可以降级 Laravel Queue 的版本?之前的版本是使用原生命令的。
  2. 是否可以切换别的 driver,但是成本有点高?
  3. 阿里云的 Redis 是否可以支持 lua 脚本?
  4. 是不是我眼花了?:no_good:

很明显我没有眼花,而且如果3可以的话,肯定是最方便快速的方案,所以马上搜索,发现今年7月份阿里刚好推出了支持 lua 脚本的 Redis,只不过需要申请。

https://yq.aliyun.com/articles/57805

好开心,马上提交工单,一会就审核通过就开通了 lua 脚本的功能,完美解决。

温馨提示: 1. 开通支持 lua 脚本会有闪断。 2. 可能会丢数据,请做好 Redis 的备份工作,不过我们没有遇见这个问题。 3. 能开通就开通吧,说不准什么时候就用上了。:rose: :rose: :rose:

讲讲我对 Laravel Policy 的认识

公司的项目中 Restful 的接口对应的资源的权限都使用 Laravel Gate Policy 做的,一般权限做了如下的限制: * 创建 * 查看 * 更新 * 删除

这几个权限使用 Policy 可以很容易做出来,比如下面官网的例子,定义了 update 的 alibity:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<?php

namespace App\Policies;

use App\User;
use App\Post;

class PostPolicy
{
    /**
     * Determine if the given post can be updated by the user.
     *
     * @param  \App\User  $user
     * @param  \App\Post  $post
     * @return bool
     */
    public function update(User $user, Post $post)
    {
        return $user->id === $post->user_id;
    }
}

Controller 中使用如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
<?php

namespace App\Http\Controllers;

use App\Post;
use Illuminate\Http\Request;
use App\Http\Controllers\Controller;

class PostController extends Controller
{
    /**
     * Update the given blog post.
     *
     * @param  Request  $request
     * @param  Post  $post
     * @return Response
     */
    public function update(Request $request, Post $post)
    {
        $this->authorize('update', $post);

        // The current user can update the blog post...
    }
}

那问题来了,如果我想做资源列表(/posts)的权限怎么做?

我没进行思考,就直接想加到 policy 里面。

1
2
3
4
5
6
7
8
9
10
11
//PostPolicy
...
public function viewList(User $user, array $posts)
{
    //todo
}
...
//PostController
...
$this->authorize('viewList', $posts);
...

发现不行啊,细细的查看了 Gate 的实现,给你看一个函数你就懂了:

1
2
3
4
5
6
7
8
9
10
11
12
13
//Gate.php
    protected function firstArgumentCorrespondsToPolicy(array $arguments)
    {
        if (! isset($arguments[0])) {
            return false;
        }

        if (is_object($arguments[0])) {
            return isset($this->policies[get_class($arguments[0])]);
        }

        return is_string($arguments[0]) && isset($this->policies[$arguments[0]]);
    }

在 authorize 的时候,Gate 会通过第一个参数(除去 user)来判断是哪个对象,知道了这个对象,才能找到对应的 policy class,所以一般我们这么用:

1
2
$this->authorize('update', $post);
$this->authorize('create', Post::class);

如果你想用 viewList,可以这么做:

1
2
3
4
5
6
7
8
9
10
11
//PostPolicy
...
public function viewList(User $user, $class, array $posts)
{
    //todo
}
...
//PostController
...
$this->authorize('viewList', Post::class, $posts);
...

是不是觉得很变扭,$class 这个参数我们根本就没有用,回过头想一想,这么做好像哪里不太对。。。。。。。。。。。

是的,Policy 本来就是针对单个对象的,我现在要控制列表的权限,正确的做法应该是这样的:

1
2
3
    \Gate::define('view-post-list', function ($user, $posts) {
        //
    });

使用

1
2
3
4
//PostController
...
$this->authorize('view-post-list',  $posts);
...

查看 Gate 的代码,你会发现,他会优先 check policy 是否存在,不存在就会去检查是否有定义的 abilities,如果有就会使用,贴一段代码你就懂了:

1
2
3
4
5
6
7
8
9
10
11
12
protected function resolveAuthCallback($user, $ability, array $arguments)
{
    if ($this->firstArgumentCorrespondsToPolicy($arguments)) {
        return $this->resolvePolicyCallback($user, $ability, $arguments);
    } elseif (isset($this->abilities[$ability])) {
        return $this->abilities[$ability];
    } else {
        return function () {
            return false;
        };
    }
}

我猜你已经懂了我的意思,若没懂,请留言。

Lumen 中 Request 要这样拿到 Route 中的 Parameter

Laravel 中获取 route 中的 parameter 的方法:

1
2
3
4
5
6
//假设 route 是这样的:
$router->resource('posts.comments', PostCommentController::class);

//获取的方法:
$postId = $request->posts;
$commentId = $request->comments;

Lumen 中的 routeResolver 有点不太一样,不能这样:

1
$postId = $request->posts;

也不能这样:

1
$postId = $request->route('posts');

报错:Call to a member function parameter() on array

所以正确的做法是自己解析:$request->route(),略坑。 有人写了一个 helper 的方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
if (!function_exists('route_parameter')) {
    /**
     * Get a given parameter from the route.
     *
     * @param $name
     * @param null $default
     * @return mixed
     */
    function route_parameter($name, $default = null)
    {
        $routeInfo = app('request')->route();

        return array_get($routeInfo[2], $name, $default);
    }
}

就这么用吧。

可能你会问我为什么要自己去 request 中拿 route 的 parameter,因为我在 policy 中要用。

PhpStorm 的一个小坑:配置记录问题

一般创建一个 Class 的时候,是这样的: file

项目创建后,第一次在敲 Namespace 的时候,不小心敲错了,本来应该是 App,结果变成 app

每次创建的时候我都需要手动修改一下,PhpStorm 记住了第一次添加的 Namespace

那今天我创建了一个 Event 和 Listen (忘记修改 app –> App)后,添加好了对应的 ServiceProvider 后,发现 Listen 怎么都无法触发,Debug 所有的 Listeners 也没有发现。

后来细致检查代码才发现,真是自己坑了自己。

解决这个问题就是:删除 rm -rf .idea

这样之前写错的 Namespace 就没有了。

file

你们有遇见类似这样抓狂的问题吗?我感觉每次都是我自己挖坑,然后埋自己。