细细品味 Laravel-Schedule 计划任务的原理

Laravel-Schedule 原理剖析

介绍原理之前,先自省几句,很长一段时间没有码字啦,那种工作停不下来的习惯得改一改。再忙都应该给自己留出一点空闲的时间,用来总结和提升技能。

到此为止


事情起因:

昨天,在工作过程中和同事讨论到Laravel-Schedule任务计划的问题。本来,对Schedule没有深入探索,并不理解任务的注册和执行过程,基于这个问题,我快速浏览了一遍这个模块的源代码。
并结合自己的理解来描述Laravel-Schedule的基本执行原理,可能有很多不正确的地方,勿喷,如果你有不同的看法,可以在下方留言并一起探索,感谢!

文章概述

LS计划任务,名称太长,本文使用LS代替Laravel-Schedule长命名。

LS流程分为两个步骤:

  • 第一步,根据配置的Command命令、Cron表达式进行注册事件;
  • 第二步,操作系统配置每分钟触发LS,由LS自主完成事件是否符合执行时间过滤重复性检查,并可选Background或者Foreground进行执行任务。

事件注册

在命令行应用程序入口文件artisan首先引入bootstrap/app.php

1
2
3
4
$app->singleton(
Illuminate\Contracts\Console\Kernel::class,
App\Console\Kernel::class
);

向容器中注册Laravel-Kernel,并使用make构建实例

1
$kernel = $app->make(Illuminate\Contracts\Console\Kernel::class);

App\Console\Kernel 继承于 Illuminate\Foundation\Console\Kernel

所以在实例过程中会调用Illuminate\Foundation\Console\Kernel构造方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
public function __construct(Application $app, Dispatcher $events)
{
if (! defined('ARTISAN_BINARY')) {
define('ARTISAN_BINARY', 'artisan');
}

$this->app = $app;
$this->events = $events;

$this->app->booted(function () {
$this->defineConsoleSchedule();
});
}

这里又完成了一次事件注册,在应用启动booted完成后回调 $this->defineConsoleSchedule()

1
2
3
4
5
6
7
8
9
10
protected function defineConsoleSchedule()
{
$this->app->singleton(Schedule::class, function ($app) {
return new Schedule;
});

$schedule = $this->app->make(Schedule::class);

$this->schedule($schedule);
}

重点在于defineConsoleSchedule这个方法,容器中注册并实例化Schedule对象,并使用址传递对Schedule实例进行操作,这里的操作就是计划任务的事件注册

Illuminate\Foundation\Console\Kernel 中的schedule方法

1
2
3
4
5
6
7
8
9
10
/**
* Define the application's command schedule.
*
* @param \Illuminate\Console\Scheduling\Schedule $schedule
* @return void
*/
protected function schedule(Schedule $schedule)
{
//
}

当然,我们不需要在这里修改任何代码。上面,我们说过Laravel-Kernel对象的实例类是App\Console\Kernel,他继承了Illuminate\Foundation\Console\Kernel类。

所以我们在官方文档中也可以清楚看到,计划任务的配置是在App\Console\Kernel中的schedule方法中定义的,例如:

1
2
3
4
5
6
7
8
9
10
/**
* Define the application's command schedule.
*
* @param \Illuminate\Console\Scheduling\Schedule $schedule
* @return void
*/
protected function schedule(Schedule $schedule)
{
$schedule->command('inspire')->hourly();
}

我们来看看官方文档的解读:

Closure 定义调度

使用Closure定义调度。例如,每天使用DB构造器方式来清空数据库一个表

1
2
3
$schedule->call(function () {
DB::table('recent_users')->delete();
})->daily();

Artisan 命令调度

除了计划 Closure 调用,你还能调度 Artisan 命令 和操作系统命令。举个例子,你可以给 command 方法传递命令名称或者类名称来调度一个 Artisan 命令:

1
$schedule->command('emails:send --force')->daily();

队列任务调度

job方法可以用来调度 队列任务。这个方法提供了一种快捷方式来调度任务,无需使用call方法手动创建闭包来调度任务:

1
$schedule->job(new Heartbeat)->everyFiveMinutes();

Shell 命令调度

exec 方法可用于向操作系统发出命令:

1
$schedule->exec('node /home/forge/script.js')->daily();

LS 提供很多提高我们开发效率的执行频率方法

方法 描述
->cron(‘ ‘); 在自定义的 Cron 时间表上执行该任务
->everyMinute(); 每分钟执行一次任务
->everyFiveMinutes(); 每五分钟执行一次任务
->everyTenMinutes(); 每十分钟执行一次任务
->everyFifteenMinutes(); 每十五分钟执行一次任务
->everyThirtyMinutes(); 每半小时执行一次任务
->hourly(); 每小时执行一次任务
->hourlyAt(17); 每小时的第 17 分钟执行一次任务
->daily(); 每天午夜执行一次任务
->dailyAt(‘13:00’); 每天的 13:00 执行一次任务
->twiceDaily(1, 13); 每天的 1:00 和 13:00 分别执行一次任务
->weekly(); 每周执行一次任务
->monthly(); 每月执行一次任务
->monthlyOn(4, ‘15:00’); 在每个月的第四天的 15:00 执行一次任务
->quarterly(); 每季度执行一次任务
->yearly(); 每年执行一次任务
->timezone(‘America/New_York’); 设置时区

了解LS给我们提供的多种任务定义和执行频率设置的方式后,我们回来思考其实现的原理是怎么样的?

schedule方法里的每一句任务的定义,就是构造一个事件对象,并将这个事件对象放到数组里

Illuminate\Console\Scheduling\Schedule.php command方法的核心实现代码如下:

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

mutex 这个变量用来控制事件当前时间执行的不可重复性,在这里先不细究。

schedule方法里的每一句调度频率设置,就是表达式的构建

这个表达式 expression 就是与我们常用crontab表达式是同样的类型,everyTenMinutes() 每十分钟执行一次,其实对应的表达式就是*/10 * * * * *,具体LS 实现代码如下,应该不难看懂。

1
2
3
4
5
6
7
8
9
10
11
12
public $expression = '* * * * * *';

public function everyTenMinutes()
{
return $this->spliceIntoPosition(1, '*/10');
}
protected function spliceIntoPosition($position, $value)
{
$segments = explode(' ', $this->expression);
$segments[$position - 1] = $value;
return $this->cron(implode(' ', $segments));
}

这个很重要,因为事件的过滤中,需要匹配执行时间是否等于当前时间。

运行事件

启动调度器,使用调度器时,只需将以下Cron项目添加到服务器:

1
* * * * * php /path-to-your-project/artisan schedule:run >> /dev/null 2>&1

上面这个Cron会每分钟调用一次LS命令调度器。执行schedule:run命令时, LS会根据你的调度运行预定任务。

让我们带着疑问继续理解LS运行事件原理。

schedule:run 是什么?

我们看 Illuminate\Console\Scheduling\ScheduleRunCommand 代码是怎么写的?和普通自定义Artisan命令一样,继承 Command 基类。然后具体任务内容在handle方法里实现。

1
2
3
4
5
6
7
8
9
10
11
12
class ScheduleRunCommand extends Command
{
//...
public function handle()
{
foreach ($this->schedule->dueEvents($this->laravel) as $event) {
// ...
$event->run($this->laravel);
}
// ...
}
}

dueEvents 完成过滤动作 collect($this->events)->filter->isDue($app) 使用 isDue 方法进行过滤。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
public function isDue($app)
{
if (! $this->runsInMaintenanceMode() && $app->isDownForMaintenance()) {
return false;
}
return $this->expressionPasses() &&
$this->runsInEnvironment($app->environment());
}
protected function expressionPasses()
{
$date = Carbon::now();

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

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

其实原理很简单,方法expressionPasses通过Carbon第三方扩展包获取当前时间,并与Event实例的Expression进行匹对。

1
return $this->getNextRunDate($currentDate, 0, true)->getTimestamp() == $currentTime

如果返回True,那就表示Event需要执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$event->run($this->laravel);


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

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

withoutOverlappingmutex 就是在这里控制任务重复执行。

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

最后,由执行器执行命令任务…done


几点疑问?

1.假设每个五分钟执行,比如08:52定义命令调度CommandSchedule,会在08:57时刻执行?

不会,只会在08:55时刻执行,也就是满足时钟的固定周期。

2.任务调度的两种执行方式runCommandInBackgroundrunCommandInForeground 有什么区别?

runCommandInBackground 代码如下:

1
2
3
4
5
6
7
8
protected function runCommandInBackground(Container $container)
{
$this->callBeforeCallbacks($container);

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

runCommandInForeground 代码如下:

1
2
3
4
5
6
7
8
9
10
 protected function runCommandInForeground(Container $container)
{
$this->callBeforeCallbacks($container);

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

$this->callAfterCallbacks($container);
}

差别在于 $this->callAfterCallbacks($container) ,是否等待当前任务执行完成,如果选择 runCommandInBackground 方式运行,任务命令直接传递给操作系统进行执行,然后直接返回,等待操作系统执行完成任务后,会执行另一条命令 schedule:finish 通过事件ID()进行异步响应对应的任务事件。

3.Closure 定义调度,和命令其他方式定义调度是不相同的,详细可以查看CallBackEvent->run() 同步方式执行