> ## 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 的 ContextualAttribute，讲解注入包配置的属性、解析优先级、make 与 call 的区别，以及长生命周期服务的测试与维护。

有时你希望包的使用者无需在服务提供者中罗列配置键，就能注入所需的值。公开上下文属性后，就可以根据附加在参数上的元数据解析包特有的配置或服务。

本页基于 Laravel Framework `v13.39.0` 的官方源码，实现一个读取 `courier` 配置的自定义属性。如果内置的 `#[Config('courier.timeout')]` 已能满足需求，就没有必要再增加自定义属性。只有当你要把自有的键约定或解析策略作为包的 API 提供时，才值得考虑。

## 属性的作用是决定注入的值

实现了 `Illuminate\Contracts\Container\ContextualAttribute` 的属性，会在 Laravel 容器解析参数时被读取。传给使用方类的不是属性对象本身，而是 resolver 返回的值。

| 方式 | 配置位置 | 适用场景 |
| - | - | - |
| `when()->needs()->give()` | 服务提供者 | 不修改使用方类，在应用侧切换依赖 |
| 内置的 `Config`、`Storage`、`Tag` 属性 | 使用方类的参数 | 声明 Laravel 提供的解析方式 |
| 包自定义的上下文属性 | 使用方类的参数和属性类 | 按包特有的约定解析值 |

常规绑定和内置属性请参阅[服务容器](https://kawax.biz/zh-CN/service-container.md)。上下文属性与 `Bind`、`Singleton`、`Scoped` 这类声明类或接口绑定的属性是不同的机制。

## 创建读取包配置的属性

### 注册默认配置

首先，在包的 `config/courier.php` 中准备默认值。

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

return [
    'timeout' => 30,
    'retries' => 3,
];
```

在 `src/CourierServiceProvider.php` 的 `register()` 中合并配置。提供者的自动注册请参阅[包自动发现的内部结构](https://kawax.biz/zh-CN/advanced/package-discovery.md)，发布与配置缓存的处理请参阅[包配置的合并与缓存](https://kawax.biz/zh-CN/advanced/package-config-merging.md)。

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

namespace Acme\Courier;

use Illuminate\Support\ServiceProvider;

class CourierServiceProvider extends ServiceProvider
{
    public function register(): void
    {
        $this->mergeConfigFrom(
            __DIR__.'/../config/courier.php', 'courier'
        );
    }
}
```

### 实现 `ContextualAttribute`

在 `src/Attributes/CourierConfig.php` 中定义一个为配置键加上 `courier.` 前缀的属性。

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

namespace Acme\Courier\Attributes;

use Attribute;
use Illuminate\Contracts\Container\Container;
use Illuminate\Contracts\Container\ContextualAttribute;
use ReflectionParameter;

#[Attribute(Attribute::TARGET_PARAMETER)]
final class CourierConfig implements ContextualAttribute
{
    public function __construct(
        public ?string $key = null,
        public mixed $default = null,
    ) {}

    public static function resolve(
        self $attribute,
        Container $container,
        ReflectionParameter $parameter,
    ): mixed {
        $key = $attribute->key ?? $parameter->getName();

        return $container->make('config')->get(
            'courier.'.$key, $attribute->default
        );
    }
}
```

在所确认的实现中，resolver 会收到以下 3 个参数。

1. 附加在参数上的属性实例。
2. 正在解析依赖的容器。
3. 表示解析目标的 `ReflectionParameter`。

本例中，省略键时会使用参数名。`#[CourierConfig(default: 30)] int $timeout` 会引用 `courier.timeout`。如果希望即使使用方修改参数名也能读取同一配置，请像 `#[CourierConfig('timeout', default: 30)]` 这样显式指定键。

在确认时，`ContextualAttribute` 是一个不声明任何方法的标记接口。因此，仅靠 `implements` 并不能保证 resolver 的存在及其签名。只要像上面这样提供 `resolve()`，就无需为该属性注册常规的 `bind()`。

> [!WARNING]
>
> 属性的构造函数只用于保存元数据。容器会在解析时和属性后处理时分别调用 `newInstance()`，因此它并不是执行“只运行一次的初始化”的地方。请不要在其中进行外部通信或持有可变状态。

### 通过容器创建使用方类

在 `src/CourierOptions.php` 中添加属性。

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

namespace Acme\Courier;

use Acme\Courier\Attributes\CourierConfig;

final class CourierOptions
{
    public function __construct(
        #[CourierConfig('timeout', default: 30)] public int $timeout,
        #[CourierConfig('retries', default: 3)] public int $retries,
    ) {}
}
```

当使用方应用覆盖配置后，下一次解析时就会注入该值。

```php theme={null}
use Acme\Courier\CourierOptions;

config(['courier.timeout' => 45]);

$options = app()->make(CourierOptions::class);
```

本例中 `$options->timeout` 为 `45`，`$options->retries` 为 `3`。属性只负责获取配置，不会进行诸如是否为正整数、是否超过允许的最大值之类的业务校验。类型声明也不能替代配置值的校验处理。

## 理解构造函数中的解析顺序

`Container::resolveDependencies()` 会按以下顺序为每个参数确定值。

1. 使用通过 `makeWith()` 等传入的、与参数名匹配的覆盖值。
2. 通过 `Util::getContextualAttributeFromDependency()` 查找并解析上下文属性。
3. 如果属性的结果为 `null`，则进入常规的基本类型值或类解析。
4. 经过属性后处理后，将确定的值传给构造函数。

第 1 步的覆盖路径会跳过该参数的属性解析和后处理。

```mermaid theme={null}
flowchart TD
    A["构造函数参数"] --> B{"已按参数名覆盖?"}
    B -->|Yes| C["使用覆盖值"]
    B -->|No| D{"有 ContextualAttribute?"}
    D -->|Yes| E["调用属性的 resolver"]
    E --> F{"结果为 null?"}
    F -->|No| G["使用属性的值"]
    F -->|Yes| H["常规的类型/基本类型解析"]
    D -->|No| H
    G --> I["属性后处理"]
    H --> I
    C --> J["传给构造函数"]
    I --> J
```

`makeWith()` 的键不是配置键，而是使用方类的参数名。

```php theme={null}
use Acme\Courier\CourierOptions;

$options = app()->makeWith(CourierOptions::class, [
    'timeout' => 5,
]);
```

此时无论配置值如何，`timeout` 都为 `5`。另一方面，当属性返回非 `null` 的值时，属性的结果优先于常规的 `when()->needs('$timeout')->give(...)`。也要测试向包中添加属性是否可能改变现有使用者的上下文绑定。

`Util` 通过 Reflection 获取实现了 `ContextualAttribute` 的属性，并选取其中第 1 个用于解析。即使并列多个不同的上下文属性，resolver 也不会按顺序组合。

### 已注册的 handler 优先于 `resolve()`

`Container::resolveFromAttribute()` 以属性类名为键，先查找通过 `whenHasAttribute()` 注册的 handler。如果没有，则使用属性的 `resolve()`；两者都不存在时，抛出 `BindingResolutionException`。

`whenHasAttribute()` 是按属性种类而非按使用方类进行的设置。对同一属性类重新注册会覆盖之前的 handler。如果包无条件地修改其他包或 Laravel 内置属性的 handler，就会影响整个使用方应用。请将自定义属性的标准行为放在 `resolve()` 中，把通过 handler 进行的修改视为显式的扩展点。

## `make()`、`call()` 与直接调用并不相同

`app()->call()` 也会解析属性，但常规的 `BoundMethod` 路径在找到属性后会直接把其结果添加到参数中。与构造函数解析不同，即使属性结果为 `null`，也不会回退到默认值解析。

以下示例在没有其他上下文绑定的情况下读取不存在的 `courier.missing`。

```php theme={null}
use Acme\Courier\Attributes\CourierConfig;

final class NullableTimeoutOptions
{
    public function __construct(
        #[CourierConfig('missing')] public ?int $timeout = 99,
    ) {}
}

$readTimeout = function (
    #[CourierConfig('missing')] ?int $timeout = 99,
): ?int {
    return $timeout;
};
```

| 调用 | 结果 | 原因 |
| - | - | - |
| `app()->make(NullableTimeoutOptions::class)->timeout` | `99` | 属性返回 `null`，进入基本类型的默认值 |
| `app()->call($readTimeout)` | `null` | 直接传入属性的结果 |
| `$readTimeout()` | `99` | 这是 PHP 的普通调用，不解析属性 |

`new CourierOptions(timeout: 5, retries: 1)` 同样是普通的 PHP 实例化。仅仅添加属性，并不会为直接的 `new` 或方法调用增加依赖注入。

> [!NOTE]
>
> 如果构造函数和 callable 都需要相同的默认行为，不要只依赖参数的默认值，而应像 `#[CourierConfig('timeout', default: 30)]` 这样在属性侧定义默认值。该 `default` 在配置键不存在时使用，若键存在而值为 `null`，则不会替换。对于把 `null` 本身视为有意义值的 API，请分别验证这两条解析路径。

## 区分值的重新注入与服务的生命周期

属性读取的是解析参数那一刻的配置。它并不具备监视或更新已创建的 `CourierOptions` 属性的功能。

例如，将 `CourierOptions` 设为 singleton 后，最初注入的值会保存在该实例中。之后即使修改配置再调用常规的 `make()`，同一实例的值也不会改变。

即使创建读取每个请求信息的属性，属性本身也不会决定 singleton 或 scoped 的生命周期。请按照[包服务的生命周期设计](https://kawax.biz/zh-CN/advanced/package-service-lifetimes.md)来选择持有注入值或对象的服务的生命周期。同时，要把配置缓存的重新生成，与长生命周期进程或已创建服务的更新分开考虑。

## 测试解析路径

属性的单元测试可以像官方测试那样，使用新的 `Container` 和 `Illuminate\Config\Repository` 编写。以下 PHPUnit 示例以已加载上文定义的 `CourierConfig`、`CourierOptions`、`NullableTimeoutOptions` 的测试环境为前提。

```php theme={null}
use Acme\Courier\Attributes\CourierConfig;
use Acme\Courier\CourierOptions;
use Illuminate\Config\Repository;
use Illuminate\Container\Container;
use Illuminate\Contracts\Container\Container as ContainerContract;
use PHPUnit\Framework\TestCase;

class CourierConfigTest extends TestCase
{
    private function container(array $settings = []): Container
    {
        $container = new Container;
        $container->instance('config', new Repository([
            'courier' => $settings,
        ]));

        return $container;
    }

    public function testConfigDefaultsAndNamedOverrides(): void
    {
        $container = $this->container();
        $this->assertSame(30, $container->make(CourierOptions::class)->timeout);

        $container->make('config')->set('courier.timeout', 45);
        $container->when(CourierOptions::class)->needs('$timeout')->give(77);
        $this->assertSame(45, $container->make(CourierOptions::class)->timeout);

        $options = $container->makeWith(CourierOptions::class, ['timeout' => 5]);
        $this->assertSame(5, $options->timeout);
        $this->assertSame(3, $options->retries);
    }

    public function testParameterNameIsAvailableToTheResolver(): void
    {
        $container = $this->container(['timeout' => 45]);

        $value = $container->call(function (
            #[CourierConfig(default: 30)] int $timeout,
        ): int {
            return $timeout;
        });

        $this->assertSame(45, $value);
    }

    public function testRegisteredHandlerOverridesResolve(): void
    {
        $container = $this->container(['timeout' => 45]);
        $container->whenHasAttribute(
            CourierConfig::class,
            static fn (CourierConfig $attribute, ContainerContract $app) => 7,
        );

        $this->assertSame(7, $container->make(CourierOptions::class)->timeout);
    }

    public function testNullDiffersBetweenMakeAndCall(): void
    {
        $container = $this->container();
        $readTimeout = function (
            #[CourierConfig('missing')] ?int $timeout = 99,
        ): ?int {
            return $timeout;
        };

        $this->assertSame(99, $container->make(NullableTimeoutOptions::class)->timeout);
        $this->assertNull($container->call($readTimeout));
        $this->assertSame(99, $readTimeout());
    }

    public function testSingletonKeepsItsResolvedValues(): void
    {
        $container = $this->container(['timeout' => 45]);
        $container->singleton(CourierOptions::class);
        $options = $container->make(CourierOptions::class);

        $container->make('config')->set('courier.timeout', 60);

        $this->assertSame($options, $container->make(CourierOptions::class));
        $this->assertSame(45, $options->timeout);
    }
}
```

将 handler 固定返回 `7` 的示例仅用于测试优先级。在该容器内，它会影响同一属性的所有解析，包括 `retries`。

仅靠这个单元测试，无法确认与包的提供者注册、`mergeConfigFrom()` 以及配置缓存的集成。还要在[使用 Orchestra Testbench 测试 Laravel 包](https://kawax.biz/zh-CN/advanced/package-testing.md)的应用环境中，验证发布前、已发布以及启用配置缓存的状态。

### 发布前的检查项

* 将属性的类名、构造函数参数名、配置键和返回值类型视为公开 API。也要注意以命名参数方式使用的 `key`、`default` 的变更。
* 对于省略键时使用参数名的规范，确认使用方类修改参数名时不会改变所引用的配置。
* 分别测试不带属性的现有绑定、带属性的解析以及按参数名覆盖。
* 在 `make()` 和 `call()` 两种方式下测试 `null`、配置缺失和不恰当的类型。
* 确认设计不会让 singleton 或长生命周期 worker 持有过时的值。
* 更新所支持的 Laravel 版本时，通过官方源码和测试重新确认 resolver 的参数、优先级以及属性后处理。

## 相关页面

> [card] [包配置的合并与缓存](https://kawax.biz/zh-CN/advanced/package-config-merging.md)
>
> 了解默认配置、已发布配置和缓存的更新边界。

> [card] [包服务的生命周期设计](https://kawax.biz/zh-CN/advanced/package-service-lifetimes.md)
>
> 为持有注入值的服务选择生命周期。

> [card] [包的容器标签与插件聚合](https://kawax.biz/zh-CN/advanced/package-container-tags.md)
>
> 了解内置的 Tag 属性以及已注册实现的聚合。

> [card] [PHP Reflection API](https://kawax.biz/zh-CN/advanced/php-reflection.md)
>
> 了解读取属性和参数元数据的机制。

## 参考的一手资料

* [Laravel 官方文档: Defining custom attributes](https://github.com/laravel/docs/blob/960eedf0911155ad16326ade49c0ea5d18eda0b8/container.md#defining-custom-attributes)
* [Laravel 官方文档: 包配置](https://github.com/laravel/docs/blob/960eedf0911155ad16326ade49c0ea5d18eda0b8/packages.md#configuration)
* [Container: resolveDependencies()、resolveFromAttribute()、whenHasAttribute()、属性后处理](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Container/Container.php)
* [BoundMethod: callable 的参数解析](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Container/BoundMethod.php)
* [Util: 通过 Reflection 检测 ContextualAttribute](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Container/Util.php)
* [ContextualAttribute: 标记接口](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Contracts/Container/ContextualAttribute.php)
* [Config: 内置配置属性](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Container/Attributes/Config.php)
* [Config Repository: get() 与配置值的获取](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Config/Repository.php)
* [Arr: get() 的默认值与已存在 null 的处理](https://github.com/laravel/framework/blob/v13.39.0/src/Illuminate/Collections/Arr.php)
* [官方测试: 属性的 resolver、已注册 handler、ReflectionParameter、call 中的解析](https://github.com/laravel/framework/blob/v13.39.0/tests/Container/ContextualAttributeBindingTest.php)


## Related topics

- [包的 Artisan 命令注册与依赖解析](https://kawax.biz/zh-CN/advanced/package-commands.md)
- [服务容器](https://kawax.biz/zh-CN/service-container.md)
- [包的容器标签与插件聚合](https://kawax.biz/zh-CN/advanced/package-container-tags.md)
- [Context（上下文）](https://kawax.biz/zh-CN/context.md)
- [会话上下文与过滤](https://kawax.biz/zh-CN/packages/laravel-copilot-sdk/session-context.md)


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