系列定位 :jeeflow 系列第 5 篇(第二季「核心设计」第 2 篇) 平台 :掘金(代码密度高,原理讲透) 素材版本 :引擎 v1.8.15,源码取自 jeeflow-java 前置阅读 :第 3 篇 · 状态机与 submitType
一、一个反直觉的问题
工作流引擎 98KB,零框架依赖,五语言同构------这些是上一篇文章讲过的"外在特征"。
但真正决定引擎能不能扛住复杂业务的,是内部怎么组织代码。
先看一个反直觉的事实:jeeflow 的核心引擎类 JeeflowEngineImpl 只有 300 行 ,其中一大半是节点路由逻辑(decision/fork/join)。真正的业务规则------状态怎么变、谁有权审批、驳回时哪些任务要废弃------一行都不在引擎里。
那它们在哪?
在两个类里:ProcessInstance(375 行)和 ProcessTask(181 行)。
这就是本文要讲的:用 DDD 的聚合根 + 充血模型,把工作流引擎的规则从"上帝服务类"里解放出来。
二、贫血模型 vs 充血模型
先定义两个概念,不扯理论,只看代码。
贫血模型:数据袋子 + 上帝服务
大多数自研工作流引擎长这样:
java
// 贫血实体:只有 getter/setter,没有行为
public class ProcessInstance {
private Long instanceId;
private Integer state;
private List<ProcessTask> tasks;
// 全是 getter/setter,没有业务方法
}
// 上帝服务类:堆所有规则,5000 行起步
public class ProcessService {
public void completeTask(Long taskId, String operator) {
// 1. 查任务
// 2. 校验状态
// 3. 校验权限
// 4. 改任务状态
// 5. 合并变量
// 6. 判断是否所有任务完成
// 7. 改实例状态
// 8. 创建下一个任务
// 9. 持久化
// ... 还有 reject/withdraw/interrupt/pending/resume
// 总共 2000 行,全在这里
}
}
问题在哪?
- 规则散落:状态校验、权限判断、级联逻辑全在 Service 里,实体自己不知道"完成"意味着什么
- 不可复用:Go/Python/Node 重写时,这套 2000 行的规则要再写一遍
- 难以测试:测一个"完成任务"要 mock 整个 Service 依赖链
充血模型:规则跟着数据走
jeeflow 的做法:
java
// 充血实体:自己知道怎么"完成"
public class ProcessTask {
public void finish(String operator, FlowData args) {
// 前置校验:状态 + 权限
if (!ProcessTaskStateEnum.DOING.getCode().equals(this.taskState)) {
throw new RuntimeException("任务不是进行中状态,无法完成");
}
if (!isAllowed(operator)) {
throw new RuntimeException("操作人不在参与者列表中");
}
// 状态转换
this.taskState = ProcessTaskStateEnum.FINISHED.getCode();
this.actorId = operator;
// ...记录时间、合并变量
}
public boolean isAllowed(String operator) {
if (FlowConst.AUTO_ID.equalsIgnoreCase(operator)
|| FlowConst.ADMIN_ID.equalsIgnoreCase(operator)) {
return true; // 系统代执行 / 超管放行
}
return isDoing() && this.actorIds != null
&& this.actorIds.contains(operator);
}
}
规则跟着数据走------任务自己知道"谁能完成我"、"完成后状态怎么变"。外部调用方只需要说"完成这个任务",不需要知道内部校验逻辑。
三、聚合根:ProcessInstance
DDD 的聚合根(Aggregate Root)是一个事务边界------外部只能通过聚合根访问内部实体,所有状态修改都经过聚合根的方法。
jeeflow 的 ProcessInstance 就是这样一个聚合根:
java
/**
* 流程实例------DDD 聚合根(充血模型)
*
* <p>流程实例是一个独立的事务边界,包含所有子任务。
* 所有状态修改都通过聚合根的方法完成,外部不直接操作子实体。</p>
*/
public class ProcessInstance {
private Long instanceId;
private Integer state; // 实例状态
private String operator; // 发起人
private FlowData variables; // 流程变量(值对象)
private List<ProcessTask> tasks; // 子实体集合
// ...
}
3.1 命令方法清单
聚合根暴露的每一个方法,都是一个业务命令:
| 方法 | 状态转换 | 说明 |
|---|---|---|
completeTask(taskId, operator, args) |
子任务 10→20 | 完成任务 + 合并变量 + 提取 f_ 前缀表单数据 |
finish() |
实例 10→20 | 所有任务完成,流程正常结束 |
reject() |
实例 10→45 | 流程被拒绝 |
interrupt(operator) |
所有子任务→40, 实例→40 | 强行终止(级联) |
pending(operator) |
所有子任务→50, 实例→50 | 挂起(级联) |
resume(operator) |
所有子任务 40→10, 实例→10 | 唤醒(级联) |
withdraw(operator) |
进行中子任务→30, 实例→30 | 撤回(级联) |
abandonTask(taskId, operator) |
子任务 10→99, 实例→99 | 废弃任务并废弃整个实例 |
注意级联 这个词------撤回/终止/挂起是实例级命令,但会级联到所有子任务。这是聚合根的职责:保证实例和任务的状态一致性。
3.2 不变量保护
聚合根最重要的作用之一是保护不变量(invariants)。看一个关键方法:
java
private ProcessTask findDoingTask(Long taskId) {
for (ProcessTask task : tasks) {
if (taskId.equals(task.getTaskId())) {
if (!task.isDoing()) {
throw new RuntimeException(
"任务[" + taskId + "]不是进行中状态");
}
return task;
}
}
throw new RuntimeException(
"未找到任务[" + taskId + "]或不在聚合根中");
}
这个方法做了两件事:
- 存在性校验:任务必须属于这个聚合根
- 状态校验:任务必须是进行中
外部调用 completeTask 时,不需要重复这两个校验------聚合根已经保证了。
3.3 引擎只做编排
有了充血聚合根,引擎层就变成了薄编排:
java
// JeeflowEngineImpl.executeProcessTask 核心逻辑
public List<ProcessTask> executeProcessTask(
Long taskId, String operator, FlowData args) {
// 1. 加载聚合根
ProcessInstance instance = repository.getInstance(defineId);
// 2. 权限校验------委托给子实体
ProcessTask task = instance.findDoingTask(taskId);
if (!task.isAllowed(operator)) {
throw new RuntimeException("无权限");
}
// 3. 完成任务------委托给聚合根
instance.completeTask(taskId, operator, args);
// 4. 路由到下一个节点------引擎的职责
Execution exec = buildExecution(instance, args, operator);
currentNode.execute(exec);
// 5. 持久化------通过 SPI
repository.updateInstance(instance);
return instance.getDoingTasks();
}
引擎和聚合根的分工:
| 引擎(编排) | 聚合根(规则) |
|---|---|
| 加载解析流程定义 | 完成任务时状态怎么变 |
| 决定下一个节点是谁 | 谁有权处理这个任务 |
| 评估决策表达式 | 驳回时哪些任务要废弃 |
| 解析参与者名单 | 任务创建时的字段约定 |
| 发布事件/调用拦截器 | 实例是否所有任务完成 |
判断标准:改动涉及"状态/字段规则"→ 聚合根;涉及"流程走向/外部 IO"→ 引擎。
四、子实体:ProcessTask
ProcessTask 是聚合根的子实体,它也有自己的行为:
java
/**
* 流程任务------聚合根 ProcessInstance 的子实体(充血模型)
* <p>任务自己知道如何完成、废弃、判断权限。</p>
*/
public class ProcessTask {
private Long taskId;
private Long processInstanceId; // 所属聚合根 ID
private String taskName; // 节点编码
private Integer taskState; // 任务状态
private List<String> actorIds; // 参与者列表
// ...
// 完成:10→20
public void finish(String operator, FlowData args) { ... }
// 废弃:10→99
public void abandon(String operator) { ... }
// 撤回:→30
public void withdraw() { ... }
// 终止:10→40(仅进行中可终止)
public void interrupt(String operator) { ... }
// 挂起:10→50
public void pending(String operator) { ... }
// 唤醒:40→10(仅已终止可唤醒)
public void resume(String operator) { ... }
}
每个方法都有前置条件校验------不是简单的 setter,而是带业务规则的命令。
权限判断的内聚
isAllowed 方法把权限逻辑内聚在实体里:
java
public boolean isAllowed(String operator) {
// 系统代执行 / 超管 → 直接放行
if (FlowConst.AUTO_ID.equalsIgnoreCase(operator)
|| FlowConst.ADMIN_ID.equalsIgnoreCase(operator)) {
return true;
}
// 普通用户:必须是进行中 + 在参与者列表中
return isDoing() && this.actorIds != null
&& this.actorIds.contains(operator);
}
如果权限规则变了(比如加一个"部门经理可代审"),只改这一个方法,不需要翻遍整个 Service。
五、值对象:FlowData
除了聚合根和子实体,jeeflow 还有一个值对象 FlowData:
java
// 继承 LinkedHashMap,零外部依赖
public class FlowData extends LinkedHashMap<String, Object> {
public String getStr(String key) { ... }
public Long getLong(String key) { ... }
public Integer getInt(String key) { ... }
public Boolean getBool(String key) { ... }
public FlowData set(String key, Object value) { ... } // 链式
public FlowData copy() { ... } // 深拷贝
}
为什么不用 Hutool Dict 或 Map<String, Object>?因为值对象要表达语义 ------FlowData 不是随便一个 Map,它是"流程变量",有类型安全的取值方法和深拷贝能力。
六、五语言同构
充血模型不是 Java 专属。jeeflow 的五语言实现都遵循同样的 DDD 结构:
| 语言 | 聚合根形式 | 关键文件 |
|---|---|---|
| Java | class + 方法 | domain/ProcessInstance.java |
| Go | struct + receiver 方法 | model/instance.go |
| Node.js | class(interface 承载不了行为) | src/model.ts |
| Python | dataclass + 方法 | jeeflow/model.py |
| PHP | class + 方法 | Domain/ProcessInstance.php |
同构的不是代码语法,而是职责边界 ------每个语言的 ProcessInstance 都有 completeTask/finish/reject/interrupt 这些命令方法,都有 findDoingTask 这个不变量保护。
这也是为什么 jeeflow 能做到"一套流程定义,五语言跑出相同结果"------规则在聚合根里,不在引擎编排里。换语言只是换语法,规则不变。
七、为什么贫血模型扛不住工作流
回到开篇的问题。
工作流引擎的复杂度不在"路由"(decision/fork/join 是算法问题),而在状态和规则的交织:
- 完成任务时要校验状态、权限、合并变量、判断是否全部完成、级联改实例状态
- 驳回时要找到上一个任务节点、废弃当前路径上的所有进行中任务、创建新任务
- 撤回时要级联所有子任务、但不能撤回已完成的子任务
- 会签一票否决时要废弃其他参与者的待办任务
这些规则如果全堆在 Service 里:
- Java 版 2000 行,Go 版再写 2000 行,Python 版再来 2000 行
- 规则改了(比如加一个"部门经理可代审"),五个语言各改一处,漏一个就是 bug
- 测试要 mock 整个 Service 依赖链
而充血模型把规则收拢到聚合根:
- 规则只写一次(每个语言各写一次,但逻辑完全同构)
- 改动只改一处 (
isAllowed方法加一行) - 测试只测聚合根(不需要 mock 引擎/仓储/SPI)
这就是 DDD 在工作流引擎场景下的核心价值:不是"架构洁癖",是"五语言同构"的工程刚需。
结语
流程图是皮,状态机是骨,聚合根是肉。
理解了 ProcessInstance/ProcessTask 的充血设计,你就理解了 jeeflow 为什么能 98KB 跑完全套工作流语义------规则内聚在领域对象里,引擎只做薄编排。
下一篇预告:[第 6 篇 · "applicant" 契约:退回发起人的闭环设计](#第 6 篇 · "applicant" 契约:退回发起人的闭环设计 "#") ------ 为什么每个流程的第一个节点必须是"发起申请"?退回发起人(submitType=6)的完整链路怎么走?
参考资料
- jeeflow GitHub 仓库
- jeeflow 文档站 · 领域模型
- jeeflow 文档站 · 数据模型
- 开源演示站(五语言后端切换)
- 集成演示站(admin/123456)
- ProcessInstance 源码
- ProcessTask 源码
下一篇预告 :[第 6 篇 · "applicant" 契约:退回发起人的闭环设计](#第 6 篇 · "applicant" 契约:退回发起人的闭环设计 "#") ------ 为什么每个流程的第一个节点必须是"发起申请"?退回发起人(submitType=6)的完整链路怎么走?