任務排程
簡介
以前,我們需要在伺服器上為每個需要排程執行的任務撰寫 Cron 設定。不過,手動設定 Cron 很快就會變得很麻煩,因為這些排程任務不在版本控制裡面,而且我們必須要 SSH 連進伺服器上才能檢視現有的 Cron 項目以及新增新項目。
Laravel 的指令排程程式提供了一種全新的方法來在伺服器上管理排程任務。Laravel 的排程程式能讓我們使用流暢與表達性的方法來在 Laravel 專案中定義指令排程。使用 Laravel 的排程程式時,我們只需要在伺服器上設定一個 Cron 項目即可。任務的排程定義在 app/Console/Kernel.php
檔案的 schedule
方法內。在該方法中已經有定義好了一個簡單的範例設定,可幫助讀者入門。
定義排程
我們可以在專案的 App\Console\Kernel
類別中 schedule
方法內定義所有的排程任務。我們先來看一個入門的範例。在這個範例中,我們會排程在每天午夜呼叫一個閉包。在這個閉包中,我們執行一條資料庫查詢來清除資料表:
1<?php23namespace App\Console;45use Illuminate\Console\Scheduling\Schedule;6use Illuminate\Foundation\Console\Kernel as ConsoleKernel;7use Illuminate\Support\Facades\DB;89class Kernel extends ConsoleKernel10{11 /**12 * Define the application's command schedule.13 */14 protected function schedule(Schedule $schedule): void15 {16 $schedule->call(function () {17 DB::table('recent_users')->delete();18 })->daily();19 }20}
1<?php23namespace App\Console;45use Illuminate\Console\Scheduling\Schedule;6use Illuminate\Foundation\Console\Kernel as ConsoleKernel;7use Illuminate\Support\Facades\DB;89class Kernel extends ConsoleKernel10{11 /**12 * Define the application's command schedule.13 */14 protected function schedule(Schedule $schedule): void15 {16 $schedule->call(function () {17 DB::table('recent_users')->delete();18 })->daily();19 }20}
除了使用閉包來排程以外,也可以排程執行 可 Invoke 的物件。可 Invoke 的物件只是一個包含 __invoke
方法的普通 PHP 類別:
1$schedule->call(new DeleteRecentUsers)->daily();
1$schedule->call(new DeleteRecentUsers)->daily();
若想檢視目前排程任務的概覽,以及各個任務下次排定的執行時間,可使用 schedule:list
Artisan 指令:
1php artisan schedule:list
1php artisan schedule:list
排程執行 Artisan 指令
除了排程執行閉包外,也可以排程執行 Artisan 指令與系統指令。舉例來說,我們可以使用 command
方法來使用指令的名稱或類別名稱來排程執行 Artisan 指令。
若使用指令的類別名稱來排程執行 Artisan 指令時,可傳入一組包含額外指令列引數的陣列,在叫用該指令時會提供這些引數:
1use App\Console\Commands\SendEmailsCommand;23$schedule->command('emails:send Taylor --force')->daily();45$schedule->command(SendEmailsCommand::class, ['Taylor', '--force'])->daily();
1use App\Console\Commands\SendEmailsCommand;23$schedule->command('emails:send Taylor --force')->daily();45$schedule->command(SendEmailsCommand::class, ['Taylor', '--force'])->daily();
排程執行放入佇列的 Job
可使用 job
方法來排程執行放入佇列的 Job。該方法提供了一個方便的方法能讓我們能排程執行放入佇列的 Job,而不需使用 call
方法來定義將該 Job 放入佇列的閉包:
1use App\Jobs\Heartbeat;23$schedule->job(new Heartbeat)->everyFiveMinutes();
1use App\Jobs\Heartbeat;23$schedule->job(new Heartbeat)->everyFiveMinutes();
job
還有可選的第二個引數與第三個引數,可用來指定該 Job 要使用的佇列名稱與佇列連線:
1use App\Jobs\Heartbeat;23// 將該 Job 分派進「sqs」連線中的「heartbeats」佇列...4$schedule->job(new Heartbeat, 'heartbeats', 'sqs')->everyFiveMinutes();
1use App\Jobs\Heartbeat;23// 將該 Job 分派進「sqs」連線中的「heartbeats」佇列...4$schedule->job(new Heartbeat, 'heartbeats', 'sqs')->everyFiveMinutes();
排程執行 Shell 指令
可使用 exec
指令來在作業系統上執行指令:
1$schedule->exec('node /home/forge/script.js')->daily();
1$schedule->exec('node /home/forge/script.js')->daily();
排程的頻率選項
我們已經看到了一些在指定間隔間執行任務的範例。不過,還有其他許多用來指派給任務的排程頻率:
方法 | 說明 |
---|---|
->cron('* * * * *'); | 在自定的 Cron 排程上執行任務 |
->everyMinute(); | 每分鐘執行任務 |
->everyTwoMinutes(); | 每 2 分鐘執行任務 |
->everyThreeMinutes(); | 每 3 分鐘執行任務 |
->everyFourMinutes(); | 每 4 分鐘執行任務 |
->everyFiveMinutes(); | 每 5 分鐘執行任務 |
->everyTenMinutes(); | 每 10 分鐘執行任務 |
->everyFifteenMinutes(); | 每 15 分鐘執行任務 |
->everyThirtyMinutes(); | 每 30 分鐘執行任務 |
->hourly(); | 每小時執行任務 |
->hourlyAt(17); | 每小時的第 17 分鐘執行任務 |
->everyOddHour(); | 每奇數小時執行任務 |
->everyTwoHours(); | 每 2 小時執行任務 |
->everyThreeHours(); | 每 3 小時執行任務 |
->everyFourHours(); | 每 4 小時執行任務 |
->everySixHours(); | 每 6 小時執行任務 |
->daily(); | 每當午夜時執行任務 |
->dailyAt('13:00'); | 每天 13:00 執行任務 |
->twiceDaily(1, 13); | 每天的 1:00 與 13:00 執行任務 |
->twiceDailyAt(1, 13, 15); | 每天的 1:15 與 13:15 執行任務 |
->weekly(); | 每週日 00:00 執行任務 |
->weeklyOn(1, '8:00'); | 每週一 8:00 執行任務 |
->monthly(); | 每月 1 號的 00:00 執行任務 |
->monthlyOn(4, '15:00'); | 每個月 4 號的 15:00 執行任務 |
->twiceMonthly(1, 16, '13:00'); | 每個月的 1 號與 16 號的 13:00 執行任務 |
->lastDayOfMonth('15:00'); | 每個月最後一天的 15:00 執行該任務 |
->quarterly(); | 每一季第一天的 00:00 執行該任務 |
->quarterlyOn(4, '14:00'); | 每一季 4 號的 14:00 執行任務 |
->yearly(); | 每年第一天的 00:00 執行該任務 |
->yearlyOn(6, 1, '17:00'); | 每年 6 月 1 日的 17:00 執行該任務 |
->timezone('America/New_York'); | 為給任務設定時區 |
可以組合使用這些方法來增加額外的條件限制,以設定更精確的排程,如在每週某日時執行任務。舉例來說,我們可以排程每週一執行某個指令:
1// 每個星期一下午 1 點執行一次...2$schedule->call(function () {3 // ...4})->weekly()->mondays()->at('13:00');56// 每個工作日的早上 8 點到下午 5 點間每小時執行一次...7$schedule->command('foo')8 ->weekdays()9 ->hourly()10 ->timezone('America/Chicago')11 ->between('8:00', '17:00');
1// 每個星期一下午 1 點執行一次...2$schedule->call(function () {3 // ...4})->weekly()->mondays()->at('13:00');56// 每個工作日的早上 8 點到下午 5 點間每小時執行一次...7$schedule->command('foo')8 ->weekdays()9 ->hourly()10 ->timezone('America/Chicago')11 ->between('8:00', '17:00');
下表中列出了其他額外的排程條件限制:
方法 | 說明 |
---|---|
->weekdays(); | 顯示該任務只在工作日執行 |
->weekends(); | 顯示該任務只在假日執行 |
->sundays(); | 顯示該任務只在週日執行 |
->mondays(); | 顯示該任務只在週一執行 |
->tuesdays(); | 顯示該任務只在週二執行 |
->wednesdays(); | 顯示該任務只在週三執行 |
->thursdays(); | 顯示該任務只在週四執行 |
->fridays(); | 顯示該任務只在週五執行 |
->saturdays(); | 顯示該任務只在週六執行 |
->days(array|mixed); | 顯示該任務只在特定日執行 |
->between($startTime, $endTime); | 限制任務只在開始時間 ($startTime ) 至結束時間 ($endTime ) 間執行 |
->unlessBetween($startTime, $endTime); | 限制任務不要在開始時間 ($startTime ) 至結束時間 ($endTime ) 間執行 |
->when(Closure); | 使用給定的真值條件測試來限制任務 |
->environments($env); | 限制任務只在特定條件上執行 |
「日」的條件限制
可使用 days
方法來限制任務只在每週的某幾天時執行。舉例來說,我們可以排程執行每週日至週三的每小時執行某個指令:
1$schedule->command('emails:send')2 ->hourly()3 ->days([0, 3]);
1$schedule->command('emails:send')2 ->hourly()3 ->days([0, 3]);
或者,我們也可以使用 Illuminate\Console\Scheduling\Schedule
類別中所提供的常數來定義任務要在哪幾天執行:
1use Illuminate\Console\Scheduling\Schedule;23$schedule->command('emails:send')4 ->hourly()5 ->days([Schedule::SUNDAY, Schedule::WEDNESDAY]);
1use Illuminate\Console\Scheduling\Schedule;23$schedule->command('emails:send')4 ->hourly()5 ->days([Schedule::SUNDAY, Schedule::WEDNESDAY]);
時間區間的條件限制
between
方法可使用一天中指定的時間來限制任務的執行:
1$schedule->command('emails:send')2 ->hourly()3 ->between('7:00', '22:00');
1$schedule->command('emails:send')2 ->hourly()3 ->between('7:00', '22:00');
類似地,unlessBetween
方法可用讓任務在某一段時間內不要執行:
1$schedule->command('emails:send')2 ->hourly()3 ->unlessBetween('23:00', '4:00');
1$schedule->command('emails:send')2 ->hourly()3 ->unlessBetween('23:00', '4:00');
真值條件測試的條件顯示
when
方法可用來依據給定真值測試的結果來限制任務的執行。換句話說,若給定的閉包回傳 true
,除非有其他的條件阻止該任務執行,否則就會執行該任務:
1$schedule->command('emails:send')->daily()->when(function () {2 return true;3});
1$schedule->command('emails:send')->daily()->when(function () {2 return true;3});
skip
方法相當於 when
的相反。若 skip
方法回傳 true
,則排程執行的任務將不被執行:
1$schedule->command('emails:send')->daily()->skip(function () {2 return true;3});
1$schedule->command('emails:send')->daily()->skip(function () {2 return true;3});
串接使用 when
方法時,只有在 when
條件為 true
時排程的任務才會被執行。
環境的條件限制
使用 environments
方法即可讓該方法只在給定的環境上執行 (即 APP_ENV
環境變數 中所定義的):
1$schedule->command('emails:send')2 ->daily()3 ->environments(['staging', 'production']);
1$schedule->command('emails:send')2 ->daily()3 ->environments(['staging', 'production']);
時區
使用 timezone
方法,就可以指定要使用給定的時區來解析排程任務的時間:
1$schedule->command('report:generate')2 ->timezone('America/New_York')3 ->at('2:00')
1$schedule->command('report:generate')2 ->timezone('America/New_York')3 ->at('2:00')
若所有的排程任務都要指派相同的時區,則可在 App\Console\Kernel
類別中定義 scheduleTimezone
方法。該方法應回傳要指派給所有排程任務的預設時區:
1use DateTimeZone;23/**4 * Get the timezone that should be used by default for scheduled events.5 */6protected function scheduleTimezone(): DateTimeZone|string|null7{8 return 'America/Chicago';9}
1use DateTimeZone;23/**4 * Get the timezone that should be used by default for scheduled events.5 */6protected function scheduleTimezone(): DateTimeZone|string|null7{8 return 'America/Chicago';9}
請注意,某些時區會使用日光節約時間。若發生日光節約時間,則某些排程任務可能會執行兩次、甚至是執行多次。因此,我們建議儘可能不要在排程上設定時區。
避免任務重疊
預設情況下,就算之前的任務實體還在執行,也會繼續執行排程的任務。若要避免任務重疊,可使用 withoutOverlapping
方法:
1$schedule->command('emails:send')->withoutOverlapping();
1$schedule->command('emails:send')->withoutOverlapping();
在這個範例中,若目前沒有在執行 emails:send
Artisan 指令,則該指令每分鐘都會執行。若任務會執行非常久的時間,因而無法預期任務要執行多久,就適合使用 withoutOverlapping
方法。
若有需要,可指定「withoutOverlapping」的 Lock 最少要過多久才算逾期。預設情況下,該 Lock 會在 24 小時候逾期:
1$schedule->command('emails:send')->withoutOverlapping(10);
1$schedule->command('emails:send')->withoutOverlapping(10);
其實,withoutOverlapping
方法會使用專案的 Cache 來取得鎖定。若有需要的話,可以使用 schedule:clear-cache
Artisan 指令來清除這些快取鎖定。通常只有在因為未預期的伺服器問題而導致任務當掉時才需要這麼做。
在單一伺服器上執行任務
若要使用此功能,則專案必須使用 memcached
、redis
、dynamodb
、database
、file
、array
等其中一個快取 Driver 作為專案的預設快取 Driver。另外,所有的伺服器都必須要連線至相同的中央快取伺服器。
若專案的排程程式在多個伺服器上執行,則可限制排程任務只在單一伺服器上執行。舉例來說,假設我們設定了一個排程任務,每週五晚上會產生新報表。若任務排程程式在三個工作伺服器上執行,則這個排程任務會在這三台伺服器上都執行,且會產生三次報表。這可不好!
若要讓任務只在單一伺服器上執行,可在定義排程任務的時候使用 onOneServer
方法。第一個取得該任務的伺服器會先在該 Job 上確保一個 Atomic Lock,以防止其他伺服器在同一時間執行相同的任務:
1$schedule->command('report:generate')2 ->fridays()3 ->at('17:00')4 ->onOneServer();
1$schedule->command('report:generate')2 ->fridays()3 ->at('17:00')4 ->onOneServer();
為單一伺服器 Job 命名
有時候,我們需要將排程分派相同的 Job,但使用不同的參數,且又要讓 Laravel 能在單一伺服器上以不同的兩種參數來執行這個 Job。這時,可以使用 name
方法來為各個排程定義指定一個不重複的名稱:
1$schedule->job(new CheckUptime('https://laravel.com'))2 ->name('check_uptime:laravel.com')3 ->everyFiveMinutes()4 ->onOneServer();56$schedule->job(new CheckUptime('https://vapor.laravel.com'))7 ->name('check_uptime:vapor.laravel.com')8 ->everyFiveMinutes()9 ->onOneServer();
1$schedule->job(new CheckUptime('https://laravel.com'))2 ->name('check_uptime:laravel.com')3 ->everyFiveMinutes()4 ->onOneServer();56$schedule->job(new CheckUptime('https://vapor.laravel.com'))7 ->name('check_uptime:vapor.laravel.com')8 ->everyFiveMinutes()9 ->onOneServer();
類似地,若要在單一伺服器上執行排程的閉包,也必須為這些閉包指定名稱:
1$schedule->call(fn () => User::resetApiRequestCount())2 ->name('reset-api-request-count')3 ->daily()4 ->onOneServer();
1$schedule->call(fn () => User::resetApiRequestCount())2 ->name('reset-api-request-count')3 ->daily()4 ->onOneServer();
背景任務
預設情況下,排程在同一個時間的多個任務會依照 schedule
方法中定義的順序依序執行。若任務的執行時間很長,則可能會導致接下來的任務比預期的時間還要完開始執行。若想讓任務在背景處理以同步執行多個任務,可使用 runInBackground
方法:
1$schedule->command('analytics:report')2 ->daily()3 ->runInBackground();
1$schedule->command('analytics:report')2 ->daily()3 ->runInBackground();
runInBackground
方法只可用在 command
與 exec
方法所定義的排程任務上。
維護模式
若網站目前在維護模式下,則專案的排程任務可能不會執行,以避免任務影響伺服器上任何未完成的維護項目。不過,若仍想讓某個任務在維護模式中強制執行,可在定義任務時呼叫 eventInMaintenanceMode
方法:
1$schedule->command('emails:send')->evenInMaintenanceMode();
1$schedule->command('emails:send')->evenInMaintenanceMode();
執行排程程式
現在,我們已經學會了如何定義排程任務,我們來看看要怎麼樣在伺服器上真正執行這些任務。schedule:run
Artisan 指令會取得所有的排程任務,並依照目前伺服器上的時間來判斷是否有需要執行這些任務。
因此,在使用 Laravel 的排程任務時,我們只需要在伺服器上新增單一一個 Cron 設定即可,該設定為每分鐘執行一次 schedule:run
指令。若讀者不知道如何在伺服器上新增 Cron 項目的話,可使用如 Laravel Forge 這樣的服務來協助你管理 Cron 設定:
1* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
1* * * * * cd /path-to-your-project && php artisan schedule:run >> /dev/null 2>&1
在本機執行排程程式
一般來說,我們不會在本機開發機上新增排程程式的 Cron 設定。在本機上,不需要新增 Cron 設定,我們可以使用 schedule:work
Artisan 指令。該指令會在前景執行,並且會每分鐘叫用排程程式,直到手動停止該指令為止:
1php artisan schedule:work
1php artisan schedule:work
任務的輸出
Laravel 的排程程式提供了數種便利的方法可處理排程任務產生的輸出。首先,可使用 sendOutputTo
方法來將輸出傳送至檔案中以在稍後檢視:
1$schedule->command('emails:send')2 ->daily()3 ->sendOutputTo($filePath);
1$schedule->command('emails:send')2 ->daily()3 ->sendOutputTo($filePath);
若想將輸出附加到給定檔案最後,可使用 appendOutputTo
方法:
1$schedule->command('emails:send')2 ->daily()3 ->appendOutputTo($filePath);
1$schedule->command('emails:send')2 ->daily()3 ->appendOutputTo($filePath);
若使用 emailOutputTo
方法,就可以將輸出以電子郵件傳送給指定的 E-Mail 位址。在將任務輸出以電子郵件寄出前,請先設定 Laravel 的電子郵件服務:
1$schedule->command('report:generate')2 ->daily()3 ->sendOutputTo($filePath)
1$schedule->command('report:generate')2 ->daily()3 ->sendOutputTo($filePath)
若只想在排程的 Artisan 或系統指令以結束代碼 0 以外的狀態退出時以電子郵件傳送輸出,可使用 emailOutputOnFailure
方法:
1$schedule->command('report:generate')2 ->daily()
1$schedule->command('report:generate')2 ->daily()
emailOutputTo
、emailOutputOnFailure
、sendOutputTo
、appendOutputTo
等方法只能在 command
與 exec
方法上使用。
任務的 Hook
使用 before
與 after
方法,即可指定要在排程任務執行前後執行的程式碼:
1$schedule->command('emails:send')2 ->daily()3 ->before(function () {4 // 該任務將被執行...5 })6 ->after(function () {7 // 已執行該任務...8 });
1$schedule->command('emails:send')2 ->daily()3 ->before(function () {4 // 該任務將被執行...5 })6 ->after(function () {7 // 已執行該任務...8 });
使用 onSuccess
與 onFailure
方法,就可以指定要在排程任務成功或失敗時要執行的程式碼。「執行失敗」即為該排程的 Artisan 指令或系統指令以結束代碼 0 以外的代碼終止執行:
1$schedule->command('emails:send')2 ->daily()3 ->onSuccess(function () {4 // 該任務成功執行...5 })6 ->onFailure(function () {7 // 該任務執行失敗...8 });
1$schedule->command('emails:send')2 ->daily()3 ->onSuccess(function () {4 // 該任務成功執行...5 })6 ->onFailure(function () {7 // 該任務執行失敗...8 });
若指令有輸出,則可在 after
、onSuccess
、onFailure
等 Hook 上存取這些輸出。只需要在這些 Hook 的閉包定義上將 $output
引數型別提示為 Illuminate\Support\Stringable
即可:
1use Illuminate\Support\Stringable;23$schedule->command('emails:send')4 ->daily()5 ->onSuccess(function (Stringable $output) {6 // 該任務已成功執行...7 })8 ->onFailure(function (Stringable $output) {9 // 該任務執行失敗...10 });
1use Illuminate\Support\Stringable;23$schedule->command('emails:send')4 ->daily()5 ->onSuccess(function (Stringable $output) {6 // 該任務已成功執行...7 })8 ->onFailure(function (Stringable $output) {9 // 該任務執行失敗...10 });
Ping 網址
使用 pingBefore
與 thenPing
方法,就可讓排程程式在任務執行前後自動 Ping 給定的網址。該方法適合用來通知如 Envoyer 等的外部服務該排程任務已開始執行或已執行完畢:
1$schedule->command('emails:send')2 ->daily()3 ->pingBefore($url)4 ->thenPing($url);
1$schedule->command('emails:send')2 ->daily()3 ->pingBefore($url)4 ->thenPing($url);
pingBeforeIf
與 thenPingIf
方法可用來只在給定條件為 true
時 Ping 給定的網址:
1$schedule->command('emails:send')2 ->daily()3 ->pingBeforeIf($condition, $url)4 ->thenPingIf($condition, $url);
1$schedule->command('emails:send')2 ->daily()3 ->pingBeforeIf($condition, $url)4 ->thenPingIf($condition, $url);
pingOnSuccess
與 pingOnFailure
方法可用來只在任務執行成功或執行失敗時 Ping 給定的網址。「執行失敗」即為該排程的 Artisan 指令或系統指令以結束代碼 0 以外的代碼終止執行:
1$schedule->command('emails:send')2 ->daily()3 ->pingOnSuccess($successUrl)4 ->pingOnFailure($failureUrl);
1$schedule->command('emails:send')2 ->daily()3 ->pingOnSuccess($successUrl)4 ->pingOnFailure($failureUrl);
所有的 Ping 方法都需要使用 Guzzle HTTP 函式庫。一般來說新安裝的 Laravel 專案都已預裝 Guzzle。若有不小心將該套件移除則可能需要手動使用 Composer 套件管理員來將 Guzzle 安裝到專案中:
1composer require guzzlehttp/guzzle
1composer require guzzlehttp/guzzle
事件
若有需要,可監聽排程程式所分派的的事件。一般來說,事件的監聽程式映射應定義在專案的 App\Providers\EventServiceProvider
類別中:
1/**2 * The event listener mappings for the application.3 *4 * @var array5 */6protected $listen = [7 'Illuminate\Console\Events\ScheduledTaskStarting' => [8 'App\Listeners\LogScheduledTaskStarting',9 ],1011 'Illuminate\Console\Events\ScheduledTaskFinished' => [12 'App\Listeners\LogScheduledTaskFinished',13 ],1415 'Illuminate\Console\Events\ScheduledBackgroundTaskFinished' => [16 'App\Listeners\LogScheduledBackgroundTaskFinished',17 ],1819 'Illuminate\Console\Events\ScheduledTaskSkipped' => [20 'App\Listeners\LogScheduledTaskSkipped',21 ],2223 'Illuminate\Console\Events\ScheduledTaskFailed' => [24 'App\Listeners\LogScheduledTaskFailed',25 ],26];
1/**2 * The event listener mappings for the application.3 *4 * @var array5 */6protected $listen = [7 'Illuminate\Console\Events\ScheduledTaskStarting' => [8 'App\Listeners\LogScheduledTaskStarting',9 ],1011 'Illuminate\Console\Events\ScheduledTaskFinished' => [12 'App\Listeners\LogScheduledTaskFinished',13 ],1415 'Illuminate\Console\Events\ScheduledBackgroundTaskFinished' => [16 'App\Listeners\LogScheduledBackgroundTaskFinished',17 ],1819 'Illuminate\Console\Events\ScheduledTaskSkipped' => [20 'App\Listeners\LogScheduledTaskSkipped',21 ],2223 'Illuminate\Console\Events\ScheduledTaskFailed' => [24 'App\Listeners\LogScheduledTaskFailed',25 ],26];