Elsa Workflows 架构决策记录(ADR)实战指南:从 ADR 0001 到自动化目录的完整实践

原创2026-09-27 17:10:171,178 阅读
文章标签:后端工作流自动化流程编排低代码

Elsa Workflows 架构决策记录(ADR)实战指南:从 ADR 0001 到自动化目录的完整实践

本篇技术指南以 ADR 0001《Record architecture decisions》 为起点,系统讲解 Elsa Workflows(elsa-core,.NET 工作流引擎)如何通过**架构决策记录(Architecture Decision Records, ADR)**固化引擎级的架构决策,包括 Nygard 模板、仓库内 36 份 ADR 的组织结构、toc.md 的自动化生成与 CI 校验,以及"何时该写 ADR"的判断标准。读完本文,你将掌握一套可直接复用的 ADR 实践方案,并能按图索骥读懂 Elsa 在书签管理、Flowchart 执行模型、租户与安全等领域的关键设计决策。

一、ADR 0001:为什么要"记录架构决策"

ADR 0001 是整个 doc/adr/ 目录的第一份记录,日期为 2025-04-01,状态为 Accepted(已接受)。它的正文极为克制,但含义深远:

  • Context(背景):项目需要记录已做出的架构决策。
  • Decision(决策):采用 Architecture Decision Records 方法,该方法由 Michael Nygard 在 2011 年发表的《Documenting Architecture Decisions》一文中提出。
  • Consequences(后果):参照 Nygard 的文章;如需轻量级 ADR 工具集,可参考 Nat Pryce 的 adr-tools。

从源码结构看,这份 ADR 是整个文档体系的"元决策":它先决定了"用 ADR 记录架构决策"这一方法本身,随后目录中的每一份记录都是该决策的落地成果。这正符合 ADR 的经典用法——架构决策本身也需要被记录,包括"采用 ADR 这件事"的决策。

二、ADR 模板结构:Context / Decision / Consequences

Nygard 提出的 ADR 模板围绕三个核心段落展开,Elsa 仓库的每一份 ADR 都严格遵守这一骨架(并在其后演化出 Status、Date 等字段):

段落 作用 Elsa 仓库中的写法示例
Status 记录决策的当前状态(Accepted / Superseded 等) 每份 ADR 开头均标注 Accepted
Context 描述触发决策的问题背景、约束与矛盾 ADR 0002 说明"子活动出错时父活动被连带置为 Faulted,导致工作流永久卡死"的隐患
Decision 明确陈述最终选择的方案 ADR 0003 决定"书签直接写入 WorkflowExecutionContext.Bookmarks,并保留一个仅存放新建书签的私有临时列表"
Consequences 列出采纳后的正面与负面影响 ADR 0004 明确指出"每次执行都会增加快照存储开销,我们接受该代价以换取正确性与开发体验"
Alternatives Considered(可选扩展) 记录被否决的备选方案及否决理由 ADR 0002 否决了"运行时动态查询后代活动聚合故障数"的方案,理由是开销过大

以 ADR 0003 为例,它完整展示了模板的实战价值:旧架构中 ActivityExecutionContext 维护一个临时书签列表,由中间件在活动完成后拷贝到 WorkflowExecutionContext.Bookmarks 再持久化。问题在于——工作流从数据库恢复时只还原 WorkflowExecutionContext 及其书签,ActivityExecutionContext 的临时列表为空,导致恢复后的活动无法取消自己之前创建的书签。决策是让活动直接向 WorkflowExecutionContext.Bookmarks 添加书签(因为每个 Bookmark 已携带 ActivityId 与 ActivityInstanceId,可准确归属),同时保留一个明确用于"新建书签"的临时列表以维持 AutoCompleteBehavior 的既有约定。这一案例说明:一份好的 ADR 不只是结论,更记录了"为什么旧方案行不通"的完整推理链。

三、Elsa 仓库中的 ADR 目录全景

截至当前仓库状态,doc/adr 目录共存有 36 份 ADR,分为两种命名风格:

  1. 顺序编号(0001–0027):NNNN-slug.md 形式,例如 0005-token-centric-flowchart-execution-model.md。
  2. 日期前缀(2026-08-25 起新增):YYYY-MM-DD-slug.md 形式,例如 2026-09-15-correlated-workflow-activation.md。

完整的索引位于 doc/adr/toc.md,而 doc/adr/graph.dot 则用 Graphviz DOT 语言描述了 ADR 之间的演进关系(虚线表示时间顺序链,Partially supersedes / Refines 等标签表示决策的替代与细化关系,例如 ADR 0021 部分取代了 0015 与 0019 的某些部分)。从文档主题分布看,这些 ADR 覆盖了工作流引擎的几个核心领域:

3.1 命名约定的演化:从顺序编号到日期前缀

ADR 2026-08-25《Identify new ADRs by date instead of a sequential number》 记录了一次重要的元决策:顺序编号在没有"预留号"机制的情况下,多个并行分支会同时猜同一个下一个编号,导致合并冲突。User Tasks 的两份记录就曾在两次合并中被连续重编号(0011/0012 → 0014/0015 → 0026/0027),每次重编号都要改文件名、改正文标题、手工重建 toc.md。新约定是:新增 ADR 命名为 YYYY-MM-DD-slug.md,标题不再带数字前缀;已存在的 0001–0027 保持原名不动。文档还明确了后果——两份 ADR 可以在同一天新增而不冲突,且 ADR 之间不再用短序号引用,而是用文件名交叉引用。

四、从 ADR 到代码:三份代表性质疑看决策落地

ADR 的价值最终体现在代码中。以下三份记录可以直接与仓库源码相互印证。

4.1 ADR 0005/0007:Flowchart 的 Token 中心执行模型与 MergeMode

ADR 0005 指出旧 Flowchart 使用"执行次数启发式"驱动 join,在循环、XOR 分流和可恢复活动中会失效(例如回边从不发出"前向"token 导致 AND-join 卡死)。决策是采用 token 中心模型:每次活动完成时,为每条活动出边发出一个携带 FromActivityId、Outcome、ToActivityId 及 Consumed/Blocked 标志的 Token,持久化在 ActivityExecutionContext.Properties["Flowchart.Tokens"] 中,并由 Flowchart 在子活动完成时执行"发射 → 消费 → 按 MergeMode 调度 → 清理"的调度循环。

ADR 0007 进一步将 join 语义收敛为显式的 MergeMode 枚举(定义于 Elsa.Workflows.Activities.Flowchart.Models 命名空间),并给出了完整的枚举源码:

public enum MergeMode
{
    /// <summary>
    /// Flows freely when possible, ignoring dead/untaken paths.
    /// Opportunistic execution based on upstream completion.
    /// </summary>
    Stream,

    /// <summary>
    /// Merges only the activated/flowing inbound branches.
    /// Waits for all branches that received tokens, ignoring unactivated ones.
    /// </summary>
    Merge,

    /// <summary>
    /// Converges all inbound paths, requiring every connection to execute.
    /// Strictest mode - will block on dead/untaken paths.
    /// </summary>
    Converge,

    /// <summary>
    /// Cascades execution for each arriving token independently.
    /// Allows multiple concurrent executions (one per arriving token).
    /// </summary>
    Cascade,

    /// <summary>
    /// Races inbound branches, executing on first arrival and blocking others.
    /// </summary>
    Race
}

五种模式覆盖了实际工作流设计中的典型语义:Stream(机会式流动,忽略未走的死分支,适合 switch 默认分支)、Merge(只等待所有"已激活"的前向入边,适合条件 fork)、Converge(严格等待所有入边,包括回边,适合关键同步点)、Cascade(每个到达 token 独立触发一次执行,适合事件流)、Race(先到先赢并阻塞/取消其他分支,适合取最快响应)。从源码结构看,Flowchart 的执行逻辑位于 src/modules/Elsa.Workflows.Core/Activities/Flowchart,OnChildCompletedTokenBasedLogicAsync 会通过 GetMergeModeAsync 读取目标活动模式并进入 switch 分支调度;doc/wiki/specs-and-adrs.md 也把 Flowchart 相关工作的推荐阅读顺序定为"wiki 工作流核心页 → ADR 0005 → ADR 0007 → Flowchart 活动源码 → 单元/集成测试"。

4.2 ADR 0006:租户删除事件的职责分离

ADR 0006 解决了一个微妙的语义混淆:此前 TenantDeactivated 事件被同时用于注销基于定时器的触发器,导致宿主应用关闭时触发器被错误注销。决策是新增 TenantDeleted 事件,专门在租户被显式删除时注销定时触发器;TenantDeactivated 只负责租户停用,不再影响触发器注册。这保证了"触发器保持注册直到租户被删除"的预期行为,属于典型的"一个事件一个职责"的架构澄清,可在 Elsa.Tenants 模块的事件处理链路中印证。

4.3 ADR 0027:User Tasks 的书签投影模型

ADR 0027 展示了跨模块一致性问题的 ADR 解法:用户任务既要持久化挂起工作流,又要支持高效的任务收件箱查询,若"写书签 + 写任务表"是未协调的双写,会出现不可见挂起工作流或孤儿任务。决策是让 User Task 活动创建载荷包含任务定义的书签,在工作流提交成功后由投影器幂等地从已提交书签生成任务记录;终止性操作持久化幂等的过渡操作并异步用专用 stimulus 恢复书签;另有有界、多节点安全的 reconciler 扫描已提交书签与任务操作,重建丢失的投影、重试过期投递并终结孤儿记录。这份 ADR 表明,Elsa.UserTasks 模块的运行契约要求每个持久化提供方都必须保持同样的幂等与 CAS 保证。

五、toc.md 的自动化生成与 CI 校验

早期 doc/adr/toc.md 是人工重打的,每次编号冲突都要手工改目录,导致目录标题与文档自身标题漂移(记录 11–27 的目录标题曾与实际标题不一致)。2026-08-25 决策 之后,目录改为由脚本生成、禁止手改。

5.1 使用 scripts/adr/generate-toc.sh

该脚本的核心逻辑是:以每份 ADR 文件自身的 # 标题为标题唯一来源(自动剥离旧的 NN. 数字前缀),按"顺序编号在前、日期前缀在后"排序输出 toc.md,并支持两种运行模式:

# 重新生成 doc/adr/toc.md
scripts/adr/generate-toc.sh

# 校验目录是否最新;若过期则退出码为 1,可供 CI 使用
scripts/adr/generate-toc.sh --check

脚本实现细节值得借鉴:

  • 文件名分类:通过 glob 区分 NNNN-*.md(编号型)与 YYYY-MM-DD-*.md(日期型),且注释特别说明必须先匹配日期型,因为日期也以四位数字开头,直接测 NNNN- 前缀会误吞日期型文件;
  • 编号渲染:add_entry 用 10#$identifier 强制十进制转换,避免前导零被当作八进制;
  • --check 模式:用 diff -u 比对当前 toc.md 与重新生成的内容,不一致即输出提示并以退出码 1 结束,这样 CI 可以在 PR 因目录过期时直接失败。

toc.md 顶部保留 <!-- Generated by scripts/adr/generate-toc.sh. Do not edit by hand. --> 注释,明确其生成物属性。

六、何时该写一份 ADR:仓库的实践标准

doc/wiki/specs-and-adrs.md 将仓库的设计历史系统划分为两类:ADR(记录"注定比单个功能活得更久"的持久架构决策)与 Spec Kit 功能规格(specs 目录,记录已规划或近期实现的功能工作,通常包含 spec.md、plan.md、research.md、data-model.md、contracts 等)。规范明确建议:做架构改动前两者都要读——Specs 解释"为什么是现在",ADRs 解释"哪些决策需要超越单个功能长期有效"。

按该文档,满足以下任一条件就应撰写或更新 ADR:

  • 改变工作流执行语义;
  • 改变持久化数据约定;
  • 改变租户/安全行为;
  • 引入持久的架构边界;
  • 否决了一个未来贡献者可能会追问的明显备选方案;
  • 影响多个模块或提供方包。

而"仅与单个功能相关的决策"可以留在 specs/*/research.md 中,除非它注定要超越该功能或指导后续无关工作。此外,specs-and-adrs.md 还按主题给出了推荐阅读顺序(运行时、Flowchart、租户、诊断、Secrets、输出转换、AI Copilot、外部认证、User Tasks、BPMN、Persistence vNext),例如 Flowchart 工作的顺序是:wiki 工作流核心页 → ADR 0005 → ADR 0007 → Flowchart 活动源码 → 流程图单元/集成测试,可直接作为新贡献者的路线图。

七、把 ADR 方法论带回你的项目

回到 ADR 0001 本身,它留给读者的是一套可移植的方法论。在 Elsa 仓库中落地并演进的实践要点可总结为:

  1. 从小处开始:第一份 ADR 只需记录"我们决定用 ADR"这一件事(如 ADR 0001 所示),模板保持 Context / Decision / Consequences 三要素,必要时补充 Alternatives Considered;
  2. 命名避免冲突:多分支并行开发时优先考虑日期前缀命名(YYYY-MM-DD-slug.md),而不是无法预留的顺序编号;
  3. 目录自动化:索引文件一律由脚本生成并纳入 CI 校验(参考 scripts/adr/generate-toc.sh 的 --check 模式),杜绝人工维护导致的漂移;
  4. 标题唯一来源:目录条目标题取自文档自身的 # 标题,作者无需在正文与目录中重复维护标题;
  5. 记录关系:用关系图(如 doc/adr/graph.dot)标注"取代/细化/部分取代"关系,让后进者看清决策的演进脉络;
  6. 与功能规格分层:持久性架构决策进 ADR,单功能决策留 research.md,避免 ADR 目录被琐碎内容淹没。

对于任何一个追求长期可维护性的 .NET 工作流项目而言,这份从 ADR 0001 出发、如今已扩展到 36 份记录的实践体系,本身就是"记录架构决策"这一决策带来的最大回报——它让每个后来者都能回答"为什么代码长成了这个样子"。

登录后查看全文
elsa-core