Skip to content
Go back

用 Microsoft.Extensions.Options.Contextual 按运行时上下文动态配置选项

Microsoft.Extensions.Options.Contextual 是一个藏在 dotnet 仓库里、几乎没什么人知道的实验性包。Andrew Lock 在浏览 .NET 仓库时偶然发现了它,写下了这篇探索性文章。本文整理了他的分析,帮你快速判断这个包是什么、怎么用,以及值不值得引入。

封面:上下文驱动的配置面板插图

这个包解决什么问题

.NET 的 IOptions<T>IOptionsSnapshot<T> 体系大家都熟悉。它的问题在于配置是”全局的”——不管你用 singleton 还是 scoped,最终拿到的 options 实例都基于同一套配置来源(appsettings.json、环境变量、代码中的 Configure() 调用等),没有办法在运行时根据某个业务对象来动态调整。

Microsoft.Extensions.Options.Contextual 要做的就是这件事:让你在取 options 的时候传入一个”上下文”对象,系统根据该上下文动态组装出对应的选项值。

官方描述是:

APIs for dynamically configuring options based on a given context

举个原文用的例子:有一个 WeatherForecastOptions,包含 TemperatureScale(摄氏/华氏)和 ForecastDays(预报天数)两个属性。现在希望根据当前用户的国籍动态决定用哪种温度单位。用这个库,调用侧看起来像这样:

// 从 DI 取 IContextualOptions<TOptions, TContext>
IContextualOptions<WeatherForecastOptions, AppContext> _contextualOptions;

AppContext context = ...; // 从请求/业务层获取上下文

// 传入上下文,拿到按该上下文配置好的 options 实例
WeatherForecastOptions options = await _contextualOptions.GetAsync(context, cancellationToken);

这和 named options 不同——named options 是预先定义好几套固定配置,这里是根据运行时传入的任意上下文对象来决定配置

安装包

这个包从未发布稳定版,所以安装时必须加 --prerelease 标志:

dotnet add package Microsoft.Extensions.Options.Contextual --prerelease

安装后 .csproj 里会出现类似这样的引用(支持 .NET Framework 和 .NET 8+):

<PackageReference Include="Microsoft.Extensions.Options.Contextual"
                  Version="10.4.0-preview.1.26160.2" />

目前的最新版本如下图所示:

Microsoft.Extensions.Options.Contextual 的可用版本列表

配置步骤

使用这个库需要配置三个部分:给上下文对象加 [OptionsContext] 属性、实现 IOptionsContextReceiver、以及注册配置逻辑。

第一步:给上下文对象加 [OptionsContext] 属性

上下文类型需要加上 [OptionsContext] 属性,并声明为 partial

[OptionsContext]          // 👈 加这个,同时类型要声明为 partial
internal partial class AppContext
{
    public Guid UserId { get; set; }
    public string Country { get; set; } = "";
}

这会触发源生成器 ContextualOptionsGenerator,自动生成如下的 partial 类:

// <auto-generated/>
partial class AppContext : IOptionsContext
{
    void IOptionsContext.PopulateReceiver(IOptionsContextReceiver receiver)
    {
        receiver.Receive(nameof(Country), Country);
        receiver.Receive(nameof(UserId), UserId);
    }
}

本质上,源生成器让你的上下文类型实现了 IOptionsContext,提供了一个把所有属性”喂给” receiver 的机制。

第二步:实现 IOptionsContextReceiver

IOptionsContextReceiver 是一个简单接口,用来从上下文中提取你关心的属性,并做适当转换:

// 实现 IOptionsContextReceiver,接收上下文属性值
internal class CountryTemperatureReceiver : IOptionsContextReceiver
{
    // 暴露提取后的结果
    public string DefaultUnit { get; private set; } = "Celsius";

    // Receive 会被每个属性调用一次
    public void Receive<T>(string key, T value)
    {
        if (key == nameof(AppContext.Country) && value is string country)
        {
            // 根据国家代码决定默认温度单位
            DefaultUnit = country == "US" ? "Fahrenheit" : "Celsius";
        }
    }
}

第三步:注册配置逻辑

在服务注册时,同时配置标准选项和上下文选项:

var builder = WebApplication.CreateBuilder(args);

builder.Services
    .AddOptions<WeatherForecastOptions>()
    .Configure(x => x.ForecastDays = 7)               // 标准配置
    .Configure<IOptionsContext>((opts, ctx) =>          // 上下文配置
    {
        // 1. 创建 receiver
        var receiver = new CountryTemperatureReceiver();

        // 2. 用上下文填充 receiver
        ctx.PopulateReceiver(receiver);

        // 3. 根据 receiver 的提取结果更新选项
        if (receiver.DefaultUnit != null)
        {
            opts.TemperatureScale = receiver.DefaultUnit;
        }
    });

第四步:从 DI 取出并使用

配置完成后,就可以在需要的地方注入 IContextualOptions<WeatherForecastOptions, AppContext>,传入运行时上下文来获取配置好的选项实例:

public class WeatherService(
    IContextualOptions<WeatherForecastOptions, AppContext> contextualOptions)
{
    public async Task<string> GetForecast(AppContext context, CancellationToken ct)
    {
        WeatherForecastOptions opts = await contextualOptions.GetAsync(context, ct);
        // opts.TemperatureScale 已按 context.Country 动态设置
        return $"{opts.ForecastDays} days forecast in {opts.TemperatureScale}";
    }
}

作者的直接判断

看完这套机制,Andrew Lock 的第一反应是:为什么要这么麻烦?

如果你想根据 AppContext.Country 来决定 TemperatureScale,直接写不就行了:

WeatherForecastOptions opts = new();
opts.TemperatureScale = appContext.Country == "US" ? "Fahrenheit" : "Celsius";

引入 receiver 后,表面上”解耦”了 options 和 context,但实际上你仍然通过魔法字符串 nameof(AppContext.Country) 耦合在一起——如果重命名 Country 属性,这里悄悄就断了。

那这个库的真实意图是什么?查找相关 API proposal 后,Andrew 找到了答案:

GitHub Issue:API Proposal: Introduce Contextual Options #5049

这个提案是关闭状态的。API review 讨论中有人问:“实际上有多少个 IOptionsContextReceiver 实现?“回答里提到了 LaunchDarkly——一个商业特性标志服务。也就是说,这套 API 最初的设想是配合第三方 feature flags 服务来用,由第三方平台实现 receiver,把远端配置注入进来。

但 .NET 已经有了 Microsoft.FeatureManagement(Andrew 7 年前写过详细介绍),下载量超过 1.4 亿,可以说完全够用。目前可以找到的唯一开源使用者是 excos-platform/config-client,一个特性标志/实验平台的客户端。

关于”实验性”警告

所有 API 都标记了 [Experimental] 属性,会产生编译时错误 EXTEXP0018。更麻烦的是,源生成器生成的代码里也有这个错误,而你没有办法给自动生成的代码加 #pragma,所以实际上你只能在 .csproj 里全局禁用它:

<PropertyGroup>
  <!-- 压制实验性 API 警告 -->
  <NoWarn>$(NoWarn);EXTEXP0018</NoWarn>
</PropertyGroup>

这让 [Experimental] 属性的保护意义大打折扣。

总结

Microsoft.Extensions.Options.Contextual 是一个设计思路有趣、但目前没有必要引入的包:

如果你深度依赖 IOptions<> 体系,并且恰好需要一个接受第三方上下文注入的 receiver 机制,那这个包或许有用武之地。否则,直接在业务层按上下文配置对象,代码更清晰,也更容易追踪。

想要 feature flags 功能,可以看看 OpenFeature,它是跨语言的、有更完善的生态。

参考


Tags


Previous

EF Core 10 追踪与非追踪查询:基准测试与决策指南

Next

AI 即文本的时代结束了——执行才是新的交互界面