> ## 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-CN/scheduling.md)的基本操作应用于包开发。关于服务提供者的加载，请参阅[包自动发现的内部结构](https://kawax.biz/zh-CN/advanced/package-discovery.md)；关于配置的发布，请参阅[包配置的合并与缓存](https://kawax.biz/zh-CN/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-CN/advanced/package-service-resolution.md)。

`runningInConsole()` 并不用于判断特定的 Artisan 命令。本例将通过 HTTP 使用 Schedule 的场景排除在外。另外，注册钩子的服务提供者本身不要设为延迟服务提供者。因为如果在需要之前 `boot()` 没有运行，钩子也不会被注册。

```mermaid theme={null}
flowchart TD
    A["包服务提供者的 boot()"] --> B["注册 Schedule 的解析后钩子"]
    B --> C{"Schedule 已解析?"}
    C -->|No| D["等待首次解析"]
    C -->|Yes| 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-CN/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()` 通常以任务类名等作为初始名称。但是，当以不同的参数或对象注册同一个任务时，应为每个对象显式指定名称。仅改变参数并不会让初始名称自动变成不同的名称。

```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` 是包所提供的任务类示例。任务的实现方法请参阅[队列与任务](https://kawax.biz/zh-CN/queues.md)。

更改名称、命令或 cron 表达式时，根据事件类型，锁的标识也会随之改变。在新旧版本进程并存的部署中，即使是相同的处理也可能获取不同的锁。不要把它当作单纯的显示文字变更，而应将任务名称作为稳定的公开规范来管理。

### 区分 onOneServer 与 withoutOverlapping

* `onOneServer()` 用于选出在该执行时刻负责执行的服务器。经确认，`CacheSchedulingMutex` 会在事件的 mutex 名称后加上时分，并以 1 小时的有效期保存。它与随任务结束而释放的防重叠锁是不同的。
* `withoutOverlapping()` 用于防止在前一次执行仍在进行时发生重叠。指定的有效期单位为分钟，默认为 1,440 分钟。有效期过短时，锁会在处理结束前失效。
* 当 `job()` 将任务投递到队列时，调度事件的完成并不等于任务处理的完成。worker 端的重复控制需要另行设计，例如任务端的唯一任务或 `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)` | 任务的创建延迟到事件回调内、执行时才进行 |

不在构造函数中放置外部连接或业务处理的设计，在 [Artisan 命令的依赖解析](https://kawax.biz/zh-CN/advanced/package-commands.md)中同样重要。

此外，`call()` 和 `job()` 属于 CallbackEvent，因此不能使用 `runInBackground()`。耗时的处理应委托给队列，或根据需要选择 `command()` 的后台执行。

## 不要篡改整个应用的配置

`Schedule::useCache()` 会更改 Schedule 所持有的 mutex 对象的存储。它并不是只针对包事件的配置，也会影响共享同一 mutex 的其他事件。不要在包的 `boot()` 中无条件调用它，而应将其视为由使用方应用选择的配置。

同样，包也不应擅自更改 `Schedule::alwaysOnOneServer()`、调度器的暂停以及维护模式期间的执行策略。

## 配置变更后要重新创建 Schedule

示例中的 `enabled`、cron 表达式和时区都是注册时的配置。对于已创建的 Schedule，仅更改 `config()` 的值并不会添加或删除事件，也不会重新读取配置。WeakMap 的判定也不是用于重新配置的。

在使用配置缓存的部署中，应重建缓存，并在加载新配置的进程中确认 `schedule:list`。对于长时间运行的调度器，以及正在执行不足一分钟任务的 `schedule:run`，也要确认运维上不会残留旧代码和旧配置。关于子分钟任务在部署时的中断，请参阅[任务调度](https://kawax.biz/zh-CN/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-CN/advanced/package-testing.md) 或验证用应用进行确认。

| 确认场景 | 完成条件 |
| - | - |
| 以默认配置安装 | 不注册包的定时任务 |
| 启用后执行 `schedule:list` | 只有一个事件，且频率与时区正确 |
| 在 Schedule 解析前、解析后启动服务提供者 | 两种情况都能反映注册 |
| 仅进行注册 | 不会开始创建或处理业务服务 |
| 单独执行任务主体 | 能解析所需依赖，并确认预期的处理与异常 |
| 多台服务器与长时间处理 | 分别验证共享缓存的前提和两种锁 |
| 在有配置缓存的情况下更新 | 新进程中不会残留旧任务或重复注册 |

不要为了通过注册测试而用 `schedule:run` 执行使用方应用的全部任务。将主体测试与在受控环境中进行的调度器集成测试分开，可以避免无关定时任务带来的副作用。

## 相关页面

> [card] [任务调度](https://kawax.biz/zh-CN/scheduling.md)
>
> 确认执行频率、cron 的配置、委托给队列以及部署时的运维。

> [card] [包的服务解析后钩子](https://kawax.biz/zh-CN/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：事件的添加、任务的延迟创建、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)
* [官方测试：任务类名的延迟创建](https://github.com/laravel/framework/blob/v13.39.0/tests/Console/Scheduling/ScheduleTest.php)


## Related topics

- [包的 Artisan 命令注册与依赖解析](https://kawax.biz/zh-CN/advanced/package-commands.md)
- [包的事件监听器注册与缓存](https://kawax.biz/zh-CN/advanced/package-events.md)
- [包的 Storage 驱动注册与重新解析](https://kawax.biz/zh-CN/advanced/package-filesystem-drivers.md)
- [包的路由注册与缓存](https://kawax.biz/zh-CN/advanced/package-routes.md)
- [包缓存与 optimize 的集成](https://kawax.biz/zh-CN/advanced/package-optimization.md)


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