> ## Documentation Index
> Fetch the complete documentation index at: https://kawax.biz/llms.txt
> Use this file to discover all available pages before exploring further.

# 套件排程任務的註冊與維護

> 解讀 Laravel 13 的 Schedule 與解析後掛鉤，說明讓套件排程任務需明確啟用的設計、重複註冊、鎖定名稱、設定變更與測試。

要為套件加入定期同步或 Heartbeat，需將**提供處理**與**註冊到排程**分開。設計上不應因為安裝套件就增加使用端應用程式的定期處理，而是讓使用者能選擇是否啟用、執行頻率，以及在多台伺服器上的運作方式。

本頁確認 Laravel Framework `v13.39.0` 的官方原始碼，將[任務排程](https://kawax.biz/zh-TW/scheduling.md)的基本操作套用到套件開發。提供者的載入請參閱[套件自動偵測的內部結構](https://kawax.biz/zh-TW/advanced/package-discovery.md)，設定的發佈請參閱[套件設定的合併與快取](https://kawax.biz/zh-TW/advanced/package-config-merging.md)。

## 在 Schedule 解析後註冊

在一般的 Laravel 應用程式中，`FoundationServiceProvider` 會將 `Schedule::class` 註冊為 singleton。其建立流程會呼叫 Console Kernel 的 `resolveConsoleSchedule()`，並套用應用程式的時區與排程器用的快取儲存。

套件應擴充這個共用的 Schedule，而不是自行建立 `new Schedule()` 就結束。以下提供者假設 `Acme\Courier\` 透過 Composer 的 PSR-4 對應到 `src/`，且套件中有 `config/courier.php`。

```php theme={null}
<?php

namespace Acme\Courier;

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Support\ServiceProvider;

class CourierServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->mergeConfigFrom(__DIR__.'/../config/courier.php', 'courier');

        $this->app->singleton(CourierSchedule::class);
    }

    public function boot(): void
    {
        if (! $this->app->runningInConsole()) {
            return;
        }

        $this->callAfterResolving(Schedule::class, function (Schedule $schedule): void {
            $this->app->make(CourierSchedule::class)->register($schedule);
        });
    }
}
```

`callAfterResolving()` 在尚未解析時會等待之後的解析，若已經解析則會取得實例並當場呼叫回呼。如此一來，即使其他提供者先使用了 Schedule，也能完成註冊。詳情請參閱[套件的服務解析後掛鉤](https://kawax.biz/zh-TW/advanced/package-service-resolution.md)。

`runningInConsole()` 並不是判斷特定的 Artisan 指令。此範例將透過 HTTP 使用 Schedule 的用途排除在外。此外，註冊掛鉤的提供者本身不應設為延遲提供者，因為若 `boot()` 未在需要的時間點之前執行，掛鉤也不會被註冊。

```mermaid theme={null}
flowchart TD
    A["套件提供者的 boot()"] --> B["註冊 Schedule 的解析後掛鉤"]
    B --> C{"Schedule 是否已解析?"}
    C -->|否| D["等待首次解析"]
    C -->|是| E["取得既有的 singleton"]
    D --> F["判斷啟用設定與是否已註冊"]
    E --> F
    F -->|已啟用且尚未註冊| G["加入事件定義"]
    F -->|未啟用或已註冊| H["不加入"]
    G --> I["之後由 schedule:run<br>評估執行條件"]
```

> [!NOTE]
>
> 事件的註冊與任務的執行是兩回事。載入這個提供者並不會當場執行 Heartbeat。單純註冊也不會啟動 cron 或 `schedule:work`。

## 由使用者明確啟用

套件端的 `config/courier.php` 預設為停用。

```php theme={null}
<?php

return [
    'schedule' => [
        'enabled' => false,
        'cron' => '*/5 * * * *',
        'timezone' => 'UTC',
        'overlap_minutes' => 10,
        'on_one_server' => false,
    ],
];
```

使用端應用程式需發佈或建立設定，並將 `schedule.enabled` 改為 `true`。此範例會在註冊時讀取設定值。時區預設為 UTC，若選擇其他時區，也需考慮夏令時間造成的重複執行或漏執行。

由於設定是[淺層合併](https://kawax.biz/zh-TW/advanced/package-config-merging.md)，註冊器中也要為每個鍵指定預設值。如此一來，即使部分覆寫導致 `schedule` 陣列中的其他鍵消失，仍能以基本設定完成註冊。

## 防止重複註冊到同一個 Schedule

以下的 `src/CourierSchedule.php` 會註冊一個只記錄一筆日誌的最小 Heartbeat。透過 `WeakMap`，確保**此註冊器對同一個 Schedule 物件只會加入一次**。

```php theme={null}
<?php

namespace Acme\Courier;

use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Contracts\Config\Repository;
use Psr\Log\LoggerInterface;
use WeakMap;

final class CourierSchedule
{
    public const EVENT_NAME = 'acme-courier:heartbeat';

    private WeakMap $registered;

    public function __construct(private Repository $config)
    {
        $this->registered = new WeakMap();
    }

    public function register(Schedule $schedule): void
    {
        if (! $this->config->get('courier.schedule.enabled', false)
            || isset($this->registered[$schedule])) {
            return;
        }

        $event = $schedule->call(static function (LoggerInterface $logger): void {
            $logger->info('Courier heartbeat');
        })
            ->name(self::EVENT_NAME)
            ->cron($this->config->get('courier.schedule.cron', '*/5 * * * *'))
            ->timezone($this->config->get('courier.schedule.timezone', 'UTC'))
            ->withoutOverlapping($this->config->get('courier.schedule.overlap_minutes', 10));

        if ($this->config->get('courier.schedule.on_one_server', false)) {
            $event->onOneServer();
        }

        $this->registered[$schedule] = true;
    }
}
```

由於這個註冊器是 singleton，即使從提供者的掛鉤再次取得，也會共用是否已註冊的判斷。另一方面，仍可註冊到新的 Schedule。請避免使用整個程序層級的靜態「已註冊」旗標，以免連新容器或測試中的 Schedule 也被排除。

`Schedule::call()`、`exec()`、`job()` 會將事件加入陣列。它們並不是會找出同名事件並加以替換或去除重複的 API。

> [!WARNING]
>
> `withoutOverlapping()` 無法修正重複註冊。若同一個任務被註冊了兩次，當先執行的那次結束並釋放鎖定後，後面的事件也可能執行。`onOneServer()` 也不是移除重複註冊的功能。
>
> 上述的 WeakMap 只能防止此註冊器的重複。若使用者在 `routes/console.php` 中加入相同的任務，應停用套件的自動註冊，讓註冊的責任集中在一處。

## 事件名稱也與鎖定的相容性有關

`name()` 是 `description()` 的別名，但對鎖定名稱的影響會因事件類型而異。以下是預設的 mutex 名稱。

| 事件 | mutex 名稱的來源值 | `name()` 的角色 |
| - | - | - |
| `command()`、`exec()` 的 Event | cron 運算式與正規化後的指令字串 | 顯示用的說明。不包含在預設的 mutex 名稱中 |
| `call()`、`job()` 的 CallbackEvent | 事件的 description | 顯示名稱與鎖定的識別。不包含 cron 運算式 |

在 CallbackEvent 中，需在 `withoutOverlapping()` 或 `onOneServer()` 之前**先設定非空的名稱**。若未設定名稱，這些方法會擲出例外。

`job()` 通常會以 Job 類別名稱等作為初始名稱。但若以不同的引數或對象註冊同一個 Job，請為每個對象明確指定名稱。僅變更引數，初始名稱並不會自動變成不同的名稱。

```php theme={null}
use Acme\Courier\Jobs\SyncCatalogJob;
use Illuminate\Support\Facades\Schedule;

Schedule::job(new SyncCatalogJob('tenant-a'))
    ->name('acme-courier:catalog:tenant-a')
    ->everyFiveMinutes()
    ->onOneServer();

Schedule::job(new SyncCatalogJob('tenant-b'))
    ->name('acme-courier:catalog:tenant-b')
    ->everyFiveMinutes()
    ->onOneServer();
```

`SyncCatalogJob` 是套件所提供的 Job 類別範例。Job 的實作方式請參閱 [Queue 與 Job](https://kawax.biz/zh-TW/queues.md)。

變更名稱、指令或 cron 運算式時，依事件類型不同，鎖定的識別碼也會改變。在新舊版本程序並存的部署中，即使是相同的處理，也可能取得不同的鎖定。請勿將其視同單純的顯示文字變更，而應將任務名稱作為穩定的公開規格來管理。

### 分開思考 onOneServer 與 withoutOverlapping

* `onOneServer()` 會選出在該執行時刻負責執行的伺服器。經確認，`CacheSchedulingMutex` 會在事件的 mutex 名稱後加上時與分，並以 1 小時的有效期限保存。這與配合任務結束而釋放的防重疊鎖定不同。
* `withoutOverlapping()` 會在前一次執行仍在進行時防止重疊。所指定期限的單位為分鐘，預設為 1,440 分鐘。期限過短時，鎖定會在處理完成前失效。
* 當 `job()` 將工作推送到 Queue 時，排程事件的完成並不代表 Job 處理完成。Worker 上的重複控制，需另外在 Job 端設計唯一 Job 或 `WithoutOverlapping` 中介層等機制。

對啟用 `onOneServer()` 的使用者，應將官方文件指定的 `database`、`memcached`、`dynamodb`、`redis` 共用快取配置作為必要條件加以說明。鎖定並不保證業務處理的 exactly-once，處理本身也需要具備重新執行也不會出錯的特性。

## 重量級相依性在執行時解析

上述 Heartbeat 中的 `LoggerInterface`，是在事件執行時由 `CallbackEvent` 使用容器的 `call()` 解析。這是一種在註冊掛鉤時不輸出日誌、也不建立用戶端的配置。

| 定義方式 | 註冊時的注意事項 |
| - | - |
| 將閉包傳給 `call()` | 本體會在之後呼叫。但在外部建立並擷取的物件已經建立 |
| `call(new Task)` | `new Task` 會在註冊時執行 |
| `command(CommandClass::class)` | Schedule 會透過容器建立類別，並取得名稱與說明 |
| `job(JobClass::class)` | Job 的建立會延後到事件回呼內、執行時才進行 |

不在建構子中放置外部連線或業務處理的設計，在[套件的 Artisan 指令註冊與相依性解析](https://kawax.biz/zh-TW/advanced/package-commands.md)中也很重要。

此外，`call()` 與 `job()` 屬於 CallbackEvent，因此無法使用 `runInBackground()`。耗時的處理可委派給 Queue，或視需要選擇 `command()` 的背景執行。

## 不要搶走整個應用程式的設定

`Schedule::useCache()` 會變更 Schedule 所持有的 mutex 物件的儲存。這並不是只針對套件事件的設定，也會影響共用同一個 mutex 的事件。請勿在套件的 `boot()` 中無條件呼叫，而應將其視為由使用端應用程式選擇的設定。

同樣地，套件不應擅自變更 `Schedule::alwaysOnOneServer()`、排程器的暫停，以及維護模式期間的執行方針。

## 變更設定後重新建立 Schedule

範例中的 `enabled`、cron 運算式與時區都是註冊時的設定。對已建立的 Schedule 而言，只變更 `config()` 的值並不會新增或移除事件，也不會重新讀取設定。WeakMap 的判斷也不是用來重新設定的。

在使用設定快取的部署中，請重建快取，並在讀取新設定的程序中確認 `schedule:list`。對於長時間運作的排程器，以及正在執行未滿 1 分鐘任務的 `schedule:run`，也要確認運作方式不會殘留舊程式碼或舊設定。子分鐘任務在部署時的中斷，請參閱[任務排程](https://kawax.biz/zh-TW/scheduling.md)。

## 將註冊與執行分開測試

以下是使用套件的 Composer 自動載入與 PHPUnit 的 `tests/CourierScheduleTest.php` 範例。將 mutex 設為測試替身，不使用外部快取或實際的 cron 來確認註冊結果。

```php theme={null}
<?php

namespace Acme\Courier\Tests;

use Acme\Courier\CourierSchedule;
use Illuminate\Config\Repository;
use Illuminate\Console\Scheduling\EventMutex;
use Illuminate\Console\Scheduling\Schedule;
use Illuminate\Console\Scheduling\SchedulingMutex;
use Illuminate\Container\Container;
use PHPUnit\Framework\TestCase;

class CourierScheduleTest extends TestCase
{
    private Container $previousContainer;
    private Repository $config;
    private CourierSchedule $registrar;

    protected function setUp(): void
    {
        parent::setUp();

        $this->previousContainer = Container::getInstance();
        $container = new Container();
        Container::setInstance($container);

        $container->instance(EventMutex::class, $this->createMock(EventMutex::class));
        $container->instance(SchedulingMutex::class, $this->createMock(SchedulingMutex::class));

        $this->config = new Repository();
        $this->registrar = new CourierSchedule($this->config);
    }

    protected function tearDown(): void
    {
        Container::setInstance($this->previousContainer);

        parent::tearDown();
    }

    public function test_disabled_by_default(): void
    {
        $schedule = new Schedule();
        $this->registrar->register($schedule);

        $this->assertCount(0, $schedule->events());
    }

    public function test_partial_config_uses_defaults_without_duplicate_registration(): void
    {
        $this->config->set('courier.schedule.enabled', true);
        $schedule = new Schedule();

        $this->registrar->register($schedule);
        $this->registrar->register($schedule);

        $this->assertCount(1, $schedule->events());
        $event = $schedule->events()[0];
        $this->assertSame(CourierSchedule::EVENT_NAME, $event->description);
        $this->assertSame('*/5 * * * *', $event->expression);
        $this->assertSame('UTC', $event->timezone);
        $this->assertTrue($event->withoutOverlapping);
        $this->assertSame(10, $event->expiresAt);
        $this->assertFalse($event->onOneServer);
    }

    public function test_explicit_schedule_settings_are_applied(): void
    {
        $this->config->set('courier.schedule', [
            'enabled' => true,
            'cron' => '0 * * * *',
            'timezone' => 'Asia/Tokyo',
            'overlap_minutes' => 30,
            'on_one_server' => true,
        ]);

        $schedule = new Schedule();
        $this->registrar->register($schedule);
        $event = $schedule->events()[0];

        $this->assertSame('0 * * * *', $event->expression);
        $this->assertSame('Asia/Tokyo', $event->timezone);
        $this->assertSame(30, $event->expiresAt);
        $this->assertTrue($event->onOneServer);
    }

    public function test_setting_changes_require_a_new_schedule(): void
    {
        $this->config->set('courier.schedule.enabled', true);
        $first = new Schedule();
        $this->registrar->register($first);

        $this->config->set('courier.schedule.cron', '0 * * * *');
        $this->registrar->register($first);

        $second = new Schedule();
        $this->registrar->register($second);

        $this->assertCount(1, $first->events());
        $this->assertCount(1, $second->events());
        $this->assertSame('*/5 * * * *', $first->events()[0]->expression);
        $this->assertSame('0 * * * *', $second->events()[0]->expression);
    }
}
```

此測試是註冊器的單元測試。提供者的自動偵測、設定合併以及與 Console Kernel 的整合，需另外透過 [Orchestra Testbench](https://kawax.biz/zh-TW/advanced/package-testing.md) 或驗證用的應用程式確認。

| 確認的情境 | 完成條件 |
| - | - |
| 以預設設定導入 | 套件的排程任務不會被註冊 |
| 啟用後執行 `schedule:list` | 事件只有 1 筆，且頻率與時區正確 |
| 在 Schedule 解析前、解析後啟動提供者 | 兩種情況下註冊皆會生效 |
| 只進行註冊 | 不會開始建立業務服務或進行處理 |
| 個別執行任務本體 | 能解析所需的相依性，並確認預期的處理與例外 |
| 多台伺服器與長時間處理 | 分別驗證共用快取的前提與兩種鎖定 |
| 使用設定快取時的更新 | 在新程序中不會殘留舊任務或重複註冊 |

請勿為了讓註冊測試通過，而以 `schedule:run` 執行使用端應用程式的所有任務。將本體的測試與在受控環境中的排程器整合測試分開，即可避免無關定期處理所造成的副作用。

## 相關頁面

> [card] [任務排程](https://kawax.biz/zh-TW/scheduling.md)
>
> 確認執行頻率、cron 的導入、委派給 Queue，以及部署時的運作方式。

> [card] [套件的服務解析後掛鉤](https://kawax.biz/zh-TW/advanced/package-service-resolution.md)
>
> 確認如何套用到尚未解析與已解析的服務，以及重新註冊時的注意事項。

## 參考的一手資料

* [Laravel 官方文件：任務排程](https://github.com/laravel/docs/blob/960eedf0911155ad16326ade49c0ea5d18eda0b8/scheduling.md)
* [Laravel 官方文件：套件開發](https://github.com/laravel/docs/blob/960eedf0911155ad16326ade49c0ea5d18eda0b8/packages.md)
* [FoundationServiceProvider：Schedule 的 singleton 註冊](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Foundation/Providers/FoundationServiceProvider.php)
* [Console Kernel：Schedule 的建立與預設設定](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Foundation/Console/Kernel.php)
* [ServiceProvider：callAfterResolving()](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Support/ServiceProvider.php)
* [Schedule：事件的新增、Job 的延遲建立、mutex 的共用](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/Schedule.php)
* [Event：指令事件的 mutex 名稱](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/Event.php)
* [CallbackEvent：名稱的必要條件與執行時的相依性解析](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/CallbackEvent.php)
* [CacheEventMutex：防重疊鎖定與期限的轉換](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/CacheEventMutex.php)
* [CacheSchedulingMutex：依執行時刻的鎖定](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Console/Scheduling/CacheSchedulingMutex.php)
* [官方測試：事件的註冊與時區](https://github.com/laravel/framework/blob/v13.39.0/tests/Console/ConsoleEventSchedulerTest.php)
* [官方測試：Job 類別名稱的延遲建立](https://github.com/laravel/framework/blob/v13.39.0/tests/Console/Scheduling/ScheduleTest.php)


## Related topics

- [任務排程](https://kawax.biz/zh-TW/scheduling.md)
- [套件快取與 optimize 的整合](https://kawax.biz/zh-TW/advanced/package-optimization.md)
- [Laravel Nightwatch 入門](https://kawax.biz/zh-TW/blog/nightwatch-introduction.md)
- [套件的事件監聽器註冊與快取](https://kawax.biz/zh-TW/advanced/package-events.md)
- [套件的 Artisan 指令註冊與相依性解析](https://kawax.biz/zh-TW/advanced/package-commands.md)


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.