Laravel 中的 job 调度机制

975 阅读5分钟

  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 方法。该方法中又调用了 jobdelay 方法,其最终目的是为 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\PendingDispatchuse 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\BusServiceProvideruse 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\QueueingDispatcherQueueingDispatcher 又继承了 Illuminate\Contracts\Bus\Dispatcher

  ⅰ解析连接器

  在注册 Dispatcher 单例的时候,还会往 Dispatcher 的构造方法中传入一个回调作为队列解析器,该回调最终返回的是 Illuminate\Queue\Queuemanager 对象(Laravel 在启动时会在容器中将 Illuminate\Queue\QueueManagerIlluminate\Contracts\Queue\Factory 进行绑定),并且 QueueManager 还会调用 connection 方法设置连接。如果连接名称没有指定,则会将连接设置为默认值(具体默认值取决于配置文件 config/queue.php 中的配置)。

namespace Illuminate\Queue\Queuemanagerpublic 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\QueueServiceProviderprotected 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 ,同时还会注册连接器。注册连接器的过程即是通过调用 QueueManageraddConnector 方法来实现的。addConnector 方法的作用则是向 QueueManager 的属性 connectors 中添加 Clousure

  以 Redis 为例,调用 getConnector 方法解析 Redis 连接器,最终会得到 Illuminate\Queue\Connectors\RedisConnector 对象。紧接着调用该对象的 connect 方法,则会得到 Illuminate\Queue\RedisQueue 对象。

   ⅱ 分发 job

  解析得到 Dispatcher 对象后,调用该对象的 dispatch 方法,然后将 job 分发到相应的队列。

namespace Illuminate\Bus\Dispatcherpublic 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\Dispatcherpublic 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\RedisQueuepublic 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\WorkCommandpublic 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\RedisQueuepublic 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];
}