×

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

独孤求败 独孤求败 发表于2026-09-07 12:25:38 浏览4 评论0

抢沙发发表评论

图片.png

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 仓储;

  • 不碰你的业务表:表单数据落哪张表、哪些字段谁可见,由你配置,引擎只管流程本身。

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

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

下一节直接跑。

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

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

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

第 1 步:组装引擎

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 里先来个内存版:

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

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

// 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 步:发起、审批、走完

// 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,真实输出:

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

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

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

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

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

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

图片.png

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

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

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 全部从一个门面入口进:

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,
});

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

{"code":0,"msg":"成功","data":{"processInstanceId":"2096621342496391168"}}

三个细节都在这一行里:

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

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

包矩阵放一张图收个尾:

ScreenShot_2026-09-07_122716_980.jpg

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

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

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 服务跑进任意老旧生产环境"的通用成本,引擎本身无责。

ScreenShot_2026-09-07_122801_417.jpg

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

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

你的需求
建议
系统里嵌审批流:请假/报销/采购,会签、退回、委托、抄送
正解
。五张表 + 一个用户 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 的引擎来扛,而不是一辆卡车。


群贤毕至

访客