Sparkdo.StateChecking 3.0.0-preview.1

This is a prerelease version of Sparkdo.StateChecking.
dotnet add package Sparkdo.StateChecking --version 3.0.0-preview.1
                    
NuGet\Install-Package Sparkdo.StateChecking -Version 3.0.0-preview.1
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="Sparkdo.StateChecking" Version="3.0.0-preview.1" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="Sparkdo.StateChecking" Version="3.0.0-preview.1" />
                    
Directory.Packages.props
<PackageReference Include="Sparkdo.StateChecking" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add Sparkdo.StateChecking --version 3.0.0-preview.1
                    
#r "nuget: Sparkdo.StateChecking, 3.0.0-preview.1"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package Sparkdo.StateChecking@3.0.0-preview.1
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=Sparkdo.StateChecking&version=3.0.0-preview.1&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Sparkdo.StateChecking&version=3.0.0-preview.1&prerelease
                    
Install as a Cake Tool

Sparkdo.StateChecking

状态检查运行时包:ISimpleStateCheckerManager<TState> 的零分配快路径默认实现(DefaultSimpleStateCheckerManager<TState>)、批量检查器基类(SimpleBatchStateCheckerBase<TState>)、全局 checker 配置(SparkdoSimpleStateCheckerOptions<TState>)与一次 AddSparkdoStateChecking() 接线。契约本身在 Sparkdo.StateChecking.Abstractions(本包已传递引用它)。

三条硬承诺(本轮以本地 feed 探针 + 包体实测,证据出处见文末「工程与验证」):

  • 不创建子 scope——管理器默认注册为 scoped,直接用解析自它的那个 IServiceProvider(宿主请求 scope)。传统「每次检查 CreateAsyncScope」的开销在这里是零,全局 checker 与 options 工厂共享当前请求生命周期。
  • 同步快路径零分配——无 checker、或只跑局部同步 checker 时,GC.GetAllocatedBytesForCurrentThread 实测 0 B/次,基准工程的 Allocated 列也直接读到 –;上下文经 CAS 守护在管理器内池化复用,检查挂起或重入时自动退化为新分配。
  • net11.0 分支的外部 NuGet 依赖只有一个——nuspec 的 net11.0 依赖组里本包只依赖 Sparkdo.StateChecking.Abstractions(net10.0 组另声明 Options / DependencyInjection.Abstractions);Microsoft.Extensions.DependencyInjection.Abstractions 与 Microsoft.Extensions.Options 由 net11.0 平台提供,实测宿主不显式引它们也能编译。

目录

快速开始

走容器(推荐)

using Microsoft.Extensions.DependencyInjection;
using Sparkdo.StateChecking;

builder.Services.AddSparkdoStateChecking();                 // 只装 open-generic 默认管理器
builder.Services.AddScoped<ISimpleStateChecker<OrderState>, PaidOrderChecker>();   // 全局 checker

var provider = builder.Services.BuildServiceProvider();
using var scope = provider.CreateScope();
var manager = scope.ServiceProvider.GetRequiredService<ISimpleStateCheckerManager<OrderState>>();

var state = new OrderState("so-1001");                       // 功能开关/订单/租户策略都一样的形态
state.StateCheckers.Add(new TenantQuotaChecker());           // 局部 checker:只属于这个状态

bool enabled = await manager.IsEnabledAsync(state);          // 局部 → 全局,任一 false 即定局
if (!enabled)
{
    // 该状态此刻不可用
}

状态对象只需要一件事:

public sealed class OrderState(string id) : IWithSimpleStateCheckers<OrderState>
{
    public string Id { get; } = id;

    public IList<ISimpleStateChecker<OrderState>> StateCheckers { get; } = [];
}

TState 必须是引用类型且把自己填进约束——struct 状态现在直接编译不过(CS0452),因为批量结果按引用相等取键,值类型会被静默装箱导致判定永远读不中。

批量检查

var states = new[] { new OrderState("a"), new OrderState("b"), new OrderState("c") };
SimpleStateCheckerResult<OrderState> result = await manager.IsEnabledAsync(states);

foreach (var state in states)
{
    if (result[state])          // 按实例取判定:必须用你传进去的那些引用
    {
        // 可用
    }
}

result.Count;                   // 条目数 = 输入里不同实例的数量
result.Values;                  // 只读视图,惰性创建

不走容器

管理器是个普通类,IOptions<T> 参数可省略(省略即用空配置):

using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options;
using Sparkdo.StateChecking;

var provider = new ServiceCollection().BuildServiceProvider();
var manager = new DefaultSimpleStateCheckerManager<OrderState>(
    provider,
    Options.Create(new SparkdoSimpleStateCheckerOptions<OrderState>()
        .AddGlobalChecker(new PaidOrderChecker())));          // 纯实例:冻结成数组,检查期 0 分配

bool ok = await manager.IsEnabledAsync(new OrderState("so-1"));

provider 只需是个能用的 IServiceProvider——checker 不解析服务时它甚至可以是空容器。测试里更省事的做法是直接给状态挂 checker,然后 new DefaultSimpleStateCheckerManager<T>(provider)。

安装与宿主侧依赖面

dotnet add package Sparkdo.StateChecking        # 默认实现 + 接线(推荐)
dotnet add package Sparkdo.StateChecking.Abstractions   # 只写契约/适配器时
你想做的事 额外要引的包 依据(实测)
手工 new 管理器 + Options.Create 无 Options 随 net11.0 平台提供,探针不引任何 Microsoft.Extensions.* 包即可编译
AddSparkdoStateChecking() + BuildServiceProvider() Microsoft.Extensions.DependencyInjection 容器实现不在平台内;Abstractions 在
services.AddOptions<T>().Configure(...) / services.Configure<T>(...) 无 同上,Options 扩展在平台内
从 IConfiguration 绑配置到别的 options 类型 Microsoft.Extensions.Options.ConfigurationExtensions 本域配置项没有需要绑定的标量,通常用不上

本包不引用 Microsoft.Extensions.DependencyInjection(容器实现),接线用描述符检查等价实现 TryAdd 语义——这也是它对外只依赖 Abstractions 的原因。

AddSparkdoStateChecking() 注册了什么

逐条实测:

服务 生命周期 实现 注册方式
ISimpleStateCheckerManager<>(open generic) Scoped DefaultSimpleStateCheckerManager<> 集合里没有 ServiceType == typeof(ISimpleStateCheckerManager<>) 的描述符时才追加(等价 TryAdd)

除此之外什么都不注册:不注册 IOptions<SparkdoSimpleStateCheckerOptions<TState>>(实测描述符总数 = 1),不注册任何 checker,不注册命名配置池。

让位语义(实测):

你的注册 结果
Add 之前注册 closed 自定义管理器 Add 仍会追加 open 默认描述符(它的让位判定只认 open 描述符),但解析期 closed 优先 → 拿到你的实现
Add 之后注册 closed 自定义管理器 两条描述符都在,解析取最后一个 → 拿到你的实现
Add 之后注册 open-generic 自定义管理器 同上,拿到你的实现
重复调用 Add 幂等:ISimpleStateCheckerManager<> 描述符始终 1 个
只 Add,不注册 options、不注册 checker 可解析可调用;空 checker 的状态直接判 true(实测)
用 keyed 方式注册管理器 keyed 描述符的 ServiceType 是 closed 类型,Add 认不出来 → 非 keyed 请求仍落到默认实现;keyed 请求走你的实现

作用域语义(实测):

  • 不同 scope → 不同管理器实例;同一 scope 内两次解析 → 同一实例。
  • 从根容器直接解析:BuildServiceProvider() 默认配置下能拿到实例(根容器被当作隐式 scope,两次解析同实例);ValidateScopes = true(ASP.NET Core 开发环境默认)下抛 InvalidOperationException: Cannot resolve scoped service 'Sparkdo.StateChecking.ISimpleStateCheckerManager1[TState]' from root provider.`
  • ValidateOnBuild = true 不会报错——本接线没有构造期不可满足的依赖(实测)。

全局 checker 的两条通道与顺序

两条通道都有效,且执行顺序是「DI 注册的在前、Options 配置的在后」:

// 通道 1:注册为服务(推荐;能用构造注入,能 scoped/transient/singleton)
services.AddSingleton<ISimpleStateChecker<OrderState>, PaidOrderChecker>();

// 通道 2:注册到 Options(能带闭包;实例或工厂)
services.AddSingleton(Options.Create(new SparkdoSimpleStateCheckerOptions<OrderState>()
    .AddGlobalChecker(new TenantQuotaChecker())                       // 实例:冻结成数组
    .AddGlobalChecker(sp => new RequestAuditChecker(sp))));           // 工厂:每次检查现调

实测细节:

场景 行为
同一 TState 注册多个 ISimpleStateChecker<TState> 全部参与,按注册顺序
Add 后追加 options 配置 首检前后追加都生效(实测 True → False):冻结数组在追加时失效重建
纯实例配置 冻结成数组缓存,检查期 0 分配
含工厂配置 每次检查现调工厂(+ 一次数组分配,实测 64 B/次),保留 scoped 语义
Transient 注册的全局 checker 每次检查解析出新实例(实测两次检查 2 个实例)——计数类 checker 别指望跨检查累积
Open 泛型注册 services.AddSingleton(typeof(ISimpleStateChecker<>), typeof(MyChecker<>)) 会被发现并解析(实测生效),前提是该泛型实参可满足其构造依赖
AddKeyedSingleton<ISimpleStateChecker<TState>, T>("k") 不参与检查:默认实现枚举的是非 keyed 服务键。表现为「明明注册了却不生效」
只有别的 TState 注册了 checker 本 TState 仍走「无全局 checker」快路径(实测 0 B/次),不会误判为有全局 checker
容器不提供 IServiceProviderIsService 判定退化为「假设有注册」→ 走 GetServices;外部容器若连 IEnumerable<T> 也不给,抛 InvalidOperationException(见异常一览)
执行顺序实测串 local(x) → di-instance(x) → di-instance2(x) → options-instance(x) → options-factory(x)

context.ServiceProvider 就是管理器解析自的那个 scope(实测 == scope.ServiceProvider 且 != root),所以工厂里 sp.GetRequiredService<T>() 拿到的 scoped 服务与同请求内别处一致(实测两次工厂拿到同一 scoped 实例)。

一次检查的固定顺序

单项 IsEnabledAsync(state)

IsEnabledAsync(state, ct)
  ├─ state 为 null ───────────────────────────────→ ArgumentNullException(state)
  ├─ ct 已撤销 ───────────────────────────────────→ OperationCanceledException(先于任何 checker,也先于快路径)
  ├─ 该状态无局部 checker 且无任何全局 checker ────→ true(同步完成,实测 0 B / 24 ns)
  └─ 借出上下文(池化空闲则复用,被占则新分配)
       ├─ 局部 checker:按 StateCheckers 顺序逐个 await,任一 false → 立即 false(后续不跑)
       └─ 全局 checker:DI 注册顺序 → Options 添加顺序,同上短路
     finally 归还上下文并关闭它(返回后再读它 → ObjectDisposedException)

批量 checker 在单项路径上走的是它的单项重载(实测 Single=1 / Batch=0);同一实例被挂在同一状态两次时,单项路径不去重(实测调用 2 次)。

批量 IsEnabledAsync(states)

IsEnabledAsync(states, ct)
  ├─ states 为 null ──────────────────────────────→ ArgumentNullException(states)
  ├─ ct 已撤销 ───────────────────────────────────→ OperationCanceledException
  ├─ states.Count == 0 ───────────────────────────→ 空结果(Count=0)
  ├─ 无全局 checker 且所有状态都无局部 checker ───→ 全 true 结果(仍建一个结果字典)
  ├─ 判定数组:从 ArrayPool<bool> 租借,先全置 true
  ├─ 阶段 A(批量):按 states 顺序遍历各状态的局部 checker
  │     遇到 ISimpleBatchStateChecker 实例 → 按引用去重 → 候选 = 当前仍 true 的状态
  │     → await 其批量重载 → 只把结果里出现的候选合并进判定数组
  │     再对全局 checker 中声明批量的实例各跑一次(同样收缩候选)
  ├─ 阶段 B(单项):对仍 true 的每个状态跑「局部非批量 checker → 全局非批量 checker」
  │     批量 checker 在这一阶段被跳过(实测 Batch=1 / Single=0,不会跑两遍)
  └─ 一次性物化 SimpleStateCheckerResult(引用相等键)→ 归还判定数组

实测的四个关键性质:

性质 证据
候选逐轮收缩 第一个批量看到 a,b,c,第二个只看到 a,c
未回填的候选保持当前判定 第二个批量只报 a=false → a=False b=False c=True
局外条目被忽略 批量实现额外塞进一个非候选状态 → 最终 Count=2(只有输入那两条)
同实例重复输入只出一条 输入 [dup, dup] → Count=1,但该实例的单项 checker 被调用 2 次

ValueTask 消费纪律:管理器 await 每个 checker 的结果(不转发),所以你的 checker 返回的 ValueTask 只被消费一次;但别把同一个 ValueTask 二次 await 写在你的 checker 内部。

Options 逐成员语义

SparkdoSimpleStateCheckerOptions<TState> 是每个 TState 一份的配置对象,只在启动/配置阶段单线程使用,不做并发防护。

成员 语义(实测)
AddGlobalChecker(ISimpleStateChecker<TState> checker) 追加实例;null → ArgumentNullException(checker);返回 this 可链式
AddGlobalChecker(Func<IServiceProvider, ISimpleStateChecker<TState>> factory) 追加工厂,每次检查调用一次;工厂返回 null → InvalidOperationException(全局状态检查器工厂不得返回 null。);null 工厂 → ArgumentNullException(factory)
添加顺序 实例与工厂可任意交错,执行严格按添加顺序(实测 factory → instance 的顺序就是添加顺序)
冻结行为 只加实例 → 冻结成数组并缓存,检查期 0 分配;一旦有工厂 → 每次检查重建数组(64 B/次)
运行期追加 立即对下一次检查生效(实测首检后追加 → 判定从 True 变 False)
读取方 默认管理器读 IOptions<SparkdoSimpleStateCheckerOptions<TState>>.Value

三条接线路径都有效(实测):

// A:直接注册 IOptions<T> 实例
services.AddSingleton(Options.Create(new SparkdoSimpleStateCheckerOptions<OrderState>()
    .AddGlobalChecker(new PaidOrderChecker())));

// B:AddOptions + Configure
services.AddOptions<SparkdoSimpleStateCheckerOptions<OrderState>>()
    .Configure(o => o.AddGlobalChecker(new PaidOrderChecker()));

// C:仅 Configure<T>(内部会补齐 Options 基础设施)
services.Configure<SparkdoSimpleStateCheckerOptions<OrderState>>(o => o.AddGlobalChecker(new PaidOrderChecker()));

IOptions<T> 与 IOptionsMonitor/IOptionsSnapshot 的关系要看清(实测):

断言 实测
IOptions<T>.Value 与 IOptionsMonitor.CurrentValue 是同一实例 False
IOptions<T>.Value 与 IOptionsSnapshot.Value 是同一实例 False
monitor.Get("other") 是另一个实例 True
改命名实例会影响默认路径判定 False(本域只读默认名的 IOptions<T>)

推论:热更配置不要指望 IOptionsMonitor。本域不订阅变更、不读命名配置;要做动态生效,就在 checker 内部读你自己的配置源(例如把 IOptionsMonitor<你的业务 options> 注进 checker),而不是改 SparkdoSimpleStateCheckerOptions<T>。

DefaultSimpleStateCheckerManager<TState> 直接 new

new DefaultSimpleStateCheckerManager<TState>(IServiceProvider serviceProvider,
                                             IOptions<SparkdoSimpleStateCheckerOptions<TState>>? options = null)
  • 第二个参数可省(实测:不注册 Options 也能构造并正常检查)。省掉时管理器自己 new 一份空 options(每个管理器实例一份,不共享)。
  • serviceProvider 为 null → ArgumentNullException(serviceProvider)。
  • 类型是 sealed,options 参数带默认值——容器解析走的是「不可解析就用默认值」这条路,所以宿主没引 Options 基础设施时不会被卡住。
  • 管理器不实现 IDisposable/IAsyncDisposable:池化上下文只持引用不持资源,交给容器释放即可。
  • 生命周期由注册方决定。注册成 singleton 时也线程安全(实测 8 线程 × 10 万次全部正确、约 41 ns/次),代价是池化槽位更易被占满而退化为新分配。

SimpleBatchStateCheckerBase<TState>

只实现批量重载,基类把单项检查适配成「只有一个候选的批量检查」:

public sealed class DerivedBatchChecker(Func<OrderState, bool> decide)
    : SimpleBatchStateCheckerBase<OrderState>
{
    public override ValueTask<SimpleStateCheckerResult<OrderState>> IsEnabledAsync(
        SimpleBatchStateCheckerContext<OrderState> context, CancellationToken cancellationToken = default)
    {
        var result = new SimpleStateCheckerResult<OrderState>(context.States);
        foreach (var state in context.States)
        {
            result[state] = decide(state);
        }

        return ValueTask.FromResult(result);
    }
}

语义与代价(实测):

  • 基类会为单项检查新建一个 SimpleBatchStateCheckerContext 和单元素候选数组,所以走基类适配的单项路径不是零分配(实测 360 B/次)。热路径上的 checker 要么写自己的单项重载,要么只作为批量 checker 使用。
  • 适配后读的是 result[context.State]——你的批量实现漏回填该状态时,单项判定静默变 false(实测:返回空结果 → False;只回填别的状态 → False)。
  • 基类的单项重载是 async 的(await + ConfigureAwait),即使批量实现同步完成也会经历一次状态机(分配同上)。
  • 派生类若不需要这层适配,直接实现 ISimpleBatchStateChecker<TState> 两个重载即可(基类不在契约包里,在本包)。

性能特征(实测)

环境:.NET 11.0.0-rc.1.26425.128 / X64 RyuJIT x86-64-v3 / Windows 11 (10.0.28120.3151) / Intel Core i9-10900K(20 逻辑、10 物理核)。BND 列来自随仓库分发的基准工程(Job.ShortRun + MemoryDiagnoser,Allocated 的 – 就是 0 B);探针列是本地 feed 探针直测(Release、ServerGC=true、TieredPGO=false,GC.GetAllocatedBytesForCurrentThread;每次调用带一次 .AsTask().GetAwaiter().GetResult() 等待,时延偏高,但覆盖了基准工程没有的通道)。

场景 BND 分配 BND 时延 探针分配 探针时延
无 checker(快路径) – 6.73 ns 0 B 24 ns
1 个同步局部 checker – 35.43 ns 0 B 68 ns
3 个同步局部 checker – 45.92 ns 0 B 99 ns
短路(第 2 个返回 false) — — 0 B —
全局=Options 纯实例 — — 0 B —
全局=DI 单例注册 64 B 93.95 ns 64 B 128 ns
全局=Options 工厂 — — 64 B —
全局=DI Transient — — 96 B —
1 个真异步局部 checker — — 289 B —
批量 10 项(每项 1 个同步局部 checker) 472 B 689.45 ns 544 B 926 ns
批量 100 项 3160 B 5,785.93 ns 3232 B 7868 ns
批量 1 / 1000 项 — — 320 / 31122 B — / 79278 ns
批量 100 项·全无 checker(快路径) — — 3232 B —
批量 100 项·编排开销(批量 checker 返回空结果) — — 3608 B —
SimpleBatchStateCheckerBase 适配的单项调用 — — 360 B —
result.Values 已建好后再访问 — — 0 B —
8 线程并发单项检查(单例管理器) — — — 41 ns/次

读这张表的四个要点:

  1. 同步路径的零分配是真的:BND 覆盖到的三行(无 checker / 1 个 / 3 个同步局部 checker)Allocated 直接读到 –,模块的分配门禁测试在 Release 下断言 0 B,两边互证;探针另测「短路」与「全局=Options 纯实例」也是 0 B。基线 6.7 ns 就是「一次虚调用 + 列表读」的量级。
  2. 一旦挂上全局 checker 就不再零分配:DI 通道每次 .ToArray() 一份全局数组(64 B、93.95 ns ≈ 基线 14 倍),工厂通道还要现调工厂(64 B),Transient 通道另加实例本身(96 B)。想把热路径压回 0 B,用「局部 checker」或「Options 纯实例」。
  3. 批量成本 ≈ 结果字典:10 → 100 项时延 689 ns → 5.79 µs、分配 472 B → 3160 B,扣除固定开销约 30 B/状态;ArrayPool<bool> 判定数组不进入分配。探针另测:全无 checker 的批量 100 项 3232 B、批量 checker 返回空结果(只算编排开销)3608 B——真正的大头是结果对象,不是编排;恒等映射(15325 B)与候选缓冲(15326 B)几乎相同,也印证了这点。
  4. 异步 checker 很贵:单个真异步局部 checker 就从 0 B 跳到 289 B/次(状态机 + ValueTask 装箱)。这是「同步完成优先」纪律的来源。

AOT / 裁剪:本包与契约包都是 IsAotCompatible + IsTrimmable + Trim/SingleFile/AOT 三套分析器常开,dotnet build/dotnet pack 零诊断;无运行时反射路径(泛型静态分发 + 普通接口调用)。注意:本轮未做真实 PublishAot 链接验证(本机缺 clang/lld-link),该结论来自分析器静态面。

池化、并发与线程安全

对象 结论(实测)
DefaultSimpleStateCheckerManager<TState> scoped/singleton 均可并发使用。8 线程 × 10 万次单项检查全部正确、无撕裂(约 41 ns/次含争用)
池化的单项上下文 单槽 CAS 守护:空闲即复用,被占(真并发或 checker 重入)就退化为新分配;异步挂起的检查归还后槽位可再次复用
单项上下文在调用返回后 读取 State / ServiceProvider 抛 ObjectDisposedException(无论这次检查用的是池化实例还是退化分配的新实例)
批量上下文 不池化;States 是该轮候选的一次性快照(恒等轮次零分配,收缩轮次按候选数分配一次)——调用返回后继续读取仍稳定,不再随后续批量轮次收缩(有守卫测试钉住)
SimpleStateCheckerResult<TState> 每次调用新建,非线程共享;Values 惰性创建且返回同一包装实例
SparkdoSimpleStateCheckerOptions<TState> 不为并发设计:只在启动/配置阶段写。运行期追加 checker 没有锁,也不保证与其他线程读取的一致顺序
你的 checker 实例 契约要求可重入可并发——单例注册的 checker,其实例字段就是共享状态
StateCheckers 列表 管理器每轮重读 Count/索引,运行期增删立即影响下一次检查;不做并发防护,别在检查进行中改它

重入是允许的:checker 内部可以再调同一个管理器的 IsEnabledAsync(实测拿到不同上下文、判定正确)。但嵌套调用里的批量检查会走完整的阶段 A/B,成本按次叠加。

典型用法配方

本包自述里的所有示例都在只引用包体(PackageReference,不引用工程)的本地 feed 探针工程里逐段编译并运行过;用到的宿主侧替身集中在下面这一段,抄走时换成你的真实抽象即可。

using Microsoft.Extensions.DependencyInjection;
using Sparkdo.StateChecking;

// 业务接口(你的应用自己的抽象)
public interface ITenantStore { bool IsAllowed(string tenantId, string featureName); }
public interface ICurrentTenant { string Id { get; } }
public interface ICurrentUser { string Id { get; } }
public interface IRemotePolicyClient { ValueTask<bool[]> EvaluateAsync(IReadOnlyList<string> names, CancellationToken cancellationToken); }
public interface IQuotaStore { ValueTask<HashSet<string>> FilterEnabledAsync(IEnumerable<string> ids, CancellationToken cancellationToken); }
public sealed class FakeQuotaStore;

// 谓词型 checker:一处定义,所有 TState 复用(约束必须带 class)
public sealed class PredicateChecker<TState>(Func<TState, bool> predicate) : ISimpleStateChecker<TState>
    where TState : class, IWithSimpleStateCheckers<TState>
{
    public ValueTask<bool> IsEnabledAsync(
        SimpleStateCheckerContext<TState> context, CancellationToken cancellationToken = default)
        => ValueTask.FromResult(predicate(context.State));
}

public sealed class PaidOrderChecker : ISimpleStateChecker<OrderState>
{
    public ValueTask<bool> IsEnabledAsync(
        SimpleStateCheckerContext<OrderState> context, CancellationToken cancellationToken = default)
        => ValueTask.FromResult(context.State.Id.StartsWith("so-", StringComparison.Ordinal));
}

public sealed class TenantQuotaChecker : ISimpleStateChecker<OrderState>
{
    public ValueTask<bool> IsEnabledAsync(
        SimpleStateCheckerContext<OrderState> context, CancellationToken cancellationToken = default)
        => ValueTask.FromResult(true);
}

public sealed class RequestAuditChecker(IServiceProvider serviceProvider) : ISimpleStateChecker<OrderState>
{
    public ValueTask<bool> IsEnabledAsync(
        SimpleStateCheckerContext<OrderState> context, CancellationToken cancellationToken = default)
        => ValueTask.FromResult(serviceProvider == context.ServiceProvider);   // 工厂拿到的就是请求 scope
}

public sealed class CurrentUserRoleChecker(ICurrentUser user) : ISimpleStateChecker<OrderState>
{
    public ValueTask<bool> IsEnabledAsync(
        SimpleStateCheckerContext<OrderState> context, CancellationToken cancellationToken = default)
        => ValueTask.FromResult(user.Id.Length > 0);
}

以下配方可编译。

1. 功能开关:局部规则 + 全局租户策略

public sealed class FeatureState(string name) : IWithSimpleStateCheckers<FeatureState>
{
    public string Name { get; } = name;
    public IList<ISimpleStateChecker<FeatureState>> StateCheckers { get; } = [];
}

// 只对某个开关生效的规则:挂在状态上
var beta = new FeatureState("checkout-beta");
beta.StateCheckers.Add(new PredicateChecker<FeatureState>(
    static state => state.Name.EndsWith("beta", StringComparison.Ordinal)));

// 对所有开关生效的策略:注册为全局
public sealed class TenantFeatureChecker(ITenantStore tenants) : ISimpleStateChecker<FeatureState>
{
    public ValueTask<bool> IsEnabledAsync(
        SimpleStateCheckerContext<FeatureState> context, CancellationToken cancellationToken = default)
    {
        var tenant = context.ServiceProvider.GetRequiredService<ICurrentTenant>();   // 同请求的 scoped 服务
        return ValueTask.FromResult(tenants.IsAllowed(tenant.Id, context.State.Name));
    }
}

services.AddSparkdoStateChecking();
services.AddScoped<ISimpleStateChecker<FeatureState>, TenantFeatureChecker>();

TenantFeatureChecker 用构造注入拿到 ITenantStore,再用 context.ServiceProvider 拿 ICurrentTenant——两条路都走同一个请求 scope(实测 context.ServiceProvider == scope.ServiceProvider)。

2. 一次外部调用换 N 个结论(批量 checker)

public sealed class RemotePolicyChecker(IRemotePolicyClient client) : ISimpleBatchStateChecker<FeatureState>
{
    public async ValueTask<bool> IsEnabledAsync(
        SimpleStateCheckerContext<FeatureState> context, CancellationToken cancellationToken = default)
    {
        var verdicts = await client.EvaluateAsync([context.State.Name], cancellationToken);
        return verdicts.Length > 0 && verdicts[0];
    }

    public async ValueTask<SimpleStateCheckerResult<FeatureState>> IsEnabledAsync(
        SimpleBatchStateCheckerContext<FeatureState> context, CancellationToken cancellationToken = default)
    {
        var names = context.States.Select(static s => s.Name).ToArray();
        var verdicts = await client.EvaluateAsync(names, cancellationToken);        // 一次 RPC
        var result = new SimpleStateCheckerResult<FeatureState>(context.States, initialValue: false);
        for (var i = 0; i < context.States.Count; i++)
        {
            result[context.States[i]] = verdicts[i];
        }

        return result;
    }
}

它同时是单项 checker(一次 RPC 查一个)与批量 checker(一次 RPC 查全部候选)。批量路径下阶段 A 跑它的批量重载、阶段 B 会跳过它,所以同一个实例在一次批量检查里只被调用一次(实测)。去重按引用:给每个状态各 new 一个 checker 就是每实例各跑一次,想「一次 RPC 问完全批」就共享同一个实例,或把它注册成全局 checker。

3. 健康条件:把「依赖是否就绪」建模成状态

public sealed class DependencyState(string name, Func<bool> probe) : IWithSimpleStateCheckers<DependencyState>
{
    public string Name { get; } = name;

    // 主构造函数参数被捕获成字段,所以这里的 lambda 不能写 static
    public IList<ISimpleStateChecker<DependencyState>> StateCheckers { get; } =
        [new PredicateChecker<DependencyState>(_ => probe())];
}

var deps = new[] { new DependencyState("redis", () => redis.IsConnected), new DependencyState("db", () => db.CanConnect) };
var result = await manager.IsEnabledAsync(deps);
var degraded = deps.Where(d => !result[d]).Select(static d => d.Name).ToArray();

4. 把整批判定编译成一次 SQL

// 状态集合来自仓储,判定来自同一张表:批量 checker 收候选、返回全量结论
public sealed class QuotaBatchChecker(IQuotaStore store) : SimpleBatchStateCheckerBase<OrderState>
{
    public override async ValueTask<SimpleStateCheckerResult<OrderState>> IsEnabledAsync(
        SimpleBatchStateCheckerContext<OrderState> context, CancellationToken cancellationToken = default)
    {
        var allowed = await store.FilterEnabledAsync(context.States.Select(static s => s.Id), cancellationToken);
        var result = new SimpleStateCheckerResult<OrderState>(context.States, initialValue: false);
        foreach (var state in context.States)
        {
            result[state] = allowed.Contains(state.Id);
        }

        return result;
    }
}

用基类的代价是本配方里的单项入口每次约 360 B(基类适配),批量入口不受影响。若单项也在热路径,就手写两个重载(配方 2)。

5. 动态判定源:工厂 + scope

services.AddSingleton(Options.Create(new SparkdoSimpleStateCheckerOptions<OrderState>()
    .AddGlobalChecker(sp => new CurrentUserRoleChecker(sp.GetRequiredService<ICurrentUser>()))));  // 每次检查现取

工厂参数就是解析自管理器的那个 scope(实测),所以工厂里可以安全取 scoped 服务,不需要自己 CreateScope。含工厂的配置每次检查多 64 B(数组重建)。

6. 启动期自检(把接线错误从第一个请求搬到启动期)

var provider = builder.Services.BuildServiceProvider();
using var scope = provider.CreateScope();

// ① 管理器能解析(也验证 open-generic 没被顶掉)
var manager = scope.ServiceProvider.GetRequiredService<ISimpleStateCheckerManager<OrderState>>();

// ② 全局 checker 真的被枚举到了:用一个空 checker 的状态探一次
var probe = new OrderState("__startup_probe__");
_ = await manager.IsEnabledAsync(probe);

// ③ 想严格到类型级:直接数服务
var globals = scope.ServiceProvider.GetServices<ISimpleStateChecker<OrderState>>().ToArray();
if (globals.Length == 0)
{
    throw new InvalidOperationException("OrderState 没有任何全局 checker,接线可能漏了。");
}

Add 不校验任何东西(实测:ValidateOnBuild 也过),所以全局 checker 拼错 TState、被注册成 keyed 这类错误必须主动探一次才会暴露。

7. 测试里不起容器

var provider = new ServiceCollection().AddSingleton(new FakeQuotaStore()).BuildServiceProvider();
var manager = new DefaultSimpleStateCheckerManager<OrderState>(provider);

var allowed = new OrderState("so-1");
allowed.StateCheckers.Add(new PredicateChecker<OrderState>(static s => s.Id == "so-1"));
var blocked = new OrderState("so-2");
blocked.StateCheckers.Add(new PredicateChecker<OrderState>(static _ => false));

Assert.True(await manager.IsEnabledAsync(allowed));

var result = await manager.IsEnabledAsync([allowed, blocked]);
Assert.True(result[allowed]);
Assert.False(result[blocked]);
Assert.True(result.Contains(blocked));            // 「包含但为 false」区别于「未包含」

要点:checker 需要服务时,把服务塞进一个只给测试用的 ServiceCollection;上下文也可以自己 new(new SimpleStateCheckerContext<OrderState>(provider, state)),不必经过管理器。

8. 替换默认实现(要做缓存/并行时)

public sealed class CachingOrderStateManager(ISimpleStateCheckerManager<OrderState> inner)
    : ISimpleStateCheckerManager<OrderState>
{
    public async ValueTask<bool> IsEnabledAsync(OrderState state, CancellationToken ct = default)
    {
        // 你的缓存策略;注意别在这里再调 inner 的批量入口造成递归
        return await inner.IsEnabledAsync(state, ct);
    }

    public ValueTask<SimpleStateCheckerResult<OrderState>> IsEnabledAsync(
        IReadOnlyList<OrderState> states, CancellationToken ct = default)
        => inner.IsEnabledAsync(states, ct);
}

using Microsoft.Extensions.Options;

services.AddSparkdoStateChecking();
services.AddScoped<ISimpleStateCheckerManager<OrderState>>(sp =>
    new CachingOrderStateManager(new DefaultSimpleStateCheckerManager<OrderState>(
        sp,
        sp.GetService<IOptions<SparkdoSimpleStateCheckerOptions<OrderState>>>())));

IOptions<T> 参数可以是 null(默认值即 null),所以包装器不引 options 基础设施也能装;闭包里的 sp 就是当前请求 scope,默认管理器因此仍共享它(不创建子 scope)。

常见坑

  1. struct 状态编译不过(CS0452)——这是刻意的:结果按引用相等取键,值类型会被静默装箱、判定永远读不中。改成 sealed class。
  2. 返回后继续持有单项上下文 → 归还到下次租用之间读它会抛 ObjectDisposedException;而一旦槽位被下一次检查租用,泄漏的引用就会读到那一次的状态而不抛。所以规则是「不得持有」:要在返回后用的服务/状态引用,请在检查期间取好局部变量。
  3. checker 注册成 keyed 服务不生效——默认实现枚举非 keyed 服务键,表现是「明明注册了却全判 true」。
  4. IOptionsMonitor 改了配置但检查不变——本域只读 IOptions<T>,不订阅变更、不读命名配置(实测 IOptions<T>.Value 与 Monitor.CurrentValue 就不是同一实例)。
  5. 从根容器解析管理器:默认 BuildServiceProvider() 能过(拿到隐式根 scope,两次解析同实例),ASP.NET Core 开发环境(ValidateScopes=true)直接抛 Cannot resolve scoped service … from root provider. 请从请求 scope 解析。
  6. 真异步 checker 破坏零分配:一个 await 就 +289 B/次。同步能判的别异步;必须异步的接受这条成本或换批量通道。
  7. 挂全局 checker 就有 64 B/次(DI 枚举 + 数组);把每次检查都要用的固定判定放成「Options 纯实例」(0 B)或状态局部 checker(0 B)。
  8. Transient 全局 checker 每次检查换新实例:CallCount 这类累加字段不会跨检查存活(实测两次检查 2 个实例)。
  9. 批量 checker 漏回填候选:默认「该候选保持当前判定」,而不是变 false;用 SimpleBatchStateCheckerBase 时漏回填会让单项读作 false(实测)。
  10. 同一状态实例在批量输入里出现两次 → 结果只 1 条(Count 会小于输入长度),但它的单项 checker 被跑了两遍。
  11. result[clone] 取不到判定:引用相等取键,克隆/反序列化副本认不出。只认你传进去的那些引用。
  12. Values 在热路径上白分配:只用索引器时不创建视图(实测 0 B);遍历/序列化才需要它。
  13. 异常不是 false:一个 checker 抛异常会打断整次调用(批量也一样,判定数组靠 finally 归还)。不可用要返回 false。
  14. Add 什么都不校验:options 没注册、checker 拼错泛型参数都静默通过(实测裸接线照样解析、空状态判 true)。用配方 6 在启动期探一次。
  15. 检查期间改 StateCheckers 列表:管理器每轮重读,行为依赖你改的时机;实测清空列表不会崩,但别把它当契约。
  16. 用 context.ServiceProvider 解析 ISimpleStateChecker<TState> 来「动态加 checker」:会绕过管理器对全局集合的冻结/顺序约定,且每次解析都要分配。要做动态请自己维护一个 checker,内部再委派。

异常一览

逐字对齐实测输出。

| 抛出点 | 类型(参数名) | 消息 | | --- | --- | --- | | 单项检查传 null 状态 | ArgumentNullException (state) | Value cannot be null. | | 批量检查传 null 集合 | ArgumentNullException (states) | Value cannot be null. | | 入口 token 已撤销(含无 checker 的快路径) | OperationCanceledException | The operation was canceled. | | 全局 checker 工厂返回 null | InvalidOperationException | 全局状态检查器工厂不得返回 null。 | | AddGlobalChecker(null)(实例 / 工厂重载) | ArgumentNullException (checker / factory) | Value cannot be null. | | 管理器构造传 null provider | ArgumentNullException (serviceProvider) | Value cannot be null. | | AddSparkdoStateChecking(null) | ArgumentNullException (services) | Value cannot be null. | | 归还后读池化上下文 | ObjectDisposedException | 首行 检查上下文已由管理器归还,不得在调用返回后继续持有;需要在返回后使用的数据请在检查期间复制到局部变量。,次行 Object name: 'SimpleStateCheckerContext<TState>'. | | ValidateScopes 下从根解析 | InvalidOperationException | Cannot resolve scoped service 'Sparkdo.StateChecking.ISimpleStateCheckerManager1[TState]' from root provider.| | 容器不给IEnumerable<ISimpleStateChecker<TState>>|InvalidOperationException|No service for type 'System.Collections.Generic.IEnumerable1[Sparkdo.StateChecking.ISimpleStateChecker1[TState]]' has been registered.` | | 你的 checker 抛出的任何异常 | 原样上抛 | 管理器不吞、不包装、不重试 |

非目标

  • 不做状态对象的存储/加载 SPI(状态从数据库、feature flag 还是内存目录来,本包不管)。
  • 不做结果缓存、TTL、失效广播;每次调用都真跑一遍 checker。
  • 不做并发编排:一次调用内的 checker 严格串行 await,不并行外部 IO。
  • 不做失败聚合、重试、熔断、超时预算(异常直接上抛,取消只认调用方的 token)。
  • 不做 checker 优先级/权重/分组/依赖 DAG;顺序就是「局部声明顺序 → DI 注册顺序 → Options 添加顺序」。
  • 不做 ASP.NET Core 端点、授权策略、中间件或 [StateCheck] 特性胶水。
  • 不代注册 options、不代注册 checker、不做 Add 的 configure 重载(配置自己走 Options.Create / AddOptions / Configure)。
  • 不做命名 options 池、不订阅配置变更、不做运行期热替换 checker 集合的协议。
  • 不内嵌任何 provider 适配(外部规则引擎、feature flag 服务留独立包)。

工程与验证

工程 职责 打包
src/Sparkdo.StateChecking.Abstractions 契约层(7 个公共类型) Sparkdo.StateChecking.Abstractions(零依赖)
src/Sparkdo.StateChecking 默认管理器、批量基类、options、DI 接线 Sparkdo.StateChecking(仅依赖 Abstractions)
test/Sparkdo.StateChecking.Tests 58 项用例:编排与顺序、引用相等结果、分配门禁、池化与重入、DI 接线、契约约束扫描与上下文/候选视图生存期守卫 不打包
benchmarks/Sparkdo.StateChecking.Benchmarks 6 个场景的 BenchmarkDotNet 观测(见该工程 README) 不打包
# 模块全量测试(Debug 58/58、Release 58/58,构建 0 警告)
dotnet test src/state-checking/Sparkdo.StateChecking.slnx

# 打包并核对包内 README 与 XML 文档随包
dotnet pack src/state-checking/Sparkdo.StateChecking -c Release -o artifacts/feed
unzip -l artifacts/feed/Sparkdo.StateChecking.1.0.0.nupkg | grep -E "README|\.xml"
Product Compatible and additional computed target framework versions.
.NET net10.0 is compatible.  net10.0-android was computed.  net10.0-browser was computed.  net10.0-ios was computed.  net10.0-maccatalyst was computed.  net10.0-macos was computed.  net10.0-tvos was computed.  net10.0-windows was computed.  net11.0 is compatible. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages (1)

Showing the top 1 NuGet packages that depend on Sparkdo.StateChecking:

Package Downloads
Sparkdo.Features

Sparkdo 功能系统库,提供功能定义、管理、检查和拦截功能

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
3.0.0-preview.1 59 10/30/2026
2.0.0-preview.2 89 7/20/2026
2.0.0-preview.1 81 7/18/2026
1.0.4-preview.3 212 6/8/2026
1.0.4-preview.2 212 6/7/2026
1.0.4-preview.1 204 6/7/2026
1.0.3 505 6/6/2026
1.0.3-preview.1 198 5/17/2026
1.0.2 348 4/27/2026
1.0.2-preview.5 126 4/15/2026
1.0.2-preview.4 143 2/8/2026
1.0.2-preview.3 134 2/1/2026
1.0.2-preview.2 146 1/31/2026
1.0.2-preview.1 233 12/4/2025
1.0.1 1,116 11/27/2025
1.0.0 1,088 11/25/2025
1.0.0-preview.5 187 10/24/2025