系列定位:jeeflow 系列第 12 篇(第四季「生态与实战」开篇 · 回归主线) 平台:掘金(实录)/ 公众号(故事线) 素材版本:jeeflow-java 1.8.19 · mldong-boot2 master · 集成仓 jeeflow-boot2/boot3/boot4 分支(2026-08-31 核对)
一、故事的下半场:独立只是走,接回来才是闭环
序章讲过 jeeflow 的出身:它原本是 mldong 快速开发框架里的一个内置工作流模块,后来独立成了多语言引擎。那是故事的上半场——走。
这篇讲下半场——接回来。
mldong 框架这边,工作流从来不是可有可无的挂件:前端 vben5 的工作流页面(发起、待办、已办、审批记录、流程图高亮)依赖一组固定的后端接口;框架的使用者习惯了开箱即用。这次换引擎,前端一行代码没改——那框架是怎么把 jeeflow 重新装回来的?
答案不是把代码搬回来,也不是写一层又一层的适配器,而是一份接口契约:响应信封、分页结构、端点清单、VO 形状、提交类型枚举。引擎把这些契约内化成自己的能力,框架只需要一层薄到可以一眼读完的映射层。
先看一组数字,感受一下这次"接回来"的代价:
| 内置工作流模块(boot2 时代) | jeeflow 薄集成层(现在) | |
|---|---|---|
| 框架侧文件数 | 156 个 .java(引擎核心 + 控制器 + VO/参数) | 8 个 .java(1 个转发控制器 + 2 个装配类 + 5 个 SPI 映射) |
| 控制器 | 7 个(定义/设计/实例/任务/委托…每个 CRUD 全套) | 1 个:/wf/** 全量转发 |
| 引擎升级 | 跟框架版本绑定,改一处要同步 boot2/boot3 两链 | 换 SDK 版本号,rebase 集成层(冲突固定:删掉的还是那个旧模块) |
156 → 8,行为一个键都没少。中间省掉的不是工作量,是重复——状态机、会签、跳转这些逻辑只存在于引擎里一次,框架侧剩下的全是"翻译"。
一句话提纲:独立带走的是实现,留下的是契约;接回来靠的不是代码,是契约。
二、契约的第一形状:code=0 与 msg
mldong 框架所有接口的响应信封,定义在 mldong-base 的 CommonResult 里(boot2 源码原样):
// mldong-boot2 mldong-framework/mldong-base/.../CommonResult.java
public class CommonResult<T> {
@ApiModelProperty(value = "状态码(0:成功,其他失败)")
private int code;
@ApiModelProperty(value = "消息描述")
private String msg;
@ApiModelProperty(value = "返回的数据")
private T data;
public static CommonResult<?> ok() {
return new CommonResult<>(0, "成功", null);
}
public static CommonResult<?> fail(String msg) {
return fail(99999999, msg);
}
}
三个不显眼但致命的约定:
code=0才是成功,不是 HTTP 200——失败的请求 HTTP 状态照样 200,前端判断成功与否只看code;- 字段叫
msg,不是message——差一个字母,前端取到的就是undefined; - 兜底错误码是
99999999——不是 500,不是 -1。
任何外部引擎想接进来,第一关就是这三个约定。HTTP 语义再正统,到这里也得低头:mldong 的前端是照着这份信封写的,改前端等于改所有使用者的项目,不可能。
信封之外还有分页。框架的分页响应必须五键:
{ "code": 0, "msg": "成功",
"data": { "pageNum": 1, "pageSize": 10, "rows": [], "recordCount": 35, "totalPage": 4 } }
少一个 totalPage,前端分页器就渲染不出来。这一条后来在六语言引擎里各踩了一遍坑(Node 的 MySQL 驱动 execute() 不返计数、PHP PDO 的 LIMIT 不能 bind 参数,都进了台账),此处不展开。
第三形状是 VO:实例详情要带 displayName/version/jsonObject/activeTaskList,高亮要独立的三键(historyNodeNames/historyEdgeNames/activeNodeNames),时间一律 yyyy-MM-dd HH:mm:ss,雪花 id 出口一律字符串(防前端 JS 精度丢失)。
这些约定散落在框架的前端代码、历史接口和使用者习惯里。jeeflow 做的决定是:不要求集成方去对齐它们,而是让引擎自己长出这些能力——这就有了 facade。
三、引擎把契约长进骨头里:JeeflowFacade
jeeflow 每门语言的实现里都有一个统一门面 JeeflowFacade:单一入口 flow(action, args),action 是 boot2/boot3 端点路径的短名。Java 版 42 个 case 分支(startAndExecute 定义/实例双入口):
// jeeflow-java jeeflow-core/.../facade/JeeflowFacade.java
public Map<String, Object> flow(String action, Map<String, Object> args) {
try {
if (args == null) args = new LinkedHashMap<>();
switch (action) {
// ── 流程定义 ──
case "processDefine/page": return definePage(args);
case "processDefine/detail": return defineDetail(args);
case "processDefine/startAndExecute": return startAndExecute(args);
// … 流程实例 / 任务 / 设计器 / 抄送 / 委托 …
case "processTask/todoList": return todoList(args);
case "processTask/execute": return execute(args);
case "processInstance/highLight": return highLight(args);
case "processInstance/approvalRecord": return approvalRecord(args);
case "processInstance/bizData": return bizData(args);
default:
return error("未知 action: " + action);
}
} catch (Exception e) {
return error(e.getMessage() != null ? e.getMessage() : e.toString());
}
}
注意两点:未知 action 不抛异常、业务异常也不抛异常,全部折成信封返回。响应工具方法的注释把出处写得明明白白:
// ═══ 响应工具(boot2 CommonResult:code=0 成功 / 99999999 失败)═══
private Map<String, Object> ok(Object data) {
Map<String, Object> r = new LinkedHashMap<>();
r.put("code", 0);
r.put("msg", "成功");
r.put("data", data);
return r;
}
private Map<String, Object> error(String msg) {
Map<String, Object> r = new LinkedHashMap<>();
r.put("code", 99999999);
r.put("msg", msg);
return r;
}
分页五键、VO 字段(ext/displayName/formData/taskFormData/jsonObject/activeTaskList/isFirstTaskNode/TIME_FMT)、审批记录扩展——这些字段契约全部由 facade 内置(台账 issues/05/15 逐项记录),集成方一个字段都不用补。
这不是 Java 独有。Go、Node、Python 的 facade 源码头部注释写着同一句话:"code=0 成功 / 99999999 失败"——契约是跨语言的,哪个语言的实现接进 mldong 系前端,信封都长一个样。
一段被"搬家"的分发逻辑
更能说明问题的是 execute(办理待办)。当年它在框架的控制器里(boot2 源码节选):
// mldong-boot2 .../wf/controller/ProcessTaskController.java(内置模块时代)
Integer submitType = args.get(FlowConst.SUBMIT_TYPE, ProcessSubmitTypeEnum.AGREE.getCode());
if (ObjectUtil.equals(submitType, ProcessSubmitTypeEnum.ROLLBACK.getCode())) {
flowEngine.executeAndJumpTask(processTaskId, operator, args, null);
} else if (ObjectUtil.equals(submitType, ProcessSubmitTypeEnum.REJECT.getCode())) {
flowEngine.executeAndJumpToEnd(processTaskId, operator, args);
} else if (ObjectUtil.equals(submitType, ProcessSubmitTypeEnum.JUMP.getCode())) {
String taskName = args.getStr(FlowConst.TASK_NAME);
flowEngine.executeAndJumpTask(processTaskId, operator, args, taskName);
} else if (ObjectUtil.equals(submitType, ProcessSubmitTypeEnum.ROLLBACK_TO_OPERATOR.getCode())) {
flowEngine.executeAndJumpToFirstTaskNode(processTaskId, operator, args);
} else if (ObjectUtil.equals(submitType, ProcessSubmitTypeEnum.COUNTERSIGN_DISAGREE.getCode())) {
args.put(FlowConst.COUNTERSIGN_DISAGREE_FLAG, 1);
flowEngine.executeProcessTask(processTaskId, operator, args);
} else {
flowEngine.executeProcessTask(processTaskId, operator, args);
}
现在它在引擎的 facade 里,分支语义逐条相同:
// jeeflow-java .../facade/JeeflowFacade.java#execute(节选)
if (ProcessSubmitTypeEnum.REJECT.getCode().equals(submitType)) {
engine.executeAndJumpToEnd(taskId, operator, flowArgs);
} else if (ProcessSubmitTypeEnum.ROLLBACK.getCode().equals(submitType)) {
engine.executeAndJumpTask(taskId, operator, flowArgs, null);
} else if (ProcessSubmitTypeEnum.JUMP.getCode().equals(submitType)) {
String taskName = toStr(args.get(FlowConst.TASK_NAME));
engine.executeAndJumpTask(taskId, operator, flowArgs, taskName);
} else if (ProcessSubmitTypeEnum.ROLLBACK_TO_OPERATOR.getCode().equals(submitType)) {
engine.executeAndJumpToFirstTaskNode(taskId, operator, flowArgs);
} else if (ProcessSubmitTypeEnum.COUNTERSIGN_DISAGREE.getCode().equals(submitType)) {
flowArgs.put(FlowConst.COUNTERSIGN_DISAGREE_FLAG, 1);
engine.executeProcessTask(taskId, operator, flowArgs);
} else {
engine.executeProcessTask(taskId, operator, flowArgs); // 0 APPLY / 1 AGREE / 5 重新提交
}
submitType 八个枚举(0 发起 / 1 同意 / 2 拒绝 / 3 退回上一步 / 4 跳转 / 5 重新提交 / 6 退回发起人 / 20 会签一票否决)的 code 值和行为表在第 3 篇里完整展开过,这里只看一件事:这段分发逻辑从框架控制器搬进了引擎门面。搬家不是复制粘贴——搬进引擎之后,它成了六语言共享的契约行为,Go/Python/Node/PHP/Rust 的 facade 里各有一份语义等价的实现,任何一门语言的集成方都直接拿到完整的 submitType 语义,不用自己在控制器里再写一遍。
四、接回去:集成层只有 8 个文件
以 boot4 为例(boot2/boot3 集成仓形状完全相同),框架侧的 jeeflow 集成层一共 8 个文件:
mldong-admin/.../wf/controller/WfFlowController.java ← 唯一的控制器
mldong-api/mldong-wf-api/.../WfJeeflowConfig.java ← 装配
mldong-core/mldong-wf-core/.../WfHandlerRegistryConfig.java
mldong-core/mldong-wf-core/.../provider/JeeflowUserProvider.java
mldong-core/mldong-wf-core/.../provider/JeeflowOrgUserProvider.java
mldong-core/mldong-wf-core/.../provider/JeeflowUserSearchProvider.java
mldong-core/mldong-wf-core/.../provider/MldongMetaProvider.java
mldong-core/mldong-wf-core/.../provider/WfProcessDictService.java
核心是那个唯一的控制器,/wf/** 全量转发(boot4 集成仓源码节选):
// jeeflow-integrations/mldong-boot4-jeeflow .../WfFlowController.java
@PostMapping("/wf/**")
public Map<String, Object> flow(HttpServletRequest request,
@RequestBody(required = false) Map<String, Object> body) {
String action = /* 从 URI 截出 /wf/ 之后的短名 */;
// 1. 权限校验(权限码 SPI:wf:{action:/→:},superAdmin 万能权限放行)
checkPermission(action);
// 2. 注入操作人(门面约定 args.operator)
if (body == null) body = new LinkedHashMap<>();
body.put("operator", LoginUserHolder.getUserId().toString());
// 3. listByType 返回结构转换(其余 action 全部直接转发)
if ("processDesign/listByType".equals(action)) {
return designListByType(body);
}
// 4. 门面转发
return jeeflowFacade.flow(action, body);
}
42 个 action 里,控制器只亲手碰了 1 个(listByType 的返回结构 boot3 前端约定数组、facade 返回 Map,转换六行代码);其余全部一行转发。框架的登录态约定也在这里兑现——operator 从 LoginUserHolder 注入,业务代码全程不感知。
框架用户体系怎么喂给引擎?SPI 薄映射(源码节选):
// .../provider/JeeflowUserProvider.java
@Component
@RequiredArgsConstructor
public class JeeflowUserProvider implements IUserProvider {
private final UserApi userApi;
@Override
public UserInfo getUser(String userId) {
if (StrUtil.isBlank(userId)) return null;
UserInfo userInfo = UserInfo.of(userId);
Dict user = userApi.findById(Long.valueOf(userId));
if (user == null || user.isEmpty()) return userInfo;
userInfo.setRealName(user.getStr("realName"));
userInfo.setDeptId(StrUtil.toStringOrNull(user.get("deptId")));
userInfo.setDeptName(user.getStr("deptName"));
userInfo.setPostId(StrUtil.toStringOrNull(user.get("postId")));
userInfo.setPostName(user.getStr("postName"));
return userInfo;
}
}
引擎不认识框架的 sys_user 表、不认识 sa-token、不认识 MyBatis-Plus——它只认识 IUserProvider。映射层把框架的用户数据翻译成引擎的 UserInfo,十几行。装配类里还有一个细节:ID 生成器覆盖为框架的 IdWorker::getId,流程主键和业务主键共用同一套雪花体系,这也是契约(id 出口为字符串)能成立的前提。
三个 boot 集成仓都保持"基础仓 + 1 commit"的形状:框架基线演进时,集成分支 rebase 上去,冲突固定且只有一处——删掉那个旧工作流模块。
五、契约不靠口头:35 条机读用例守门
契约定了、代码对了,还差最后一步:怎么保证下一次改动不把它弄漂?
jeeflow 集成区里有一份机读契约验收:jeeflow-integrations/verify/contract/cases.yaml,35 条用例,runner 按 L0/L1/L2 分层执行,对每个集成栈(八栈镜像发版前必过)跑一遍。挑 L1 层几条看:
| 用例 | 守的是什么 |
|---|---|
| L1-01 响应信封 code+msg | 成功 code=0、字段是 msg 不是 message |
| L1-02 分页结构 | 五键齐全,少一键即红 |
| L1-03 id 出口为字符串 | 雪花 id 不许以数字出接口(防前端精度丢失) |
| L1-03b sys/user/page id 为字符串 | 专门防 Laravel toArray() 路径把 id 吐成数字 |
| L1-05 未知 action 返回 99999999 | 错误也必须用信封,不许裸异常 |
L1-06 删除/启停类接受 {ids} 批量 | 前端真实载荷形态,空数组必须报错不许静默 |
用例的备注栏里全是事故出处:L1-03b 写着"防 Laravel toArray() 路径",L1-06 引着台账 issues/95、issues/96。每一条断言背后都有一次真实的漂移——这正是契约测试和"写几个冒烟脚本"的区别:断言的是契约本身,不是某次碰巧能跑通的路径。
引擎侧发版、框架侧演进、新语言接入,谁动了 /wf/** 的行为,门禁红给谁看。
六、接回来之后:一条双向通道
契约对齐不是单向的"引擎迁就框架"。boot4 作为第一个真实集成方,反过来给引擎喂了一串缺口:
- 流程定义写侧要 SPI 化(设计器保存不该只写引擎五表);
- 级联任务、assignee 变量、AUTO_ID 这些框架场景引擎没见过;
- 设计器要展示"当前注册了哪些处理器实现",引擎得吐出元数据。
这些全部进了 jeeflow-hub/issues/ 台账,1.0.1~1.4.0 逐条闭环:最后一项甚至重塑了引擎的能力面——1.4.0 起引擎内置枚举字典注册表 EnumDictRegistry 和处理器清单 HandlerRegistry,boot4 侧原本的一堆扫描器塌缩成一个 WfProcessDictService 薄映射。
于是这条链路跑成了闭环:
框架场景 → 集成层发现缺口 → 台账立项 → 引擎实现 → 六语言对齐 → 发版 → 集成仓 rebase → 门禁验收
框架得到的是一个可替换的引擎——三个 boot 版本、以及 FastAPI/NestJS/GoFrame/Laravel/Salvo 各栈,用的都是同一份契约,换语言不换接口;引擎得到的是真实的集成方——不是自己造的 demo,而是有登录态、有权限码、有雪花主键体系的真实框架。
结语:契约是两个系统之间最短的路径
回头看这次对接:没有适配器大山,没有双向数据同步,没有"兼容旧接口"的泥潭。有的只是——
- 一份从框架历史里长出来的契约(
code=0/msg、分页五键、40+ action、VO 形状); - 一个把契约内化成自身能力的引擎(facade 六语言同构);
- 一层薄到 8 个文件的映射(转发 + SPI 翻译);
- 一道 35 条机读用例的门禁(谁改漂了红给谁看)。
内置模块时代,工作流和框架是一具身体,升级牵一发动全身;现在它们是两个系统,中间只有一张契约纸。156 个文件变 8 个,引擎却从"框架的一个模块"变成了"六门语言的联邦",而前端一行代码没改——独立不是目的,独立之后还能原样接回来,才是这次重构真正的验收标准。
参考资料
- jeeflow GitHub(Java 参考实现) · jeeflow-rust
- jeeflow 文档站(SPEC 06-facade 统一门面契约)
- mldong 快速开发框架(官网 · 一键部署区)
- 开源演示站(一前端多后端,无登录态显式传 operator)
- 集成演示站(真实框架集成:登录态 + 权限码 + 契约门禁)