这篇文章源自 Laravel China 教程中的第二本书 《 Web 实战开发进阶 》,整本书创建了一个论坛系统。前面我们完成了 SEO 友好的 URL,即:将帖子标题翻译成英文并显示在该帖子的 URL 上,这个功能调用了百度翻译接口,默认情况下是实时请求 API,一般情况下,网络请求会存在各种不确定性,如果请求 API 出现超时情况,或者发生不可预知的错误,我们的用户将无法发帖。
生成翻译标题后的 URL 只是一个 优化 功能,并非是发帖的 必要 功能,我们希望无论生成 标题英文 的结果如何,用户都能顺利的发帖,并且完全察觉不到延迟。
因此我们可以使用队列来完成这项功能,队列允许你异步执行消耗时间的任务,比如请求一个 API 并等待返回的结果。这样可以有效的降低请求响应的时间。实现这个功能需要以下几个步骤:
1.配置队列
我们将使用 Redis 来作为我们的队列驱动器,先使用 Composer 安装依赖:
$ composer require "predis/predis:~1.1"
修改环境变量 QUEUE_DRIVER 的值为 redis:
.env
.
.
.
QUEUE_CONNECTION=redis
.
.
.
失败任务
有时候队列中的任务会失败。Laravel 内置了一个方便的方式来指定任务重试的最大次数。当任务超出这个重试次数后,它就会被插入到 failed_jobs 数据表里面。我们可以使用 queue:failed-table
命令来创建 failed_jobs 表的迁移文件:
$ php artisan queue:failed-table
会新建 database/migrations/{timestamp}_create_failed_jobs_table.php
文件:
接着使用 migrate Artisan 命令生成 failed_jobs 表:
$ php artisan migrate
2.生成任务类
使用以下 Artisan 命令来生成一个新的队列任务:
$ php artisan make:job TranslateSlug
该命令会在 app/Jobs 目录下生成一个新的类:
app/Jobs/TranslateSlug.php
<?php
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use App\Models\Topic;
use App\Handlers\SlugTranslateHandler;
class TranslateSlug implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
protected $topic;
public function __construct(Topic $topic)
{
// 队列任务构造器中接收了 Eloquent 模型,将会只序列化模型的 ID
$this->topic = $topic;
}
public function handle()
{
// 请求百度 API 接口进行翻译
$slug = app(SlugTranslateHandler::class)->translate($this->topic->title);
// 为了避免模型监控器死循环调用,我们使用 DB 类直接对数据库进行操作
\DB::table('topics')->where('id', $this->topic->id)->update(['slug' => $slug]);
}
}
该类实现了 Illuminate\Contracts\Queue\ShouldQueue 接口,该接口表明 Laravel 应该将该任务添加到后台的任务队列中,而不是同步执行。
3.任务分发
接下来我们要修改 Topic 模型监控器,将 Slug 翻译的调用修改为队列执行的方式:
app/Observers/TopicObserver.php
<?php
namespace App\Observers;
use App\Models\Topic;
use App\Jobs\TranslateSlug;
// creating, created, updating, updated, saving,
// saved, deleting, deleted, restoring, restored
class TopicObserver
{
public function saving(Topic $topic)
{
// XSS 过滤
$topic->body = clean($topic->body, 'user_topic_body');
// 生成话题摘录
$topic->excerpt = make_excerpt($topic->body);
// 如 slug 字段无内容,即使用翻译器对 title 进行翻译
if ( ! $topic->slug) {
// 推送任务到队列
dispatch(new TranslateSlug($topic));
}
}
}
4.开始测试
开始之前,我们需要在命令行启动队列系统,队列在启动完成后会进入监听状态:
$ php artisan queue:listen
浏览器打开话题发布页面,填写测试内容:
点击『保存』按钮提交表单后,可以在命令行中看到监听的状态:
可以看到我们的任务 Failed 执行失败了。打开数据库查看 failed_jobs 里的数据:
虽然我们能够从 payload 和 exception 字段中看到报错的信息,但因为是序列化以后的信息,所以并不直观:
接下来我们将寻找更好的队列监控方案。
5.队列监控 Horizon
Horizon 是 Laravel 生态圈里的一员,为 Laravel Redis 队列提供了一个漂亮的仪表板,允许我们很方便地查看和管理 Redis 队列任务执行的情况。
使用 Composer 安装:
$ composer require "laravel/horizon:~1.3"
安装完成后,使用 vendor:publish Artisan 命令发布相关文件:
$ php artisan vendor:publish --provider="Laravel\Horizon\HorizonServiceProvider"
分别是配置文件 config/horizon.php 和存放在 public/vendor/horizon 文件夹中的 CSS 、JS 等页面资源文件。
至此安装完毕,浏览器打开 http://larabbs.test/horizon 访问控制台:
Horizon 是一个监控程序,需要常驻运行,我们可以通过以下命令启动:
$ php artisan horizon
安装了 Horizon 以后,我们将使用 horizon 命令来启动队列系统和任务监控,无需使用 queue:listen。
接下来我们再次尝试下发帖,发帖之前,请确保 horizon 命令处于监控状态:
这一次多亏了 Horizon,我们可以清晰的看到更加详尽的错误信息,错误异常是 ModelNotFoundException,最重要的:
我们发现 Data 区块里,id 的值居然为 null。我们知道的,队列系统对于构造器里传入的 Eloquent 模型,将会只序列化 ID 字段,因为我们是在 Topic 模型监控器的 saving() 方法中分发队列任务的,此时传参的 $topic 变量还未在数据库里创建,所以 $topic->id 为 null。
6.代码调整
既然我们已经定位到了问题,解决的方法也很简单,只需要确保分发任务时 $topic->id 有值即可。我们需要修改任务分发的时机:
app/Observers/TopicObserver.php
<?php
namespace App\Observers;
use App\Models\Topic;
use App\Jobs\TranslateSlug;
// creating, created, updating, updated, saving,
// saved, deleting, deleted, restoring, restored
class TopicObserver
{
public function saving(Topic $topic)
{
// XSS 过滤
$topic->body = clean($topic->body, 'user_topic_body');
// 生成话题摘录
$topic->excerpt = make_excerpt($topic->body);
}
public function saved(Topic $topic)
{
// 如 slug 字段无内容,即使用翻译器对 title 进行翻译
if ( ! $topic->slug) {
// 推送任务到队列
dispatch(new TranslateSlug($topic));
}
}
}
模型监控器的 saved() 方法对应 Eloquent 的 saved 事件,此事件发生在创建和编辑时、数据入库以后。在 saved() 方法中调用,确保了我们在分发任务时,$topic->id 永远有值。
需要注意的是,artisan horizon 队列工作的守护进程是一个常驻进程,它不会在你的代码改变时进行重启,当我们修改代码以后,需要在命令行中对其进行重启操作。
重启 horizon 命令后再次尝试,即可看到成功运行的队列:
7.线上部署须知
在开发环境中,我们为了测试方便,直接在命令行里调用 artisan horizon 进行队列监控。然而在生产环境中,我们需要配置一个进程管理工具来监控 artisan horizon 命令的执行,以便在其意外退出时自动重启。当服务器部署新代码时,需要终止当前 Horizon 主进程,然后通过进程管理工具来重启,从而使用最新的代码。
简而言之,生产环境下使用队列需要注意以下两个问题:
每一次部署代码时,需 artisan horizon:terminate
然后再 artisan horizon
重新加载代码