C# 开发者也有自己的轻量工作流引擎了:NuGet 装包,5 分钟跑通一条审批流

一、搜"dotnet 工作流引擎",你会先搜到什么

场景很常见:.NET 系统里要加个审批流。请假、报销、采购,单子从申请人出发,走到部门领导,复杂一点的要会签、按比例通过、退回发起人改材料、抄送一把手。

你去搜,会搜到 Elsa Workflows、Workflow Core,再往外还有 Temporal、Camunda。这些名字都很强,但花一个下午读下来你会发现,它们的主场是编排:长事务、Saga、可视化活动流、分布式补偿。把它们请进一个 CRUD 系统里审批三张单子,就像开卡车去楼下取快递。

而你真正想要的,其实是件小事:一段审批语义,嵌进自己的系统,用自己已有的 MySQL,配一个能画流程的前端

jeeflow 就是做这件小事的引擎:串行/并行/按比例会签、一票否决、退回发起人、委托代理、抄送------这套 OA 审批语义,引擎核心自己扛;业务方接进来只欠它两样东西:一张用户表(SPI 接口)、五张 wf_ 前缀的表。它之前已经在 Java/Go/Python/Node/PHP/Rust/MoonBit 七种语言上跑着,同一份 LogicFlow 流程 JSON 八门语言通用。今天的主角是刚收口的第 8 门语言:C#/.NET。

先把"它不是什么"也说清楚,省得你装错:

  • 不是 BPM 平台:不带用户体系、不带表单引擎,审批人解析要你自己接组织架构(也就一个接口的事);
  • 不带 UI :但有配套开源前端 jeeflow-ui,?lang=csharp 直连 C# demo 可用;
  • 数据库只支持 MySQL:内存仓储开箱即用(测试/内嵌场景),生产走 MySqlConnector 的 MySQL 仓储;
  • 不碰你的业务表:表单数据落哪张表、哪些字段谁可见,由你配置,引擎只管流程本身。

安装就一行,四件东西按需拿:

bash 复制代码
dotnet add package Mldong.Jeeflow.Facade            # 统一门面(传递 Core + Persist)
dotnet add package Mldong.Jeeflow.Repository.MySql  # 生产 MySQL 仓储(可选)
# 内嵌/测试场景只要 Core 一个包:零第三方依赖

下一节直接跑。

二、5 分钟跑通一条审批流:新工程实录

光说不练是伪代码,下面是一个全新 console 工程 的真实记录------dotnet new console 之后从 nuget.orgMldong.Jeeflow.Facade 1.0.1,到一条请假审批走完,全程 5 分钟(版本号 1.0.1 是写稿时 nuget.org 上的最新版)。

流程用联邦共享测试资产里最简单的一条(01-simple.json):开始 → 申请(assignee=applicant)→ 上级审批(assignee=leader)→ 结束。LogicFlow 格式,和 Java/Go/Python 等七门语言用的是同一份文件。

第 1 步:组装引擎

csharp 复制代码
using System.Text;
using Mldong.Jeeflow.Core;

// 1. 内存仓储 + 服务上下文;引擎唯一的必填 SPI 是 IUserProvider(按 id 取用户)
var repo = new MemoryRepository();
var ctx  = new ServiceContext(repo);
ctx.UserProvider = new FixedUserProvider();

// 2. 组装引擎
var engine = new JeeflowEngine(ctx);

IUserProvider 是引擎对"用户"的全部认知------一个方法 GetUserAsync(userId)。真实系统里换成查你的用户表即可,demo 里先来个内存版:

csharp 复制代码
class FixedUserProvider : IUserProvider
{
    public Task<IUserProvider.UserInfo?> GetUserAsync(string userId) =>
        Task.FromResult<IUserProvider.UserInfo?>(new IUserProvider.UserInfo { UserId = userId });
}

第 2 步:部署一份流程定义

csharp 复制代码
// 3. 部署流程定义:LogicFlow JSON,八门语言共用同一份
var content = File.ReadAllText("01-simple.json");
var define = new ProcessDefine
{
    Id = 1,                                        // 内存仓储不自增,主键应用侧生成
    Name = "simple", DisplayName = "简单审批流程", Type = "approval",
    State = 1, Version = 1,
    Content = Encoding.UTF8.GetBytes(content),
};
await repo.SaveDefineAsync(define);

这里插一个我第一次跑就踩到的真坑Id 不赋值,后面按 id 发起会直接报"没有流程定义"。原因是联邦契约里那 5 张 wf_没有自增主键,主键全部由应用侧雪花生成,内存仓储也不会替你补号------这个设计在任何一门语言里都一样。

第 3 步:发起、审批、走完

csharp 复制代码
// 4. 发起:user1 提交请假。引擎只把流程推到第一个任务节点,不会替你办它
var inst = await engine.StartProcessInstanceByIdAsync(define.Id, "user1",
    new FlowData { ["f_days"] = 3, ["f_reason"] = "年假" });
Console.WriteLine($"实例已发起:id={inst.InstanceId} state={inst.State}(10=进行中)");

// 5. 当前待办是"申请"节点,处理人是发起人自己(applicant 契约)
var apply = (await repo.FindDoingTasksAsync(inst.InstanceId!.Value, null))[0];
Console.WriteLine($"待办:{apply.TaskName} → {string.Join(",", await repo.FindTaskActorsAsync(apply.TaskId!.Value))}");

// 6. user1 提交申请(submitType=1 同意),流程推进到"上级审批"
await engine.ExecuteProcessTaskAsync(apply.TaskId!.Value, "user1",
    new FlowData { ["submitType"] = 1 });
var review = (await repo.FindDoingTasksAsync(inst.InstanceId.Value, null))[0];
Console.WriteLine($"待办:{review.TaskName} → {string.Join(",", await repo.FindTaskActorsAsync(review.TaskId!.Value))}");

// 7. leader 点同意,流程走到终点
await engine.ExecuteProcessTaskAsync(review.TaskId!.Value, "leader",
    new FlowData { ["submitType"] = 1 });
var after = await repo.FindInstanceByIdAsync(inst.InstanceId);
Console.WriteLine($"审批后实例 state={after!.State}(20=已完成)");

dotnet run,真实输出:

ini 复制代码
实例已发起:id=2096621026388475904 state=10(10=进行中)
待办:apply → user1
待办:task1 → leader
审批后实例 state=20(20=已完成)

四行输出里有三处契约,值得停一下:

一,state=10state=20 实例状态机就三档:10 进行中、20 已完成、99 已撤销(任务侧另有两档)。这是系列第 3 篇讲过的那套状态机,C# 一个数都没跑偏。

二,第一站待办的处理人是 user1 自己。 流程 JSON 里申请节点写的是 assignee=applicant------applicant 是引擎级特殊值,运行时替换成流程发起人。这就是系列第 6 篇讲过的 applicant 契约:申请节点让发起人把材料确认一遍再往下走,"退回发起人重办"能闭环,靠的也是它。

三,注意第 4 步的注释------引擎不会替你办任何节点。 StartProcessInstanceByIdAsync 只把流程推到第一个任务节点就停。我第一次跑时想当然以为"发起"会顺手把申请节点办掉,让 leader 直接去执行,结果引擎扔过来一个异常:"当前参与者不能执行该流程任务" ------leader 不在 user1 那条待办的处理人名单里,引擎直接拒了。后来才知道,要"发起即自动办完申请节点"得用 startAndExecute(门面里的对应 action,或者引擎侧两步连调),这是 mldong 契约的显式设计:每一步都是有人点的,引擎只认提交

顺带一提,那个异常是引擎的负向行为------错误信息就是给前端 msg 字段的原话。想看门面的 HTTP 契约长什么样,下一节换生产姿势。

三、从内存到生产:MySQL 仓储与 45 action 门面

内存仓储适合测试和内嵌,生产要落库。换 MySQL 仓储,业务代码一行不用改:

csharp 复制代码
using Mldong.Jeeflow.Repository.MySql;

var factory = MySqlConnectionFactory.FromEnv();   // 读 JEFFLOW_DB_HOST/PORT/USER/PWD/NAME
var repo = new MySqlRepository(factory);          // 上面 MemoryRepository 换成它
var ctx  = new ServiceContext(repo);
repo.Configure(ctx);                              // 两阶段接线(ctx ↔ repo 解循环)
ctx.TransactionTemplate = new MySqlTransactionTemplate(factory, repo);  // 可选真事务
  • 建表 SQL 随包内嵌(schema-mysql.sql),5 张 wf_ 表,无自增,主键应用侧雪花生成;
  • 语句级 autocommit 是联邦现状;要同事务回滚,注入 ITransactionTemplate,同事务内所有仓储方法共用同一连接;
  • IProcessRepository 是全异步接口------整条引擎链路没有一个 .Result.Wait(),仓库里有条 CI 级门禁:grep 这三个词必须为零。

如果你在做一个要给前端用的服务,别一个 action 一个 action 地拼------引擎的 45 个 action 全部从一个门面入口进:

csharp 复制代码
using Mldong.Jeeflow.Facade;

var facade = new JeeflowFacade(ctx);
var json = await facade.FlowJsonAsync("processInstance/startAndExecute", new FlowData
{
    ["processDefineId"] = "1",
    ["operator"]        = "user1",
    ["f_days"]          = 3,
});

真实返回(写稿时真机输出):

json 复制代码
{"code":0,"msg":"成功","data":{"processInstanceId":"2096621342496391168"}}

三个细节都在这一行里:

细节 契约
{code,msg,data} 信封 成功恒 code=0;失败只发明 99999999,错误语义在 msg
processInstanceId字符串 雪花 id 超出 JavaScript Number.MAX_SAFE_INTEGER,出口全字符串化(含嵌套数组),前端不会再丢精度
集成方只写一个转发 controller HTTP body → FlowDataFlowAsync(action, args),45 个 action 一个循环搞定

45 个 action 覆盖流程定义 8 个、流程设计 9 个、实例 14 个(含统计 3 个)、委托 5 个、任务 9 个------就是 jeeflow-ui 前端要调的全集。不想自己写 controller,可以看在线演示站:jeeflow-ui ?lang=csharp 直连 C# demo(:8093),画流程、发起、审批、看统计,整条前端链路开着箱。点这里直达

包矩阵放一张图收个尾:

四、两个半小时的移植:第八语言怎么进联邦

写到这里该讲讲这块 NuGet 包是哪来的了。答案在 git 时间线里(jeeflow-csharp 仓真实提交记录):

vbnet 复制代码
09-06 00:05  移植方案 v1.1 定稿(此时未动一行代码)
09-06 01:07  M0 仓骨架:45 action manifest 与 Java 实查 diff 无差集,5 个 spike 全绿
09-06 01:33  M1 Core 全模块 + 内存仓储,T0 100 用例全绿
09-06 02:00  M2 MySQL 仓储 + 事务模板 + 命令级串行化,T1 120 用例(160 真库)
09-06 02:53  M3 Persist 动态入库 + Facade 45 action + 契约出口层,153/153 全绿
09-06 03:07  M4 轻量 demo :8093 + T2 冒烟 20/20
09-06 03:28  M5 一致性快照与七语言逐字段全等,联邦登记完成
09-06 09:47  tag v1.0.0 → CI 推 NuGet(Trusted Publishing,免 API key)
09-06 10:20  tag v1.0.1 修正包元数据

从方案定稿到引擎六里程碑全绿,两小时二十一分;到 NuGet 可装,一个上午。这套"八小时从零到发版"能跑通,一半靠前辈铺路,一半靠 C# 自己争气。

前辈铺路:第七语言 MoonBit(jeeflow-moon)前一周刚趟完整条路------45 action 清单怎么固化对账、五张表怎么逐字对齐、22 个合规场景怎么建、一致性快照怎么逐字段比对,全部成了可复制的模板。C# 的移植方案就是照着它写的,工程上的新决策只剩一个:要不要真事务(答案:给,MySqlConnector 环境事务,联邦里第一个)。

C# 自己争气,是它把前两门语言撞的"墙"全绕开了:

Rust 移植时 MoonBit 移植时 C# 这次
异步墙 嵌套 block_on panic(同步门面 + async 引擎组合独有) async test + wasm 运行时组合拳 ------.NET 原生 async,全链 await 一把梭
构建目标墙 wasm-gc/native 双目标,client 连接要 vendored 解锁 ------本机 native 直跑
工具链风险 链接器环境敏感 工具链未成熟,版本钉死 ------官方 SDK zip 解压即用

结果就是引擎核心本身一个坑都没踩 ------153 个用例(含负向变异破坏)一次推过,反倒是坑都埋在引擎外面:老 docker 的 seccomp 拦 CoreCLR 系统调用(容器起循环,加 seccomp=unconfined 才好)、托管机内存紧张要给 GC 上限(GCHeapHardLimit 256MB,常驻约 22MB)、NuGet 官方 Action 输出名是大写 NUGET_API_KEY(小写引用取空导致 push 401)。这些属于"把一个 .NET 服务跑进任意老旧生产环境"的通用成本,引擎本身无责。

五、什么时候用它,什么时候别用

最后摆正预期,这张表比任何吹捧都有用:

你的需求 建议
系统里嵌审批流:请假/报销/采购,会签、退回、委托、抄送 正解。五张表 + 一个用户 SPI + 一个门面,jeeflow-ui 直连可用
前端还没有流程设计器 用 jeeflow-ui(开源,Vue3),?lang=csharp 就是给 C# 后端留的档位
多语言技术栈,流程定义要共用 同一份 LogicFlow JSON 八门语言跑,迁移引擎/混合栈不锁语言
BPMN 建模、长时编排、Saga 补偿、分钟级以上的人类等待混合机器任务 别用,去 Elsa Workflows / Temporal / Camunda,它们是那个赛道的
数据库不是 MySQL 等后续版本,或者自己实现 IProcessRepository(接口就在 Core 包里,PgSQL 仓储大概是几百行 ADO.NET 的事)

Mldong.Jeeflow.* 四个包都在 nuget.org,Apache-2.0,net8.0;net10.0 双目标框架。Core 包零第三方依赖(csproj 里连一个 PackageReference 都没有,编译产物约 180KB),MySqlConnector 只在仓储包里。装之前想先玩,演示站在跑着;想看代码,仓库和文档站都在下面。

一条审批流的复杂度,值得一个 180KB 的引擎来扛,而不是一辆卡车。

参考资料

  • jeeflow-csharp 仓库(2026-09-06 核对):NuGet 四包 Mldong.Jeeflow.* 1.0.1;45 action 清单 docs/action-manifest.json(Java↔规范↔MoonBit↔C# 四方对账);测试矩阵 T0 153 用例 / T1 MySQL 真库 / T2 冒烟 20 项 / 一致性 15 key 与七语言全等
  • 系列前篇:第 3 篇《工作流引擎的"灵魂":状态机与 submitType》、第 6 篇《"applicant" 契约:退回发起人的闭环设计》、第 13 篇《同一份 15 个流程 JSON,第六种语言也跑通了》、第 16 篇《引擎从不发一条消息》
  • 在线体验:C# 演示站 jeeflow-demo.mldong.com/?lang=cshar...
  • 仓库:github.com/mldong/jeef...(NuGet:Mldong.Jeeflow.Facade)· jeeflow-ui · 文档站 · 开源演示站 · 集成演示站
相关推荐
爱学堂IT分享3 小时前
鹤翔老师pmp项目管理培训认证考试课 (直播录播题库送学时证明)【共58课时】-51CTO学堂
后端
王的宝库3 小时前
Go 项目结构:从单文件到标准工程布局
开发语言·后端·golang
码事漫谈4 小时前
类型即世界:一个 C++ 程序员的本体论入门
后端
Rain的Java大神之路5 小时前
JavaWeb开发如何解决跨域问题
java·前端·后端·nginx·web安全·面试·运维开发
samble5 小时前
从零搭建工业控制系统(二十二):线程安全与并发控制
c#·wpf·并发·mvvm·线程安全·工业控制
UIU1146 小时前
scanf与cout的误区:探究其内部的机制
c++·学习·c#·scanf
IT_陈寒7 小时前
Python的多线程居然是个假把式?搞清GIL让我少熬三天夜
前端·人工智能·后端
明月_清风7 小时前
位图与布隆过滤器:海量数据下的"存在性判断"艺术
前端·后端·算法
明月_清风7 小时前
Hash 表从入门到精通:Go 实战与工程细节
前端·后端·算法