> ## 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-CN/service-container)的基本注册方式与包的长期维护联系起来。仅仅选择 `scoped()`，并不会重置对象之间的所有引用和静态状态。

## 根据服务的保存期限选择注册方式

| 注册 | 通过常规 `make()` 获取 | 适合的设计 |
| - | - | - |
| `bind()` | 每次解析时创建 | 可以按操作单位创建的服务 |
| `singleton()` | 在同一容器中复用已创建的实例 | 不保存请求或任务专属状态的服务 |
| `scoped()` | 在同一生命周期内复用，并在边界处移除已保存的实例 | 在单个请求或单个任务内共享的状态 |

此表比较的是常规解析。显式传入参数的解析、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 请求或任务的开始。

* **队列 worker**：`QueueServiceProvider` 会将重置作用域的回调传给 Worker。`Worker::daemon()` 在获取下一个任务之前的循环中调用它，并在回调中执行 `forgetScopedInstances()` 以及清除 Facade 的已解析实例等操作。
* **Octane**：官方默认配置会在 `OperationTerminated` 上注册 `FlushTemporaryContainerInstances`。该监听器会调用 `forgetScopedInstances()`。
* **自定义的常驻命令或循环**：仅仅重复调用 `make()` 并不会形成边界。需要在运行基础设施一侧设计生命周期。

<Warning>
  请不要在常规的应用程序处理或包的 `boot()` 中自行调用 `forgetScopedInstances()`。这会影响整个容器中的 scoped 服务。此外，包括同步执行在内的所有任务路径，并不一定都会经过与常驻 Worker 相同的重置路径。
</Warning>

## 将处理内共享的状态设为 scoped

假设一个配送包希望在单个请求或任务内共享配送目的地的标识符。不直接保存 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);
    }
}
```

在使用该包的应用程序的控制器或任务的 `handle()` 中，设置已完成输入验证和权限确认的标识符。在同一生命周期内解析该类型的其他服务，可以引用同一个 context。

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

此 context 并不是授权功能。能够设置标识符，与被允许访问该配送目的地是两回事。另外，`scoped()` 也不是持久化到数据库或外部缓存的机制。需要传递给下一个任务的值，请作为任务数据显式传递。

<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 执行连续请求 | 第二次请求不会继承第一次的配送目的地等 |
| 在常驻队列 worker 中执行连续任务 | 每个任务的设置值相互隔离 |
| 更新 Laravel 或 Octane | 重新确认重置的调用路径与使用方应用的配置 |

容器单元测试并不能验证 Octane 的 sandbox 或队列 worker 的实际执行路径。还需要在声明支持的运行环境中进行连续处理的集成测试。在变更了注册方式或服务实现的部署中，请重新加载常驻进程以应用新代码。

## 相关页面

<Columns cols={2}>
  <Card title="包的服务解析后钩子" icon="puzzle-piece" href="/zh-CN/advanced/package-service-resolution">
    了解 afterResolving、共享实例的获取以及重新解析时配置的差异。
  </Card>

  <Card title="Laravel Octane" icon="bolt" href="/zh-CN/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() 中的作用域重置](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-CN/advanced/package-development.md)
- [进阶主题](/zh-CN/advanced/index.md)
- [请求生命周期](/zh-CN/lifecycle.md)
- [会话生命周期事件](/zh-CN/packages/laravel-copilot-sdk/session-lifecycle-event.md)
- [PHP FFI](/zh-CN/advanced/ffi.md)


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