- 后端
- 工作流自动化
【免费下载链接】stateless
A simple library for creating state machines in C# code
本文是 Stateless 状态机库的完整使用指南。Stateless 是一个在 .NET 代码中直接创建状态机(State Machine)与轻量级状态机工作流(State Machine-based Workflows)的库,本文以项目文档 src/Stateless/docs/README.md 为核心脉络,并结合 src/Stateless 源码、example 示例与 test 测试展开讲解。读完本文,你将掌握:用StateMachine<TState, TTrigger>在一行代码内创建状态机、通过链式 API 配置层次化状态 / 守卫子句 / 参数化触发器 / 异步动作、接入 ORM 与 JSON 序列化的外部状态存储、订阅与注销状态转换事件,以及把运行中的状态机导出为 DOT 与 Mermaid 图并集成到文档与可视化工具中。
初识 Stateless:在 .NET 代码中直接定义状态机
Stateless 的核心用法非常直观:用泛型类型StateMachine<TState, TTrigger>实例化状态机,其中TState是状态的类型,TTrigger是触发(事件)的类型,二者可以是任意 .NET 类型——枚举、字符串、整数、数字均可。文档首页的"电话通话"示例完整演示了这一点:
var phoneCall = new StateMachine<State, Trigger>(State.OffHook); phoneCall.Configure(State.OffHook) .Permit(Trigger.CallDialled, State.Ringing); phoneCall.Configure(State.Connected) .OnEntry(t => StartCallTimer()) .OnExit(t => StopCallTimer()) .InternalTransition(Trigger.MuteMicrophone, t => OnMute()) .InternalTransition(Trigger.UnmuteMicrophone, t => OnUnmute()) .InternalTransition<int>(_setVolumeTrigger, (volume, t) => OnSetVolume(volume)) .Permit(Trigger.LeftMessage, State.OffHook) .Permit(Trigger.PlacedOnHold, State.OnHold); // ... phoneCall.Fire(Trigger.CallDialled); Assert.AreEqual(State.Ringing, phoneCall.State);流程是:Configure(state)打开某个状态的配置;链式调用Permit(trigger, destinationState)声明"在源状态收到某触发后迁移到目标状态";最后用Fire(trigger)触发事件,由State属性读取当前状态。项目说明指出,该示例的灵感来源于 Simple State Machine(已归档项目)。
安装与目标框架
仓库中 src/Stateless/Stateless.csproj 定义了核心库的项目元数据:程序集名为Stateless,NuGet 版本前缀为5.20.1,许可证为 Apache-2.0。当前版本通过多目标框架同时面向:
| 目标框架 | 说明 |
|---|---|
netstandard2.0 | .NET Standard 2.0,可被绝大多数现代 .NET 平台消费 |
net462 | .NET Framework 4.6.2 及更高版本 |
net8.0/net9.0/net10.0 | 现代 .NET 长期支持与最新版本 |
文档明确说明:Stateless 运行在 .NET runtime 4.0 及以上,通过上述目标框架几乎覆盖所有现代 .NET 平台;构建解决方案需要 Visual Studio 2017 或更高版本。在你的 .NET 项目中,直接添加对Stateless包的引用即可使用。
核心特性总览
Stateless 支持绝大多数标准状态机构造:
- 通用泛型支持:状态与触发可以是任意 .NET 类型(数字、字符串、枚举等);
- 层次化状态(Hierarchical States):子状态继承父状态的行为;
- 状态的进入/退出动作(Entry/Exit Actions):进入或离开状态时自动执行回调;
- 守卫子句(Guard Clauses):支持带条件的转换,多个候选转换之间按守卫求值选择;
- 内省(Introspection):运行时查询当前允许的触发与完整状态配置。
同时提供若干实用扩展:
- 外部状态存储:例如把状态存到由 ORM 跟踪的属性中;
- 参数化触发(Parameterised Triggers):触发携带强类型参数;
- 可重入状态(Reentrant States):状态可转换回自身并重新执行进出动作;
- 导出 DOT 与 Mermaid 图:把状态机渲染为可视化图形。
基础用法:配置、迁移与触发
定义状态与触发
推荐用枚举表达状态与触发,可获得编译期检查和良好的可读性。以 example/TelephoneCallExample/PhoneCall.cs 为参考:
enum State { OffHook, Ringing, Connected, OnHold, PhoneDestroyed } enum Trigger { CallDialed, CallConnected, LeftMessage, PlacedOnHold, TakenOffHold, PhoneHurledAgainstWall, MuteMicrophone, UnmuteMicrophone, SetVolume }Configure / Permit / Fire
Configure(TState state)返回一个 StateConfiguration 对象(其实现位于 src/Stateless/StateConfiguration.cs#L42-L46 的Permit方法),所有 API 都以链式调用方式编排:
_machine.Configure(State.OffHook) .Permit(Trigger.CallDialed, State.Ringing); _machine.Configure(State.Ringing) .Permit(Trigger.CallConnected, State.Connected); _machine.Configure(State.Connected) .Permit(Trigger.LeftMessage, State.OffHook) .Permit(Trigger.PlacedOnHold, State.OnHold);调用Fire(trigger)时,状态机根据当前状态的配置寻找处理程序并执行转换;State属性始终返回精确的当前状态。注意Permit(trigger, destinationState)会强制校验"目标状态不等于源状态"(防止无意中的自环),自环场景应改用文档后文介绍的PermitReentry/PermitDynamic。
触发模式:Immediate 与 Queued
从 src/Stateless/StateMachine.cs#L12-L18 可以看到触发模式由FiringMode枚举控制:
FiringMode.Immediate:立即处理触发。无 run-to-completion 保证,若在OnEntry等回调中再次Fire,会立即嵌套执行;FiringMode.Queued(默认,推荐):触发先入队,按 FIFO 顺序逐个处理,保证 run-to-completion。源码中StateMachine的两个公开构造函数(src/Stateless/StateMachine.cs#L51-L61)默认都采用FiringMode.Queued。
两个模式的调度逻辑分别对应InternalFireQueued与InternalFireOne(src/Stateless/StateMachine.cs#L336-L467)。可通过构造函数第三个参数显式指定模式:
var sm = new StateMachine<State, Trigger>(State.OffHook, FiringMode.Immediate);未处理触发的默认行为
默认情况下,在当前状态没有为某触发配置任何允许的转换时,Fire会抛出InvalidOperationException。该默认动作实现在 src/Stateless/StateMachine.cs#L788-L800 的DefaultUnhandledTriggerAction中:若存在未满足的守卫条件,异常信息会列出这些守卫;否则提示"该触发在当前状态不被允许"。后文"忽略转换与可重入状态"一节介绍用OnUnhandledTrigger覆盖此默认行为。
层次化状态(Hierarchical States)
子状态通过SubstateOf(superstate)声明。下面的例子中,OnHold是Connected的子状态,因此处于OnHold的通话依然是"已连接"状态:
phoneCall.Configure(State.OnHold) .SubstateOf(State.Connected) .Permit(Trigger.TakenOffHold, State.Connected) .Permit(Trigger.PhoneHurledAgainstWall, State.PhoneDestroyed);与返回精确当前状态的StateMachine.State属性不同,IsInState(State)方法会把子状态纳入考量:若状态机当前处于OnHold,则IsInState(State.Connected)同样返回true。其实现位于 src/Stateless/StateMachine.cs#L578-L581,本质是调用当前表示的IsIncludedIn沿父状态链向上判断。
从源码看(src/Stateless/StateConfiguration.cs#L1128-L1158),SubstateOf还会做防御性校验:把状态配置为自身的子状态、或构成嵌套循环(如 A 是 B 的子状态且 B 又是 A 的子状态)时会抛出ArgumentException。
子状态的另一个重要行为是触发查找沿父状态链上溯:在子状态中找不到某触发的处理程序时,会在父状态(superstate)中继续查找。这允许子状态继承父状态的转换,同时子状态可以通过重新指定相同触发来覆盖父状态的行为。
进入/退出动作(Entry/Exit Actions)
在电话示例中,StartCallTimer()在通话接通时执行,StopCallTimer()在通话结束(挂断或摔手机)时执行:
_machine.Configure(State.Connected) .OnEntry(t => StartCallTimer()) .OnExit(t => StopCallTimer());由于OnHold是Connected的子状态,通话在Connected与OnHold之间往返时不会反复执行StartCallTimer()/StopCallTimer()——只有当真正离开Connected状态树时,退出/进入动作才会被触发。
进入/退出动作的处理函数可以接收一个Transition类型参数,该对象描述了触发、源状态与目标状态。Transition类的定义在 src/Stateless/Transition.cs 中,包含Source、Destination、Trigger、Parameters四个只读属性,以及指示"是否为自环重入"的IsReentry属性;构造函数保证Parameters永不为 null(无参数触发时为空数组)。
如果需要"仅当通过某个特定触发进入状态时"才执行动作,使用OnEntryFrom(trigger, action)(实现见 src/Stateless/StateConfiguration.cs#L892-L1071),这在参数化触发器一节会有实际用例。
内部转换(Internal Transitions)
有时某个触发需要被处理,但状态不应改变——这就是内部转换,使用InternalTransition:
_machine.Configure(State.Connected) .InternalTransition(Trigger.MuteMicrophone, t => OnMute()) .InternalTransition(Trigger.UnmuteMicrophone, t => OnUnmute());内部转换不触发 Exit / Entry 动作,也不改变状态,只执行关联的动作。从 src/Stateless/StateConfiguration.cs#L54-L57 的实现看,它本质是注册一个InternalTriggerBehaviour,在处理时(src/Stateless/StateMachine.cs#L457-L463)构造Transition(source, source, trigger, args)并仅执行内部动作。
内部转换同样支持携带参数与守卫条件。电话示例中,设置音量是带int参数的内部转换:
_internalTransition<int>(_setVolumeTrigger, (volume, t) => OnSetVolume(volume));重载形式包括InternalTransition<TArg0>、InternalTransition<TArg0, TArg1>、InternalTransition<TArg0, TArg1, TArg2>以及对应的InternalTransitionIf(...)(带守卫版本,可附加守卫描述字符串),详见 src/Stateless/StateConfiguration.cs#L126-L283。
初始状态转换(Initial State Transitions)
子状态可以被标记为初始状态(initial state):当状态机进入超状态(superstate)时,会自动继续进入该初始子状态:
sm.Configure(State.B) .InitialTransition(State.C); sm.Configure(State.C) .SubstateOf(State.B);InitialTransition的实现见 src/Stateless/StateConfiguration.cs#L1817-L1822。从源码的EnterState逻辑(src/Stateless/StateMachine.cs#L529-L545)可以看到两点重要约束:
InitialTransition的目标必须是该状态的子状态,否则抛出InvalidOperationException("The target ... for the initial transition is not a substate");- 初始转换同样会触发
OnTransitioned事件(以InitialTransition形式通知监听者),并递归进入更深层的初始子状态。
文档特别指出一个设计局限:由于 Stateless 的内部结构,状态机本身并不知道自己何时"启动",因此无法以传统方式处理初始转换。绕过该局限的推荐做法是:添加一个哑初始状态(dummy initial state),然后借助Activate()来"启动"状态机:
sm.Configure(InitialState) .OnActivate(() => sm.Fire(LetsGo)) .Permit(LetsGo, StateA)外部状态存储(External State Storage)
Stateless 被设计为可嵌入各种应用模型:某些 ORM 对映射数据的存放位置有要求,UI 框架常要求把状态存到特殊的"可绑定"(bindable)属性中。为此,StateMachine构造函数可以接受一对函数参数,分别用于读取和写入状态值:
var stateMachine = new StateMachine<State, Trigger>( () => myState.Value, s => myState.Value = s);该构造函数(src/Stateless/StateMachine.cs#L46-L76)要求stateAccessor与stateMutator均非 null,并把初始状态读取为stateAccessor()的返回值。此时状态机本体不持有任何内部状态(这就是"Stateless"名称的由来,详见文末),State属性的 getter/setter(src/Stateless/StateMachine.cs#L111-L121)直接委托给这两个函数。
文档提到的 JsonExample 解决方案位于 example/JsonExample,是外部状态存储的典型实践。其 Member.cs 把会员状态(MembershipState:Inactive/Active/Terminated)通过构造函数注入到状态机中,并提供ToJson()/FromJson()完成序列化往返:
[JsonConstructor] private Member(string state, string name) { var memberState = (MembershipState) Enum.Parse(typeof(MembershipState), state); _stateMachine = new StateMachine<MembershipState, MemberTriggers>(memberState); Name = name; ConfigureStateMachine(); }Program.cs 演示了完整流程:从 JSON 反序列化重建会员 →Suspend()/Reactivate()/Terminate()驱动状态迁移 → 再序列化为 JSON 并反序列化验证对象相等。这意味着状态的持久化位置完全由你掌控,状态机只负责转换规则。
激活与停用(Activation / Deactivation)
有时在存储对象状态之前、以及在恢复对象状态之后需要执行某些代码。为此提供Deactivate与Activate:
Activate():执行当前状态的激活(activate)动作,例如恢复外部资源;Deactivate():执行当前状态的停用(deactivate)动作,例如保存前释放或提交资源。
文档建议:激活只在正常操作开始前调用一次,停用只在状态存储前调用一次。对应 API 为OnActivate(() => ...)与OnDeactivate(() => ...),异步版本为ActivateAsync()/DeactivateAsync()(src/Stateless/StateMachine.Async.cs#L16-L31)。
激活/停用动作是幂等的:对同一状态的重复激活不会导致回调重复执行。测试 test/Stateless.Tests/ActiveStatesFixture.cs 专门验证了这一点(WhenActivateIsIdempotent),并验证了执行顺序——激活时从最深子状态开始、停用时从当前状态逐级向外(WhenActivate断言期望顺序为"ActivatedC", "ActivatedA",WhenDeactivate断言为"DeactivatedA", "DeactivatedC")。此外激活/停用不会触发转换事件(测试中注册了OnTransitioned回调并断言其未被调用)。
内省(Introspection)
状态机提供两类内省能力:
1. 当前可触发的触发列表
StateMachine.PermittedTriggers属性返回在当前状态下可被成功触发的触发集合。需要说明的是,源码 src/Stateless/StateMachine.cs#L123-L145 中该属性及GetPermittedTriggers(...)已被标记为[Obsolete](出于兼容保留),推荐使用异步版本PermittedTriggersAsync/GetPermittedTriggersAsync(params object[] args),以及能返回带参数信息的GetDetailedPermittedTriggers(...)。
2. 状态配置快照
调用StateMachine.GetInfo()可获取一个 StateMachineInfo 对象,它暴露InitialState、States(含各状态的进入/退出动作、转换、守卫条件等)、StateType与TriggerType。该对象是后文 DOT / Mermaid 图形导出的输入,也可用于自省与调试。GetInfo()的实现位于 src/Stateless/StateMachine.cs#L158-L184。
此外,CanFire(trigger)可预判某触发在当前状态是否可触发;其带out ICollection<string> unmetGuards的重载(src/Stateless/StateMachine.cs#L663-L720)还能返回未满足的守卫条件描述,便于诊断"为什么不能触发"。示例项目 example/AlarmExample/Program.cs 展示了如何结合CanFire列出当前所有可触发命令。
守卫子句(Guard Clauses)
状态机会根据守卫子句在多个转换候选中选择目标。典型场景是"同一个触发,按条件迁移到不同状态":
phoneCall.Configure(State.OffHook) .PermitIf(Trigger.CallDialled, State.Ringing, () => IsValidNumber) .PermitIf(Trigger.CallDialled, State.Beeping, () => !IsValidNumber);异步版本:
phoneCall.Configure(State.OffHook) .PermitIfAsync(Trigger.CallDialled, State.Ringing, async () => await IsValidNumber()) .PermitIfAsync(Trigger.CallDialled, State.Beeping, async () => !await IsValidNumber());使用守卫子句时必须遵守两条规则(文档明示):
- 同一状态内的守卫必须互斥——同一触发下多个守卫不能同时为真,否则行为未定义;
- 子状态可以覆盖(respecify)父状态的转换,但不能禁止父状态允许的转换——上溯查找机制保证了这一点。
守卫会在触发被Fire的每一次求值,因此守卫**必须是无副作用(side effect free)**的纯判断函数。
从源码看,守卫由TransitionGuard承载(见 src/Stateless/TransitionGuard.cs),PermitIf的各重载(src/Stateless/StateConfiguration.cs#L295-L461)支持:
- 简单守卫
Func<bool>,可附带guardDescription描述字符串; - 多守卫组合
params Tuple<Func<bool>, string>[] guards; - 带参数的守卫
Func<TArg0, bool>、Func<TArg0, TArg1, bool>、Func<TArg0, TArg1, TArg2, bool>。
守卫描述会进入StateMachineInfo(GuardConditionsMethodDescriptions,见 src/Stateless/Reflection/TransitionInfo.cs),从而出现在 DOT/Mermaid 导出图与未满足守卫异常信息中,这也是CanFire(out unmetGuards)数据来源。
参数化触发器(Parameterised Triggers)
强类型参数可以绑定到触发上。先通过SetTriggerParameters<TArg0>(trigger)声明参数类型,得到TriggerWithParameters<TArg0>对象,再在配置与触发时使用它:
var assignTrigger = stateMachine.SetTriggerParameters<string>(Trigger.Assign); stateMachine.Configure(State.Assigned) .OnEntryFrom(assignTrigger, email => OnAssigned(email)); stateMachine.Fire(assignTrigger, "joe@example.com");参数化触发在源码层面由TriggerWithParameters家族表示(src/Stateless/TriggerWithParameters.cs),SetTriggerParameters的重载位于 src/Stateless/StateMachine.cs#L245-L250(非泛型、按参数类型数组)与 src/Stateless/StateMachine.cs#L741-L777(泛型 1~3 个参数)。Fire时(src/Stateless/StateMachine.cs#L232-L307)会先经ValidateParameters校验参数个数与类型,再执行内部触发。
触发参数还可以用于动态选择目标状态(见下节PermitDynamic()),也可配合OnEntryFrom/InternalTransition使用。完整的带参数触发示例可参考 example/TelephoneCallExample/PhoneCall.cs 中的_setVolumeTrigger(int参数)与_setCalleeTrigger(string参数,用于在Ringing状态记录被叫号码)。
忽略转换与可重入状态(Ignored Transitions and Reentrant States)
Stateless 的默认策略是"一切转换必须显式定义":在当前状态触发一个没有允许转换的触发会抛出异常,从而防止意外状态变更。要"吞掉"某状态下的特定触发,使用Ignore(TTrigger):
phoneCall.Configure(State.Connected) .Ignore(Trigger.CallDialled);另一种思路是把状态标记为可重入(reentrant)。可重入状态可以转换回自身,此时该状态的退出与进入动作都会执行——非常适合"需要重置或重新初始化"的事件:
stateMachine.Configure(State.Assigned) .PermitReentry(Trigger.Assigned) .OnEntry(() => SendEmailToAssignee());可重入同样支持带守卫的同步与异步条件版本:
stateMachine.Configure(State.Assigned) .PermitReentryIf(Trigger.Assigned, () => ShouldSendEmailAgain()) .OnEntry(() => SendEmailToAssignee()); stateMachine.Configure(State.Assigned) .PermitReentryIfAsync(Trigger.Assigned, async () => await ShouldSendEmailAgain()) .OnEntry(() => SendEmailToAssignee());PermitReentry/PermitReentryIf的实现见 src/Stateless/StateConfiguration.cs#L473-L645。需要注意(源码注释也明确):重入只作用于当前状态本身,不会重新执行超状态(superstate)的动作,也不会触发超状态与子状态之间的转换动作。
默认情况下触发必须被显式忽略。要覆盖"未处理触发即抛异常"的默认行为,用OnUnhandledTrigger注册兜底回调:
stateMachine.OnUnhandledTrigger((state, trigger) => { });OnUnhandledTrigger有两种重载(src/Stateless/StateMachine.cs#L555-L570):Action<TState, TTrigger>与带ICollection<string> unmetGuards的Action<TState, TTrigger, ICollection<string>>,后者能拿到未满足的守卫描述列表;异步版本为OnUnhandledTriggerAsync(src/Stateless/StateMachine.Async.cs#L411-L415)。示例 example/AlarmExample/Program.cs 就是靠捕获InvalidOperationException来提示"当前状态不允许该命令"。
动态状态转换(Dynamic State Transitions)
动态状态转换允许在运行时根据触发参数或其他逻辑决定目标状态:
stateMachine.Configure(State.Start) .PermitDynamic(Trigger.CheckScore, () => score < 10 ? State.LowScore : State.HighScore);PermitDynamic的实现见 src/Stateless/StateConfiguration.cs#L1172-L1222,其重载支持 1~3 个触发参数(Func<TArg0, TState>等),并可附带destinationStateSelectorDescription描述与possibleDestinationStates候选目标列表(用于图形导出)。带守卫版本为PermitDynamicIf(src/Stateless/StateConfiguration.cs#L1308-L1758)。
动态转换的一个精妙之处:当动态计算出的目标状态与当前状态相同时,它实际上变成一次重入转换,状态的退出与进入动作会被执行——非常适合"按状态刷新/重置"的场景:
stateMachine.Configure(State.Waiting) .OnEntry(() => Console.WriteLine($"Elapsed time: {elapsed} seconds...")) .PermitDynamic(Trigger.CheckStatus, () => ready ? State.Done : State.Waiting);当!ready时状态机"自环"于Waiting,OnEntry反复执行以刷新等待计时;一旦ready则迁移到Done。
状态变更通知(事件)
Stateless 支持两类状态机事件:
- 状态转换(State transition)——每次状态变更时触发;
- 状态机转换完成(State machine transition completed)——在触发处理的最后、最后一个进入动作执行完毕之后触发。
同步与异步注册方式:
// 状态转换(同步 / 异步) stateMachine.OnTransitioned((transition) => { }); stateMachine.OnTransitionedAsync((transition) => { return Task.FromResult(0); }); // 转换完成(同步 / 异步) stateMachine.OnTransitionCompleted((transition) => { }); stateMachine.OnTransitionCompletedAsync((transition) => { return Task.FromResult(0); });两类事件都携带Transition参数(含Source、Destination、Trigger、Parameters)。从源码的事件时序看(src/Stateless/StateMachine.cs#L495-L514):先执行源状态树的 Exit 动作 → 更新State→ 触发OnTransitioned→ 执行目标状态树的 Entry 动作(含初始子状态递归)→ 最后触发OnTransitionCompleted。事件实现位于 src/Stateless/OnTransitionedEvent.cs,注册/注销 API 集中在 src/Stateless/StateMachine.cs#L802-L859。
注销回调的三种方式
除了注册,Stateless 还提供三种取消订阅的途径:状态转换注销(同步/异步)、转换完成注销(同步/异步)、以及一次性注销全部(同步与异步一并清除)。
状态转换注销(同步)——先保存回调引用,再交给OnTransitionedUnregister:
Action transitionCallbackAction = (transition) => { }; stateMachine.OnTransitionedUnregister(transitionCallbackAction);状态转换注销(异步):
Func<Transition, Task> transitionAsyncCallback = (transition) => { return Task.FromResult(0); }; stateMachine.OnTransitionedAsyncUnregister(transitionAsyncCallback);转换完成注销(同步):
Action transitionCompletedCallbackAction = (transition) => { }; stateMachine.OnTransitionCompletedUnregister(transitionCompletedCallbackAction);转换完成注销(异步):
Func<Transition, Task> transitionCompletedAsyncCallback = (transition) => { return Task.FromResult(0); }; stateMachine.OnTransitionCompletedAsyncUnregister(transitionCompletedAsyncCallback);注销全部回调(同步与异步):
stateMachine.UnregisterAllCallbacks();UnregisterAllCallbacks()会同时清空"已转换"与"转换完成"两个事件上的所有同步、异步回调(src/Stateless/StateMachine.cs#L855-L859)。订阅/注销的实际使用可以参考 example/OnOffExample/Program.cs:运行中按r订阅、按u注销OnTransitioned回调,这正是长驻应用中防止内存泄漏的典型姿势。
导出 DOT 状态图
运行时可视化状态机非常有用:代码是权威来源(authoritative source),状态图只是始终与代码保持同步的副产品。导出方式:
phoneCall.Configure(State.OffHook) .PermitIf(Trigger.CallDialled, State.Ringing, IsValidNumber); string graph = UmlDotGraph.Format(phoneCall.GetInfo());UmlDotGraph.Format()(src/Stateless/Graph/UmlDotGraph.cs)接收GetInfo()返回的StateMachineInfo,返回 DOT 图语言(Graphviz 的 DOT 格式)字符串,例如:
digraph { OffHook -> Ringing [label="CallDialled [IsValidNumber]"]; }从 src/Stateless/Graph/UmlDotGraphStyle.cs 的实现看,生成的图带有compound=true、node [shape=Mrecord]、rankdir="LR"(从左到右布局)等预设,并把进入/退出动作写进节点标签、守卫写进转换标签([守卫描述])、PermitDynamic渲染为菱形决策节点。底层先由 StateGraph 把StateMachineInfo转成符号化的图结构(状态、转换、决策节点),再由样式类GraphStyleBase派生类负责格式化输出。
生成后的 DOT 文本可由支持 DOT 语言的各种工具渲染,例如 Graphviz 的dot命令行工具或 viz.js 等浏览器端引擎。命令行示例(生成 PDF):
dot -T pdf -o phoneCall.pdf phoneCall.dot如果你已有.dot源文件,也可用dot -Tpng、-Tsvg等参数输出其他格式。完整可运行的 DOT 导出参考 example/TelephoneCallExample/PhoneCall.cs 中的ToDotGraph()方法。
导出 Mermaid 状态图
除了 DOT,Stateless 还能直接生成 Mermaid 图(适用于 GitHub Markdown、Obsidian 等支持 Mermaid 的渲染引擎):
phoneCall.Configure(State.OffHook) .PermitIf(Trigger.CallDialled, State.Ringing); string graph = MermaidGraph.Format(phoneCall.GetInfo());MermaidGraph.Format()(src/Stateless/Graph/MermaidGraph.cs)返回 MermaidstateDiagram-v2格式文本,例如:
stateDiagram-v2 [*] --> OffHook OffHook --> Ringing : CallDialled这段文本可以直接嵌入支持 Mermaid 的 Markdown 文档渲染为图形:
Mermaid 导出还支持方向控制:Format(machineInfo, MermaidGraphDirection? direction)可传入方向枚举(src/Stateless/Graph/MermaidGraphDirection.cs),包括LeftToRight(LR)、RightToLeft(RL)、TopToBottom(TB)、BottomToTop(BT),对应 MermaidGraphStyle 的GetDirectionCode映射。此外,Mermaid 样式类会自动把含空格、冒号、连字符等非法字符的状态名"净化"为别名,并在图中以别名 : 原名形式标注(见BuildSanitizedNamedStateMap),保证生成的图合法可渲染。
异步触发器(Async Triggers)
在提供Task<T>的平台上,StateMachine支持异步的进入/退出动作等:
stateMachine.Configure(State.Assigned) .OnEntryAsync(async () => await SendEmailToAssignee());异步处理器必须使用带*Async()后缀的方法注册(如OnEntryAsync、OnExitAsync、PermitIfAsync、PermitReentryIfAsync、OnTransitionedAsync、OnTransitionCompletedAsync等,见 src/Stateless/StateMachine.Async.cs 及 src/Stateless/StateConfiguration.Async.cs)。触发会调用异步动作的触发也必须用FireAsync():
await stateMachine.FireAsync(Trigger.Assigned);异步触发同样支持 Queued 模式(InternalFireQueuedAsync,src/Stateless/StateMachine.Async.cs#L177-L201),事件与动作通过ConfigureAwait(RetainSynchronizationContext)决定是否回到调用方同步上下文。
重要限制(文档明示):尽管StateMachine可以异步使用,但它仍然是单线程的,不能被多个线程并发使用。异步只表示动作可等待,不代表线程安全。
高级特性:保留 SynchronizationContext
在某些特定场景下,必须让所有处理器方法都在消费方的SynchronizationContext上被调用。此时在创建状态机时设置RetainSynchronizationContext属性:
var stateMachine = new StateMachine<State, Trigger>(initialState) { RetainSynchronizationContext = true };该属性默认值为false(src/Stateless/StateMachine.cs#L96)。文档给出的典型场景是 Microsoft Orleans 的 Grain:Grain 需要保留SynchronizationContext才能调用其他 Grain。开启后,异步回调会通过TaskScheduler.FromCurrentSynchronizationContext()调度(src/Stateless/StateMachine.Async.cs#L313-L315)。
构建与运行环境
Stateless 通过 src/Stateless/Stateless.csproj 多目标编译:netstandard2.0;net462;net8.0;net9.0;net10.0,因此可在 .NET runtime 4.0 及以上、几乎所有的现代 .NET 平台上运行(对应文档中的 ".NET Framework 4.6.2、.NET Standard 2.0、.NET 8.0/9.0/10.0")。构建解决方案需要 Visual Studio 2017 或更高版本;仓库根目录的 Stateless.sln 与测试解决方案 test/Stateless.Tests/Stateless.Tests.sln 可用于还原与编译。
深入源码:触发分发与执行链路
结合文档内容,理解Fire的底层链路有助于排查"为什么没按预期迁移"的问题。核心流程在InternalFireOne(src/Stateless/StateMachine.cs#L392-L467):
- 参数校验:若触发已配置参数(
_triggerConfiguration命中),先ValidateParameters(args); - 查找处理程序:
TryFindHandler沿当前状态及其父状态链查找匹配的TriggerBehaviourResult;未找到时执行_unhandledTriggerAction(默认抛异常); - 按行为类型分发:
IgnoredTriggerBehaviour→ 直接返回(忽略);ReentryTriggerBehaviour→ 执行退出/进入动作的自环处理(HandleReentryTrigger);DynamicTriggerBehaviour→ 动态求值目标后处理;TransitioningTriggerBehaviour→ 常规转换(若目标与源相同则不执行,防止超状态意外重入);InternalTriggerBehaviour→ 仅执行内部动作,不改变状态。
Queued 模式下InternalFireQueued(src/Stateless/StateMachine.cs#L358-L384)用_eventQueue按 FIFO 排队,正在处理时新触发的触发会入队等待,从而保证 run-to-completion。
示例项目与测试佐证
仓库 example 目录提供了可直接运行的参考实现:
- example/TelephoneCallExample:文档首页的完整电话通话状态机(含子状态、内部转换、参数化触发、事件订阅与 DOT 导出);
- example/OnOffExample:最简单的开关机示例——状态用
string、触发用char,演示通用类型支持与事件的订阅/注销; - example/JsonExample:会员状态机与 JSON 序列化往返,演示状态作为数据持久化;
- example/AlarmExample:用定时器实现临时状态(布防延时、触发延时、超时返回),演示
CanFire、OnTransitioned与InvalidOperationException处理。
测试方面,test/Stateless.Tests 覆盖了本文涉及的大部分特性,包括:层次化状态与初始转换(InitialTransitionFixture.cs)、守卫与未处理触发(StateMachineFixture.cs)、动态触发(DynamicTriggerBehaviourFixture.cs 等)、内省信息(GetInfoFixture.cs)、异步触发(AsyncTransitionTests.cs、AsyncFiringModesFixture.cs)、DOT 与 Mermaid 导出(DotGraphFixture.cs、MermaidGraphFixture.cs)以及激活/停用(ActiveStatesFixture.cs)。需要了解某个 API 的确切语义时,这些测试是最可靠的参照。
设计目标与命名由来
文档在结尾明确该项目目标:本页面是 Stateless 近乎完整的描述,且刻意保持最小化(remain minimal)——API 小而聚焦,不引入额外运行时依赖。关于命名:"Stateless"(无状态)的含义是——它实现的是关于状态转换的那套规则,但当使用委托版本(外部状态存储)的构造函数时,它自身并不维护任何内部状态。这也解释了为什么它如此适合嵌入 ORM 实体、可绑定属性与序列化模型:状态机只是规则引擎,状态存放位置完全由你决定。
- 后端
- 工作流自动化
【免费下载链接】stateless
A simple library for creating state machines in C# code
相关推荐
Stateless:轻量级状态机与工作流引擎
Stateless:轻量级状态机与工作流引擎 项目介绍 Stateless 是一个用于在 .NET 代码中直接创建状态机和基于状态机的工作流的轻量级库。它允许开
后端工作流自动化Stateless完全指南:在C中构建轻量级状态机的终极教程
Stateless完全指南:在C 中构建轻量级状态机的终极教程 Stateless是一个简单而强大的C 状态机库,专门为开发者提供轻量级、易用的状态机实现方案。
后端工作流自动化【亲测免费】 状态机轻量级解决方案:Stateless 使用指南
状态机轻量级解决方案:Stateless 使用指南 项目介绍 Stateless 是一个简单易用的库,专为 C 语言设计,旨在简化状态机在 .NET 项目中的创
后端工作流自动化
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考