Elsa Workflows 状态机活动(State Machine Activity)设计研究:从决策到实现的完整解读
Elsa Workflows 状态机活动(State Machine Activity)设计研究:从决策到实现的完整解读
本文以
elsa-core仓库中 specs/006-state-machine-activity/research.md 为核心骨架,结合同目录下的规格、数据模型、快速上手与 JSON 契约文档,以及Elsa.Workflows.Core中的实际实现源码与单元测试,系统讲解 Elsa(.NET 工作流引擎)如何以服务端原生活动的方式落地"命名状态 + 触发器驱动转移"的状态机能力。读完本文,你将掌握状态机活动的数据模型、执行契约(Entry → 触发器 → Condition → Action → Exit → 目标 Entry)、竞争触发器取消与失败触发器重挂(re-arm)的持久化语义,以及如何在 C# 中构建并运行一个可暂停、可恢复的状态机工作流。
一、研究文档核心:三个关键架构决策
research.md 是本次特性立项阶段产出的决策记录,围绕三个核心问题给出了明确结论,这三个决策直接决定了后续规格、实现与测试的全部走向。
决策一:首个实现仅限服务端
决策:State Machine Activity 的第一版实现放在 elsa-core 服务端,不包含 Studio 设计器支持。
理由:该特性对应的 GitHub issue(#5085)同时包含后端活动语义与一个规模较大的 Studio 设计器工作。后端能力可以在 elsa-core 中独立交付;设计器支持属于独立的 Studio 工作范畴。
备选方案:在同一变更中一并完成设计器改造。该方案被否决,原因是它跨越了仓库与产品边界,且体量远大于运行时活动本身。
从源码印证:plan.md 的"Constitution Check"明确标注 Scope 为"One coherent backend concern",并记录约束"Keep Studio designer work out of this change"。仓库中实际的实现位置也验证了这一点——状态机活动只出现在 StateMachine.cs,没有对应任何 Studio 模块的改动。
决策二:转移(Transition)按状态名称引用状态
决策:转移中的 From / To 使用状态名称(name)而非对象引用。
理由:issue 示例 JSON 中即使用 from / to 状态名称;名称引用在持久化 JSON 中是稳定的,并且对首个后端模型而言最简单。
备选方案:直接对象引用。被否决的原因是:转移对象可能与状态对象被分开序列化,对象引用无法跨序列化边界保持稳定;且名称引用与 issue 的契约一致。
从源码印证:Transition.cs 中 From 与 To 均为 string 类型;StateMachine.cs 通过 FindState、GetOutboundTransitions、IsCurrentSource 等方法以字符串相等(StringComparison.Ordinal)完成状态查找与来源校验,整个运行路径不持有状态对象引用。
决策三:复用既有的子活动调度与取消行为
决策:利用 Elsa 已有的"取消子活动上下文并清理书签"的行为来承载状态机语义——把每个触发活动(Trigger)作为子活动调度,当某个转移获胜时,取消其余"落败"触发器的执行上下文。
理由:Elsa 在取消一个活动上下文时已经会取消其子活动上下文并清理书签;因此状态机无需引入独立的触发器注册表。
备选方案:新增独立的触发器注册表(trigger registry)。被否决——对首个实现而言没有必要。
从源码印证:StateMachine.cs 中的 CancelCompetingTriggersAsync 正是这一决策的落地:它先收集获胜转移 From 状态的所有其他出站转移的 Trigger Id,然后在 WorkflowExecutionContext.ActivityExecutionContexts 中找出这些竞争触发器对应的子上下文并逐个调用 CancelActivityAsync(),同时从调度器移除尚未执行的竞争触发工作项、移除对应的完成回调(RemoveScheduledCompetingTriggers / RemoveCompetingTriggerCallbacks)。
二、数据模型:StateMachine / State / Transition 三实体
data-model.md 给出了三个核心实体的完整字段定义,它们与源码中的模型类一一对应。
StateMachine(活动本体)
| 属性 | 说明 |
|---|---|
States |
有序的状态定义集合(声明顺序即求值顺序) |
Transitions |
有序的有向转移定义集合 |
InitialState |
当没有 CurrentState 时首先进入的状态名称 |
CurrentState |
当前活动状态名称,随转移完成而更新;无有效出站转移的当前状态即终止态 |
对应源码见 StateMachine.cs,其中 CurrentState 属性同时写入工作流执行上下文的属性存储(属性键为常量 CurrentStateProperty = "CurrentState",见 SetCurrentState),从而获得持久化能力——工作流暂停再恢复后可从上次的 CurrentState 继续。
State(状态)
| 属性 | 说明 |
|---|---|
Name |
状态名称;按约定应唯一,重复名称以第一个声明为准 |
Entry |
可选活动,进入该状态时调度执行 |
Exit |
可选活动,离开该状态时调度执行 |
对应源码见 StateMachineState.cs。Entry / Exit 均为 [Port] 修饰的 IActivity 标量槽位(非数组),因此当需要在单个槽位执行多个活动时,必须使用 Sequence 活动包裹。
Transition(转移)
| 属性 | 说明 |
|---|---|
Name |
可选、机器可读的转移名称 |
DisplayName |
可选、人类可读的转移名称 |
From |
源状态名称 |
To |
目标状态名称 |
Trigger |
可选活动,等待触发事件或条件来源;条件为 false 时该触发器保持活动以待后续再次尝试 |
Condition |
可选布尔表达式输入;缺失视为 true;false 时源状态保持当前、同一转移触发器被重新挂起(re-arm) |
Action |
可选活动,在触发器获胜之后、源状态 Exit 之前调度 |
对应源码见 Transition.cs。注意 Condition 是 Input<bool>(带 <a href="https://link.gitcode.com/i/6735a4627f282713da7cd5291bb43799" target="_blank">Input(UIHint = InputUIHints.SingleLine)]),这意味着它支持表达式求值,实际求值经由 IExpressionEvaluator 完成(见 [EvaluateConditionAsync),因此条件可以是常量、变量引用或脚本表达式。
三、执行契约:状态转移的完整生命周期
data-model.md 用一段状态转移图总结了核心语义,quickstart.md 则给出了逐步执行的详细契约。两者共同构成状态机的运行时行为基线。
3.1 标准转移路径
EnterState -> Entry -> OutboundTriggers
EnterState -> Entry -> NoValidOutboundTransitions -> CompleteStateMachine
OutboundTrigger -> ConditionFalse -> ReArmFailedTrigger + OutboundTriggersRemainPending
OutboundTrigger -> ConditionTrue -> CancelCompetingTriggers -> Action -> SourceExit -> TargetEntry -> OutboundTriggers
逐条对照源码:
- 进入状态优先调度 Entry:EnterStateAsync 先检查
state.Entry,非空则用PhaseEntering标记调度该活动,Entry 完成回调 OnStateEntryCompletedAsync 再触发出站触发器调度。 - Entry 完成后调度出站触发器:ScheduleOutboundTriggersAsync 按声明顺序先处理无 Trigger 的自动转移(立即求值
TryTakeTransitionAsync),再为所有带 Trigger 的转移逐个调度触发器活动;无任何有效出站转移时直接CompleteActivityAsync()完成状态机。 - 条件求值:TryTakeTransitionAsync 中
Condition == null || EvaluateConditionAsync(...)成立才算合格;缺失条件恒为 true。 - 竞争触发器取消:合格转移先调用
CancelCompetingTriggersAsync(见上文决策三的源码印证)。 - 生命周期顺序:
SourceExit → TransitionAction → TargetEntry → TargetOutboundTriggers。ExitStateAsync 优先调度源状态 Exit;RunTransitionActionAsync 调度转移 Action;CompleteTransitionAsync 先更新CurrentState再EnterStateAsync进入目标状态。
3.2 无触发器自动转移与终止态
- 无 Trigger 的转移立即求值:在
ScheduleOutboundTriggersAsync中按声明顺序逐个尝试,Condition缺失视为 true 即被接受;条件为 false 则"落到"下一条转移继续尝试;若存在事件型转移,其触发器仍会被调度。 - 自转移(self-transition):
quickstart.md明确"自转移运行完整的 Exit → Action → Entry 生命周期",即不会绕过任何槽位。 - 终止态:进入一个没有任何有效出站转移的状态后,状态机完成(见
ScheduleOutboundTriggersAsync的空集合分支与 spec.md 的 FR-013)。 - 缺失状态的防御:
ExecuteAsync中若解析出的初始状态不存在,直接完成状态机且不调度任何子活动;转移的目标状态缺失则该转移不参与调度(见 ExecuteAsync 与ScheduleOutboundTriggersAsync中的FindState(x.To) != null过滤)。
3.3 触发器的"重新挂起"(Re-arm)语义
这是本特性区别于 WF4 的关键语义点,也是 research.md 决策三的延伸:
- 若事件型 Trigger 完成但其
Condition求值为 false,则不取消竞争触发器,并把该失败触发器重新调度以待将来再次触发(OnTriggerCompletedAsync 中的ScheduleAsync(... OnTriggerCompletedAsync ...)分支)。 quickstart.md明确注释:这是 Elsa 有意为之的持久化语义偏差——WF4 的行为是"全部取消并重新调度",而 Elsa 选择"保留已在运行的竞争者 + 重挂失败触发器"。该边界被记录为已接受的状态机语义 ADR,并通过校验与回归测试可观察。
3.4 触发器循环与调用栈安全
quickstart.md 指出:若所有出站转移都是无 Trigger 且条件为 false,状态机保持运行但不会产生忙循环;可达的无触发器循环会经由 Elsa 调度器在转移之间让步(yield),从而不会让同步调用栈无限增长。
源码印证:CompleteTransitionAsync 通过 HasAutomaticCycle 做图遍历检测——若当前状态可通过无 Trigger 转移回到自身,则调度一个内部 _automaticTransitionContinuation 内联活动(OnAutomaticTransitionContinuationCompletedAsync),让调度器先解开会话再继续下一轮状态进入,既保持 WF4 的"转移已完成、目标状态为当前"顺序,又避免同步递归爆栈。
3.5 延续标签与定义变更敏感性
quickstart.md 提醒一个兼容性要点:延续标签(continuation tag)使用声明顺序的转移身份。从源码看,GetTransitionKey 以转移在 Transitions 集合中的下标拼接显示键(Name 或 From->To)生成标签。因此当工作流实例处于挂起状态时,重排转移声明顺序属于兼容性敏感的定义变更,可能导致恢复时按标签找不到原转移(源码中 FindTransitionByKey 会先在当前状态出站转移中查找,再回退到全量转移中查找)。
四、JSON 序列化契约与持久化
contracts/state-machine-json.md 给出了后端活动经由 Elsa 既有活动 JSON 管道序列化时接受的形状。entry、exit、trigger、action 内部的精确活动负载均由 Elsa 既有的活动序列化器解析。
{
"type": "StateMachine",
"initialState": "NewOrder",
"currentState": "NewOrder",
"states": [
{
"name": "NewOrder",
"entry": { "type": "WriteLine", "text": "New order" },
"exit": { "type": "WriteLine", "text": "Leaving new order" }
},
{
"name": "Paid",
"entry": { "type": "WriteLine", "text": "Paid" }
}
],
"transitions": [
{
"name": "MarkPaid",
"displayName": "Mark paid",
"from": "NewOrder",
"to": "Paid",
"trigger": { "type": "Event" },
"condition": true,
"action": { "type": "WriteLine", "text": "Payment accepted" }
}
]
}
三个与该契约直接相关的实现事实:
currentState持久化:CurrentState被写入执行上下文属性(CurrentStateProperty = "CurrentState"),恢复工作流时GetCurrentState优先读取上下文属性(GetCurrentState),因此持久化 JSON 中的currentState与运行时进度一致。- 共享触发器实例被显式拒绝:
quickstart.md指出内联 StateMachine JSON 无法保留 WF4 的共享 Trigger 对象身份,因此 Elsa 3.8 显式拒绝共享 Trigger 实例与重复的触发器活动 ID。EnsureSupportedTriggerIdentities 在ExecuteAsync入口做双重校验(引用相等 + ID 相等),命中任一情况即抛出InvalidOperationException,提示每个转移必须使用带唯一 ID 的独立触发器活动;GetActivities 在构建工作流图时也做了同样的去重过滤。 - 终止态与失败条件语义内嵌于契约:目标状态无有效出站转移即终止;条件求值为 false 的转移保持源状态为当前状态,并让该转移触发器继续活跃以待后续尝试。
五、C# 构建快速上手
quickstart.md 给出了用 C# 构建状态机的四步法,以下是补全细节后的可运行版本(模式参考单元测试 StateMachineTests.cs 中的构造方式):
using Elsa.Workflows.Activities.StateMachine.Activities;
using Elsa.Workflows.Activities.StateMachine.Models;
var stateMachine = new StateMachine
{
InitialState = "New",
States =
{
new StateMachineState { Name = "New", Entry = new WriteLine("enter new") { Id = "new-entry" }, Exit = new WriteLine("exit new") { Id = "new-exit" } },
new StateMachineState { Name = "Paid", Entry = new WriteLine("enter paid") { Id = "paid-entry" } },
new StateMachineState { Name = "Closed" }
},
Transitions =
{
new Transition
{
Name = "Pay",
From = "New",
To = "Paid",
Trigger = new WriteLine("pay trigger") { Id = "pay-trigger" },
Condition = new(true),
Action = new WriteLine("pay action") { Id = "pay-action" }
},
new Transition
{
Name = "Cancel",
From = "New",
To = "Closed",
Trigger = new WriteLine("cancel trigger") { Id = "cancel-trigger" },
Condition = new(true)
}
}
};
构建要点(均来自 quickstart.md):
InitialState必须非空。- 状态名称必须唯一;每个状态可带可选的
Entry/Exit活动。 - 转移按声明顺序处理;每个转移指定源与目标状态名,可包含一个可选的
Trigger、Condition、Action。 - 槽位是标量而非数组:
Trigger与Action都是单活动槽位,若一个槽位需要运行多个活动,必须使用Sequence包裹。 - 每个 Trigger 必须是独立实例且拥有唯一
Id,否则EnsureSupportedTriggerIdentities会在执行时抛错。
六、运行与验证:测试套件与命令
该特性的全部任务均已完成(见 tasks.md,T001–T016 全部勾选),实现与测试位于:
- 实现:StateMachine.cs(416 行,含调度、取消、重挂、自动循环让步与身份校验)
- 模型:StateMachineState.cs、Transition.cs
- 测试:StateMachineTests.cs(701 行)
运行聚焦的测试命令(来自 quickstart.md):
dotnet test test/unit/Elsa.Activities.UnitTests/Elsa.Activities.UnitTests.csproj --no-restore --filter FullyQualifiedName~StateMachine
测试套件覆盖范围(quickstart.md 原文 + 源码对照):
- 触发器调度顺序:
SchedulesInitialStateEntryBeforeOutboundTriggers验证 Entry 先于出站 Trigger 调度;SchedulesOutboundTriggersAfterEntryCompletes验证 Entry 完成后两个出站触发器(Pay、Cancel)均被调度且状态机保持Running。 - 完整转移路径:
ExecutesAcceptedTransitionPath按"Trigger → 源 Exit → Action → 目标 Entry → 目标 Trigger"逐步完成调度,并断言CurrentState与上下文属性均更新为Paid;AcceptedTransitionExecutesObservableLifecycleOrder用生命周期列表验证可观测的执行顺序。 - 缺失 / false 条件:缺失条件视为 true;false 条件不调度 Action、不取消竞争者、失败触发器被重挂(T015/T016 专门为 false 条件重挂增加了断言与实现)。
- 空槽位、自转移、终止态:缺失的 Entry / Exit / Action 被视为空步骤直接推进;自转移执行完整生命周期;进入无出站转移状态即完成状态机。
- 竞争触发器取消:一个触发器获胜后,同一源状态的其他触发器上下文被取消(对应 CancelCompetingTriggersAsync)。
- 身份拒绝:共享 Trigger 实例 / 重复 Trigger ID 的校验(EnsureSupportedTriggerIdentities)。
- 持久化 / 恢复:
CurrentState属性在上下文与 JSON 中的读写。 - 自动循环调度器让步:无触发器环不爆同步调用栈(HasAutomaticCycle)。
七、规格约束与成功标准一览
spec.md 将上述行为固化为 14 条功能需求(FR-001~FR-014)与 5 条可度量成功标准(SC-001~SC-005),并补充了完整的边界情况清单。核心约束摘录如下:
- FR-004:从
CurrentState(若已设置)启动,否则从InitialState启动——这正是持久化恢复的依据。 - FR-005 / FR-006:进入状态先调度 Entry,Entry 完成后调度所有有效出站触发器。
- FR-007 / FR-008:触发器完成后求值条件;缺失条件视为 true;合格转移先运行 Action 再离开源状态。
- FR-009 / FR-010:Action 完成后调度源 Exit 再调度目标 Entry;转移完成必须先将
CurrentState更新为目标状态,再调度目标出站触发器。 - FR-011:一个转移被接受时,同一源状态的其他挂起出站触发器必须全部取消。
- FR-012:false 条件必须保持状态不变、不得取消竞争触发器、必须让失败触发器保持活跃以待后续尝试。
- FR-013:无有效出站转移的状态在 Entry 完成后完成状态机。
- FR-014:单元测试必须覆盖初始执行、条件 true/false、空槽位、终止态与竞争触发器取消。
边界情况(Edge Cases)还包括:缺失初始状态直接完成且不调度子活动;转移缺失源/目标状态则不调度该转移;缺失触发器则该转移无法被调度;缺失 Entry / Exit / Action 视为空步骤直接推进;重复状态名以第一个声明为准。
范围假设(Assumptions)再次强调:本变更仅覆盖 elsa-core 服务端工作流执行;状态与转移作为活动属性、沿用 Elsa 既有序列化模式存储;转移的源/目标引用使用状态名称;触发器书签清理依赖 Elsa 既有的取消行为——这三条与 research.md 的三个决策一一呼应。
八、总结:一份从决策到测试闭环的工程样本
State Machine Activity 是 Elsa 工作流引擎中一个典型的"研究先行、规格闭环"特性:research.md 的三个决策(服务端优先、名称引用、复用子活动取消机制)为后续所有文档定下了基调;spec.md 将其展开为可测试的功能需求与边界情况;data-model.md 与 contracts/state-machine-json.md 定义了序列化契约;plan.md 与 tasks.md 规划了分阶段实施路径;最终 StateMachine.cs 与 StateMachineTests.cs 以约 400 行实现加约 700 行测试将其完整落地,并引入了两项 Elsa 特有的持久化语义——竞争触发器取消与失败触发器重挂,同时通过延续标签身份、共享触发器拒绝与自动循环让步等机制保证了挂起恢复与调用栈安全。
对于需要在 .NET 服务端建模"等待外部事件、按条件分派、多路径竞争"类流程的开发者,可以直接基于本文第六节的构建模式接入该活动,并借助 quickstart.md 中的测试命令验证行为;而理解 research.md 中三个决策的取舍逻辑,则有助于在自行扩展(例如为状态机增加可选的 StateMachineExit 语义或 Studio 设计器)时保持与既有架构约束一致。