LoginSignup
5
12

More than 5 years have passed since last update.

Laravel Scheduler のコードリーディング

Last updated at Posted at 2018-01-27
* * * * * php /path-to-your-project/artisan schedule:run >> /dev/null 2>&1

これがどうやって動いているかを考える。

Schedule コマンド

コマンドの本体は 以下にある。

    public function handle()
    {
        $eventsRan = false;

        foreach ($this->schedule->dueEvents($this->laravel) as $event) {
            if (! $event->filtersPass($this->laravel)) {
                continue;
            }

            $this->line('<info>Running scheduled command:</info> '.$event->getSummaryForDisplay());

            $event->run($this->laravel);

            $eventsRan = true;
        }

        if (! $eventsRan) {
            $this->info('No scheduled commands are ready to run.');
        }
    }

となっていて、$this->schedule\Illuminate\Console\Scheduling\Schedule のよう。

Schedule サービス

\Illuminate\Console\Scheduling\Schedule は、 \App\Console\Kernel::schedule にて引数に落ちてくるオブジェクト。 スケジュールの設定を行う際に触るオブジェクトである。

foreach で参照されている、 dueEvents は以下のようになっている。

    public function dueEvents($app)
    {
        return collect($this->events)->filter->isDue($app);
    }

この$this->events\Illuminate\Console\Scheduling\Eventの配列。
$this->eventsは、\App\Console\Kernel::schedule 内で $schedule->command("hoge:piyo") の様に command メソドを実行した際に 追加されていく。

以下は、\Illuminate\Console\Scheduling\Schedule::command の中身。

    public function command($command, array $parameters = [])
    {
        if (class_exists($command)) {
            $command = Container::getInstance()->make($command)->getName();
        }

        return $this->exec(
            Application::formatCommandString($command), $parameters
        );
    }

    public function exec($command, array $parameters = [])
    {
        if (count($parameters)) {
            $command .= ' '.$this->compileParameters($parameters);
        }

        $this->events[] = $event = new Event($this->mutex, $command);

        return $event;
    }  

$this->events の正体がつかめたところで、\Illuminate\Console\Scheduling\Schedule::dueEventsの記述に戻る。

    public function dueEvents($app)
    {
        return collect($this->events)->filter->isDue($app);
    }

collectnew Collection なのだけども、ここで Collection::$filter は存在しない。

ややこしいけど色々マジック的な魔法を使っていて、上記の記述は以下とほぼ等価になる。

    public function dueEvents($app)
    {
        return collect($this->events)->filter(function(Event $event)use($app){
            return $event->isDue($app)        
        });
    }

絶対こっちの方が可読性は高いと思う。

Event オブジェクト

次に Event オブジェクトを見ていく。 Event オブジェクトは Schedule::command から生成されリストに追加されていくもので、

そのリストのフィルタ関数内でコールされている \Illuminate\Console\Scheduling\Event::isDueは以下のような形になっている。

    public function isDue($app)
    {
        if (! $this->runsInMaintenanceMode() && $app->isDownForMaintenance()) {
            return false;
        }

        return $this->expressionPasses() &&
               $this->runsInEnvironment($app->environment());
    }

最初のセクションはメンテナンスチェック。常に false となる。

お目当ては、\Illuminate\Console\Scheduling\Event::expressionPasses

\Illuminate\Console\Scheduling\Event::runsInEnvironment は発行される環境の制限。Kernel 側で以下のようにして実行環境を制限出来るよう。

$schedule->command('emails:send --force')->environment([...])

\Illuminate\Console\Scheduling\Event::expressionPasses の方は以下のようになっている。

    protected function expressionPasses()
    {
        $date = Carbon::now();

        if ($this->timezone) {
            $date->setTimezone($this->timezone);
        }

        return CronExpression::factory($this->expression)->isDue($date->toDateTimeString());
    }

ここで $this->expression は 次の実行時刻の生成に用いられる。

\Cron\CronExpression::isDue は色々あるものの、最終的には、

    public function isDue($currentTime = 'now')
    {
        // 割愛: $currentTime を timestamp 変換する処理

        try {
            return $this->getNextRunDate($currentDate, 0, true)->getTimestamp() == $currentTime;
        } catch (Exception $e) {
            return false;
        }
    }

となるため、スケジューラは毎分実行しなければならない(前回実行したのがいつか…などを知る術はこの記述の中にはない)。

重複制御の仕組み

withoutOverlapping メソドに関しては、

    public function withoutOverlapping($expiresAt = 1440)
    {
        $this->withoutOverlapping = true;

        $this->expiresAt = $expiresAt;

        return $this->then(function () {
            $this->mutex->forget($this);
        })->skip(function () {
            return $this->mutex->exists($this);
        });
    }

    public function then(Closure $callback)
    {
        $this->afterCallbacks[] = $callback;

        return $this;
    }
    public function skip($callback)
    {
        $this->rejects[] = is_callable($callback) ? $callback : function () use ($callback) {
            return $callback;
        };

        return $this;
    }

thenskip の引数取扱の違いは謎だが、とりあえず上記の様になっている。

中でも skip で登録される、reject\Illuminate\Console\Scheduling\Event::filtersPass の中で以下のように利用されている。

    public function filtersPass($app)
    {
        foreach ($this->filters as $callback) {
            if (! $app->call($callback)) {
                return false;
            }
        }

        foreach ($this->rejects as $callback) {
            if ($app->call($callback)) {
                return false;
            }
        }

        return true;
    }

filterPassScheduler::handle のイベントループ内で最初にコールされるため、rejects 内の callable がfalseを返せば、イベントはスキップされる仕組みになっている。

Mutex

先程からちょくちょく挙がってくる mutex\Illuminate\Console\Scheduling\Mutex で、\Illuminate\Console\Scheduling\Schedule::__construct にて登録されるものが、$event等にも Pass されている。

    public function __construct()
    {
        $container = Container::getInstance();

        $this->mutex = $container->bound(Mutex::class)
                                ? $container->make(Mutex::class)
                                : $container->make(CacheMutex::class);
    }

Mutex はタスクの実行記録等を行うサービスで、Mutexそのものはインターフェイスとなっている。サービスプロバイダ等で紐付けを行っていない限り、実装クラスの CacheMutex が用いられる。CacheMutex は Laravel の Cache を用いた Mutex 実装のため、ストレージの種別は Cache の設定に従う事となる。 expire の有効・無効もキャッシュの設定次第なので注意が必要

\Illuminate\Console\Scheduling\Event::runは以下のような実装になっており、実行前に mutex の登録が実施されることが伺える。

    public function run(Container $container)
    {
        if ($this->withoutOverlapping &&
            ! $this->mutex->create($this)) {
            return;
        }

        $this->runInBackground
                    ? $this->runCommandInBackground($container)
                    : $this->runCommandInForeground($container);
    }

    protected function runCommandInForeground(Container $container)
    {
        $this->callBeforeCallbacks($container);

        (new Process(
            $this->buildCommand(), base_path(), null, null, null
        ))->run();

        $this->callAfterCallbacks($container);
    }

結論

コードを見る限りでは

  • 複数台サーバで CRON 実行しても、redis 等で mutex を共有すれば、重複制御は可能
  • Heroku など 10分に1回の ツールでタタイても、everyMinutes のコマンドは5分に一回しか処理されない
  • 逆に(複数台の運用も含めて)1分に1回 以上実行しても、その度にeveryMinutesコマンドは実行される。

と言う感じのよう。

思っていること

  • どっかでイベント取って、実行スケジュールをログ吐き出し出来ないか。
  • then で確実に forgetするのではなく、expire を調整する形で、永続化 mutex に載せた、next due time までの overlapping 制御は出来ないか。

どちらも overlaping 前提であれば、Mutex の実装で結構簡単にできそう。

前者は Mutex::create の実装で。

後者は Mutex::forget の実装で。 引数で落ちてくる $event は expression をもっている。

Task Hook という仕組みもあるよう。

面倒な時はもはや自分でイベントリストを作っても良いかも

5
12
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
12