> ## 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 中任务的存储与恢复，讲解类名与属性的兼容性、模型的重新获取、旧载荷的测试以及 worker 的更新顺序。

即使更新了包，也要确保更新前投递的任务仍能被处理。队列中保存的不是要执行的代码，而是任务类的名称和状态。除了新代码的单元测试之外，还需要**用新代码恢复旧代码所保存状态的测试**。

> [!NOTE]
>
> 本页面向 Laravel 13，确认了 Framework `v13.42.0` 的实现。前提是已了解[队列基础](https://kawax.biz/zh-CN/queues.md)和[包的测试](https://kawax.biz/zh-CN/advanced/package-testing.md)。以下兼容性设计是包一方的方针，并不是 Laravel 自动迁移自定义载荷格式的功能。

## 存储与执行之间代码会发生变化

对于对象任务，`Queue::createObjectPayload()` 会执行 `serialize(clone $job)`，并将结果放入载荷的 `data.command`。即使启用了加密，在此之前也会进行同样的序列化。

worker 一侧的 `CallQueuedHandler::getCommand()` 会对已保存的数据执行 `unserialize()`，并将恢复后的任务交给 Dispatcher。通常的 `handle()` 会使用此时 worker 正在运行的代码和服务容器来执行。

```mermaid theme={null}
sequenceDiagram
    participant P as 投递方<br>包的旧版本
    participant Q as 队列
    participant W as worker<br>包的新版本

    P->>Q: 保存类名和属性
    Note over P,W: 通过部署更新代码
    Q->>W: 获取已保存的载荷
    W->>W: 恢复为当前类
    W->>W: 执行当前的 handle()
```

如果没有重启长时间运行的 worker，更新后旧代码仍会继续运行。在滚动部署中，不仅要考虑"旧投递方与新 worker"的组合，还要考虑"新投递方与旧 worker"的组合。

| 变更的内容 | 对已保存任务的影响 |
| - | - |
| 任务的完全限定类名 | 如果无法以旧名称加载类，就无法恢复为正常的任务 |
| 属性名称与可见性 | 存储时的键与恢复时的键可能不再一致 |
| 属性类型 | 旧值可能无法赋值，恢复时可能失败 |
| 属性的声明默认值 | 被省略存储的值的含义可能发生变化 |
| `handle()` 或依赖的服务 | 即使能够恢复，对旧数据的处理结果也可能发生变化 |

判断类的重命名或删除时，不能只看用户是否直接调用了该类。等待中、延迟中、等待重试的任务以及失败任务的保留期间，也都属于兼容性的范围。

## SerializesModels 保存的内容

`Illuminate\Foundation\Queue\Queueable` 在内部使用了 `SerializesModels`。它不仅负责替换模型，还提供列举任务自身属性的 `__serialize()` 和还原值的 `__unserialize()`。

### 与声明默认值相等的值不会被保存

`__serialize()` 会排除 static、未初始化等属性，并且**当属性的当前值与声明默认值严格相等时也会省略保存**。

```php theme={null}
public string $format = 'csv';
```

如果这个属性仍是 `'csv'`，保存的数组中就不会包含 `format`。如果之后的版本把声明默认值改为 `'json'`，旧任务也可能会使用 `'json'`。这不是单纯外观上的默认值变更，而是旧数据读取方式的变更。

### 恢复时不会重新调用构造函数

`__unserialize()` 会为当前类中存在、且保存数组中也有对应键的属性赋值。它不会执行通过构造函数进行的输入补全或验证。

```php theme={null}
public function __construct(
    public string $format = 'csv',
) {}
```

这个例子中的 `'csv'` 是**构造函数参数的默认值**。它不是属性本身的声明默认值，因此在恢复不含 `format` 的旧载荷时不会起到补全作用。如果新增的类型化属性保持未初始化状态，之后访问时就会失败。

此外，public 属性的键就是名称本身，protected 属性的键带有表示可见性的前缀，private 属性的键还包含类名。将属性从 public 改为 private 的变更也要用旧载荷进行验证。

> [!WARNING]
>
> `SerializesModels` 列举的是任务的属性。不要认为任意 DTO 的内部或数组深处的模型也会被递归地进行同样的转换。传递模型以外的对象时，这些类及其存储格式也属于兼容性的范围。

## 将标识符与执行服务分开

将执行所需的标识符和选项保存在任务中，而 API 客户端或 Exporter 等执行服务则注入到 `handle()` 中。如果把服务传给构造函数并保留下来，其内部状态也可能成为保存对象。

下面的示例为旧版本中只持有 `reportId` 的同名任务添加了格式选择和载荷版本。`ReportExporter` 是包提供并绑定到容器的契约，假设它拥有 `export(string $reportId, string $format): void`。

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

namespace Acme\Reports\Jobs;

use Acme\Reports\Contracts\ReportExporter;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Foundation\Queue\Queueable;
use UnexpectedValueException;

class GenerateReport implements ShouldQueue
{
    use Queueable;

    public string $format = 'csv';

    public int $payloadVersion = 1;

    public function __construct(
        public string $reportId,
        string $format = 'csv',
    ) {
        $this->format = $format;
        $this->payloadVersion = 2;
    }

    public function handle(ReportExporter $exporter): void
    {
        if (! in_array($this->payloadVersion, [1, 2], true)) {
            throw new UnexpectedValueException('Unsupported report payload version.');
        }

        $exporter->export($this->reportId, $this->format);
    }
}
```

这个设计将缺失值的含义与新投递的值分开。

* 旧载荷中没有 `format` 时，使用声明默认值 `'csv'`。
* 旧载荷中没有 `payloadVersion` 时，按声明默认值 `1` 处理。
* 新建任务时，构造函数会把 `payloadVersion` 设为 `2`。由于与声明默认值不同，这个值会被保存。
* 新任务的 `format` 为 `'json'` 时，该值也会被保存。为 `'csv'` 时会被省略，因此今后也要保持声明默认值 `'csv'`。

如果将来只想更改新任务的默认格式，可以修改构造函数参数的默认值和赋值，并与用于旧数据的属性默认值分开考虑。载荷版本不是包的发布版本号，而是作为**存储格式及其解释的世代**来管理。

> [!TIP]
>
> 仅仅添加版本号并不能获得兼容性。本例中的新版本也能处理旧格式，但无法保证旧版本的 worker 能理解新的 `format` 或 `payloadVersion`。还需要后文所述的投递方与 worker 的更新顺序。

## 传递模型时要与快照区分开

将模型直接保存在属性中时，`SerializesAndRestoresModelIdentifiers` 会将其替换为包含类、ID、连接名、已加载关联名等信息的 `ModelIdentifier`。恢复单个模型时，会通过 `firstOrFail()` 从数据库重新获取，并加载目标关联。

这并不是固定投递时属性或关联数据的机制。

* 如果记录在执行前被更新，恢复后引用的是更新后的内容。
* 事先应用于关联的筛选条件不会被保存，因此需要在执行时重新附加必要的条件。
* 不需要的关联可以通过 `withoutRelations()` 或 `#[WithoutRelations]` 排除在保存对象之外。
* 如果模型已被删除，任务可能会在 `handle()` 之前的恢复阶段失败。

如果可以丢弃已删除模型的任务，可以考虑使用官方的 `#[DeleteWhenMissingModels]`。不过，包不应擅自将丢弃设为默认行为，而应向用户公开失败方针。如果采用只传递 ID 并在 `handle()` 中查询的设计，则由自己控制该查询的失败或跳过。

如果需要投递时的内容，就明确保存所需的值，或者将不可变的报表请求等记录到数据库并传递其 ID。要区分单纯传递模型与保存业务数据快照这两件事。

### 提交后投递是另一个问题

处理在事务中创建的记录时，要确保 worker 不会在提交之前开始读取。

```php theme={null}
use Acme\Reports\Jobs\GenerateReport;
use Illuminate\Support\Facades\DB;

DB::transaction(function () use ($reportId) {
    // 在这个事务中创建或更新目标记录。

    GenerateReport::dispatch($reportId)->afterCommit();
});
```

`afterCommit()` 会等到打开的父事务提交后再投递，回滚时则丢弃该投递。它并不是解决类名、属性兼容性或外部处理重复执行的功能。

## 将 fake 与旧载荷的测试分开

通常的 `Queue::fake()` 用于记录投递。仅仅 `assertPushed()` 通过，并不代表已经确认了存储、恢复，或新版本对旧载荷的处理。

当前类的往返可以这样验证。

```php theme={null}
use Acme\Reports\Jobs\GenerateReport;
use Illuminate\Support\Facades\Queue;

Queue::fake()->serializeAndRestore();

GenerateReport::dispatch('report-001', 'json');

Queue::assertPushed(GenerateReport::class, function (GenerateReport $job) {
    return $job->reportId === 'report-001'
        && $job->format === 'json'
        && $job->payloadVersion === 2;
});
```

`serializeAndRestore()` 会在 fake 内执行 `serialize()` 和 `unserialize()`。但它不会验证向真实后端的投递、worker 的执行以及旧版本代码的生成。

### 保留用旧版本生成的 fixture

在旧版本的 `GenerateReport` 只持有 `reportId` 的状态下，生成不包含真实数据的固定任务。以下是在旧版本的测试环境中执行一次的准备代码。`tests/fixtures/` 需要事先准备好。

```php theme={null}
use Acme\Reports\Jobs\GenerateReport;

file_put_contents(
    __DIR__.'/fixtures/generate-report-v1.serialized',
    serialize(new GenerateReport('report-legacy')),
);
```

将 fixture 纳入版本管理，并在新版本的测试中读取。如果每次测试都用新版本重新生成，就不再是向后兼容性的验证，因此要保持固定。

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

namespace Acme\Reports\Tests;

use Acme\Reports\Jobs\GenerateReport;

class JobCompatibilityTest extends TestCase
{
    public function test_new_payload_keeps_explicit_values(): void
    {
        $job = unserialize(serialize(new GenerateReport('report-001', 'json')));

        $this->assertInstanceOf(GenerateReport::class, $job);
        $this->assertSame('report-001', $job->reportId);
        $this->assertSame('json', $job->format);
        $this->assertSame(2, $job->payloadVersion);
    }

    public function test_default_format_is_omitted_but_new_version_is_saved(): void
    {
        $values = (new GenerateReport('report-001'))->__serialize();

        $this->assertArrayNotHasKey('format', $values);
        $this->assertSame(2, $values['payloadVersion']);
    }

    public function test_old_payload_uses_legacy_defaults(): void
    {
        $job = unserialize(file_get_contents(
            __DIR__.'/fixtures/generate-report-v1.serialized',
        ));

        $this->assertInstanceOf(GenerateReport::class, $job);
        $this->assertSame('report-legacy', $job->reportId);
        $this->assertSame('csv', $job->format);
        $this->assertSame(1, $job->payloadVersion);
    }
}
```

本例中的 `TestCase` 是在[包的测试环境](https://kawax.biz/zh-CN/advanced/package-testing.md)中准备的基类。传给 `unserialize()` 的只能是自己管理的 fixture，不要传入外部输入。

此外，还要用测试用的 Exporter 执行恢复后旧任务的 `handle()`，验证到输出和保存结果为止。传递模型时，要另外验证更新、删除、连接和关联的重新获取；在实际使用的队列连接上，还要另外验证提交后投递和重试。

## 在投递新格式之前先更新读取方

**1. 准备能够读取旧格式的版本**

先发布保持旧类名和旧值解释、同时也能读取新格式的代码。用 fixture 不仅确认恢复，也确认处理结果。

**2. 确认没有残留的旧 worker**

在使用方应用的部署流程中加入 worker 的替换。对于通常的 `queue:work`，使用 `php artisan queue:restart` 请求在处理完当前任务后退出，并确认进程管理器启动了新版本。重启信号会使用缓存。

**3. 启用新格式的投递**

在读取方完成支持之后，再通过功能开关等切换到新格式的投递。如果仅发布同一份代码就会开始生成新格式，就需要暂停投递，或将新旧队列与对应的 worker 分开等，确保不会交给旧 worker 的流程。

**4. 在重试和回滚期间也要持续维护**

还要确认失败任务的重试以及在任务链中稍后投递的任务。如果在保存新格式后回退到旧代码，要验证旧代码也能读取。不要仅仅因为队列曾经清空过一次就删除对旧类或旧格式的支持。

`queue:restart` 并不是重写已保存载荷的命令。在包的更新说明中，要写明支持的格式、所需的 worker 替换以及废弃旧格式的条件。同样的变更也要记录在[版本兼容性管理](https://kawax.biz/zh-CN/advanced/package-versioning.md)和 [Changelog 的运用](https://kawax.biz/zh-CN/advanced/package-changelog.md)中。

## 参考的一手资料

* [Laravel 官方文档：任务的结构与依赖注入](https://github.com/laravel/docs/blob/13.x/queues.md#class-structure)
* [Laravel 官方文档：提交后投递](https://github.com/laravel/docs/blob/13.x/queues.md#jobs-and-database-transactions)
* [Laravel 官方文档：worker 与部署](https://github.com/laravel/docs/blob/13.x/queues.md#queue-workers-and-deployment)
* [Laravel 官方文档：已删除模型的处理](https://github.com/laravel/docs/blob/13.x/queues.md#ignoring-missing-models)
* [Queue：对象载荷的创建](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Queue/Queue.php)
* [Queueable：任务用 trait 的组成](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Foundation/Queue/Queueable.php)
* [SerializesModels：属性的保存与恢复](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Queue/SerializesModels.php)
* [SerializesAndRestoresModelIdentifiers：模型的替换与重新获取](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Queue/SerializesAndRestoresModelIdentifiers.php)
* [CallQueuedHandler：恢复与执行](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Queue/CallQueuedHandler.php)
* [QueueFake：fake 内的保存与恢复](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Support/Testing/Fakes/QueueFake.php)
* [Laravel 官方测试：模型的序列化](https://github.com/laravel/framework/blob/v13.42.0/tests/Integration/Queue/ModelSerializationTest.php)


## Related topics

- [使用 Orchestra Testbench 测试 Laravel 包](https://kawax.biz/zh-CN/advanced/package-testing.md)
- [进阶主题](https://kawax.biz/zh-CN/advanced/index.md)
- [包的 Morph map 与已存储数据的兼容性](https://kawax.biz/zh-CN/advanced/package-morph-maps.md)
- [包的公开资源与更新](https://kawax.biz/zh-CN/advanced/package-assets.md)
- [包翻译的覆盖与更新](https://kawax.biz/zh-CN/advanced/package-translations.md)


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