Sparkdo.StateChecking.Abstractions 3.0.0-preview.1

This is a prerelease version of Sparkdo.StateChecking.Abstractions.
dotnet add package Sparkdo.StateChecking.Abstractions --version 3.0.0-preview.1
                    
NuGet\Install-Package Sparkdo.StateChecking.Abstractions -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.Abstractions" 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.Abstractions" Version="3.0.0-preview.1" />
                    
Directory.Packages.props
<PackageReference Include="Sparkdo.StateChecking.Abstractions" />
                    
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.Abstractions --version 3.0.0-preview.1
                    
#r "nuget: Sparkdo.StateChecking.Abstractions, 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.Abstractions@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.Abstractions&version=3.0.0-preview.1&prerelease
                    
Install as a Cake Addin
#tool nuget:?package=Sparkdo.StateChecking.Abstractions&version=3.0.0-preview.1&prerelease
                    
Install as a Cake Tool

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 或上一次的脏数据。

目录

什么时候只引这个包

你的位置 引哪个
应用要真正跑状态检查 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);

契约只承诺三件事,实现可以换:

  1. 单项:null 状态 → ArgumentNullException;已撤销 token → OperationCanceledException;无 checker 可用 → true。
  2. 批量:null 集合 → ArgumentNullException;空集合 → 空结果(Count == 0)。
  3. 批量阶段先跑批量 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>'.

五条要点:

  1. 抛异常而不是给 null:属性声明为非可空,归还后返回 null 会让调用方在别处踩空引用。现在读取即抛,诊断信息直接指回这条规则。
  2. 退化分配也照样关闭:检查挂起或重入时管理器为第二次检查新分配的上下文,同样在返回时归还并关闭——别指望「这次不是池化的那个所以能留着」。
  3. 保证的窗口是「归还后到再次租用前」:池化槽位被下一次检查租用期间,泄漏的引用读到的是那一次的状态而不抛。异常只是把最常见的误用变得可见,规则本身仍是「不得持有」。
  4. 批量上下文的 States 是快照:SimpleBatchStateCheckerContext<TState> 的 States 是该轮候选的一次性快照(恒等轮次直接复用调用方列表、零分配;收缩轮次按候选数分配一次)——调用返回后延后读取仍稳定,不会读到后续轮次的数据。
  5. 你自己 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(实测)。

实现者守则

  1. TState 用引用类型,且实现 IWithSimpleStateCheckers<TState> 时把 TState 填成自己(写父类类型不满足自引用约束)。
  2. StateCheckers 返回同一个稳定实例;不要每次 new List —— 那会让「运行期追加 checker」失效并白白分配。
  3. 判定要可重入、可并发:checker 会被同一个 scope 内的多次检查复用,实例字段就是共享可变状态。
  4. 无副作用地读 context;需要跨检查缓存东西请放你自己的单例服务里,别放上下文。
  5. 外部 IO 一律转发 cancellationToken,别用 CancellationToken.None,也别在 ValueTask 里 .Wait()/.Result。
  6. 业务性「不可用」返回 false,异常只留给真故障——管理器把异常原样上抛,一次故障会打断整批。
  7. 批量实现必须为每个候选回填;不要返回局外条目(会被忽略),也不要返回 null 结果——默认实现直接取结果的底层字典,null 会让整次批量检查以 NullReferenceException 收场。
  8. 批量 checker 的单项重载不要偷懒写 => ValueTask.FromResult(true)——单项路径会真的放过它。
  9. 别把 ISimpleStateChecker<TState> 注册成 keyed 服务:默认实现枚举的是非 keyed 服务键(实测 keyed 注册的 checker 不参与检查)。
  10. 声明式能力(TState 是不是 sealed、集合是否并发安全)不靠契约保证,靠你的实现纪律。
  11. 想让某个状态永不可用,用「局部 checker 返回 false」而不是「抛异常」——后者会打断调用方而不是给出 false。
  12. 需要「一批里各结论不同但值相等」的正确行为时,别复用实例:同实例两次出现在输入里只产生一条结果条目(实测 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 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.
  • 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