Sparkdo.StateChecking
3.0.0-preview.1
dotnet add package Sparkdo.StateChecking --version 3.0.0-preview.1
NuGet\Install-Package Sparkdo.StateChecking -Version 3.0.0-preview.1
<PackageReference Include="Sparkdo.StateChecking" Version="3.0.0-preview.1" />
<PackageVersion Include="Sparkdo.StateChecking" Version="3.0.0-preview.1" />
<PackageReference Include="Sparkdo.StateChecking" />
paket add Sparkdo.StateChecking --version 3.0.0-preview.1
#r "nuget: Sparkdo.StateChecking, 3.0.0-preview.1"
#:package Sparkdo.StateChecking@3.0.0-preview.1
#addin nuget:?package=Sparkdo.StateChecking&version=3.0.0-preview.1&prerelease
#tool nuget:?package=Sparkdo.StateChecking&version=3.0.0-preview.1&prerelease
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 平台提供,实测宿主不显式引它们也能编译。
目录
- 快速开始
- 安装与宿主侧依赖面
AddSparkdoStateChecking()注册了什么- 全局 checker 的两条通道与顺序
- 一次检查的固定顺序
- Options 逐成员语义
DefaultSimpleStateCheckerManager<TState>直接 newSimpleBatchStateCheckerBase<TState>- 性能特征(实测)
- 池化、并发与线程安全
- 典型用法配方
- 常见坑
- 异常一览
- 非目标
快速开始
走容器(推荐)
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/次 |
读这张表的四个要点:
- 同步路径的零分配是真的:BND 覆盖到的三行(无 checker / 1 个 / 3 个同步局部 checker)
Allocated直接读到–,模块的分配门禁测试在 Release 下断言 0 B,两边互证;探针另测「短路」与「全局=Options 纯实例」也是 0 B。基线 6.7 ns 就是「一次虚调用 + 列表读」的量级。 - 一旦挂上全局 checker 就不再零分配:DI 通道每次
.ToArray()一份全局数组(64 B、93.95 ns ≈ 基线 14 倍),工厂通道还要现调工厂(64 B),Transient 通道另加实例本身(96 B)。想把热路径压回 0 B,用「局部 checker」或「Options 纯实例」。 - 批量成本 ≈ 结果字典: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)几乎相同,也印证了这点。 - 异步 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)。
常见坑
struct状态编译不过(CS0452)——这是刻意的:结果按引用相等取键,值类型会被静默装箱、判定永远读不中。改成sealed class。- 返回后继续持有单项上下文 → 归还到下次租用之间读它会抛
ObjectDisposedException;而一旦槽位被下一次检查租用,泄漏的引用就会读到那一次的状态而不抛。所以规则是「不得持有」:要在返回后用的服务/状态引用,请在检查期间取好局部变量。 - checker 注册成 keyed 服务不生效——默认实现枚举非 keyed 服务键,表现是「明明注册了却全判
true」。 IOptionsMonitor改了配置但检查不变——本域只读IOptions<T>,不订阅变更、不读命名配置(实测IOptions<T>.Value与Monitor.CurrentValue就不是同一实例)。- 从根容器解析管理器:默认
BuildServiceProvider()能过(拿到隐式根 scope,两次解析同实例),ASP.NET Core 开发环境(ValidateScopes=true)直接抛Cannot resolve scoped service … from root provider.请从请求 scope 解析。 - 真异步 checker 破坏零分配:一个
await就 +289 B/次。同步能判的别异步;必须异步的接受这条成本或换批量通道。 - 挂全局 checker 就有 64 B/次(DI 枚举 + 数组);把每次检查都要用的固定判定放成「Options 纯实例」(0 B)或状态局部 checker(0 B)。
- Transient 全局 checker 每次检查换新实例:
CallCount这类累加字段不会跨检查存活(实测两次检查 2 个实例)。 - 批量 checker 漏回填候选:默认「该候选保持当前判定」,而不是变
false;用SimpleBatchStateCheckerBase时漏回填会让单项读作false(实测)。 - 同一状态实例在批量输入里出现两次 → 结果只 1 条(
Count会小于输入长度),但它的单项 checker 被跑了两遍。 result[clone]取不到判定:引用相等取键,克隆/反序列化副本认不出。只认你传进去的那些引用。Values在热路径上白分配:只用索引器时不创建视图(实测 0 B);遍历/序列化才需要它。- 异常不是
false:一个 checker 抛异常会打断整次调用(批量也一样,判定数组靠finally归还)。不可用要返回false。 Add什么都不校验:options 没注册、checker 拼错泛型参数都静默通过(实测裸接线照样解析、空状态判true)。用配方 6 在启动期探一次。- 检查期间改
StateCheckers列表:管理器每轮重读,行为依赖你改的时机;实测清空列表不会崩,但别把它当契约。 - 用
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 | Versions 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. |
-
net10.0
- Microsoft.Extensions.DependencyInjection.Abstractions (>= 10.0.8)
- Microsoft.Extensions.Options (>= 10.0.8)
- Microsoft.Extensions.Primitives (>= 10.0.8)
- Sparkdo.StateChecking.Abstractions (>= 3.0.0-preview.1)
-
net11.0
- Sparkdo.StateChecking.Abstractions (>= 3.0.0-preview.1)
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 |