> ## 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 中 bind、singleton、scoped 的內部實作，說明在長時間執行的 worker 中不延續狀態的套件設計，以及跨越生命週期邊界的測試。

若要讓套件不只能用於一般的 HTTP 請求，也能用於 Octane 或佇列 worker，就必須決定服務**可以在多長期間內保存哪些內容**。若將每個請求的資料存放在 singleton 中，其他處理也可能參照到舊的資料。

本頁將確認 Laravel Framework `v13.36.0` 的容器、佇列 worker 以及 Octane 的實作，把[服務容器](/zh-TW/service-container)的基本註冊方式與套件的長期維護連結起來。僅僅選擇 `scoped()`，並不會重設物件之間的參照或靜態狀態。

## 依服務的保存期間選擇註冊方式

| 註冊 | 透過一般 `make()` 取得 | 適合的設計 |
| - | - | - |
| `bind()` | 每次解析時都建立 | 可依每次操作建立的服務 |
| `singleton()` | 在同一個容器中重複使用已建立的實例 | 不保存請求或 Job 專屬狀態的服務 |
| `scoped()` | 在同一個生命週期內重複使用，並在邊界移除已儲存的實例 | 在單一請求或單一 Job 內共用的狀態 |

此表比較的是一般的解析流程。明確指定參數的解析或 contextual binding 等特殊路徑，請另行確認。

即使使用 `bind()`，若將取得的服務存放到其他 singleton 的屬性中，該參照仍會留存。註冊方式是容器的建立與重複使用規則，並不是強制重新建立使用端所保存物件的規則。

## scoped 結合了共用註冊與移除對象的記錄

所確認的 `Container::scoped()` 會執行以下兩件事。

1. 將註冊鍵記錄到 `scopedInstances`。
2. 呼叫 `singleton()`，註冊為共用綁定。

實例本身與 singleton 一樣儲存在 `instances` 中。`forgetScopedInstances()` 會針對已記錄的鍵，對已儲存的實例執行 `unset()`。綁定的建立處理仍會保留，因此下一次一般解析時可以建立新的物件。

```mermaid theme={null}
flowchart TD
    A["在 register() 中註冊 scoped"] --> B["記錄建立方式與移除對象的鍵"]
    B --> C["在處理 A 中首次 make()"]
    C --> D["建立並儲存實例 A"]
    D --> E["在處理 A 內取得相同實例"]
    E --> F["在生命週期邊界<br>forgetScopedInstances()"]
    F --> G["移除容器中已儲存的實例"]
    G --> H["在處理 B 的 make() 中建立實例 B"]
```

此處理不會初始化舊物件的屬性，也不會改寫其他物件所持有的參照。仍被參照的舊物件，依然可以繼續被使用。

### 建立邊界的是執行環境

容器本身並不會監視 HTTP 請求或 Job 的開始。

* **佇列 worker**：`QueueServiceProvider` 會將重設 scope 的回呼傳給 Worker。`Worker::daemon()` 會在取得下一個 Job 之前的迴圈中呼叫它，並在回呼內執行 `forgetScopedInstances()` 以及清除 Facade 已解析的實例等處理。
* **Octane**：官方的預設設定會將 `FlushTemporaryContainerInstances` 註冊到 `OperationTerminated`。此監聽器會呼叫 `forgetScopedInstances()`。
* **自訂的常駐指令或迴圈**：單純重複執行 `make()` 並不會形成邊界。必須由執行基礎設施端設計生命週期。

<Warning>
  請勿在一般的應用程式處理或套件的 `boot()` 中，自行判斷呼叫 `forgetScopedInstances()`。這會影響整個容器的 scoped 服務。此外，包含同步執行在內的所有 Job 路徑，不一定都會經過與常駐 Worker 相同的重設路徑。
</Warning>

## 將處理內共用的狀態設為 scoped

以配送套件為例，假設它想在單一請求或 Job 內共用配送目的地的識別碼。不直接保存 HTTP 的 Request，只儲存必要的值。

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

namespace Acme\Courier;

final class DeliveryContext
{
    private ?string $destinationId = null;

    public function setDestinationId(string $destinationId): void
    {
        $this->destinationId = $destinationId;
    }

    public function destinationId(): ?string
    {
        return $this->destinationId;
    }
}
```

在套件的服務提供者中，建立時不讀取使用端的狀態，而是註冊一個空的 context。

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

namespace Acme\Courier;

use Illuminate\Support\ServiceProvider;

class CourierServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->app->scoped(DeliveryContext::class);
    }
}
```

在使用端應用程式的控制器或 Job 的 `handle()` 中，設定已完成輸入驗證與權限確認的識別碼。在同一個生命週期中解析此型別的其他服務，可以參照相同的 context。

```php theme={null}
$context = app(\Acme\Courier\DeliveryContext::class);
$context->setDestinationId($authorizedDestinationId);
```

此 context 並不是授權功能。能夠設定識別碼，與是否被允許存取該配送目的地是兩回事。此外，`scoped()` 也不會持久化到資料庫或外部快取。要傳給下一個 Job 的值，請明確地作為 Job 的資料傳遞。

<Info>
  在 Octane 中，服務提供者的 `register()` 與 `boot()` 會在 worker 初次啟動時執行。請勿設計成在 `boot()` 中將目前使用者或配送目的地存入 context。每次處理的值，應在處理開始後再設定。
</Info>

## 從 singleton 到 scoped 的參照不會自動更新

以下的註冊方式，即使只把 context 設為 scoped，也無法達到預期效果。

```php theme={null}
$this->app->singleton(DeliveryService::class, function ($app) {
    return new DeliveryService($app->make(DeliveryContext::class));
});
```

若 `DeliveryService` 在建構子中將 context 存入屬性，就會一直持有最初建立的 context。即使從容器移除已儲存的 context，已建立的 `DeliveryService` 的屬性也不會改變。

請依服務的職責選擇對策。

1. **以方法參數傳遞每次處理所需的值**。若不需要共用狀態，這是最簡單的方式。
2. **將持有端也設為 scoped**。讓 `DeliveryService` 與 context 兩者都依處理單位重新建立。
3. **傳入在使用時才解析的 resolver**。若必須維持 singleton，就每次從目前的容器取得所需的服務。

在第 3 種方式的範例中，不是將舊的 context 擷取到閉包中，而是在呼叫時才解析。

```php theme={null}
use Illuminate\Container\Container;

$this->app->singleton(DeliveryService::class, function () {
    return new DeliveryService(
        fn () => Container::getInstance()->make(DeliveryContext::class),
    );
});
```

此範例中的 `DeliveryService` 必須改為在建構子中接收 `Closure`，並成為**每次操作時都呼叫它的 API**。若在建構子內只呼叫一次 resolver 並保存結果，就無法解決問題。這是將官方 Octane 文件中的 container resolver 應用到套件專屬 context 的設計範例。

### 也要確認 scoped 不會清除的狀態

`scoped()` 不會自動移除以下狀態。

* 新增到套件自有靜態屬性中的值
* 存放在其他 singleton 或全域變數中的 context 參照
* 保存在 singleton 內部的「目前使用者」「目前配送目的地」
* 自訂 Facade 或快取層所持有的參照

請區分標準執行基礎設施所做的重設，與套件自有狀態的重設。減少保存每次處理狀態的位置，會比在所有執行環境中維護自訂的清除處理更簡單。

## 撰寫在同一個容器中跨越邊界的測試

若 HTTP 測試只是每次建立新的應用程式，可能會遺漏常駐程序持續使用同一個容器時的問題。首先以容器單體驗證同一處理內的共用，以及跨越邊界後的重新建立。

以下測試採用 PHPUnit 格式。第一個測試與官方的 Container 測試一樣使用 `forgetScopedInstances()`，在不啟動整個 Worker 的情況下確認邊界的作用。

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

namespace Acme\Courier\Tests;

use Acme\Courier\DeliveryContext;
use Illuminate\Container\Container;
use PHPUnit\Framework\TestCase;

class ServiceLifetimeTest extends TestCase
{
    public function test_context_is_shared_then_recreated(): void
    {
        $container = new Container;
        $container->scoped(DeliveryContext::class);

        $first = $container->make(DeliveryContext::class);
        $first->setDestinationId('destination-a');

        $this->assertSame($first, $container->make(DeliveryContext::class));
        $this->assertSame('destination-a', $first->destinationId());

        $container->forgetScopedInstances();

        $second = $container->make(DeliveryContext::class);
        $this->assertNotSame($first, $second);
        $this->assertNull($second->destinationId());
    }

    public function test_singleton_keeps_a_captured_scoped_dependency(): void
    {
        $container = new Container;
        $container->scoped(DeliveryContext::class);
        $container->singleton('courier.holder', function ($app) {
            return (object) ['context' => $app->make(DeliveryContext::class)];
        });

        $holder = $container->make('courier.holder');
        $oldContext = $holder->context;

        $container->forgetScopedInstances();

        $this->assertSame($holder, $container->make('courier.holder'));
        $this->assertSame($oldContext, $holder->context);
        $this->assertNotSame($oldContext, $container->make(DeliveryContext::class));
    }
}
```

第二個測試記錄了參照會留存的行為。實際的套件中，也請加入驗證所採用的對策確實會使用新的 context 或值的測試。

| 更新時的確認 | 預期結果 |
| - | - |
| 在同一處理內多次解析 context | 共用相同的實例與已設定的值 |
| 在同一個容器中跨越邊界解析 | 為新的實例，且不持有先前的值 |
| singleton 使用 context | 不保存舊的 context，而是使用目前的值 |
| 在使用端應用程式中執行連續的 Octane 請求 | 第 2 次不會沿用第 1 次的配送目的地等資料 |
| 在常駐佇列 worker 中執行連續的 Job | 分離每個 Job 的設定值 |
| 更新 Laravel 或 Octane | 重新確認重設的呼叫路徑與使用端應用程式的設定 |

容器單體測試並不會驗證 Octane 的 sandbox 或佇列 worker 的實際執行路徑。在聲明支援的執行環境中，也需要進行連續處理的整合測試。在變更註冊方式或服務實作的部署中，請重新載入常駐程序以套用新的程式碼。

## 相關頁面

<Columns cols={2}>
  <Card title="套件的服務解析後掛鉤" icon="puzzle-piece" href="/zh-TW/advanced/package-service-resolution">
    確認 afterResolving 與共用實例的取得，以及重新解析時設定的差異。
  </Card>

  <Card title="Laravel Octane" icon="bolt" href="/zh-TW/octane">
    確認 Request 與容器的保存，以及常駐程序在維運上的注意事項。
  </Card>
</Columns>

## 參考的一手資料

* [Laravel 官方文件：Binding scoped singletons](https://github.com/laravel/docs/blob/13.x/container.md#binding-scoped-singletons)
* [Laravel 官方文件：Dependency injection and Octane](https://github.com/laravel/docs/blob/13.x/octane.md#dependency-injection-and-octane)
* [Container：scoped()、singleton()、forgetScopedInstances()、resolve()](https://github.com/laravel/framework/blob/v13.36.0/src/Illuminate/Container/Container.php)
* [ContainerTest：scoped 的共用與重新建立](https://github.com/laravel/framework/blob/v13.36.0/tests/Container/ContainerTest.php)
* [QueueServiceProvider：傳給 Worker 的 resetScope](https://github.com/laravel/framework/blob/v13.36.0/src/Illuminate/Queue/QueueServiceProvider.php)
* [Worker：在 daemon() 中重設 scope](https://github.com/laravel/framework/blob/v13.36.0/src/Illuminate/Queue/Worker.php)
* [Octane：FlushTemporaryContainerInstances](https://github.com/laravel/octane/blob/ecc9254e6d79cd607f191015f634f9be944e2267/src/Listeners/FlushTemporaryContainerInstances.php)
* [Octane：OperationTerminated 的預設監聽器](https://github.com/laravel/octane/blob/ecc9254e6d79cd607f191015f634f9be944e2267/config/octane.php)


## Related topics

- [Laravel 套件開發](/zh-TW/advanced/package-development.md)
- [進階主題](/zh-TW/advanced/index.md)
- [請求生命週期](/zh-TW/lifecycle.md)
- [Session 生命週期事件](/zh-TW/packages/laravel-copilot-sdk/session-lifecycle-event.md)
- [PHP FFI](/zh-TW/advanced/ffi.md)


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