Laravel 中提供了队列机制,使得开发者可以将耗时较长的任务以 job
的形式在后台运行,从而最终缩短请求的响应时间,提升用户体验。
⒈ job 创建
在 Laravel 框架中,通过命令行 php artisan make:job {JobClassName}
的形式来创建 job
。创建的示例 job
如下:
namespace App\Jobs;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldBeUnique;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Queue\SerializesModels;
class ProcessPodcast implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function __construct()
{
//
}
public function handle()
{
// 具体的业务逻辑代码
}
}
⒉ job 分发
创建好的 job
如果要运行,则需要分发到相应的队列中。Laravel 中的 job
支持两种分发机制:即时分发和延迟分发。前者分发到队列的任务会被立即执行,而后者则会先将任务分发到延迟队列,待到达执行的运行时间之后,再将任务从延迟队列分发到运行队列。
⓵ 即时分发
job
的即时分发通过调用 dispatch
方法实现,示例如下:
$podcast = Podcast::create(...);
ProcessPodcast::dispatch($podcast)
dispatch
的方法体如下:
namespace Illuminate\Foundation\Bus\Dispatchable // trait
public static function dispatch(...$arguments)
{
return new PendingDispatch(new static(...$arguments));
}
调用 dispatch
方法最终会返回一个 PendingDispatch
对象。要分发的 job
(此处为 ProcessPodcast
对象)为 PendingDispatch
对象的属性。
namespace Illuminate\Foundation\Bus;
use Illuminate\Contracts\Bus\Dispatcher;
class PendingDispatch
{
protected $job;
public function __construct($job)
{
$this->job = $job;
}
}
⓶ 延迟分发
所谓延迟分发,即是在即时分发的基础上调用 delay
方法。示例如下:
ProcessPodcast::dispatch($podcast)
->delay(now()->addMinutes(10));
由之前的分析可知,其最终调用的是 PendingDispatch
对象的 delay
方法。该方法中又调用了 job
的 delay
方法,其最终目的是为 job
设置一个 delay
属性。
namespace Illuminate\Foundation\Bus\PendingDispatch;
public function delay($delay)
{
$this->job->delay($delay);
return $this;
}
namespace Illuminate\Bus\Queueable // trait
public function delay($delay)
{
$this->delay = $delay;
return $this;
}
⓷ 具体分发过程
在分发操作执行完成之后,新创建的 PendingDispatch
对象会销毁。在销毁对象之前,该对象会调用自身的析构方法 __destruct
。而析构方法中则又会对 job
进行后续的分发操作
namespace Illuminate\Foundation\Bus\PendingDispatch;
use Illuminate\Contracts\Bus\Dispatcher;
public function __destruct()
{
if (! $this->shouldDispatch()) {
return;
} elseif ($this->afterResponse) {
app(Dispatcher::class)->dispatchAfterResponse($this->job);
} else {
app(Dispatcher::class)->dispatch($this->job);
}
}
在析构方法中,Laravel 容器首先会实例化 Illuminate\Contracts\Bus\Dispatcher
,该实例化操作实际由服务提供者 Illuminate\Bus\BusServiceProvider
来实现。
namespace Illuminate\Bus\BusServiceProvider;
use Illuminate\Contracts\Queue\Factory as QueueFactoryContract;
public function register()
{
$this->app->singleton(Dispatcher::class, function ($app) {
return new Dispatcher($app, function ($connection = null) use ($app) {
return $app[QueueFactoryContract::class]->connection($connection);
});
});
/* ... ... */
}
该服务提供者将 Illuminate\Bus\Dispatcher
注册为一个单例,而 Illuminate\Bus\Dispatcher
实现了 Illuminate\Contracts\Bus\QueueingDispatcher
,QueueingDispatcher
又继承了 Illuminate\Contracts\Bus\Dispatcher
。
ⅰ解析连接器
在注册 Dispatcher
单例的时候,还会往 Dispatcher
的构造方法中传入一个回调作为队列解析器,该回调最终返回的是 Illuminate\Queue\Queuemanager
对象(Laravel 在启动时会在容器中将 Illuminate\Queue\QueueManager
与 Illuminate\Contracts\Queue\Factory
进行绑定),并且 QueueManager
还会调用 connection
方法设置连接。如果连接名称没有指定,则会将连接设置为默认值(具体默认值取决于配置文件 config/queue.php
中的配置)。
namespace Illuminate\Queue\Queuemanager;
public function connection($name = null)
{
$name = $name ?: $this->getDefaultDriver();
if (! isset($this->connections[$name])) {
$this->connections[$name] = $this->resolve($name);
$this->connections[$name]->setContainer($this->app);
}
return $this->connections[$name];
}
protected function resolve($name)
{
$config = $this->getConfig($name);
if (is_null($config)) {
throw new InvalidArgumentException("The [{$name}] queue connection has not been configured.");
}
return $this->getConnector($config['driver'])
->connect($config)
->setConnectionName($name);
}
protected function getConnector($driver)
{
if (! isset($this->connectors[$driver])) {
throw new InvalidArgumentException("No connector for [$driver].");
}
return call_user_func($this->connectors[$driver]);
}
public function addConnector($driver, Closure $resolver)
{
$this->connectors[$driver] = $resolver;
}
由以上代码可知,在解析队列的默认连接时,会调用 getConnector
方法取得相应的连接器。而 getConnector
方法则是在 connectors
属性中找到相应的 Clousure
进行调用。这些 Clousure
则是在 QueueServiceProvider
中完成注册的。
namespace Illuminate\Queue\QueueServiceProvider;
protected function registerManager()
{
$this->app->singleton('queue', function ($app) {
return tap(new QueueManager($app), function ($manager) {
$this->registerConnectors($manager);
});
});
}
public function registerConnectors($manager)
{
foreach (['Null', 'Sync', 'Database', 'Redis', 'Beanstalkd', 'Sqs'] as $connector) {
$this->{"register{$connector}Connector"}($manager);
}
}
protected function registerRedisConnector($manager)
{
$manager->addConnector('redis', function () {
return new RedisConnector($this->app['redis']);
});
}
由代码可知,在容器中注册 queue
服务时,会实例化 QueueManager
,同时还会注册连接器。注册连接器的过程即是通过调用 QueueManager
的 addConnector
方法来实现的。addConnector
方法的作用则是向 QueueManager
的属性 connectors
中添加 Clousure
。
以 Redis
为例,调用 getConnector
方法解析 Redis
连接器,最终会得到 Illuminate\Queue\Connectors\RedisConnector
对象。紧接着调用该对象的 connect
方法,则会得到 Illuminate\Queue\RedisQueue
对象。
ⅱ 分发 job
解析得到 Dispatcher
对象后,调用该对象的 dispatch
方法,然后将 job
分发到相应的队列。
namespace Illuminate\Bus\Dispatcher;
public function dispatch($command)
{
return $this->queueResolver && $this->commandShouldBeQueued($command)
? $this->dispatchToQueue($command)
: $this->dispatchNow($command);
}
protected function commandShouldBeQueued($command)
{
return $command instanceof ShouldQueue;
}
默认生成的 job
都实现了 ShouldQueue
,并且在解析 Dispatcher
的时候也向其中注册了队列解析器。所以此时会调用 dispatchToQueue
方法。
namespace Illuminate\Bus\Dispatcher;
public function dispatchToQueue($command)
{
$connection = $command->connection ?? null;
$queue = call_user_func($this->queueResolver, $connection);
if (! $queue instanceof Queue) {
throw new RuntimeException('Queue resolver did not return a Queue implementation.');
}
if (method_exists($command, 'queue')) {
return $command->queue($queue, $command);
}
return $this->pushCommandToQueue($queue, $command);
}
protected function pushCommandToQueue($queue, $command)
{
if (isset($command->queue, $command->delay)) {
return $queue->laterOn($command->queue, $command->delay, $command);
}
if (isset($command->queue)) {
return $queue->pushOn($command->queue, $command);
}
if (isset($command->delay)) {
return $queue->later($command->delay, $command);
}
return $queue->push($command);
}
在 dispatchToQueue
方法中,首先会解析得到连接器,紧接着调用 pushCommandToQueue
方法,根据 job
的属性分别调用连接器中的方法将 job
推送到队列当中。
仍然以 Redis
为例,根据 job
是否设置了延迟以及是否设置了特定的队列,分别调用不同的方法将 job
推送到相应的队列上。
namespace Illuminate\Queue\RedisQueue;
public function pushOn($queue, $job, $data = '')
{
return $this->push($job, $data, $queue);
}
public function push($job, $data = '', $queue = null)
{
return $this->enqueueUsing(
$job,
$this->createPayload($job, $this->getQueue($queue), $data),
$queue,
null,
function ($payload, $queue) {
return $this->pushRaw($payload, $queue);
}
);
}
public function pushRaw($payload, $queue = null, array $options = [])
{
$this->getConnection()->eval(
LuaScripts::push(), 2, $this->getQueue($queue),
$this->getQueue($queue).':notify', $payload
);
return json_decode($payload, true)['id'] ?? null;
}
public function laterOn($queue, $delay, $job, $data = '')
{
return $this->later($delay, $job, $data, $queue);
}
public function later($delay, $job, $data = '', $queue = null)
{
return $this->enqueueUsing(
$job,
$this->createPayload($job, $this->getQueue($queue), $data),
$queue,
$delay,
function ($payload, $queue, $delay) {
return $this->laterRaw($delay, $payload, $queue);
}
);
}
protected function laterRaw($delay, $payload, $queue = null)
{
$this->getConnection()->zadd(
$this->getQueue($queue).':delayed', $this->availableAt($delay), $payload
);
return json_decode($payload, true)['id'] ?? null;
}
以队列名称 default
为例,如果是即时分发,则相应的 job
会被推送到名称为 queues:default
的队列,同时还会向名称为 queues:default:notify
的队列上推送一条通知。
而对于延迟分发的 job
,则会将其推送到名称为 queues:default:delayed
的队列上。该队列是一个有序集合(zset),job
相对应的延时则会转换成一个时间戳,最后被当作该 job
在有序集合中的 score
值。
至此,job
的分发过程完成。
⒊ job 调度
在将 job
分发到相应的队列上后,就需要有相应的守护进程从队列上读取 job
,并执行相应的业务逻辑。在 Laravel 中,通过命令行 php artisan queue:work
来消费队列上的 job
。
在命令行运行之前,首先仍然需要解析得到相应的连接类型和队列名称,之后再根据传参消费相应的 job
。默认情况下,命令行会以守护进程的方式消费队列上的 job
。
namespace Illuminate\Queue\Console\WorkCommand;
public function handle()
{
/* ... ... */
$queue = $this->getQueue($connection);
return $this->runWorker(
$connection, $queue
);
}
protected function runWorker($connection, $queue)
{
return $this->worker->setName($this->option('name'))
->setCache($this->cache)
->{$this->option('once') ? 'runNextJob' : 'daemon'}(
$connection, $queue, $this->gatherWorkerOptions()
);
}
在消费之前,首先需要从队列上获取 job
。该过程首先需要通过连接类型解析得到连接器,然后通过调用连接器的 pop
方法得到相应的 job
。
namespace Illuminate\Queue\Worker;
public function daemon($connectionName, $queue, WorkerOptions $options)
{
/* ... ... */
while (true) {
/* ... ... */
$job = $this->getNextJob(
$this->manager->connection($connectionName), $queue
);
/* ... ... */
if ($job) {
/* ... ... */
$this->runJob($job, $connectionName, $options);
/* ... ... */
} else {
$this->sleep($options->sleep);
}
/* ... ... */
}
}
protected function getNextJob($connection, $queue)
{
$popJobCallback = function ($queue) use ($connection) {
return $connection->pop($queue);
};
try {
/* ... ... */
foreach (explode(',', $queue) as $queue) {
if (! is_null($job = $popJobCallback($queue))) {
return $job;
}
}
} catch (Throwable $e) {
/* ... ... */
}
}
仍然以 Redis
为例,在 pop
方法中,首先会尝试将 queues:default:delayed
队列上 score
值小于当前时间戳的 job
都移动到 queues:default
队列上,然后再从队列 queues:default
上弹出一个 job
,这个弹出的 job
会被包装成一个 Illuminate\Queue\Jobs\RedisJob
对象,job
的具体执行则是通过调用 RedisJob
都想的 fire
方法实现。
namespace Illuminate\Queue\RedisQueue;
public function pop($queue = null)
{
$this->migrate($prefixed = $this->getQueue($queue));
[$job, $reserved] = $this->retrieveNextJob($prefixed);
if ($reserved) {
return new RedisJob(
$this->container, $this, $job,
$reserved, $this->connectionName, $queue ?: $this->default
);
}
}
protected function migrate($queue)
{
$this->migrateExpiredJobs($queue.':delayed', $queue);
/* ... ... */
}
public function migrateExpiredJobs($from, $to)
{
return $this->getConnection()->eval(
LuaScripts::migrateExpiredJobs(), 3, $from, $to, $to.':notify', $this->currentTime()
);
}
protected function retrieveNextJob($queue, $block = true)
{
$nextJob = $this->getConnection()->eval(
LuaScripts::pop(), 3, $queue, $queue.':reserved', $queue.':notify',
$this->availableAt($this->retryAfter)
);
if (empty($nextJob)) {
return [null, null];
}
[$job, $reserved] = $nextJob;
/* ... ... */
return [$job, $reserved];
}