1.1 QueueServiceProvider
Illuminate\Queue\QueueServiceProvider
佇列服務由服務提供者QueueServiceProvider註冊。
- registerManager() 註冊佇列管理器,同時新增 Null/Sync/Database/Redis/Beanstalkd/Sqs 連線驅動
- Null:不啟動佇列,生產者產生的任務被丟棄
- Sync:同步佇列,生產者產生的任務直接執行
- Database:資料庫佇列驅動,生產者產生的任務放入資料庫
- Redis:Redis佇列驅動,生產者產生的任務放入Redis
- Beanstalkd:略過
- Sqs:略過
- registerConnection() 註冊佇列連線獲取閉包,當需要用到佇列驅動連線時,例項化連線
- registerWorker() 註冊佇列消費者
- registerListener() Listen模式註冊佇列消費者
- registerFailedJobServices() 註冊失敗任務服務
註冊方法 | 物件 | 別名 |
---|---|---|
QueueServiceProvider::registerManager() | \Illuminate\Queue\QueueManager::class | queue |
QueueServiceProvider::registerConnection() | \Illuminate\Queue\Queue::class | queue.connection |
QueueServiceProvider::registerWorker() | \Illuminate\Queue\Worker::class | queue.worker |
QueueServiceProvider::registerListener() | \Illuminate\Queue\Listener::class | queue.listener |
QueueServiceProvider::registerFailedJobServices() | \Illuminate\Queue\Failed\FailedJobProviderInterface::class | queue.failer |
1.2 BusServiceProvider
Illuminate\Bus\BusServiceProvider
這個服務提供者註冊了Dispatcher這個服務,可以將具體的任務派發到佇列。
一個可放入佇列的任務類:
<?php
use Illuminate\Bus\Queueable;
use Illuminate\Queue\SerializesModels;
use Illuminate\Queue\InteractsWithQueue;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Bus\Dispatchable;
class Job implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
}
任務在佇列中需要經過兩個過程:一個是任務入隊,就是將要執行的任務,放到佇列中去的過程,所有會發生這一過程的業務、物件、呼叫等等,可以統稱為生產者;與之對應的,將任務從佇列中取出,並執行的過程,叫做任務出隊,所有會發生這一過程的業務、物件、呼叫等等,可以統稱為消費者。
2.1 任務的要素
2.1.1 Dispatchable
Illuminate\Foundation\Bus\Dispatchable
這個trait給任務新增了兩個靜態方法dispatch/withChain
,賦予了任務派發的介面。
dispatch
dispatch方法觸發任務指派動作。當執行 Job::dispatch()
時,會例項化一個Illuminate\Foundation\Bus\PendingDispatch
物件PendingDispatch
,並且將任務呼叫類例項化後的物件job
當作建構函式的引數:
// Illuminate\Foundation\Bus\Dispatchable::trait
public static function dispatch()
{
// 這裡的static轉發到實際執行dispath的類 Job::dispatch,也就是Job類
return new PendingDispatch(new static(...func_get_args()));
}
PendingDispatch
物件接下來可以透過鏈式呼叫來指定佇列相關資訊 onConnection/onQueue/allOnConnection/allOnQueue/delay/chain
,然後在解構函式中,做實際的派發動作:
// Illuminate\Foundation\Bus\PendingDispatch::class
public function __destruct()
{
// Illuminate\Contracts\Bus\Dispatcher
// 這個服務由Illuminate\Bus\BusServiceProvider註冊
app(Dispatcher::class)->dispatch($this->job);
}
PendingDispath
這個中間指派者的作用,就是引出這裡從容器中解析出來的服務Dispatcher::class
,真正的任務指派者Dispatcher
。
withChain
withChain用於指定應該按順序執行的佇列列表。它只是一個語法糖,實際上的效果等同於:
// 1. withChain的用法
Job::withChain([
new OptimizePodcast,
new ReleasePodcast
])->dispatch();
// 2. 等同於dispatch的用法
Job::dispatch()->chain([
new OptimizePodcast,
new ReleasePodcast
])
2.1.2 Queueable
Illuminate\Bus\Queueable
上文提到的PendingDispath
,可以指定佇列資訊的方法,都是轉發到任務對應的方法進行呼叫,Queueable就是實現了這部分的功能。這部分包括以下介面:
方法名 | 描述 |
---|---|
onConnection | 指定連線名 |
onQueue | 指定佇列名 |
allOnConnection | 指定工作鏈的連線名 |
allOnQueue | 指定工作鏈的佇列名 |
delay | 設定延遲執行時間 |
chain | 指定工作鏈 |
以及最後一個方法dispatchNextJobInChain
。上述方法都是在任務執行前呼叫,設定任務相關引數。dispatchNextJobInChain
是在任務執行期間,如果檢查到任務定義了工作鏈,就會派發工作鏈上面的任務到佇列中。
2.1.3 SerializesModels
Illuminate\Queue\SerializesModels
這個trait的作用是字串化任務資訊,方便將任務資訊儲存到資料庫或Redis等儲存器中,然後在佇列的消費端取出任務資訊,並據此重新例項化為任務物件,便於執行任務。
2.1.4 InteractsWithQueue
Illuminate\Queue\InteractsWithQueue
這個trait賦予了任務與佇列進行資料互動的能力。InteractsWithQueue是任務的必要組成,如果一個任務只能被執行,而不能與佇列進行互動,那麼這個任務在佇列中的狀態就是未知的,必然會造成混亂。InteractsWithQueue與佇列的互動能力來源於$job屬性,它是一個QueueJob例項,需要與任務的概念進行區別:任務是泛指可執行的物件,而這個$job,是在任務出隊以後,解析出來的QueueJob物件。
即時一個任務類實現了InteractsWithQueue,它在例項化的時候並沒有$job這個屬性。需要等到出隊後的執行過程中,這個$job才被手動設定給任務。
2.1.5 ShouldQueue
Illuminate\Contracts\Queue\ShouldQueue
ShouldQueue也是是任務的必要實現的介面。只有實現了ShouldQueue介面的任務,才可以被放入佇列。上文所提到的真正的任務指派者Dispatcher
,它在PendingDispath
銷燬時所執行的dispatch
方法程式碼如下:
// Illuminate\Bus\Dispatcher::class
public function dispatch($command)
{
// 檢查任務指派者是否注入了佇列服務
// 並且當前任務需要方法佇列
if ($this->queueResolver && $this->commandShouldBeQueued($command)) {
return $this->dispatchToQueue($command);
}
return $this->dispatchNow($command);
}
commandShouldBeQueued
方法的作用就是檢查任務物件是否實現了ShouldQueue介面。如果是,就是執行dispatchToQueue方法,將任務放入佇列之中;否則執行dispatchNow,放入佇列執行棧(pipeline),進行同步執行。
2.2 任務入隊
任務是如何被放入佇列中的呢?這就引出了payload這個概念。當Dispatcher這個服務透過dispatch方法派發任務時,會透過佇列服務,將任務push到佇列中,在push的過程中會執行createPayloadArray方法:
// Illuminate\Queue\Queue
protected function createPayloadArray($job, $data = '')
{
// 如果我們文中所提到的“任務”,是一個物件的話,會呼叫createObjectPayload方法
// 進行一次封裝,將封裝後的資料Payload存入佇列,如果不是一個物件的話,呼叫
// createStringPayload進行一次封裝,然後存入佇列
return is_object($job)
? $this->createObjectPayload($job)
: $this->createStringPayload($job, $data);
}
protected function createObjectPayload($job)
{
return [
'displayName' => $this->getDisplayName($job),
'job' => 'Illuminate\Queue\CallQueuedHandler@call',
'maxTries' => $job->tries ?? null,
'timeout' => $job->timeout ?? null,
'timeoutAt' => $this->getJobExpiration($job),
'data' => [
'commandName' => get_class($job),
'command' => serialize(clone $job),
],
];
}
protected function createStringPayload($job, $data)
{
return [
'displayName' => is_string($job) ? explode('@', $job)[0] : null,
'job' => $job, 'maxTries' => null,
'timeout' => null, 'data' => $data,
];
}
createObjectPayload/createStringPayload
這兩個方法return的陣列就是payload,是便於儲存的一種格式。請特別注意 Illuminate\Queue\CallQueuedHandler@call
這部分出現的CallQueuedHandler這個物件,他是任務機制中的重要一環。
2.3 任務出隊
任務出隊是建立在消費者開始工作的基礎之上的。在laravel的應用中,一類消費者就是Worker,佇列處理器。透過命令列php artisan queue:work
來啟動一個Worker。Worker在daemon模式下,會不斷的嘗試從佇列中取出任務並執行,這一過程有以下執行環節:
- 第一步:檢查是否要暫停佇列,是則暫停一段時間,否則經行下一步
- 第二步:取出當前要執行的任務,並給任務設定一個超時程式,
- 第三步:執行任務,如果當前沒有任務,暫停一段時間
- 第四步:檢查是否要停止佇列
遇到三種情況會停止佇列:
- Worker程式收到SIGTERM訊號
- 使用記憶體超過限制
- 收到重啟命令
第二步就是任務出隊的過程。
// 該方法表示,從連線$connection中,名稱為$queue的佇列中取出下一個任務。
protected function getNextJob($connection, $queue)
{
try {
foreach (explode(',', $queue) as $queue) {
if (! is_null($job = $connection->pop($queue))) {
return $job;
}
}
} catch (Exception $e) {
$this->exceptions->report($e);
$this->stopWorkerIfLostConnection($e);
} catch (Throwable $e) {
$this->exceptions->report($e = new FatalThrowableError($e));
$this->stopWorkerIfLostConnection($e);
}
}
如果佇列驅動使用的時Database,那麼$connection指的就是Illuminate\Queue\DatabaseQueue的例項,如果佇列驅動使用的時Redis,那麼$connection指的就是Illuminate\Queue\RedisQueue的例項。
$connection的pop方法會從佇列儲存中取出下一個payload,經過佇列驅動的轉化,得到不同的QueueJob例項,也就是上文提到的$job物件,呼叫$job的fire方法,任務就開始執行。
2.4 任務執行 - CallQueuedHandler
CallQueuedHandler就像是佇列這個軌道上的一輛車,是任務機制中的重要環節。
我們可以把入隊與出隊稱為佇列的“內部操作”,他們是屬於Queue這個概念之內的問題。而CallQueuedHandler可以看成Queue與外部任務對接的“標準介面”,如果把所有要執行的任務稱為“可執行物件”,那麼,只需要用CallQueueHandle這個物件來裝載“可執行物件”,就可以讓這個“可執行物件”利用佇列的機制來執行。
在入隊時,CallQueueHandle與“可執行物件”組合成為payload。在出隊時,payload重放成為QueueJob,QueueJob呼叫fire的下一個環節,就是CallQueueHandle。
2.4.1 CallQueuedHandler的作用
在入隊與出隊的過程中,CallQueuedHandler並不發生任何作用,他只是隨payload在佇列的儲存中流轉進出。當任務被取出執行時,CallQueuedHandler就開始發揮作用。CallQueuedHandler的作用可以歸納為兩點:
- 繼承QueueJob呼叫的fire方法,轉發到CallQueuedHandler的handle方法,然後啟動任務的執行方法。
- 處理任務執行結果與佇列中資料(payload)的去留關係
2.4.2 任務執行的呼叫棧
QueueJob::fire() -> CallQueuedHandler::handle() -> [任務或其他可執行物件呼叫]
// worker->process() 消費者執行一個任務
public function process($connectionName, $job, WorkerOptions $options)
{
try {
$this->raiseBeforeJobEvent($connectionName, $job);
$this->markJobAsFailedIfAlreadyExceedsMaxAttempts(
$connectionName, $job, (int) $options->maxTries
);
$job->fire();
$this->raiseAfterJobEvent($connectionName, $job);
} catch (Exception $e) {
$this->handleJobException($connectionName, $job, $options, $e);
} catch (Throwable $e) {
$this->handleJobException(
$connectionName, $job, $options, new FatalThrowableError($e)
);
}
}
// Job->fire()
public function fire()
{
$payload = $this->payload();
// 解析佇列任務資訊,檢視上文中的createPayload方法: $payload = ['job' => 'Illuminate\Queue\CallQueuedHandler@call']
// 所以這裡$class = Illuminate\Queue\CallQueuedHandler, $method = call
list($class, $method) = JobName::parse($payload['job']);
// 如果payload是透過CallQueuedHandler進行包裝的,那麼此時instance就是CallQueuedHandler的例項,method就是call方法
// 如果payload是透過字串進行包裝的,那麼此時的instance就是制定的任務物件,method就是制定的呼叫方法
($this->instance = $this->resolve($class))->{$method}($this, $payload['data']);
}
2.5 小結
laravel提供的隊裡機制的執行呼叫棧,就是上述過程。當我們指佇列的任務機制時,包含的內容有以下兩點:
- 佇列底層提供的入隊與出隊機制
- 任務出隊後的執行呼叫棧
在充分理解任務機制的前提下,事件機制就很好理解了。事件監聽器的原理是,透過Illuminate\Events\CallQueuedListener 來作為一個特殊的“任務”,將事件繫結與監聽資訊儲存到這個“任務”中,當事件被觸發時,透過事件解析出與之對應的“任務”,然後對這個“任務”進行派發,執行這個“任務”時,再去執行事件監聽器。所以,這個環節的重點其實是,事件、監聽器、CallQueuedListener三者之間是如何進行關聯的,也就是事件監聽機制。所以我們後面在分析laravel事件機制相關原始碼時,遇到CallQueuedListener這個物件時就知道,這是要開始與佇列進行對接了。
// 1. 觸發一個事件
event(new Event);
// 2. 從觸發事件到進入佇列的過程形容如下
$eventCommand = new \Illuminate\Events\CallQueuedListener(new Event);
new PendingDispatch($eventCommand);
訊息機制的實現與事件機制類似。透過Illuminate\Notifications\SendQueuedNotifications 來作為一個特殊的“任務”,與訊息相關資訊進行關聯,透過SendQueuedNotifications物件來完成入隊與出隊相關過程,然後在執行“任務”SendQueuedNotifications的時候解析出關聯的notifiables和notification,然後據此執行訊息相關邏輯。
上面提到的都是系統提供的佇列機制,除此之外,你還可以手動推送任務到佇列,即透過Queue Facade來指派任務。
// MyTask
class MyTask implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle($job, $args)
{
echo "MyTask";
return true;
}
}
// 推送至佇列
Queue::push('MyTask@handle', $args, $queueName);
// MyAnotherTask
class MyAnotherTask implements ShouldQueue
{
use Dispatchable, InteractsWithQueue, Queueable, SerializesModels;
public function handle($args)
{
echo "MyTask";
return true;
}
}
// 推送至佇列
Queue::push(new MyAnotherTask, $args, $queueName);
5.1 入隊物件是一個例項
透過例項的方式,將任務推送到佇列中,在createPayload的環節,執行的是createObjectPayload方法,這時可以利用系統提供的佇列機制,例項只需要有一個handle方法作為執行方法,來承接CallQueuedHandler::handle()傳遞的呼叫棧。此時,handle方法只需要業務本身涉及到的資料作為引數。
5.2 入隊物件是一個字串
透過字串的方式,將任務推送到佇列中,在createPayload的環節,執行的是createStringPayload方法,這時無法利用系統體統的佇列機制中的第二層內容:執行呼叫棧,在QueueJob::fire()之後會呼叫字串指定的物件及方法。此時,方法除了需要業務本身涉及的資料作為引數外,還需要任務重放得到的QueueJob物件,作為第一個引數,所以上面程式碼中兩個自定義任務的函式簽名是不同的。
如果僅僅透過上述程式碼來執行的話,MyAnotherTask
這個任務可能會一直執行下去,原因是缺少對執行任務後的處理:如果任務執行成功,因該從佇列中刪除掉;如果執行失敗,也要有對應的處理措施。也就是CallQueuedHandler的第二個作用。我們不妨來看看CallQueuedHandler,是如何來處理這個問題的。
無論是系統的任務機制,或是事件機制,消費端從佇列中取出任務資訊後,還原出一個Job物件(RedisJob/DatabaseJob),然後執行這個Job的fire方法時,都會藉助CallQueuedHandler這個物件來執行任務的具體內容:
// CallQueuedHandler->call()
public function call(Job $job, array $data)
{
// 預處理任務資訊
try {
$command = $this->setJobInstanceIfNecessary(
$job, unserialize($data['command'])
);
} catch (ModelNotFoundException $e) {
return $this->handleModelNotFound($job, $e);
}
// 透過dispatcher同步執行任務
$this->dispatcher->dispatchNow(
$command, $this->resolveHandler($job, $command)
);
// 如果任務未失敗,且未釋放,確保工作鏈上的任務都已派發
if (!$job->hasFailed() && !$job->isReleased()) {
$this->ensureNextJobInChainIsDispatched($command);
}
// 如果任務未刪除或未釋放,刪除任務
if (!$job->isDeletedOrReleased()) {
$job->delete();
}
}
也就是說,如果透過字串的方式,手動派發任務到佇列,需要自己手動進行像CallQueuedHandler::call()方法中那樣的收尾工作,使任務執行完畢後,清除任務儲存在佇列中的資訊,避免任務被重新執行。
常用的資料儲存驅動是Database與Redis,我們以Redis作為例子來做說明。
6.1 Redis
假設我們現在設定有一個名叫queue的佇列,那麼,在佇列執行的過程中,會有下列幾個key被redis用到:
- queue 任務資訊預設儲存的key
- queue:reserved 任務執行過程中,臨時儲存的key
- queue:delayed 任務執行失敗,被重新發布到的key,或者延遲執行的任務被髮布到的key
6.1.1 入隊
任務資訊被推入佇列時,呼叫RedisQueue的push方法,將任務資訊的載體payload,rpush到鍵名為queue的lists中:
/**
* Push a new job onto the queue.
*
* @param object|string $job
* @param mixed $data
* @param string $queue
* @return mixed
*/
public function push($job, $data = '', $queue = null)
{
return $this->pushRaw($this->createPayload($job, $data), $queue);
}
/**
* Push a raw payload onto the queue.
*
* @param string $payload
* @param string $queue
* @param array $options
* @return mixed
*/
public function pushRaw($payload, $queue = null, array $options = [])
{
$this->getConnection()->rpush($this->getQueue($queue), $payload);
return json_decode($payload, true)['id'] ?? null;
}
如果是將一個任務推入佇列中延遲執行,呼叫的是RedisQueue的later方法,zadd到鍵名為queue:delayed的zset中,延遲時長作為排序的依據:
/**
* Push a new job onto the queue after a delay.
*
* @param \DateTimeInterface|\DateInterval|int $delay
* @param object|string $job
* @param mixed $data
* @param string $queue
* @return mixed
*/
public function later($delay, $job, $data = '', $queue = null)
{
return $this->laterRaw($delay, $this->createPayload($job, $data), $queue);
}
/**
* Push a raw job onto the queue after a delay.
*
* @param \DateTimeInterface|\DateInterval|int $delay
* @param string $payload
* @param string $queue
* @return mixed
*/
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;
}
6.1.2 出隊
出隊的方法只有一個,就是RedisQueue的pop方法。
/**
* Pop the next job off of the queue.
*
* @param string $queue
* @return \Illuminate\Contracts\Queue\Job|null
*/
public function pop($queue = null)
{
$this->migrate($prefixed = $this->getQueue($queue));
list($job, $reserved) = $this->retrieveNextJob($prefixed);
if ($reserved) {
return new RedisJob(
$this->container, $this, $job,
$reserved, $this->connectionName, $queue ?: $this->default
);
}
}
/**
* Migrate any delayed or expired jobs onto the primary queue.
*
* @param string $queue
* @return void
*/
protected function migrate($queue)
{
$this->migrateExpiredJobs($queue.':delayed', $queue);
if (! is_null($this->retryAfter)) {
$this->migrateExpiredJobs($queue.':reserved', $queue);
}
}
/**
* Migrate the delayed jobs that are ready to the regular queue.
*
* @param string $from
* @param string $to
* @return array
*/
public function migrateExpiredJobs($from, $to)
{
return $this->getConnection()->eval(
LuaScripts::migrateExpiredJobs(), 2, $from, $to, $this->currentTime()
);
}
在出隊之前,先檢查queue:delayed上是否有到期的任務,有的話,先將這部分任務的資訊轉移到queue上,如果設定有超時時間,還會檢查queue:reserved上是否有到期的任務,將這部分的任務資訊也轉移到queue上。
接著透過retrieveNextJob方法獲取下一個要執行的任務資訊:從queue中取出第一個任務,將他的attempt值加一後放入到queue:reserved中。
/**
* Retrieve the next job from the queue.
*
* @param string $queue
* @return array
*/
protected function retrieveNextJob($queue)
{
return $this->getConnection()->eval(
LuaScripts::pop(), 2, $queue, $queue.':reserved',
$this->availableAt($this->retryAfter)
);
}
// LuaScripts::pop
/**
* Get the Lua script for popping the next job off of the queue.
*
* KEYS[1] - The queue to pop jobs from, for example: queues:foo
* KEYS[2] - The queue to place reserved jobs on, for example: queues:foo:reserved
* ARGV[1] - The time at which the reserved job will expire
*
* @return string
*/
public static function pop()
{
return <<<'LUA'
-- Pop the first job off of the queue...
local job = redis.call('lpop', KEYS[1])
local reserved = false
if(job ~= false) then
-- Increment the attempt count and place job on the reserved queue...
reserved = cjson.decode(job)
reserved['attempts'] = reserved['attempts'] + 1
reserved = cjson.encode(reserved)
redis.call('zadd', KEYS[2], ARGV[1], reserved)
end
return {job, reserved}
LUA;
}
6.1.3 執行結果
在任務執行成功時,檢查任務是否被刪除或Release,如果沒有的話,就從queue:reserved中刪除任務資訊;如果執行失敗的話,檢查是否超過最大執行次數,超過則刪除任務資訊,否則標記為已刪除,從queue:reserved中刪除任務資訊,並重新發布任務到queue:delayed中。
/**
* Delete a reserved job from the reserved queue and release it.
*
* @param string $queue
* @param \Illuminate\Queue\Jobs\RedisJob $job
* @param int $delay
* @return void
*/
public function deleteAndRelease($queue, $job, $delay)
{
$queue = $this->getQueue($queue);
$this->getConnection()->eval(
LuaScripts::release(), 2, $queue.':delayed', $queue.':reserved',
$job->getReservedJob(), $this->availableAt($delay)
);
}
// LuaScripts::release()
/**
* Get the Lua script for releasing reserved jobs.
*
* KEYS[1] - The "delayed" queue we release jobs onto, for example: queues:foo:delayed
* KEYS[2] - The queue the jobs are currently on, for example: queues:foo:reserved
* ARGV[1] - The raw payload of the job to add to the "delayed" queue
* ARGV[2] - The UNIX timestamp at which the job should become available
*
* @return string
*/
public static function release()
{
return <<<'LUA'
-- Remove the job from the current queue...
redis.call('zrem', KEYS[2], ARGV[1])
-- Add the job onto the "delayed" queue...
redis.call('zadd', KEYS[1], ARGV[2], ARGV[1])
return true
LUA;
}
6.2 Database
如果佇列驅動是資料庫,這個過程也基本一致,不過只需要用一張資料表來儲存任務資訊,用reserved_at,available_at兩個欄位來表示不同的任務狀態,在取出任務及任務失敗等複雜情況下,透過事務來保證任務執行的結果與資料的一致性。:
- 給reserved_at欄位賦值,對應Redis中push到queue:reserved
- 給available_at欄位賦值,對應Redis中push到queue:delayed
- Redis中LuaScripts指令碼部分的執行,對應資料庫中的事務
關於laravel的佇列就是這些了,具體的細節部分,有大家去針對性的檢視對應原始碼。這裡對整體的邏輯做一個總結,如圖:
具體payload在Redis中的流轉過程如圖:
本作品採用《CC 協議》,轉載必須註明作者和本文連結