【Laravel-海賊王系列】第十一章,Job&佇列消費端實現

Jijilin發表於2019-02-27

啟動指令

php artisan queue:work

啟動檔案

namespace Illuminate\Queue\Console;

use Illuminate\Queue\Worker;
use Illuminate\Support\Carbon;
use Illuminate\Console\Command;
use Illuminate\Contracts\Queue\Job;
use Illuminate\Queue\WorkerOptions;
use Illuminate\Queue\Events\JobFailed;
use Illuminate\Queue\Events\JobProcessed;
use Illuminate\Queue\Events\JobProcessing;

class WorkCommand extends Command
{
  
    ...
    
    /**
     * @var \Illuminate\Queue\Worker
     */
    protected $worker;

    public function __construct(Worker $worker)
    {
        parent::__construct();
        $this->worker = $worker;

    }
    
    public function handle()
    {

        if ($this->downForMaintenance() && $this->option('once')) {
            return $this->worker->sleep($this->option('sleep'));
        }

        $this->listenForEvents();

        $connection = $this->argument('connection')
                        ?: $this->laravel['config']['queue.default'];

        $queue = $this->getQueue($connection);

        $this->runWorker(
            $connection, $queue
        );
    }

   ...
}

複製程式碼

我們先從建構函式和 handle() 方法開始分析,這是入口。

片段一:判斷是否維護模式或者 --force 強制啟動

if ($this->downForMaintenance() && $this->option('once')) {
    return $this->worker->sleep($this->option('sleep'));
}
複製程式碼

片段二:通過事件繫結在控制檯輸出資訊

$this->listenForEvents();

protected function listenForEvents()
{
    $this->laravel['events']->listen(JobProcessing::class, function ($event) {
        $this->writeOutput($event->job, 'starting');
    });

    $this->laravel['events']->listen(JobProcessed::class, function ($event) {
        $this->writeOutput($event->job, 'success');
    });

    $this->laravel['events']->listen(JobFailed::class, function ($event) {
        $this->writeOutput($event->job, 'failed');

        $this->logFailedJob($event);
    });
}
複製程式碼

片段三:通過配置檔案中配置的驅動獲取對應驅動的佇列名,如果沒有則返回 default

$connection = $this->argument('connection') ?: $this->laravel['config']['queue.default'];

$queue = $this->getQueue($connection);

protected function getQueue($connection)
{
    return $this->option('queue') ?: $this->laravel['config']->get(
        "queue.connections.{$connection}.queue", 'default'
    );
}
    
複製程式碼

片段四:傳入連線驅動和佇列名稱到 runWorker 方法執行任務。

$this->runWorker(
            $connection, $queue
        );
複製程式碼

這裡是啟動的重點,我們傳入的 $connection = 'redis' $queue = 'default',繼續分析

protected function runWorker($connection, $queue)
{
    // "這裡的 $this->laravel['cache'] 是 Illuminate\Cache\CacheManager 類的例項。
      (是在 app.providers.Illuminate\Cache\CacheServiceProvider::class 註冊的)
       $this->laravel['cache']->driver() 返回 Illuminate\Cache\Repository 類的例項。"

    // "框架通過 CacheManager 對很多儲存管理進行了統一。
       可以通過修改 app.config.cache.default 和 `app.config.cache.stores 中的值來修改儲存驅動。"
    
    // "將獲取的驅動賦值給 workder 的 cache成員"
    $this->worker->setCache($this->laravel['cache']->driver());
    
    // "當 worker 物件擁有了cache物件之後便擁有了操作對應資料的能力 !"
    return $this->worker->{$this->option('once') ? 'runNextJob' : 'daemon'}(
        $connection, $queue, $this->gatherWorkerOptions()
    );
}
複製程式碼

繼續執行

return $this->worker->{$this->option('once') ? 'runNextJob' : 'daemon'}(
        $connection, $queue, $this->gatherWorkerOptions()
    );
複製程式碼

這裡傳入的引數分別是,可以看出都是對佇列消費的一些基本設定。

【Laravel-海賊王系列】第十一章,Job&佇列消費端實現

當執行模式非 --once 的情況下就會以 daemon 的方式執行。

我們看 \Illuminate\Queue\Worker 物件的 daemon 方法即可

守護程式模式

public function daemon($connectionName, $queue, WorkerOptions $options)
{
    if ($this->supportsAsyncSignals()) {
        $this->listenForSignals();
    }

    $lastRestart = $this->getTimestampOfLastQueueRestart();

    while (true) {
    
        if (! $this->daemonShouldRun($options, $connectionName, $queue)) {
            $this->pauseWorker($options, $lastRestart);

            continue;
        }

        $job = $this->getNextJob(
            $this->manager->connection($connectionName), $queue
        );

        if ($this->supportsAsyncSignals()) {
            $this->registerTimeoutHandler($job, $options);
        }

        if ($job) {
            $this->runJob($job, $connectionName, $options);
        } else {
            $this->sleep($options->sleep);
        }

        $this->stopIfNecessary($options, $lastRestart, $job);
    }
}
複製程式碼

程式引數設定

先設定程式的一些管理引數

if ($this->supportsAsyncSignals()) { // extension_loaded('pcntl'); 是否支援 'pcntl' 擴充,支援多程式的擴充。
        $this->listenForSignals();
}

protected function listenForSignals()
{
    // "PHP7.1訊號新特性 -- 開啟非同步訊號處理"
    pcntl_async_signals(true);

    // "安裝訊號處理器,後面可以傳入相應的訊號來終止或其他操作"
    pcntl_signal(SIGTERM, function () {
        // "SIGTERM    終止程式      軟體終止訊號"
        $this->shouldQuit = true; 
    });

    pcntl_signal(SIGUSR2, function () {
        // "SIGUSR2  終止程式 使用者定義訊號2"
        $this->paused = true;
    });

    pcntl_signal(SIGCONT, function () {
        // "SIGCONT 忽略訊號  繼續執行一個停止的程式"
        $this->paused = false;
    });
}
複製程式碼

關於 pcntl 的用法可以參考 PCNTL

訊號可以參考對照表

接著看,從 cache 中獲取上一次重啟的時間戳

$lastRestart = $this->getTimestampOfLastQueueRestart();
複製程式碼

迴圈任務執行

判斷是否終止執行

if (! $this->daemonShouldRun($options, $connectionName,$queue)) {
    
    // "$opions 就是 呼叫artisan 傳入的引數
       $connectionName 我用了redis驅動,所有就是 'redis'
       $queue 這裡沒有傳入佇列則是 'default'"
    
    $this->pauseWorker($options, $lastRestart);
    continue;
}
複製程式碼

下面程式碼一共三個判斷:

1.是否是關站模式並且非強制執行。

2.是否有外部傳入的暫停訊號

3.是否有繫結 Looping 事件執行並返回結果

如果符合條件則暫停或者傳送終止訊號。

主要功能是為了控制是否繼續執行任務。

protected function daemonShouldRun(WorkerOptions $options, $connectionName, $queue)
{
    return ! (($this->manager->isDownForMaintenance() && ! $options->force) ||
        $this->paused ||
        $this->events->until(new Events\Looping($connectionName, $queue)) === false);
}
複製程式碼

獲取待執行的 Job

// "$this->manager->connection($connectionName) 是 Illuminate\Queue\RedisQueue 物件
   $queue : 'default'"

$job = $this->getNextJob(
    $this->manager->connection($connectionName), $queue
);
複製程式碼

繼續看 getNextJob

protected function getNextJob($connection, $queue)
{
    try {
        foreach (explode(',', $queue) as $queue) {
            if (! is_null($job = $connection->pop($queue))) {
                return $job;
            }
        }
    } catch (Exception $e) {
    // "異常處理主要是報告異常"
    // "設定 '$this->shouldQuit = true;' 後續就會終止"
        $this->exceptions->report($e);

        $this->stopWorkerIfLostConnection($e);

        $this->sleep(1);
    } catch (Throwable $e) {
        $this->exceptions->report($e = new FatalThrowableError($e));

        $this->stopWorkerIfLostConnection($e);

        $this->sleep(1);
    }
}
複製程式碼

上面分析過了 $connectionRedisQueue 物件,所有展開 RedisQueuepop 方法,獲取要執行的任務物件。

public function pop($queue = null)
{
    $this->migrate($prefixed = $this->getQueue($queue));

    if (empty($nextJob = $this->retrieveNextJob($prefixed))) {
        return;
    }
    [$job, $reserved] = $nextJob;
    if ($reserved) {
        return new RedisJob(
            $this->container, $this, $job,
            $reserved, $this->connectionName, $queue ?: $this->default
        );
    }
}
複製程式碼

遷移延遲佇列

pop 的過程中首先遷移延遲佇列的相關資料

protected function migrate($queue)
{
    // "這裡是不是很熟悉了,上一章儲存端分析的時候延遲"
    // "佇列就是用的這個key來存的"
    
    // "將延遲的佇列遷移到主佇列"
    $this->migrateExpiredJobs($queue.':delayed', $queue);
    
    // "將過期佇列遷移到主佇列"
    if (! is_null($this->retryAfter)) {
        $this->migrateExpiredJobs($queue.':reserved', $queue);
    }
}
複製程式碼

繼續看如何遷移到主佇列的

public function migrateExpiredJobs($from, $to)
{
    return $this->getConnection()->eval(
        LuaScripts::migrateExpiredJobs(), 
        2,
        $from,
        $to,
        $this->currentTime()
    );
}

public static function migrateExpiredJobs()
{
    return <<<'LUA'
    if(next(val) ~= nil) then
        redis.call('zremrangebyrank', KEYS[1], 0, #val - 1)
        
        for i = 1, #val, 100 do
            redis.call('rpush', KEYS[2], unpack(val, i, math.min(i+99, #val)))
        end
    end
    
    return val
    LUA;
}    
複製程式碼

最終通過 eval 命令使用 Lua 直譯器執行指令碼。 請看 Redis Eval

真香,這僅僅是把延遲任務切回主佇列,繼續!

檢索資料

從佇列檢索下一個 Job

if (empty($nextJob = $this->retrieveNextJob($prefixed))) {
            return; // 沒有資料就返回
        }
複製程式碼

展開檢索程式碼

protected function retrieveNextJob($queue)
{
    // "預設值是 null"
    if (! is_null($this->blockFor)) {
        return $this->blockingPop($queue);
    }

    // "這段是直接通過 lua 從 redis lpop出物件,"
    // "在lua中完成封裝,執行邏輯和 blockingPop 相似"
    return $this->getConnection()->eval(
        LuaScripts::pop(), 2, $queue, $queue.':reserved',
        $this->availableAt($this->retryAfter)
    );
}
複製程式碼

我們主要看 blockingPop 的程式碼

protected function blockingPop($queue)
{
    // "以阻塞的方式彈出佇列的第一個元素"
    $rawBody = $this->getConnection()->blpop($queue, $this->blockFor);
    
    // "解析獲取的資料,同時再封裝一個重試物件並寫入有序集合。"
    if (! empty($rawBody)) {
        $payload = json_decode($rawBody[1], true);

        $payload['attempts']++;

        $reserved = json_encode($payload);

        $this->getConnection()->zadd($queue.':reserved', [
            $reserved => $this->availableAt($this->retryAfter),
        ]);

        return [$rawBody[1], $reserved];
    }

    return [null, null];
}
複製程式碼

檢索完成之後回到 pop 中繼續執行

public function pop($queue = null)
{
    $this->migrate($prefixed = $this->getQueue($queue));

    if (empty($nextJob = $this->retrieveNextJob($prefixed))) {
        return;
    }
    
    // "到這裡了!"
    [$job, $reserved] = $nextJob;
    if ($reserved) {
        return new RedisJob(
            $this->container, $this, $job,
            $reserved, $this->connectionName, $queue ?: $this->default
        );
    }
}
複製程式碼

我們來看看 $nextJob 是什麼

【Laravel-海賊王系列】第十一章,Job&佇列消費端實現

最後呼叫

return new RedisJob(
                $this->container, $this, $job,
                $reserved, $this->connectionName, $queue ?: $this->default
            );
複製程式碼

看看 Illuminate\Queue\Jobs\RedisJob 的建構函式

public function __construct(Container $container, RedisQueue $redis, $job, $reserved, $connectionName, $queue)
{
    $this->job = $job;
    $this->redis = $redis;
    $this->queue = $queue;
    $this->reserved = $reserved;
    $this->container = $container;
    $this->connectionName = $connectionName;

    $this->decoded = $this->payload();
}
複製程式碼

這應該是最後一層封裝,最後要返回給最外層的任務物件。

執行 Job

回到 Worker 物件中

...

$job = $this->getNextJob(
            $this->manager->connection($connectionName), $queue
        );

// "剛剛我們從 redis 中拿到了封裝好的 $job 物件,繼續執行"

// "$job 就是 Illuminate\Queue\Jobs\RedisJob 物件"

// "是否支援 pcntl 擴充,非同步模式傳遞訊號"
if ($this->supportsAsyncSignals()) {
// "設定超時訊號處理"
$this->registerTimeoutHandler($job, $options);
}
複製程式碼

繼續註冊超時訊號控制

protected function registerTimeoutHandler($job, WorkerOptions $options)
{
    pcntl_signal(SIGALRM, function () {
        $this->kill(1);
    });

    pcntl_alarm(
        max($this->timeoutForJob($job, $options), 0)
    );
}
複製程式碼

總算要到執行 Job 的部分了

if ($job) {
        $this->runJob($job, $connectionName, $options);
    } else {
        // "不存在 $job 則睡眠,最低睡眠1秒"
        $this->sleep($options->sleep);
    }
複製程式碼

解析 runJob

到這一步我們已經拿到了所有的物件,接下來就是把 物件用起來!

protected function runJob($job, $connectionName, WorkerOptions $options)
{
    try {
        return $this->process($connectionName, $job, $options);
    } catch (Exception $e) {
        
        // "異常處理和上部分的一樣,"
        // "設定停止訊號,在迴圈的結尾會檢測訊號"
        // "因此我們不需要分析這段"
        
        $this->exceptions->report($e);

        $this->stopWorkerIfLostConnection($e);
    } catch (Throwable $e) {
        $this->exceptions->report($e = new FatalThrowableError($e));

        $this->stopWorkerIfLostConnection($e);
    }
}
複製程式碼

展開

$this->process($connectionName, $job, $options);
複製程式碼

繼續展開

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()

$job => Illuminate\Queue\Jobs\RedisJob 繼承了 Illuminate\Queue\Jobs\Job 所以呼叫了抽象父類的 fire() 方法

public function fire()
{
    $payload = $this->payload();

    [$class, $method] = JobName::parse($payload['job']);

    ($this->instance = $this->resolve($class))->{$method}($this, $payload['data']);
}
複製程式碼

我們看看 $payload 的結構實際就是 json_decode($job, true)

【Laravel-海賊王系列】第十一章,Job&佇列消費端實現

轉換後的[$class, $method] 分別是 Illuminate\Queue\CallQueuedHandlercall

最後就是從容器中解析出 Illuminate\Queue\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);
    }

    $this->dispatcher->dispatchNow(
        $command, $this->resolveHandler($job, $command)
    ); 

    if (! $job->hasFailed() && ! $job->isReleased()) {
        $this->ensureNextJobInChainIsDispatched($command);
    }

    if (! $job->isDeletedOrReleased()) {
        $job->delete();
    }
}
複製程式碼

先看看 $command 獲取的是什麼

protected function setJobInstanceIfNecessary(Job $job, $instance)
{
    if (in_array(InteractsWithQueue::class, class_uses_recursive($instance))) {
        $instance->setJob($job);
    }

    return $instance;
}
複製程式碼

列印 class_uses_recursive($instance)

【Laravel-海賊王系列】第十一章,Job&佇列消費端實現

接著就呼叫了 $instance->setJob($job);

這裡的 $instance 就是對應我們自己編寫的任務物件。

執行完之後最終 $command 返回的就是自己編寫的類

【Laravel-海賊王系列】第十一章,Job&佇列消費端實現

RedisJob$command 傳給 dispatchNow 方法 $this->dispatcherIlluminate\Bus\Dispatcher 物件

 $this->dispatcher->dispatchNow(
            $command, $this->resolveHandler($job, $command)
        );
複製程式碼

最後的真像

public function dispatchNow($command, $handler = null)
    {
        if ($handler || $handler = $this->getCommandHandler($command)) {
            $callback = function ($command) use ($handler) {
                // "劃重點,要考!"
                return $handler->handle($command); 
            };
        } else {
            $callback = function ($command) {
                return $this->container->call([$command, 'handle']);
            };
        }

        return $this->pipeline->send($command)->through($this->pipes)->then($callback);
    }
複製程式碼

其實費了那麼大的力氣,最後就是呼叫 $command->handle 回頭看看 job 的定義

【Laravel-海賊王系列】第十一章,Job&佇列消費端實現

就像煙火過後一樣,消失於無形。

最後

整體分析下來感覺使用 pcntl 擴充來做非同步訊號控制和程式中斷來實現終止迴圈是一個亮點!

至此完成了任務佇列消費端的分析,後續有機會分析 Horizon 是如何消費佇列的哈~

相關文章