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

# 套件的 Queue Job 與更新時的相容性

> 解讀 Laravel 13 中 Job 的儲存與還原，說明類別名稱與屬性的相容性、model 的重新取得、舊 payload 的測試以及 worker 的更新順序。

即使更新了套件，也要確保更新前分派的 Job 仍能被處理。Queue 中儲存的不是要執行的程式碼，而是 Job 類別的名稱與狀態。除了新程式碼的單元測試之外，還需要**以新程式碼還原舊程式碼所儲存狀態的測試**。

> [!NOTE]
>
> 本頁以 Laravel 13 為對象，確認了 Framework `v13.42.0` 的實作。前提是已了解 [Queue 的基礎](https://kawax.biz/zh-TW/queues.md)與[套件的測試](https://kawax.biz/zh-TW/advanced/package-testing.md)。以下的相容性設計是套件端的方針，並不是 Laravel 自動遷移自訂 payload 格式的功能。

## 儲存與執行之間程式碼會改變

對於物件 Job，`Queue::createObjectPayload()` 會執行 `serialize(clone $job)`，並將結果放入 payload 的 `data.command`。即使啟用加密，在那之前也會進行同樣的序列化。

worker 端的 `CallQueuedHandler::getCommand()` 會對已儲存的資料執行 `unserialize()`，並將還原後的 Job 交給 Dispatcher。一般的 `handle()` 會以當下 worker 正在執行的程式碼與服務容器來執行。

```mermaid theme={null}
sequenceDiagram
    participant P as 分派端<br>套件舊版
    participant Q as Queue
    participant W as worker<br>套件新版

    P->>Q: 儲存類別名稱與屬性
    Note over P,W: 透過部署更新程式碼
    Q->>W: 取得已儲存的 payload
    W->>W: 還原為目前的類別
    W->>W: 執行目前的 handle()
```

若沒有重新啟動長時間執行的 worker，更新後舊程式碼仍會持續運作。在滾動部署中，不只要考慮「舊分派端與新 worker」，也要考慮「新分派端與舊 worker」的組合。

| 變更的內容 | 對已儲存 Job 的影響 |
| - | - |
| Job 的完整類別名稱 | 若無法以舊名稱載入類別，就無法還原為正常的 Job |
| 屬性名稱與可見性 | 儲存時的鍵與還原時的鍵可能不再一致 |
| 屬性型別 | 舊值可能無法指派，還原時可能失敗 |
| 屬性的宣告預設值 | 被省略儲存的值的意義可能改變 |
| `handle()` 或相依的服務 | 即使能夠還原，對舊資料的處理結果也可能改變 |

判斷類別的重新命名或刪除時，不能只看使用者是否直接呼叫該類別。等待中、延遲中、等待重試的 Job，以及失敗 Job 的保存期間，也都屬於相容性的範圍。

## SerializesModels 儲存的內容

`Illuminate\Foundation\Queue\Queueable` 在內部使用了 `SerializesModels`。它不只負責替換 model，也提供列舉 Job 本身屬性的 `__serialize()` 與還原值的 `__unserialize()`。

### 與宣告預設值相等的值不會被儲存

`__serialize()` 會排除 static、未初始化等屬性，並且**當屬性目前的值與宣告預設值嚴格相等時也會省略儲存**。

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

若這個屬性仍是 `'csv'`，儲存的陣列中就不會包含 `format`。若之後的版本將宣告預設值改為 `'json'`，舊 Job 也可能套用 `'json'`。這不是單純外觀上的預設值變更，而是讀取舊資料方式的變更。

### 還原時不會重新呼叫建構子

`__unserialize()` 會為目前類別中存在、且儲存陣列中也有對應鍵的屬性指派值。它不會執行透過建構子進行的輸入補全或驗證。

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

這個範例中的 `'csv'` 是**建構子參數的預設值**。它不是屬性本身的宣告預設值，因此在還原不含 `format` 的舊 payload 時不會補上這個值。若新增的具型別屬性維持未初始化狀態，之後存取時就會失敗。

此外，public 屬性的鍵就是名稱本身，protected 屬性的鍵帶有表示可見性的前綴，private 屬性的鍵還包含類別名稱。將屬性從 public 改為 private 的變更也要用舊 payload 驗證。

> [!WARNING]
>
> `SerializesModels` 列舉的是 Job 的屬性。不要認為任意 DTO 的內部或陣列深處的 model 也會被遞迴地進行同樣的轉換。傳遞 model 以外的物件時，這些類別及其儲存格式也屬於相容性的範圍。

## 將識別碼與執行服務分開

將執行所需的識別碼與選項儲存在 Job 中，而 API 用戶端或 Exporter 等執行服務則注入到 `handle()`。若將服務傳給建構子並保留下來，其內部狀態也可能成為儲存對象。

下面的範例為舊版中只持有 `reportId` 的同名 Job 加入格式選擇與 payload 版本。`ReportExporter` 是套件提供並 bind 到容器的契約，假設它擁有 `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);
    }
}
```

這個設計將缺少的值的意義與新分派的值分開。

* 舊 payload 中沒有 `format` 時，使用宣告預設值 `'csv'`。
* 舊 payload 中沒有 `payloadVersion` 時，視為宣告預設值 `1`。
* 新建立 Job 時，建構子會將 `payloadVersion` 設為 `2`。由於與宣告預設值不同，這個值會被儲存。
* 新 Job 的 `format` 為 `'json'` 時，該值也會被儲存。為 `'csv'` 時會被省略，因此今後也要維持宣告預設值 `'csv'`。

若將來只想變更新 Job 的預設格式，可以修改建構子參數的預設值與指派，並與用於舊資料的屬性預設值分開考量。payload 版本不是套件的發行版本號，而是作為**儲存格式及其解讀的世代**來管理。

> [!TIP]
>
> 只加入版本號並不能獲得相容性。本範例的新版也能處理舊格式，但無法保證舊版的 worker 能理解新的 `format` 或 `payloadVersion`。還需要後文所述的分派端與 worker 的更新順序。

## 傳遞 model 時要與快照區分

將 model 直接儲存在屬性中時，`SerializesAndRestoresModelIdentifiers` 會將其替換為包含類別、ID、連線名稱、已載入關聯名稱等資訊的 `ModelIdentifier`。還原單一 model 時，會透過 `firstOrFail()` 從資料庫重新取得，並載入目標關聯。

這並不是固定分派當下屬性或關聯資料的機制。

* 若記錄在執行前被更新，還原後參照的是更新後的內容。
* 事先套用在關聯上的篩選條件不會被儲存，因此需要在執行時重新加上必要的條件。
* 不需要的關聯可以透過 `withoutRelations()` 或 `#[WithoutRelations]` 排除在儲存對象之外。
* 若 model 已被刪除，Job 可能會在 `handle()` 之前的還原階段失敗。

若可以捨棄已刪除 model 的 Job，可以考慮使用官方的 `#[DeleteWhenMissingModels]`。不過，套件不應擅自將捨棄設為預設行為，而應向使用者公開失敗方針。若採用只傳遞 ID 並在 `handle()` 內查詢的設計，則由自己控制該查詢的失敗或略過。

若需要分派當下的內容，就明確儲存所需的值，或將不可變的報表請求等記錄到資料庫並傳遞其 ID。要區分單純傳遞 model 與儲存業務資料快照這兩件事。

### 提交後分派是另一個問題

處理在交易中建立的記錄時，要確保 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 與舊 payload 的測試分開

一般的 `Queue::fake()` 用於記錄分派。光是 `assertPushed()` 通過，並不代表已確認儲存、還原，或新版對舊 payload 的處理。

目前類別的往返可以這樣驗證。

```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` 的狀態下，產生不含實際資料的固定 Job。以下是在舊版的測試環境中執行一次的準備程式碼。`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-TW/advanced/package-testing.md)中準備的基底類別。傳給 `unserialize()` 的只能是自己管理的 fixture，不要傳入外部輸入。

此外，也要以測試用的 Exporter 執行還原後舊 Job 的 `handle()`，驗證到輸出與儲存結果為止。傳遞 model 時，要另外驗證更新、刪除、連線與關聯的重新取得；在實際使用的 Queue 連線上，也要另外驗證提交後分派與重試。

## 在分派新格式之前先更新讀取端

**1. 準備能讀取舊格式的版本**

先發佈維持舊類別名稱與舊值解讀、同時也能讀取新格式的程式碼。用 fixture 不只確認還原，也確認處理結果。

**2. 確認沒有殘留的舊 worker**

在使用端應用程式的部署流程中加入 worker 的替換。對於一般的 `queue:work`，使用 `php artisan queue:restart` 要求在處理完目前的 Job 後結束，並確認行程管理器啟動了新版。重新啟動訊號會使用快取。

**3. 啟用新格式的分派**

在讀取端完成支援後，再透過功能旗標等切換到新格式的分派。若光是發佈同一份程式碼就會開始產生新格式，就需要暫停分派，或將新舊 Queue 與對應的 worker 分開等，確保不會交給舊 worker 的流程。

**4. 在重試與回滾期間也要持續維護**

也要確認失敗 Job 的重試，以及在 Job 鏈中稍後分派的 Job。若在儲存新格式後回退到舊程式碼，要驗證舊程式碼也能讀取。不要只因為 Queue 曾經清空過一次就移除對舊類別或舊格式的支援。

`queue:restart` 並不是改寫已儲存 payload 的指令。在套件的更新說明中，要寫明支援的格式、所需的 worker 替換以及廢除舊格式的條件。同樣的變更也要記錄在[版本相容性管理](https://kawax.biz/zh-TW/advanced/package-versioning.md)與 [Changelog 的運用](https://kawax.biz/zh-TW/advanced/package-changelog.md)中。

## 參考的第一手資料

* [Laravel 官方文件：Job 的結構與相依性注入](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 官方文件：已刪除 model 的處理](https://github.com/laravel/docs/blob/13.x/queues.md#ignoring-missing-models)
* [Queue：物件 payload 的建立](https://github.com/laravel/framework/blob/v13.42.0/src/Illuminate/Queue/Queue.php)
* [Queueable：Job 用 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：model 的替換與重新取得](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 官方測試：model 的序列化](https://github.com/laravel/framework/blob/v13.42.0/tests/Integration/Queue/ModelSerializationTest.php)


## Related topics

- [以 Orchestra Testbench 測試 Laravel 套件](https://kawax.biz/zh-TW/advanced/package-testing.md)
- [進階主題](https://kawax.biz/zh-TW/advanced/index.md)
- [套件的版本相容性管理](https://kawax.biz/zh-TW/advanced/package-versioning.md)
- [套件的 Morph map 與已儲存資料的相容性](https://kawax.biz/zh-TW/advanced/package-morph-maps.md)
- [套件的公開資源與更新](https://kawax.biz/zh-TW/advanced/package-assets.md)


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