Sparkdo.StateChecking.Abstractions
3.0.0-preview.1
dotnet add package Sparkdo.StateChecking.Abstractions --version 3.0.0-preview.1
NuGet\Install-Package Sparkdo.StateChecking.Abstractions -Version 3.0.0-preview.1
<PackageReference Include="Sparkdo.StateChecking.Abstractions" Version="3.0.0-preview.1" />
<PackageVersion Include="Sparkdo.StateChecking.Abstractions" Version="3.0.0-preview.1" />
<PackageReference Include="Sparkdo.StateChecking.Abstractions" />
paket add Sparkdo.StateChecking.Abstractions --version 3.0.0-preview.1
#r "nuget: Sparkdo.StateChecking.Abstractions, 3.0.0-preview.1"
#:package Sparkdo.StateChecking.Abstractions@3.0.0-preview.1
#addin nuget:?package=Sparkdo.StateChecking.Abstractions&version=3.0.0-preview.1&prerelease
#tool nuget:?package=Sparkdo.StateChecking.Abstractions&version=3.0.0-preview.1&prerelease
Sparkdo.StateChecking.Abstractions
状态检查的稳定公共契约:状态对象、单项/批量 checker、检查上下文、结果集合与管理器入口。本包不含任何实现——谁按什么顺序跑这些 checker、上下文怎么复用、结果怎么合并,全在运行时包 Sparkdo.StateChecking 里,或者由你自己替换。
三条硬承诺(本轮以本地 feed 探针 + 包体实测):
- 外部 NuGet 依赖为零——
nuspec的<dependencies>是空组:<group targetFramework="net11.0" />。IServiceProvider来自 corelib,Microsoft.Extensions.Options与Microsoft.Extensions.DependencyInjection.Abstractions由 net11.0 平台提供,实测单独引本包的类库不引任何Microsoft.Extensions.*包即可编译。 - 状态必须是引用类型——
TState带class约束。这不是风格选择:结果集合按引用相等取键,值类型状态会被静默装箱,判定读起来永远不对(见下文「为什么class是契约」)。 - 上下文生存期由管理器说了算——单项上下文会被池化复用,调用返回即归还;归还后读取
ServiceProvider或State抛ObjectDisposedException(消息原文见「上下文生存期」),不会让你拿到null或上一次的脏数据。
目录
- 什么时候只引这个包
- 安装与宿主侧依赖面
- 四个角色与分工
- 公共 API 逐成员语义
- 引用相等键语义
- 为什么
class约束是契约而不是风格 - 上下文生存期与池化纪律
- 批量 checker 的语义边界
- 实现者守则
- 异常一览
- 非目标
什么时候只引这个包
| 你的位置 | 引哪个 |
|---|---|
| 应用要真正跑状态检查 | Sparkdo.StateChecking(已传递引用本包,不必单独引) |
| 领域库/其他产品域只声明状态与 checker,由宿主决定用哪个管理器 | 本包 |
| 你要自己写管理器(集群化判定、结果缓存、并行编排、把 checker 编译成表达式) | 本包;运行时包可选 |
| 你写 provider 适配器(把外部规则引擎建模成 checker) | 本包 |
Sparkdo.StateChecking 只额外提供四个公共类型:DefaultSimpleStateCheckerManager<TState>(默认管理器)、SimpleBatchStateCheckerBase<TState>(批量基类)、SparkdoSimpleStateCheckerOptions<TState>(全局 checker 配置)与 StateCheckingServiceCollectionExtensions.AddSparkdoStateChecking()。契约本身不依赖它们,所以基类不在本包——只引契约包时 SimpleBatchStateCheckerBase 不可用(实测:编译不出该类型)。
安装与宿主侧依赖面
dotnet add package Sparkdo.StateChecking.Abstractions # 契约层
dotnet add package Sparkdo.StateChecking # 契约 + 默认实现 + DI 接线
| 你想做的事 | 额外要引的包 | 依据(实测) |
|---|---|---|
| 声明状态、写 checker、写批量 checker | 无 | 只用 IServiceProvider 与泛型接口,corelib 足够 |
手工 new 上下文/结果做单元测试 |
无 | 两者都是公共构造,不碰容器 |
new ServiceCollection() + BuildServiceProvider() 端到端跑 |
Microsoft.Extensions.DependencyInjection |
容器实现不在平台内(Abstractions 在) |
Options.Create(...)、services.AddOptions<T>()、services.Configure<T>(...) |
无 | Microsoft.Extensions.Options 由 net11.0 平台提供 |
四个角色与分工
| 角色 | 契约 | 成员数 | 谁实现 |
|---|---|---|---|
| 状态对象 | IWithSimpleStateCheckers<TState> |
1(StateCheckers) |
你的应用/领域层 |
| 判定单元 | ISimpleStateChecker<TState> |
1(IsEnabledAsync(context)) |
你的应用、其他产品域 |
| 批量判定单元 | ISimpleBatchStateChecker<TState> |
+1(IsEnabledAsync(batchContext)) |
想把 N 次外部调用压成 1 次时实现 |
| 编排入口 | ISimpleStateCheckerManager<TState> |
2(单项 / 批量重载) | 运行时包默认实现,或你替换 |
| 载体 | SimpleStateCheckerContext<TState> / SimpleBatchStateCheckerContext<TState> / SimpleStateCheckerResult<TState> |
— | 由管理器创建;实现批量 SPI 时按入参消费 |
选型只看一个问题:这个判定需不需要看到整批状态。不需要就实现 ISimpleStateChecker;需要(一次 RPC 拿回 N 个结论、一次 SQL 查完 N 行)就实现 ISimpleBatchStateChecker。批量 checker 同时也是单项 checker——它的单项重载会在「只检查一个状态」时被调用。
公共 API 逐成员语义
IWithSimpleStateCheckers<TState>
IList<ISimpleStateChecker<TState>> StateCheckers { get; } // 这个状态自己挂的检查器
TState必须同时是引用类型并实现本接口(自引用约束:where TState : class, IWithSimpleStateCheckers<TState>)。- 集合是
IList(可读写),语义是声明顺序即执行顺序。管理器每轮重新读Count与索引(不缓存 enumerator),所以运行期增删会立刻影响下一次检查。 - 返回空列表是合法的:该状态只看全局 checker;全局也没有时直接判
true。 - 属性必须是同一个实例的视图——管理器不复制它。跨线程共享状态对象时,检查期间改这个列表的可见性由你负责(实测:检查进行中
Clear()不会崩,但结果取决于你)。
ISimpleStateChecker<TState>
ValueTask<bool> IsEnabledAsync(SimpleStateCheckerContext<TState> context,
CancellationToken cancellationToken = default);
- 返回
false即定局:同一状态上后面的 checker 不再执行(局部与全局都短路)。 - 不要抛「业务性 false」:异常一律原样上抛,管理器不吞、不重试、不计入 false。
- 尽量同步完成(
ValueTask.FromResult)。一旦真挂起,管理器为这次检查分配的异步状态机就落在热路径上(实测:1 个真异步局部 checker ≈ 289 B/次,而同步路径 0 B)。 cancellationToken由管理器透传调用方传入的 token(实测:checker 收到的就是调用方那个 token;调用方不传时是CancellationToken.None)。管理器自己在入口先ThrowIfCancellationRequested,所以已撤销的 token 根本不会走到任何 checker。
ISimpleBatchStateChecker<TState> : ISimpleStateChecker<TState>
ValueTask<SimpleStateCheckerResult<TState>> IsEnabledAsync(
SimpleBatchStateCheckerContext<TState> context, CancellationToken cancellationToken = default);
- 批量重载收的
context.States是当时仍启用的候选,不保证是你调用批量检查时传入的那批(实测:第一个批量 checker 看到a,b,c,第二个只看到a,c)。 - 返回的结果只会合并候选状态:没在结果里出现的候选保持当前判定(实测
4.5:第二个批量只报a=false,c保持true);结果里夹带的局外状态被忽略(实测4.6:局外条目不进最终结果,Count仍等于输入去重后的数量)。 - 同一个实例被多个状态共享时只跑一次(按引用去重,实测
4.3:共享实例BatchCalls=1);同一状态上把两个值相等但不同实例的批量 checker 都挂上,两个都会跑(实测:Equals被重写为恒等也不合并)。 - 批量重载里请为每个候选都回填判定;漏一个就等于「该候选不置灰」,而不是「默认 false」——除非你的实现自己读了结果索引器(索引器对缺失键返回
false)。
ISimpleStateCheckerManager<TState>
ValueTask<bool> IsEnabledAsync(TState state, CancellationToken ct = default);
ValueTask<SimpleStateCheckerResult<TState>> IsEnabledAsync(IReadOnlyList<TState> states, CancellationToken ct = default);
契约只承诺三件事,实现可以换:
- 单项:
null状态 →ArgumentNullException;已撤销 token →OperationCanceledException;无 checker 可用 →true。 - 批量:
null集合 →ArgumentNullException;空集合 → 空结果(Count == 0)。 - 批量阶段先跑批量 checker、单项阶段只对仍启用的状态跑非批量 checker(默认实现的顺序保证,见运行时包自述)。
SimpleStateCheckerContext<TState>
| 成员 | 语义(实测) |
|---|---|
new SimpleStateCheckerContext(IServiceProvider, TState) |
两个参数都拒绝 null(serviceProvider / state)。测试里手工构造完全可行 |
IServiceProvider ServiceProvider { get; } |
即管理器解析自的那个作用域容器——默认实现下就是宿主的请求 scope(实测 == scope.ServiceProvider、!= root)。经它可以取到同 scope 的 scoped 服务(实测同实例) |
TState State { get; } |
被检查的状态实例本身(ReferenceEquals 成立),不是副本 |
| 两者在归还后 | 抛 ObjectDisposedException——见「上下文生存期」 |
SimpleBatchStateCheckerContext<TState>
| 成员 | 语义(实测) |
|---|---|
new SimpleBatchStateCheckerContext(IServiceProvider, IReadOnlyList<TState>) |
两参数拒 null |
IServiceProvider ServiceProvider |
同上,管理器解析自的作用域容器 |
IReadOnlyList<TState> States |
当前候选。默认实现下它是该轮候选的一次性快照:调用返回后继续读取仍稳定,不随后续批量轮次收缩(实测:第二个批量 checker 在调用内看到 2 个候选,整次调用返回后仍是那 2 个) |
默认实现把候选集做成了视图(全启用时直接复用原始列表;有关闭时按下标压缩),所以 States 的枚举顺序稳定但不保证是原列表实例。
SimpleStateCheckerResult<TState>
| 成员 | 语义(实测) |
|---|---|
new() |
空结果,Count == 0 |
new(IReadOnlyList<TState> states, bool initialValue = true) |
按集合建条目并统一初值;states 为 null → ArgumentNullException(states);集合含 null 元素 → ArgumentNullException(key)(来自底层字典) |
this[TState state] 读 |
缺失键返回 false,不抛;state 为 null → ArgumentNullException(state) |
this[TState state] 写 |
添加或覆盖;null 键同样拒 |
Set(state, value) / TrySetExisting(state, value) |
前者等价于索引器写入;后者只在键已存在时写并返回 false(实测不新增条目) |
Contains(state) |
区分「未包含」与「包含但为 false」的唯一手段 |
Count |
条目数 = 输入里不同实例的数量(同一实例出现两次只有一条) |
Values |
ReadOnlyDictionary 包装,惰性创建:首次访问才分配,之后每次返回同一实例(实测 ReferenceEquals 为 True);写入抛 NotSupportedException: Collection is read-only.;只用索引器时这项分配不会发生(实测 0 B) |
引用相等键语义
internal sealed class ValueEqualState(string name) : IWithSimpleStateCheckers<ValueEqualState>
{
public string Name { get; } = name;
public IList<ISimpleStateChecker<ValueEqualState>> StateCheckers { get; } = [];
public override bool Equals(object? obj) => obj is ValueEqualState other && other.Name == Name;
public override int GetHashCode() => Name.GetHashCode(StringComparison.Ordinal);
}
var first = new ValueEqualState("same");
var second = new ValueEqualState("same"); // 与 first 值相等,但是另一个实例
var result = new SimpleStateCheckerResult<ValueEqualState>([first, second]);
result[first] = false;
result.Count; // 2 —— 两个实例各一条
result[first]; // False
result[second]; // True —— 没有被 first 的写入覆盖
这是本域刻意选择的语义:判定挂在实例上,不挂在值上。同一批里两个「长得一样」的状态(同一个功能开关模板的两个副本)必须能被分别判定,谁先写谁覆盖是不可接受的。代价是取键只能用 RuntimeHelpers.GetHashCode + ReferenceEquals,所以 TState 只能是引用类型。
由此推论的两条纪律:
- 别把状态做成记录类型(
record)并按值当字典键用——result[state]找的是那个对象。 - 想在结果里认出一个状态,就得持有你交给管理器的那个实例引用;克隆/反序列化出来的副本认不出。
为什么 class 约束是契约而不是风格
TState 曾经可以写成 struct(public struct PointState(int x, int y) : IWithSimpleStateCheckers<PointState>),那时语义是静默崩坏的。装箱后 RuntimeHelpers.GetHashCode 每次给的都是那个临时盒子的哈希,查找与插入全部落空。收紧约束之前,同一版本探针实测到的行为是:
| 操作 | 读数 | 应为 |
|---|---|---|
r[new PointState(1, 2)] |
False |
命中该值已有条目 |
同一个 st 反复 r[st] = true 写 11 次 |
Count = 11 |
1 |
r.Contains(st) |
False |
True |
r.TrySetExisting(st, false) |
False(什么都没改) |
True |
不报错、不警告、判定永远是 false、字典无界增长。所以公共面自己钉住了 class(10 个公共泛型类型全部带上引用类型约束,并有守卫测试逐类型钉住)。
修后的失败方式(实测):
error CS0452: 类型“PointState”必须是引用类型才能用作泛型类型或方法
“IWithSimpleStateCheckers<TState>”中的参数“TState”
反射层面也一并可见(守卫测试断言的就是这个信号):typeof(IWithSimpleStateCheckers<>).GetGenericArguments()[0].GenericParameterAttributes 含 ReferenceTypeConstraint。对不可满足的实参做 MakeGenericType 会在运行期抛 ArgumentException:
GenericArguments[0], 'StructState', on
'Sparkdo.StateChecking.IWithSimpleStateCheckers`1[TState]' violates the constraint of type 'TState'.
上下文生存期与池化纪律
默认实现为了在「仅局部同步 checker」这条热路径上做到零分配,会在管理器内复用同一个 SimpleStateCheckerContext<TState> 实例(并发或重入时自动退化为新分配)。复用要求归还时清引用,于是本契约把它写成了硬规则:
// 错的写法:把上下文留在字段上,返回后再读它就是死引用
public sealed class LeakyChecker : ISimpleStateChecker<FeatureState>
{
private SimpleStateCheckerContext<FeatureState>? _leaked;
public ValueTask<bool> IsEnabledAsync(
SimpleStateCheckerContext<FeatureState> context, CancellationToken cancellationToken = default)
{
_leaked = context; // ✗
return ValueTask.FromResult(true);
}
}
// 对的写法:检查期间把需要的东西复制成局部值
public sealed class SnapshotChecker : ISimpleStateChecker<FeatureState>
{
public ValueTask<bool> IsEnabledAsync(
SimpleStateCheckerContext<FeatureState> context, CancellationToken cancellationToken = default)
{
var state = context.State; // ✓ 局部快照
var store = context.ServiceProvider.GetRequiredService<ITenantStore>(); // ✓ 同请求的服务
return ValueTask.FromResult(store.IsAllowed(state.Name));
}
}
本自述示例用到的宿主侧替身(换成你的真实抽象即可)。本包自述的每一段示例都在只引用本包、不引任何 Microsoft.Extensions.* 包的探针工程里编译并运行过——这同时复核了上面的零依赖承诺:
using Sparkdo.StateChecking;
public sealed class FeatureState(string name) : IWithSimpleStateCheckers<FeatureState>
{
public string Name { get; } = name;
public IList<ISimpleStateChecker<FeatureState>> StateCheckers { get; } = [];
}
public interface ITenantStore
{
bool IsAllowed(string featureName);
}
实测行为:
System.ObjectDisposedException: 检查上下文已由管理器归还,不得在调用返回后继续持有;
需要在返回后使用的数据请在检查期间复制到局部变量。
Object name: 'SimpleStateCheckerContext<TState>'.
五条要点:
- 抛异常而不是给
null:属性声明为非可空,归还后返回null会让调用方在别处踩空引用。现在读取即抛,诊断信息直接指回这条规则。 - 退化分配也照样关闭:检查挂起或重入时管理器为第二次检查新分配的上下文,同样在返回时归还并关闭——别指望「这次不是池化的那个所以能留着」。
- 保证的窗口是「归还后到再次租用前」:池化槽位被下一次检查租用期间,泄漏的引用读到的是那一次的状态而不抛。异常只是把最常见的误用变得可见,规则本身仍是「不得持有」。
- 批量上下文的
States是快照:SimpleBatchStateCheckerContext<TState>的States是该轮候选的一次性快照(恒等轮次直接复用调用方列表、零分配;收缩轮次按候选数分配一次)——调用返回后延后读取仍稳定,不会读到后续轮次的数据。 - 你自己
new出来的上下文永远不会被管理器关闭(实测读得到)——测试替身可以直接构造它。
批量 checker 的语义边界
契约承诺的只有「候选收缩 + 只合并候选」,不承诺:
- 候选的具体顺序(默认实现按输入顺序,但自定义管理器可以乱序或并行)
- 你的批量重载被调用的次数(默认实现按实例去重后每轮一次;候选集已空时不再调用你)
- 单项重载与批量重载之间的一致性义务——默认实现下,单项检查走你的单项重载、批量检查走你的批量重载,两条路径都得给出同样的判定,否则同一个状态在两种入口下结论不同。
写批量 checker 的最小正确形态:
public sealed class TenantFeatureChecker : ISimpleBatchStateChecker<FeatureState>
{
// 单项路径:把「一个状态」包装成「一个候选的批量检查」
public async ValueTask<bool> IsEnabledAsync(
SimpleStateCheckerContext<FeatureState> context,
CancellationToken cancellationToken = default)
{
var result = await IsEnabledAsync(
new SimpleBatchStateCheckerContext<FeatureState>(context.ServiceProvider, [context.State]),
cancellationToken);
return result[context.State];
}
public async ValueTask<SimpleStateCheckerResult<FeatureState>> IsEnabledAsync(
SimpleBatchStateCheckerContext<FeatureState> context,
CancellationToken cancellationToken = default)
{
var verdicts = await LoadAsync(context.States, cancellationToken); // 一次外部调用
var result = new SimpleStateCheckerResult<FeatureState>(context.States, initialValue: false);
foreach (var state in context.States)
{
result[state] = verdicts.Contains(state.Name); // 每个候选都回填
}
return result;
}
private Task<HashSet<string>> LoadAsync(
IReadOnlyList<FeatureState> states, CancellationToken cancellationToken) =>
Task.FromResult(new HashSet<string>(states.Select(static s => s.Name), StringComparer.Ordinal));
}
嫌两个重载都写麻烦,用运行时包的 SimpleBatchStateCheckerBase<TState>:它把单项检查适配成「只有一个候选的批量检查」,你只实现批量重载。注意基类不校验你是否回填了那个状态——漏回填时单项读作 false(实测)。
实现者守则
TState用引用类型,且实现IWithSimpleStateCheckers<TState>时把TState填成自己(写父类类型不满足自引用约束)。StateCheckers返回同一个稳定实例;不要每次new List—— 那会让「运行期追加 checker」失效并白白分配。- 判定要可重入、可并发:checker 会被同一个 scope 内的多次检查复用,实例字段就是共享可变状态。
- 无副作用地读
context;需要跨检查缓存东西请放你自己的单例服务里,别放上下文。 - 外部 IO 一律转发
cancellationToken,别用CancellationToken.None,也别在ValueTask里.Wait()/.Result。 - 业务性「不可用」返回
false,异常只留给真故障——管理器把异常原样上抛,一次故障会打断整批。 - 批量实现必须为每个候选回填;不要返回局外条目(会被忽略),也不要返回
null结果——默认实现直接取结果的底层字典,null会让整次批量检查以NullReferenceException收场。 - 批量 checker 的单项重载不要偷懒写
=> ValueTask.FromResult(true)——单项路径会真的放过它。 - 别把
ISimpleStateChecker<TState>注册成 keyed 服务:默认实现枚举的是非 keyed 服务键(实测 keyed 注册的 checker 不参与检查)。 - 声明式能力(
TState是不是sealed、集合是否并发安全)不靠契约保证,靠你的实现纪律。 - 想让某个状态永不可用,用「局部 checker 返回
false」而不是「抛异常」——后者会打断调用方而不是给出false。 - 需要「一批里各结论不同但值相等」的正确行为时,别复用实例:同实例两次出现在输入里只产生一条结果条目(实测
Count=1)。
异常一览
消息文本逐字对齐实测输出(参数名一并给出)。
| 抛出点 | 类型(参数名) | 消息 |
|---|---|---|
SimpleStateCheckerContext / SimpleBatchStateCheckerContext 构造 |
ArgumentNullException (serviceProvider/state/states) |
Value cannot be null. |
归还后的单项上下文读 State / ServiceProvider |
ObjectDisposedException |
首行 检查上下文已由管理器归还,不得在调用返回后继续持有;需要在返回后使用的数据请在检查期间复制到局部变量。,次行 Object name: 'SimpleStateCheckerContext<TState>'. |
SimpleStateCheckerResult 索引器 / Set / Contains / TrySetExisting 传 null 键 |
ArgumentNullException (state) |
Value cannot be null. |
SimpleStateCheckerResult 构造传 null 集合 |
ArgumentNullException (states) |
Value cannot be null. |
SimpleStateCheckerResult 构造集合含 null 元素 |
ArgumentNullException (key) |
Value cannot be null. |
SimpleStateCheckerResult.Values 写入 |
NotSupportedException |
Collection is read-only. |
struct 状态写进泛型实参 |
编译期 CS0452 |
类型“X”必须是引用类型才能用作泛型类型或方法“…”中的参数“TState” |
| 反射构造不满足约束的泛型 | ArgumentException(ParamName 为空,实测) |
GenericArguments[0], 'X', on '…' violates the constraint of type 'TState'. |
契约包本身不抛 OperationCanceledException——那是管理器入口的取消检查与 checker 内部 ThrowIfCancellationRequested 的结果(默认实现下消息是 The operation was canceled.)。
非目标
- 不提供任何编排实现:不承诺顺序、不承诺去重、不承诺并行度、不承诺分配。
- 不提供状态存储/加载 SPI,不做结果缓存、TTL、失效广播。
- 不做 ASP.NET Core 端点、授权策略、
[StateCheck]之类特性胶水。 - 不做 checker 依赖图、优先级、权重、分组;不做失败聚合与重试(异常一律上抛)。
- 不做值相等语义的结果键,不做
null状态条目,不做可空TState。 - 不内嵌 provider 适配(外部规则引擎、feature flag 服务都留给独立包)。
| 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
- No dependencies.
-
net11.0
- No dependencies.
NuGet packages (1)
Showing the top 1 NuGet packages that depend on Sparkdo.StateChecking.Abstractions:
| Package | Downloads |
|---|---|
|
Sparkdo.StateChecking
Sparkdo 可扩展状态检查运行时:零分配快路径默认管理器、批量编排与 DI 接线。 |
GitHub repositories
This package is not used by any popular GitHub repositories.
| Version | Downloads | Last Updated |
|---|---|---|
| 3.0.0-preview.1 | 65 | 10/30/2026 |
| 2.0.0-preview.2 | 100 | 7/20/2026 |
| 2.0.0-preview.1 | 92 | 7/18/2026 |