一、第六格不是"翻译"
这个系列写过五门语言的工作流引擎:Java、Go、Python、Node、PHP。五门语言读的是同一份东西------引擎仓库里 flows/ 目录下的 15 个流程 JSON:简单审批、多任务、条件路由、fork-join、三种会签、退回发起人、混合模式......六语言 demo 共读这一个目录,改一处,六边验证。
第六种语言是 Rust。
先说清楚一件事,免得标题被误读:Rust 里不缺"工作流引擎" ------做任务编排(durable execution)的有 Temporal、workflow-rs、wfaas 一大票;解析 BPMN 的有 snurr、wfrs;连外部集群的客户端有 zeebe-rs、camunda-client。这些是编排型 和 BPMN 型,解决的是"长任务怎么可靠地跑完"。
缺的是另一类:嵌入式审批流引擎------不解析 BPMN、不连外部集群、带会签/抄送/委托/退回发起人这套 OA 语义、嵌进业务系统里用的那种。Java 里这块是 Flowable 的地盘,Rust 里基本没人做。jeeflow 的 Rust 实现填的就是这个格。
所以这篇不是"Rust 没有工作流引擎,我们造了一个"------那是假话。它是:同一份契约,在执行模型完全不同的第六种语言里重新落地 。没有 GC、没有反射、没有 ORM、async/await、借用检查器------Java 里一行 new ProcessTask(...) 背后的一整套运行时习惯,在 Rust 里全要重新想。
移植不是翻译。被钉死的是契约 :code=0/msg 响应信封、submitType 提交类型、五张表(define/instance/task/task_actor/cc_instance)、22 个合规场景。"跑通"的定义不是 demo 能点,是 22 个场景的行为逐一对齐。
这篇是实录:四个坑,四个解法。前两个是 Rust 特有的,后两个是任何语言都会踩、只是 Rust 这次踩得最疼的。
二、移植前:被钉死的东西
先看这 15 个 JSON 长什么样。01-simple.json,最朴素的请假审批:发起 → 上级审批 → 结束。
json
// jeeflow-rust/flows/01-simple.json(节选)
{
"name": "simple",
"displayName": "简单审批流程",
"type": "approval",
"nodes": [
{ "id": "start", "type": "snaker:start", "text": { "value": "开始" } },
{
"id": "apply",
"type": "snaker:task",
"properties": { "form": "apply-form", "assignee": "applicant", "taskType": 0, "performType": 0 },
"text": { "value": "发起申请" }
},
{
"id": "task1",
"type": "snaker:task",
"properties": { "form": "leave-form", "assignee": "leader", "taskType": 0, "performType": 0 },
"text": { "value": "上级审批" }
},
{ "id": "end", "type": "snaker:end", "text": { "value": "结束" } }
],
"edges": [
{ "id": "e0", "sourceNodeId": "start", "targetNodeId": "apply" },
{ "id": "e_apply_1", "sourceNodeId": "apply", "targetNodeId": "task1" },
{ "id": "e2", "sourceNodeId": "task1", "targetNodeId": "end" }
]
}
节点、边、表达式------就是第 4 篇拆过的那套 LogicFlow JSON。这 15 个文件的唯一编辑源是 Java 仓 ,各语言仓持有一份镜像副本(维护者机器上自动镜像、保持同步,读取点只读本仓副本;JEFFLOW_FLOWS_DIR 可覆盖)。改一处,六边验证------"共享"共享的是内容,不是文件句柄。
契约的另一半是行为。spec/08 定义了 22 个合规场景:线性流程、多任务、条件分支、fork-join、三种会签、退回发起人、权限校验、拦截器事件、抄送、加签......移植验收 = 22 个场景在 Rust 引擎里的可观察行为与参考实现逐一一致。
这里埋个伏笔:移植初期,Rust 的测试没有 按 22 场景成建制------测试用手写 JSON 片段,场景零散覆盖,流程 JSON 也不读共享目录。这条 issue(#87)后来闭环,测试按 test_c01~test_c22 编号重建,JSON 驱动源改成直读共享 flows 目录------测试数据从此和五语言同源。"怎么验收"的故事放到第八节,先说架构。
三、四个 crate:把架构 Rust 化
模块布局对照 Java 侧(jeeflow-java 的 core / repository-jdbc / starter / facade):
| crate | 对应 Java | 职责 |
|---|---|---|
jeeflow-core |
jeeflow-core | 引擎核心:零第三方依赖,纯 stdlib |
jeeflow-repository-sqlx |
jeeflow-repository-jdbc | sqlx 0.8 MySQL 仓储(含 DDL) |
jeeflow-persist |
persist 扩展 | ARCHIVE/SYNC 动态表写入(元数据驱动) |
jeeflow-facade |
facade | 统一门面:一个入口转发全部 action |
jeeflow-demo-salvo |
demo-boot4 | Salvo 演示服务(不发布) |
前四个发 crates.io,1.0.5 于 2026-08-25 首发,当前 1.0.6。
最硬的一个工程事实:jeeflow-core 的 Cargo.toml------
toml
# jeeflow-core/Cargo.toml
[dependencies]
# Zero third-party dependencies - core is pure Rust stdlib only
[dependencies] 段是空的 。serde_json 和 tokio 全在 [dev-dependencies]------只给测试用。grep 整个 core 的生产代码,外部 crate import 数为零。
这意味着什么?Java 侧的核心是"98KB、只依赖 slf4j-api";Rust 侧更极端------连日志库都没有 。引擎不感知 JSON 库、不感知数据库、不感知 HTTP,全靠 SPI 注入(ProcessRepository / UserProvider / IdGenerator)。
那 async fn 怎么来的?Rust 的 async fn 是语言特性 ,不需要 tokio------引擎只有 5 个生产 async fn(start / execute / 三种 jump),定义的是"可挂起的计算",运行时(tokio runtime)由调用方注入。SPI 的 ProcessRepository trait 本身是同步 的(Send + Sync),sqlx 仓储在内部做 sync-over-async。
这个"core 同步、门面 async"的分层,就是下面第一个坑的架构根源。先看正常路径长什么样------README 快速开始(真实代码):
rust
use std::sync::Arc;
use std::collections::HashMap;
use jeeflow_core::{
context::ServiceContext,
id_gen::AtomicIdGenerator,
memory::MemoryRepository,
spi::{ProcessRepository, UserProvider},
};
use jeeflow_facade::JeeflowFacade;
// 1. 装配上下文:仓储 + 用户 Provider + ID 生成器
let repo = Arc::new(MemoryRepository::new());
let ctx = ServiceContext::new()
.with_repository(repo.clone() as Arc<dyn ProcessRepository>)
.with_user_provider(Arc::new(my_user_provider()))
.with_id_generator(Arc::new(AtomicIdGenerator::new(1)));
// 2. 注册流程定义(LogicFlow JSON,与其他五语言共享同一套 flows 文件)
// repo.save_define(&mut define); ← 略,见 README
// 3. 统一门面:一个入口转发全部 action
let facade = JeeflowFacade::new(ctx);
let start_args: HashMap<String, serde_json::Value> = [
("name", serde_json::json!("leave")),
("operator", serde_json::json!("user1")),
("days", serde_json::json!("3")),
].into_iter().collect();
// 发起(startAndExecute:发起并自动完成发起节点)
let resp = facade.flow("processDefine/startAndExecute", &start_args).await;
一个 facade.flow(action, args) 进去,{"code":0,"data":{...}} 出来------和另外五门语言的门面同形。flow 的签名是:
rust
// jeeflow-facade/src/lib.rs
pub async fn flow(&self, action: &str, args: &HashMap<String, Json>) -> Json
注意它是 async fn。第一版不是 ------第一版是同步函数,内部 block_on 调引擎。这就是坑一。
四、坑一:嵌套 block_on panic(Rust 特有)
症状
demo 起来,第一个请求打过去:
bash
POST /processDefine/startAndExecute
→ worker 线程 panic + Mutex poisoned
不是业务报错,是运行时直接 panic。
根因
门面要同时服务两类调用方:框架控制器(同步上下文)和未来可能的 async 调用方。第一版的设计是------flow() 保持同步签名,内部用 block_on 把 async 引擎"压平":
rust
// 第一版(错的)
pub fn flow(&self, action: &str, args: &HashMap<String, Json>) -> Json {
// ...
let resp = tokio::task::block_in_place(|| {
Handle::current().block_on(self.engine.start_async(...))
});
// ...
}
问题在 Handle::current().block_on(...):demo 用的是 Salvo,handler 本身就跑在 tokio runtime 的 worker 线程里------你已经在 runtime 里,还要 block_on 一个 future,这就是 tokio 明令禁止的嵌套阻塞,直接 panic。
Java/Go/Python/Node 没有这个概念:它们的门面要么天然 async(协程),要么同步且没有"runtime 内再阻塞"这回事。这是**"同步门面 + async 引擎"组合独有的坑**,台账里归为 Rust 实现缺口。
为什么 170 个测试全绿没抓住
facade 的测试全是同步 #[test]------仓内当时零个 #[tokio::test]。同步测试跑在没有 runtime 的上下文里,代码走了"新建 runtime"的兜底分支,永远 happy path。170 个测试全绿,全绿得理直气壮。
修复
flow()改成async fn,内部直接.await引擎,不再 block;- facade 全部测试改
#[tokio::test],在真实 runtime 里跑。
一句话教训:测试必须和生产在同类运行时里跑 。你的生产代码跑在 tokio 里,测试却在裸线程里 block_on 新建 runtime------两个世界,测试绿得不算数。
五、坑二:聚合根水合缺失(DDD 在 Rust)
症状
发起正常,办理第一步就报错:
arduino
Task 100002 not found in instance 100001
根因
第 5 篇讲过 jeeflow 的 DDD 设计:ProcessInstance 是聚合根,ProcessTask 是它的子实体,任务挂在实例的 tasks 列表上。移植时这个结构被忠实地 搬进了 Rust------ProcessInstance { tasks: Vec<ProcessTask>, ... } 原样。
坑不在设计,在水合责任 。MemoryRepository(内存仓储,测试和 demo 都用它)把 instance 和 tasks 分开存:find_instance_by_id 只查实例主记录,不填充 tasks。于是 execute_task_async 加载出来的实例,tasks 是空的------complete_task 在空列表里找任务,当然找不到。
Java 侧同样的"分开存"由仓储实现层统一水合,调用方无感;Rust 侧移植初期,水合这行责任落在了调用路径上,而三条执行路径(execute_task_async / execute_and_jump_async / execute_and_jump_to_end_async)全都漏了。
修复
三条路径在 find_instance_by_id 之后补上水合:
rust
// 加载实例后,补全聚合根的子实体
instance.tasks = self.repo().find_history_tasks(instance.instance_id)?;
影响范围:所有"加载 instance 后操作 task"的路径。一行修复,但它标出了移植 DDD 聚合时的通用陷阱:聚合结构可以照搬,聚合的加载语义(谁负责把子实体装回来)必须逐个仓储实现核实。
六、坑三:测试全绿,契约还是错的
前两个坑是"功能坏了",这个坑更阴:功能看着是好的,测试是全绿的,但响应契约错了。而且是两个同族的错,都是全链路 curl 实测才暴露的。
6.1 execute 响应的 taskIds 是 0
demo :8091 全链路实测:
bash
execute {"id":"100001","operator":"superAdmin"}
→ {"code":0,"data":{"taskIds":[0]}} ← 新任务 id 是 0?
todoList operator=user2
→ rows[0].id = "100002" ← 新任务真实 id 在这
引擎存储是对的 ------新任务确实落库了、id 是 100002、todoList 能查到。错的只有响应 :办理推进出新任务时,响应里的 taskIds 是 [0]。
调用方要是拿 execute 响应里的新任务 id 直接做下一步(比如接着 execute 下一个任务),拿到 0 就炸。
根因是一条很隐蔽的克隆时序:
- 节点执行时,新任务以 id=0 收集进
exec.new_tasks; persist_tasks落库时,对克隆体 赋真实 id 再存------let mut t = task.clone(); t.task_id = self.next_id(); save_task(&mut t);- 但
execute_task_async返回的new_tasks,是 persist 之前克隆的那份------原片 id 还是 0。
对照:start 路径(assign_ids)在 persist 前就对原片赋 id,所以 startAndExecute 的 taskIds 是对的([100001])------同一条响应契约,两条路径,一条对一条错。这种"一条路径对"的 bug,最容易骗过测试:测试通常只测走得通的那条。
6.2 出口 stringifier 漏了复数 Ids
第二个同族错在 facade 出口。联邦契约要求 id 字符串化(防止大数字跨语言丢精度),facade 出口有个 stringify_ids 递归改写响应里的 id 字段。实测:
typescript
startAndExecute → {"data":{"taskIds":[100001], ...}} ← 数组里是裸 number,不是 "100001"
根因在键匹配:
rust
// jeeflow-facade/src/lib.rs(第一版)
if k == "id" || k.ends_with("Id") || k.ends_with("_id") { ... }
taskIds / roleIds / actorIds 以 s 结尾------三个条件全不命中,数组原样放行。
为什么"测试 id 小看不出来"?因为测试用的 id 是 100001 这种小数字,裸 number 过 JSON 没问题。但生产是雪花 id------19 位,超过 2^53,JavaScript 拿到直接丢精度,前端算出来的 id 和后端对不上。测试数据没暴露生产形态,这是契约测试的经典死法。
为什么仓内测试全绿没抓住
两个 bug 各自的漏网原因(台账原文核对):
- 6.1 :facade 测试断言
taskIds != 0的"跨调用一致性"从来没写过------只断"非空"或干脆没断这条路径; - 6.2 :stringifier 的测试只测单数 键(
id/taskId/user_id);"出口审计无 >2^53 的 number"这条审计标准本身也漏了"复数 Ids 键下裸 number 数组"这个形态------小 id 的裸 number 根本过得了审计。
怎么发现的
都不是仓内测试抓的,是全链路 curl 仲裁 :起 demo(:8091),发起 → 办理 → 再查 todoList,跨调用对照 响应里的 id。6.1 的修复验收断言就按这个写的:execute 响应的 taskIds 不仅非 0,还要和 todoList 查到的新任务 id 一致。
两个 bug 同一个修复 commit(dfea338)闭环,测试补齐后全绿。
这一节的通用教训
- 测试全绿 ≠ 契约正确。断言要跨调用(这次响应的 id,下次查询对得上),不能只断形状;
- 测试数据形态要覆盖生产形态。雪花 id 的精度问题,小 id 测试永远测不出来------出口审计必须按"生产 id 会多大"设计;
- 呼应本系列反复出现的三场景测试纪律:正向 + 负向还不够,全链路仲裁是第四道门。
七、坑四:串行会签"一次建全"------还发现了一段死代码
症状
06-countersign-sequential.json------串行会签,userA、userB 依次审批:
json
// jeeflow-rust/flows/06-countersign-sequential.json(节选)
{
"id": "task1",
"type": "snaker:task",
"properties": {
"assignee": "userA,userB",
"performType": 1,
"countersignType": "SEQUENTIAL"
},
"text": { "value": "串行会签" }
}
数据侧清清楚楚:两人、串行。契约语义(第 8 篇讲过):串行 = 完成一个建一个,任意时刻只有 1 个 DOING 任务。
实测:
ini
start + apply 后:DOING = 2(userA/userB 一次建全)
userA execute 后:实例 state = 20(直接 FINISHED!),userB 任务留在 DOING(孤儿待办)
任意一人完成 → 整单定稿 → 其余成员变孤儿待办。
根因:比"一次建全"更糟------是死代码
挖下去,发现比台账最初描述的"只数人头"更糟:
- 创建侧:
create_countersign_tasks对所有 actor 一次建全,actor_ids.iter().map(...)不区分串行/并行; - 推进侧:负责会签完成判定的
CountersignHandler(含 SEQUENTIAL 分支的is_complete)是全仓零调用的死代码 ------会签 Task 节点在execute_task_async完成后无条件沿出边流转,没有任何完成门控。
台账里原话:
台账"只数人头"的描述是对这段死代码的误读,实际比它说的更糟。
------排查时先按死代码的注释理解行为,实测才发现那段代码根本没被执行过。Rust 编译期不删死代码(trait impl 不产生警告压力),一段逻辑可以安安静静躺在仓库里几个月。
为什么测试没抓住
test_c06_countersign_sequential 的断言:
rust
let tasks = repo.find_doing_tasks(iid, &[]).unwrap();
assert!(!tasks.is_empty(), "c06: sequential countersign should create first task");
// 只断言"非空",未断言"恰好 1 个"
断言过软:一次建全了 2 个,"非空"照样绿。
这个坑的特殊位置
它是 Java/PHP 的 issue/93 的同类缺陷(五语言对齐里那批),但 Rust 当时未发版------台账的处理方式值得引用:
Rust 未正式发版,故单独立项跟踪,不并入五语言对齐,避免污染发版基线。
没为了"五语言一起修"的整齐把没发版的 Rust 塞进对齐批次,而是独立跟踪、独立闭环。
修复(六步)
- 串行逐个创建 :SEQUENTIAL 分支只建
actors[0],operatorList/loopCounter 写进 instance 变量,完成一人建下一人; - 会签完成门控 (新增前置):
execute_task_async沿出边流转前插一道 gate,条件不满足直接 return; - 一票否决条件化 + 软拒绝 + merged 废弃残留 :
submitType=20默认软拒绝,节点配ONE_VOTE_VETO才阻断;merged 后废弃该节点剩余 DOING 任务,不留孤儿; - 死代码处置 :删除
CountersignHandler的 IHandler impl 和旧测试,重构为纯函数check_merge; #前缀变量解析(比例表达式);countersignDisagreeFlag注入。
验收:测试全绿,test_c28 重写为 08 流程(会签 → leader 审批 → end)全链硬断言 4 步------每步都断 DOING 数 + actor + 实例状态,不再"只断非空"。
八、怎么验收:22 个场景 + 15 个 JSON + 真 MySQL
四个坑讲完,回到"移植到底怎么算完成"。Rust 侧的验收是三道门:
第一道:仓内快测(T0)。22 个合规场景按 spec/08 成建制编号------
test_c01_simple_linear test_c04_parallel_fork_join
test_c02_multi_task test_c05_countersign_parallel
test_c03_decision_branch test_c06_countersign_sequential
test_c07_countersign_ratio test_c08_reject_to_applicant
test_c09_permission_check ... 直到 test_c22
流程 JSON 不手写片段 ,测试直读 flows/ 目录的 15 个共享文件(JEFFLOW_FLOWS_DIR 可覆盖)------测试数据和 Java 侧永远是同一份。
第二道:真 MySQL 冒烟(T1)。连隔离库跑全流程 / persist / 抄送 / 委托 / 事务回滚。移植初期这道门是缺失的(issue #86:全仓零个连库测试,sqlx 的 16 个测试全是 DDL 语法断言------"连库测试"被错误地豁免成了"第二步"),后来补上 M1--M4。
第三道:全链路 curl 仲裁。demo 起在 :8091,发起 → 待办 → 办理 → 查新任务,跨调用对照。坑三的两个 bug 都是这一道门抓的。
数字收口(crates.io 1.0.6 基线):
| 合规场景 | 22 个(spec/08,test_c01~test_c22 成建制) |
| 仓内测试 | 252 个 |
| 共享流程 JSON | 15 个(六语言共读同一目录) |
| 发布 crate | 4 个(core / repository-sqlx / persist / facade) |
| 首发 | 1.0.5,2026-08-25,crates.io |
其余一批小坑不展开,各有一条台账、各自闭环:字符串 id 双容错({"id":"100000"} 和 {"id":100000} 都得收,#84)、m_{alias}_{op}_{column} 三段式查询参数解析(#85)、demo 静默吞 body 解析错误改显式报错(#88)。
结语:六语言到底是什么
回头看这第六格,"六语言"这三个字的准确含义就清楚了:
六语言 ≠ 六份一样的代码拷贝,= 一份契约 + 15 个 JSON + 六层薄实现。
Java 用反射和 Spring,Go 用 goroutine,Python 用 asyncio,Node 用 event loop,PHP 用同步过程式,Rust 用 async/await + 借用检查器------执行模型没有两门是相同的,但 22 个合规场景的行为逐一一致,15 个流程 JSON 一处定义六边共读,五张表的列一个不差。Rust 这一格恰恰证明了这件事:连"core 零第三方依赖、纯 stdlib、SPI 同步 + 门面 async"这种和 Java 完全不同的架构切法,契约也能把行为钉住。
而这篇实录真正的教训,比"Rust 难"更深一层:
- 测试全绿不是验收标准。 170 个测试全绿时,第一个请求就打崩了运行时;单测全绿时,响应里的 id 还是 0------这两个 bug 都是全链路 curl 对照才抓到的。测试要和生产同运行时、断言要跨调用、数据要覆盖生产形态(雪花 id);
- 死代码比 bug 危险。 bug 会报错,死代码会沉默------你以为它在工作,它一行都没执行过;
- 移植的验收单位不是"功能",是"契约"。 22 个场景逐一对齐,才算这一格补上。
前五种语言各走各的快,Rust 走得最慢------它逼着契约把没说清的地方全说清了。这可能就是第六种语言最大的价值。
参考资料
- jeeflow-rust GitHub(四 crate 源码) · jeeflow GitHub(Java 参考实现)
- jeeflow 文档站(SPEC 06-facade 契约 / 08-合规场景)
- 开源演示站(一前端多后端,Rust 栈 :8091)
- 集成演示站(真实框架集成:登录态 + 权限码 + 契约门禁)