系列定位 :jeeflow 系列第 10 篇(第三季「多语言联邦」第 2 篇) 平台 :掘金(方法论讲透)/ 公众号(故事线) 素材版本:引擎 Java 1.8.18 / Go·Python·Node 1.8.20 / PHP 1.3.5 / Rust 1.0.5;契约测试取自 jeeflow-integrations/verify/contract
一、"没有差别"是测出来的,不是承诺出来的
上一篇我们说:同一份流程定义,六语言引擎跑出来的结果没有差别------今天 Java 起的流程实例,Go 栈的引擎能直接读,因为表结构、状态码、字段约定都一样。
这个结论说出来很轻,但它不是设计出来的美好愿望。多语言实现的默认结局是漂移 :同一个"驳回"语义,Java 写了状态 45,Python 顺手写了 40,Go 把分页字段名拼成了 total_pages,PHP 的 LIMIT ? 被绑成了字符串 '5'。每一处都是"小差异",攒够了就是六个互相不兼容的引擎。
对齐不能靠自觉,要靠机制。这篇把 jeeflow 联邦的对齐机制完整拆开:一份事实源、一份输入、三层门票、一条串行传播链------以及一次真实的对齐全程复盘。
二、契约同代:对齐的唯一事实源
"对齐"先要回答对齐什么。jeeflow 的答案不是"对齐 Java 的代码",而是对齐契约:
| 契约项 | 内容 |
|---|---|
| 数据表 | wf_process_define / instance / task / task_actor / cc_instance 五张表,DDL 一致 |
| 状态机 | 实例 10/20/45,任务 10/20/99 |
| 提交行为 | submitType 0~6 + 20 全枚举行为表 |
| 响应层 | 成功 code=0/msg,失败 code=99999999;分页五键 pageNum/pageSize/recordCount/totalPage/rows;id 出口一律字符串;时间 yyyy-MM-dd HH:mm:ss |
| 流程格式 | LogicFlow JSON,申请节点约定 assignee="applicant",单一 end |
这些写在 jeeflow-doc/SPEC.md,是六语言共同的事实源。契约同代的意思是:任何行为变更先进 SPEC,再进实现;发现语言和 SPEC 不一致,记 issue 修语言,不许改 SPEC 迁就实现。
注意对齐的是行为 ,不是代码。六语言的实现各写各的地道代码:Go 没有类,聚合根是结构体加方法;Rust 的所有权决定了任务创建必须显式走 saveTask;PHP 用数组和弱类型生态。这些都允许存在------只要同一份输入进去,同一份契约形状出来。
Java 被称作"参考实现"是历史事实:引擎从 mldong 框架的 Java 工作流模块独立出来,最先稳定、先有完整测试。它是默认的变更发起方,不是正确性证书------第七节会看到一次参考实现自己错了、向其他语言对齐的例子。
三、同一份输入:15 个共享流程的唯一编辑源
契约测试要成立,先得保证六语言"考的是同一张卷子"。
15 个流程定义(简单审批、多级审批、决策路由、并行分支、三种会签、驳回、混合模式、assignee 变量、内置取人......)的唯一编辑源 在 jeeflow-java/jeeflow-core/src/test/resources/flows/。各语言仓自带 flows/ 副本入库------单语言用户下载即用,不依赖隔壁有 Java 仓;维护者机器上则由各自的 resolver 自动同步。以 Python 为例:
python
def _mirror(root):
"""若 Java 源存在则精确镜像到本仓 flows/(拷所有 + 删孤儿),不存在则原样返回。"""
src = os.path.join(root, _JAVA_FLOWS_REL)
dst = os.path.join(root, "flows")
if not os.path.isdir(src): # 用户单仓 / 容器:无 Java 源,跳过镜像
return
src_names = set()
for f in os.listdir(src):
if not f.endswith(".json"):
continue
src_names.add(f)
shutil.copyfile(os.path.join(src, f), os.path.join(dst, f))
# 孤儿清理:本仓有、Java 源已无的 .json(防 id 错位)
for f in os.listdir(dst):
if f.endswith(".json") and f not in src_names:
os.remove(os.path.join(dst, f))
两个细节值得说:
- 流程 id 按文件名排序生成(01-simple → id=1......),所以同步必须"全量复制 + 删孤儿",不能只增不删------源里删掉一个文件,副本里留着就成孤儿,后面所有流程的 id 全体错位,所有按 id 断言的测试静默跑偏。
- 发版前有漂移门禁 。
jeeflow-hub/scripts/release-checklist.sh逐仓diff -rq副本与 Java 源,漂移直接 fail:
bash
for repo in jeeflow-go jeeflow-python jeeflow-node jeeflow-php jeeflow-rust; do
FLOWS_DST="${HUB_DIR}/${repo}/flows"
if [ ! -d "$FLOWS_DST" ]; then
check fail "${repo}: flows/ 副本缺失"
elif diff -rq "$FLOWS_SRC" "$FLOWS_DST" >/dev/null 2>&1; then
check pass "${repo}: flows/ 与 java 源一致"
else
check fail "${repo}: flows/ 与 java 源漂移"
fi
done
同一份卷子还要同一张评分表:五张表的建表 SQL 也是唯一编辑源 + 脚本分发 (编辑源在 jeeflow-java 的 repository-jdbc 测试资源,scripts/sync-schema.sh 同步到各语言)。改表结构只改一处,跑脚本分发,逐仓 commit------不存在"Go 的表多一列"这种状态。
四、发版门票:仓内全绿 ≠ 能发
有了同一份输入,接下来是"怎么算过"。jeeflow 的发版门票分三层,缺一层不能打 tag:
| 层 | 测什么 | 挡什么 |
|---|---|---|
| T0 仓内快测 | 内存库(H2/SQLite)跑语义回归 | 逻辑回归 |
| T1 仓内 MySQL 冒烟 | 真 MySQL,固定 define ID 段 9xxxxx,测完自清理 |
方言坑:LIMIT 占位、主键类型、VARCHAR 扫描、列名大小写 |
| T2 对该栈契约 | 集成仓跑 L0--L2(见第五节) | 集成层契约形状 |
T1 这一层是交过学费才立起来的。2026-08 集成验收时抓出一串"T0 全绿、真库爆炸"的缺陷,全是内存库测不出来的方言问题:
| issue | 语言 | 现象 |
|---|---|---|
| #66 | Node | mysql2 execute() 绑 LIMIT ? 占位符失败,分页整段返回 99999999 |
| #67 | PHP | PDO 把 LIMIT ? 绑成 LIMIT '5',语法错误 |
| #65 | Go | JdbcTableReader 把 VARCHAR 扫成 []byte,JSON 字段变 Base64 |
| #68 | PHP | PDO 把小整数主键 hydrate 成 int,后续 setTaskId 类型不符 |
这四个问题在各自的 SQLite/内存测试里全部通过 ,只在真 MySQL 下现形。从此规矩改成:各语言仓必须有一套"连得上才跑、发版机连不上直接 fail"的 MySQL 冒烟(开发机可 SKIP_MYSQL=1 跳过,发版 checklist 禁止),最低覆盖四个用例:
| # | 用例 | 过线标准 |
|---|---|---|
| M1 | 分页查询 | SQL 走生产代码路径,响应含分页五键 |
| M2 | 发起后 hydrate 主键 | 主键出口是 string(防 bigint 精度丢失) |
| M3 | persist ARCHIVE | 定稿后业务表插入一行,bizData 是明文不是 Base64 |
| M4 | persist SYNC + 字段权限 | 发起即 INSERT,只读字段不被写穿 |
一句话:T0 保语义,T1 保方言,谁缺谁不能发版。
五、契约测试:一个 runner 打八栈
引擎仓内的门票管的是"引擎自己",语言之间、栈之间的对齐靠另一层:契约测试。
jeeflow-integrations/verify/contract/runner.py 是一份语言无关的断言集------它只打 HTTP API,不关心后端是哪门语言。同一套用例,换个 --base 地址就能打任何一栈:
bash
python jeeflow-integrations/verify/contract/runner.py \
--base http://192.168.1.160:25100 \
--frontend http://192.168.1.160:22180 \
--stack boot3
断言分三层,一共 19 条:
| 层 | 断言什么 | 例子 |
|---|---|---|
| L0 部署就绪(4 条) | 登录可用、未带 token 被拦 | POST /sys/login → code=0 且有 token |
| L1 API 形状(6 条) | 响应信封、分页结构、id 字符串 | 成功响应含 code+msg 而不是 message;未知 action → code=99999999 |
| L2 引擎语义(9 条) | 提交类型、状态机、persist | submitType=2 驳回 → 实例 state=45;SYNC 模式下只读字段不落库 |
(L1 有一条补验用例 L1-03b,专查用户分页接口的 id 出口,三层合计 19 条。)
L2 的用例不依赖任何演示数据:runner 自己 save → updateDefine → deploy 建一个短流程,跑完发起/办理/驳回/退回发起人/定稿全链路,再断言持久化结果。测的是引擎语义,不是环境。
再往上还有一层 L3:Playwright 驱动 vben5 前端,跑 12 个页面场景(单线审批、并行会签、驳回、顺序会签、加签转办、抄送、撤回、设计器、字段权限......),除了表单设计器拖拽画布固定 skip,其余全自动化。
最近一轮(2026-08-24 七栈 + 08-25 Rust 栈)的结果:八栈 L0--L2 全部 19/19,L3 全部 14 过 / 1 固定 skip / 0 失败,单栈 6~8 分钟。这份矩阵每次引擎发版后重跑------上一篇说的"发布过的字节 = 你拿到的字节",靠的就是它。
六、升级传播流:严格串行,发版分轨
契约变了怎么传播到六语言?两条原则:传播严格串行,发版各自分轨。
go
jeeflow-doc 规范变更(SPEC)
│
├→ jeeflow-java(参考实现先改,T0+T1 全测,自己发下一版)
│ │
│ ├→ jeeflow-go 对齐行为 → T0+T1 → 自己发
│ ├→ jeeflow-python 同上
│ ├→ jeeflow-node 同上
│ ├→ jeeflow-php 同上(发自己的 1.3.x)
│ └→ jeeflow-rust 同上(发自己的 1.0.x)
│
└→ 交叉验证:同一份流程 JSON,各语言结果一致
└→ 各集成仓升依赖 → 重跑 L0--L3
为什么严格串行? 并行改六个仓的代价是六个半成品状态互相干扰:一个行为改到一半,你分不清测试失败是新契约没实现对,还是旧契约被改坏了。串行意味着任一时刻最多一个语言处于"契约未对齐"状态,问题定位空间从六维降到一维。这和 mldong 框架四分支修复流程是同一条纪律:先改完一个、测通一个,再动下一个。
为什么发版分轨? 对齐的是行为,不是版本号。旧叙事是"五仓等一个联邦同号发版",结果后进联邦的 PHP 把整个发布卡住------它修完自己的三个缺陷后,要么等别的语言凑齐,要么硬贴一个不属于自己的 1.8.17。现在的规矩:
- Java / Go / Python / Node 各自走
1.8.x,不必同号同天(当前 Java 1.8.18,Go·Python·Node 1.8.20); - PHP 走自己的
1.3.x,Rust 从1.0.x起步,永不改号贴联邦; - 每个语言发版只对自己负责:T0+T1 全绿、集成仓升依赖后契约矩阵重跑,就能发。
版本号不同号,契约同代------这是"多实现单一契约"能长期运转的关键妥协。
七、一次真实对齐全程复盘:串行会签(issues/93)
机制说得再多,不如走一遍真的。2026-08-23,第 8 篇(会签三兄弟)素材核对时发现一个回归:
现象 :Java 串行会签启动后一次创建全部任务------3 人会签立刻出现 3 个 DOING 待办,串行形同并行。与 mldong 内置版、Go/Python/Node 引擎全部不一致,六语言共享流程在这一个行为上裂了。
找基准:有意思的是,这次的"参考实现"是错的一方。正确的参照系是 Go/Python/Node 三个语言加 mldong 内置版语义------串行 = 完成一个建一个,任意时刻只有一个 DOING。参考实现先行是传播顺序,不是正确性豁免;谁错修谁,这次修的正是仅有的两个错法语言:Java 和 PHP。
修复 (Java 2cb24d3 / PHP 6a706c2):创建侧只建第一位成员任务,把会签计数写进任务变量;推进侧每完成一位,由 handler 创建下一位:
java
// jeeflow-core ProcessInstance.createCountersignTasks(issues/93 修复后)
if (CountersignTypeEnum.SEQUENTIAL.equals(taskModel.getCountersignType())) {
String node = taskModel.getName();
ProcessTask first = ProcessTask.create(
this.instanceId,
node,
taskModel.getDisplayName(),
taskModel.getTaskType(),
taskModel.getPerformType(),
taskModel.getForm(),
new ArrayList<>(java.util.Collections.singletonList(actorIds.get(0))),
operator
);
first.getVariables().put(FlowConst.COUNTERSIGN_OPERATOR_LIST + "_" + node, new ArrayList<>(actorIds));
first.getVariables().put(FlowConst.LOOP_COUNTER + "_" + node, 0);
first.getVariables().put(FlowConst.NR_OF_INSTANCES + "_" + node, actorIds.size());
list.add(first);
this.tasks.add(first);
return list;
}
这个修复里藏着三个值得讲的细节:
- 计数存哪,跟着正确的多数派走。 内置版把计数存在实例变量(
csv_{node}_*前缀),Go/Python/Node 存在任务变量。jeeflow 选了任务变量------计数器随会签任务的生命周期走,"当前 DOING 任务"天然就是唯一携带计数者,也和另外三个正确语言一致。对齐的锚点是行为一致的多数派,不是资历。 - 语义对齐会引爆潜伏缺陷。 PHP 原来有一处死分支:串行判定写成
$csType === 2(实际枚举值是 1),串行被当并行处理------在"一次建全"下恰好结果等价,从未暴露;改成逐个创建后它会立刻变成真 bug(首位完成就提前流转到 end,跳过其余成员)。行为对齐逼着这类"恰好没炸"的代码现形。 - 隐含依赖要一起搬。 逐个创建后,会签节点任一时刻只有一个任务,而进度回显要展示全量成员------Java/PHP 的
buildNodeProgress必须改为从任务变量operatorList_{node}还原成员列表(对齐 Go/Python/Node 的既有做法),否则进度条会丢人。只改创建逻辑不改读侧,测试照样红给你看。
验证(三场景,两语言):
| 语言 | 正向 | 负向 | 回归 | 结果 |
|---|---|---|---|---|
| Java | 逐人办理,任一时刻 1 个 DOING | 软拒绝:未办人无任务,办理后才建 | core+jdbc+persist 全量 | 104/104 |
| PHP | 同上,成员列表经任务变量还原 | 同上 | 含并行会签 3 任务、共享流程解析 15 个 | 280 tests / 1300 assertions |
残留:Rust 引擎存在同类"一次建全"缺陷,因未发版单独挂 issues/94 跟踪------已知差异进台账,不当作不存在,也不阻塞其他语言发布。
结语:这套方法论可以带走
回头看,对齐机制其实就四件:
- 唯一事实源------契约进 SPEC,行为变更先改规范再改实现;
- 同一份输入------共享流程定义与建表 SQL 单点编辑、镜像分发、漂移门禁;
- 分层门票------仓内快测保语义、真库冒烟保方言、集成契约保形状,缺一层不发版;
- 串行传播、分轨发版------任一时刻最多一个语言未对齐,版本号各走各的,契约同代。
它不是工作流引擎特有的。任何"一份契约、多个实现"的场景------多语言 SDK、多端 API、新旧系统并行------漂移都是默认结局,对齐都只能靠机制。区别只在于你有没有在第一次被方言坑咬过之后,把门票立起来。
下一篇是本季的收尾:[第 11 篇 · 六语言引擎实现对比:同构背后的妥协与差异](#第 11 篇 · 六语言引擎实现对比:同构背后的妥协与差异 "#")------同一个契约在六种语言里长什么样?包结构怎么映射?哪些差异是刻意保留的、为什么?
参考资料
- jeeflow GitHub(Java 参考实现) · jeeflow-rust
- jeeflow 文档站(SPEC 与多语言指南)
- 开源演示站(一前端多后端,nginx path 前缀切换语言)
- 集成演示站
- 一键部署区(八栈命令)
下一篇预告 :[第 11 篇 · 六语言引擎实现对比:同构背后的妥协与差异](#第 11 篇 · 六语言引擎实现对比:同构背后的妥协与差异 "#")------包结构对比、测试矩阵、已知差异清单。