这几年一直泡在企业内部的流程类系统里,从最早的 OA 二次开发,到后来基于 .NET 框架自研审批流引擎,再到把工作流引擎嵌进整个开发平台,说实话踩过的坑比写过的业务代码还多。今天这篇就把我基于 .NET 框架做开发平台、梳理工作流源码的完整经历拆开讲一遍,重点聊选型思路、引擎内部机制、一个可以直接改来用的审批实例,以及源码级排错经验。适合正在做审批流、工单流、业务编排的 .NET 开发者,也适合想从零搭内部开发平台的团队参考。
我之前在团队里主导过一个不算小的平台化项目,目标是让业务部门通过配置就能生成自己的业务流程,而不是每个需求都找开发写代码。当时技术栈固定在 .NET 上,所以工作流引擎的选择就成了整个平台生死攸关的事。这里面的门道远比想象的多,不是随便找一个开源项目就好,也不是自己造一个轮子就完事。
1. 工作流开发平台的整体思路与选型拆解
1.1 先把需求讲透:你要做的是平台,不是单个流程
做工作流开发,最容易犯的错就是一上来就写流程代码。实际上,一个真正能复用的工作流开发平台,流程引擎只是其中一部分,整个平台至少要包含四块独立能力:流程定义、流程执行、表单绑定、组织权限。
- 流程定义负责把设计器里的图形转换成引擎能识别的结构化数据。
- 流程执行负责节点调度、状态流转、暂停恢复、事件分发。
- 表单绑定负责流程每个节点要填什么数据、数据如何存储和回显。
- 组织权限负责谁能发起流程、谁能审批、谁能看到哪些实例。
这四块如果揉在一起,短期开发快,长期维护就是灾难。我见过有团队把业务规则直接写在节点代码里,导致流程一变就要发版,完全失去平台的意义。源码上的边界一定要清晰:流程引擎只干活,业务方的行为通过注入的处理器和订阅事件来扩展。
1.2 .NET 生态里常见的工作流引擎横向对比
我接触过的 .NET 生态工作流方案主要有三个:微软自家的 Windows Workflow Foundation(WF)、轻量级的 Workflow Core、以及可视化能力很强的 Elsa Workflows。先放一张横向对比表,方便你根据自己的场景做选型。
| 引擎 | 维护状态 | 可视化设计器 | 持久化支持 | 体积/复杂度 | 适合场景 |
|---|---|---|---|---|---|
| WF | 基本停止维护 | 有(但体验老旧) | 有 | 重 | 老系统维护,新项目慎选 |
| Workflow Core | 社区活跃 | 无官方内置 | 多数据库 Provider | 轻量 | 嵌入业务系统、自研流程平台 |
| Elsa Workflows | 社区活跃 | 自带拖拽设计器 | 有 | 中 | 快速搭低代码流程平台 |
为什么我不建议新项目用 WF?WF 的问题不是功能不够,而是微软后来把重心转移到别的方向上了,生态基本停滞。而且 WF 的序列化机制、持久化模型在云原生和容器化部署下很别扭,调试起来也费劲。早期我们平台就是基于 WF 做的,后来为了支持更灵活的节点扩展和更好的容器部署,花了很大的力气迁到 Workflow Core。
Workflow Core 的核心优势是轻,它只做流程引擎该做的事:定义流程、执行步骤、暂停恢复、持久化。没有自己的 Designer,但你完全可以自研设计器对接它的 JSON 定义格式。Elsa 则更重一些,自带可视化编辑器和 API,适合产品化交付。如果你们团队有前端资源,想做低代码平台,Elsa 会更省力;如果只想把流程能力嵌入到现有系统,Workflow Core 更合适。
1.3 平台架构:设计器、引擎、运行时三者如何配合
工作流平台的核心原则是"定义与执行分离"。设计器产生的是流程定义,它是一份结构化描述,通常用 JSON 表达,包含节点列表、连线关系、每个节点的类型和参数。流程引擎不关心这份 JSON 是怎么来的,它只负责把定义解析成可执行的工作流实例。
运行时则是另一层概念。流程引擎把定义加载进来后,创建流程实例,实例里保存着当前执行到哪个节点、节点的输入输出数据、等待什么事件。这三者的关系,其实很像电影剧本、导演和拍摄现场:设计器产出剧本,引擎是导演,而每次实际的执行过程就是一台现场拍摄,同一个剧本可以反复拍出多场不同的戏。
搞懂这个分层,很多问题就能想明白。比如线上流程乱了,你要先区分是定义错了、引擎理解错了,还是运行时的数据出错了。大部分新手都会把这三层混着查,结果越查越乱。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 工作流引擎核心源码拆解
2.1 从数据结构看工作流引擎的本质
所有工作流引擎,说到底都是"状态机 + 步骤调度器"。在 Workflow Core 的源码里,IWorkflow<TData> 接口是流程定义的入口:
csharp复制public interface IWorkflow<TData>
{
string Id { get; }
int Version { get; }
void Build(IWorkflowBuilder<TData> builder);
}
任何一个具体业务流程都要实现这个接口。Id 是流程类型的唯一标识,Version 用于定义升级,Build 里面就是把独立的 Step 串成有向图。以请假审批为例,最简单的构建代码是:
csharp复制public class LeaveApprovalWorkflow : IWorkflow<LeaveRequestData>
{
public string Id => "leave-approval";
public int Version => 1;
public void Build(IWorkflowBuilder<LeaveRequestData> builder)
{
builder
.StartWith<ApplyLeaveStep>()
.Then<ManagerApproveStep>()
.Then<HRArchiveStep>();
}
}
每个 Step 继承 StepBody,核心是 Run 方法:
csharp复制public class ManagerApproveStep : StepBody
{
public bool Approved { get; set; } // 步骤的输入/输出参数
public override ExecutionResult Run(IStepExecutionContext context)
{
// 在这里执行审批逻辑
return ExecutionResult.Next();
}
}
引擎拿到 IWorkflow<TData> 构建出来的定义后,会生成 WorkflowDefinition。真正执行时,每次启动流程会创建 WorkflowInstance,里面保存着当前实例的全部执行状态。WorkflowDefinition 和 WorkflowInstance 的分离,是理解一切工作流问题的关键点:前者是类,后者是对象;前者一变,后面已经启动的实例不受影响。
2.2 节点执行与状态流转的内部机制
Workflow Core 的执行流程可以用一句话概括:调度器轮询可运行的步骤,执行器执行步骤,然后根据步骤返回的结果决定下一步走向。
每个 WorkflowInstance 里有一个 ExecutionPointers 集合,它记录着这个实例当前所有"执行指针"的位置。执行指针就像书签,指向流程图里的某个节点。引擎每次调度时会遍历这些指针,检查它们指向的步骤是否满足执行条件。条件满足就执行,执行完根据返回值把指针移动到下一个节点继续等待调度。
这里有个很关键的机制:引擎会反复调度,不是执行完一个节点就去执行下一个。它不是递归调用,而是通过循环扫描的方式,每次都看看还有没有"能跑的节点没跑"。这样做的好处是,节点在执行过程中可能抛出异常、可能等待事件,引擎都能以统一的方式处理——让流程停留在某个状态,等待下一次调度机会。
对于分支节点,Workflow Core 支持通过 Outcome 来决定走向。比如审批节点返回"通过"或"驳回",代码可以这样写:
csharp复制public class ManagerApproveStep : StepBody
{
public string Decision { get; set; }
public override ExecutionResult Run(IStepExecutionContext context)
{
Decision = "approved"; // 实际这里应结合业务逻辑判断
return ExecutionResult.Outcome(Decision);
}
}
然后在流程定义里用 When 分派:
csharp复制builder
.StartWith<ApplyLeaveStep>()
.Then<ManagerApproveStep>()
.When("approved").Do(approve => approve
.Then<HRArchiveStep>())
.When("rejected").Do(reject => reject
.Then<NotifyRejectStep>());
看到这里你应该明白了,工作流引擎本质上不关心你的节点里干了什么,它只关心每个节点执行完以后往哪里跳。这种设计让步骤本身可以非常纯粹,业务逻辑全部封装在 Step 内部。
2.3 持久化与并发控制的实现细节
工作流实例在执行过程中要经历很长的等待,比如"等待经理审批"这个状态可能持续好几天。如果整个进程在这期间重启了,或者服务被缩容了,实例状态怎么恢复?答案就是持久化。
Workflow Core 的持久化设计有几个核心表概念:WorkflowDefinition 存流程定义,WorkflowInstance 存实例主状态,ExecutionPointer 存执行指针。执行指针这块最容易忽略,但它恰恰是最重要的状态。它记录了流程停在哪一步、步骤入参出参是什么、等待什么事件。反序列化恢复实例时,引擎只需要把这些指针读出来,重新装入内存,调度器就能继续干活,整个流程像从来没中断过一样。
并发控制也是个坑。同一个流程实例,如果被两条线程同时调度,就可能出现节点重复执行。Workflow Core 的持久化层用了数据库行锁来控制并发,WorkflowInstance 会带一个版本号或类似机制,更新前校验,防止脏写。我在自研扩展时踩过这个坑,一开始没在意,结果压测时同一个审批节点被并发执行了两次,数据库里多出两条审批记录。后来看了源码才发现,持久化层的 Provider 必须实现事务性更新,而且调度器的轮询必须和持久化在同一事务边界内,否则就会出问题。
3. 从零实现一个请假审批工作流实例
3.1 项目初始化和依赖安装
直接动手做一个可以跑的实例。我用的是 .NET 8,控制台项目方便演示,实际生产建议用 ASP.NET Core 承载。先建项目:
bash复制dotnet new console -n ApprovalDemo
cd ApprovalDemo
dotnet add package WorkflowCore
dotnet add package WorkflowCore.Persistence.SqlServer
dotnet add package Microsoft.Data.SqlClient
WorkflowCore 是引擎本体,WorkflowCore.Persistence.SqlServer 是数据库持久化 Provider。如果不想连数据库,也可以先用内存持久化快速验证逻辑,我建议开发初期先用内存持久化,跑通流程再上数据库,排查起来更快。
3.2 定义流程节点和审批分支
首先定义流程数据类,这是整个流程期间共享的业务数据:
csharp复制public class LeaveRequestData
{
public string Applicant { get; set; }
public int Days { get; set; }
public string Reason { get; set; }
public bool ManagerApproved { get; set; }
}
然后写三个步骤。申请步骤、经理审批步骤、人事归档步骤。经理审批步骤是最典型的阻塞型节点,它不直接返回 ExecutionResult.Next(),而是让流程进入等待状态:
csharp复制public class ManagerApproveStep : StepBody
{
public string Decision { get; set; }
public override ExecutionResult Run(IStepExecutionContext context)
{
// 这里只是让流程暂停,等待外部事件触发
return ExecutionResult.WaitForEvent("ManagerApproveEvent", "leave-approval");
}
}
注意 WaitForEvent 的作用:流程执行到这一步就挂起了,不会继续往后走,直到有人调用引擎的 PublishEvent 发布事件。这正好对应真实场景中的"审批人打开审批单,点了同意或驳回"这一动作。后续节点根据事件数据决定走向:
csharp复制public class ManagerApproveStep : StepBody
{
public string Decision { get; set; }
public override ExecutionResult Run(IStepExecutionContext context)
{
var data = context.WorkflowData as LeaveRequestData;
data.ManagerApproved = true;
Decision = data.ManagerApproved ? "approved" : "rejected";
return ExecutionResult.Outcome(Decision);
}
}
工作流定义里把分支接上:
csharp复制public class LeaveApprovalWorkflow : IWorkflow<LeaveRequestData>
{
public string Id => "leave-approval";
public int Version => 1;
public void Build(IWorkflowBuilder<LeaveRequestData> builder)
{
builder
.StartWith<ApplyLeaveStep>()
.Then<ManagerApproveStep>()
.When("approved").Do(approve => approve
.Then<HRArchiveStep>())
.When("rejected").Do(reject => reject
.Then<NotifyRejectStep>())
.EndWorkflow();
}
}
3.3 启动流程、订阅事件与暂停恢复
在 Program.cs 里配置引擎并启动。这里先使用内存持久化:
csharp复制using WorkflowCore.Interface;
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddLogging();
services.AddWorkflow();
var serviceProvider = services.BuildServiceProvider();
var host = serviceProvider.GetRequiredService<IWorkflowHost>();
await host.Start();
// 注册流程定义
host.RegisterWorkflow<LeaveApprovalWorkflow>();
模拟发起一条请假申请:
csharp复制var data = new LeaveRequestData
{
Applicant = "张三",
Days = 5,
Reason = "年假"
};
string instanceId = await host.StartWorkflowAsync("leave-approval", 1, data);
Console.WriteLine($"流程实例已启动: {instanceId}");
启动后流程会执行到经理审批节点并挂起等待事件。紧接着模拟审批人操作:
csharp复制await host.PublishEvent("ManagerApproveEvent", "leave-approval", true);
PublishEvent 有三个参数:事件名、事件 key、事件数据。这个 key 用来匹配具体挂起的流程实例,所以在真实系统里,key 通常可以设计成"流程实例 ID"或"业务单据 ID",确保事件能被正确的流程接收。发布事件后,挂起的经理审批节点被唤醒,流程继续往下走,最终执行人事归档或驳回通知节点。
从代码量你能感受到,引擎把复杂的状态管理接走了,开发者只需要关心节点的业务逻辑和事件定义。但正因为简单,更要理解底层的匹配规则,否则事件发出去流程不响应,排查起来全靠经验和日志。
3.4 平台化扩展:表单、权限、消息怎么接进去
跑通一个审批流之后,真正的平台化扩展还要解决三个问题:表单怎么绑定到节点、审批人怎么动态计算、节点完成后怎么通知相关人。
表单绑定我采用的方案是"步骤元数据":流程定义里每个节点可以带一个 Metadata 字段,里面放表单 ID 或表单 JSON Schema。引擎执行到节点时,把表单 ID 传回前端,前端动态渲染表单。这样流程与表单是松耦合的,换表单不需要改流程定义。
审批人动态计算就更有意思了。Workflow Core 本身不关心"谁"来审批,它只负责执行节点。我们把审批人信息放到工作流数据里,比如在节点执行前先跑一个 AssignApproverStep,从组织架构服务和表单数据里动态算出审批人 ID,写入 LeaveRequestData.Approver。后续流程在展示待办时,直接按流程实例关联的业务单据去查审批人字段。注意一点:审批人计算和审批动作要分开,前者是确定性逻辑,后者是事件驱动,合在一起会导致测试时无法独立验证。
消息通知我用的是订阅流程终止事件。引擎有 OnStepCompleted、OnWorkflowCompleted 这类钩子,可以在流程走完以后触发站内信、邮件或者企微机器人通知。这里有个经验:通知的发送要保证幂等,否则流程重试一次,用户就收到两遍消息。实际做法是在通知表里存 SourceInstanceId + EventId 做唯一索引,重复投递直接跳过。
4. 源码级排错与实战经验
4.1 高频问题速查表
| 现象 | 大概率原因 | 排查思路 |
|---|---|---|
| 流程卡在 Waiting 不执行 | 事件名或 key 不匹配,事件没发到对应指针 | 查 EventSubscription 表,对比发布事件名和等待事件名 |
| 节点重复执行 | 并发调度,持久化事务边界没控制好 | 看数据库执行指针,确认是否多次写入同类指针 |
| 反序列化报错 | 流程数据类改了字段后,旧实例恢复失败 | 给数据类加 JSON 序列化兼容,比如默认值、可空类型 |
| 启动流程时并发冲突 | 数据库行锁等待超时,或唯一键冲突 | 检查流程定义版本号,确认启动时是否重复注册定义 |
| 恢复流程后审批人变了 | 审批人信息未持久化,每次加载时重新计算 | 把审批人计算结果落到独立的业务表,不要依赖动态计算 |
| 事件发布了但流程没反应 | 事件 key 不匹配或事件被提前 GC | 检查事件在流程挂起之前是否已发布,先到的事件不会补偿 |
这张表我建议团队里做流程开发的同事人手一份。工作流的问题有一个共同特点:表面现象是"流程不动了",但真正的原因往往在业务流程之外,比如事件订阅、序列化、幂等控制这些底层机制上。
4.2 几个值得留意的源码细节
第一,不要在工作流步骤里直接写数据库操作。引擎的调度机制决定了同一个步骤可能被执行多次,比如持久化失败后的重试。如果步骤里有副作用操作(发消息、写台账、改库存),必须自己保证幂等。我自己的习惯是,把所有副作用操作放到一个独立的 BusinessActionStep 里,这个步骤执行前先查操作记录表,如果已有成功记录就直接跳过。
第二,Sleep 步骤的精度问题。有人用 SleepStep 做定时等待,比如"提交后 24 小时自动提醒"。实际生产环境里,如果服务在 Sleep 期间重启了,持久化恢复后虽然能继续走,但 Sleep 的到期时间计算要小心。Workflow Core 在恢复时会重新计算剩余时间,如果你把时间写死在步骤数据里,恢复时会出现错误。最好存一个"期望唤醒时间",而不是存"要睡多久"。
第三,日志一定要贯穿全链路。引擎的执行链路太长了,从调度到执行到持久化,任何一个环节出了问题都很难直接看代码定位。我在程序里给每个流程实例加了一个全局日志集合,所有节点执行时都把业务数据、输入输出、异常信息写进这个集合,最后统一入库。这样出了问题直接按实例 ID 查日志,比在服务器上翻异常栈高效太多了。
4.3 实测记录:一次线上流程卡住的完整排查过程
这里分享一次真实的线上事故。同事反馈某个请假单在"经理审批"节点卡了两天,经理那边明明已经点了同意,流程却毫无反应。我看了一眼数据,流程实例状态是 Suspended,执行指针指向经理审批节点。
第一步查事件订阅表,发现这个实例确实有一条 ManagerApproveEvent 的订阅记录,key 是流程实例 ID。第二步查事件历史,经理点击同意后系统确实发布了事件,但发布的事件 key 是"单据号",不是流程实例 ID。第三步看代码,发现前端在调用 PublishEvent 时,key 是从业务表里取的,而流程挂起时等待的 key 是从流程实例 ID 生成的。两边对不上,事件自然投递失败。
根因清楚了,就是事件 key 的约定不统一。后来我在代码里加了一个统一封装,所有事件 key 一律使用流程实例 ID,不允许业务单据 ID 混入。这个问题属于典型的"代码能跑,但约定没定好"引起的故障,排查本身不难,难的是在第一时间意识到事件 key 的重要性。
5. 轻量级工作流的实践心得与扩展思路
5.1 为什么轻量级工作流更适合嵌入现有系统
这几年我在对外交流和技术选型时发现一个明显趋势:很多团队不再追求大而全的 BPM 套件,而是更愿意用一个轻量级工作流嵌入到自己的业务系统里。原因很简单,大而全的套件学习和部署成本都很高,而且要被迫接受它的建模思路和交互方式。轻量级引擎只提供底层机制,具体流程长什么样、怎么操作,完全由业务系统决定。
Workflow Core 就是典型的轻量级方案。它没有自带的管理界面,没有设计器,甚至没有默认的数据库表结构定义——但这也意味着它不会被自己的"大而全"束缚住。你需要数据库,就自己建表;你需要设计器,就自己写一个;你需要组织权限,就用自己的用户体系对接。一切都是可控的。
5.2 工作流引擎在开发平台中的角色定位
如果你在搭建开发平台,工作流引擎就是平台的"流程内核",但它不应该是全部。我看到不少人把开发平台等同于工作流平台,这是误区。开发平台的核心是让开发者快速交付业务应用,而不是让所有业务都以"流程"为核心。对于审批类、工单类业务,工作流是天然的表达方式;但对于一些数据录入、报表查询类的需求,强行套工作流只会增加复杂度。
所以我在平台设计里把工作流作为"流程编排能力"独立成模块,对外提供 API。其他业务模块需要流程时就调用它,不需要就没有感知。这种模块化的方式让工作流引擎的升级和替换都变得更容易。将来即使要换成别的引擎,最多就是适配层改一改,业务代码完全不用动。
5.3 后续可以扩展的方向
这个工作流平台后续有几个方向可以继续深入。一个是可视化流程设计器,可以把现有的 JSON 定义通过前端图形化展示和编辑,进一步降低业务部门的使用门槛。另一个是流程分析统计,比如平均审批耗时、节点耗时占比、驳回率,这些数据对优化流程非常有价值。再有就是 AI 能力的接入,比如通过 AI 自动预审表单数据、自动生成流程定义、对历史流程实例做异常检测。
实际上热词里提到的 AI 工作流、Coze、Dify 这类东西,跟我这里讲的工作流引擎虽然都叫"工作流",但侧重点不一样。AI 工作流更多是编排大模型调用和数据处理步骤,而企业级 .NET 工作流重点在状态管理、审批流转、人机交互和系统集成。不过两者的核心思想是相通的,都是把复杂的多步骤过程拆成可复用的节点,再把这些节点编排起来。如果你能理解企业工作流的源码,再看 AI 工作流的节点编排,会发现很多概念都能对应上。
最后再分享一个我个人的经验,如果你团队第一次做工作流相关系统,我不建议自己从零写引擎。先用 Workflow Core 或 Elsa 这类成熟引擎把业务跑通,让团队积累对工作流状态机、持久化、事件机制的理解,再根据演进方向决定要不要自研。我在实战中的体会是,工作流引擎本身的技术难度不是最大的,最大的难点在于业务建模:哪些状态需要持久化、哪些步骤需要等待人工操作、哪些数据要跨节点共享,这些设计问题想清楚,用什么引擎都能做好。用错了引擎还能换,业务模型想错了,返工的成本才是真正让人肉疼的。
