上一篇(DDD 系列 ①)我用两笔账回答了"贫血和充血差在哪"。后台收到一个很合理的质疑,今天专门回答它:
"你说流程任务不能自己改状态,可我翻了你引擎的仓储接口,里面明明就有
saveTask和updateTask——这不是自己打脸吗?任务要真没有仓储,这两个方法是哪来的?"
问得好。这篇文章就把这句话掰开:仓储的数量为什么跟着聚合走,而不是跟着表走、跟着类走;以及,如果把 ProcessTask 拆成一个独立聚合、给它配一个独立的仓储,半状态是从哪扇门进来的。
一、先看三个必须"同时发生"的时刻
工作流引擎里,大部分"看起来只动一张表"的操作,其实都要同时改好几行。挑三个最高频的:
办理。审批人点"通过",引擎要做的事:这条任务置为已完成(20)、操作人写入办理人、任务变量合并、表单里 f_ 前缀的字段沉淀为流程变量——下游节点的条件表达式吃的就是这些变量。任务和变量如果分开落库,中间断一次,下游的金额判断就吃到空值,单子停死或走错分支。
撤回。申请人撤回整单,引擎要做的事:实例置为已撤回(30)、全部进行中的任务跟着置 30,已办结的历史行一行都不许改。七个任务改了六个、第七个断线,这单子就变成"撤回单里还挂着一张能办的待办"——业务侧照常提交,单子诈尸。
退回上一步。审批人点"退回",引擎要做的更多:先在本实例的全部任务行里找到当前行的血缘父行(按 task_parent_id,不是按流程图拓扑倒推——拓扑版在分支和回环流上会退到本实例从没走过的节点);再过 canRejected 守卫(上一步是并行分支、汇聚、子流程、会签的,不许退);最后复活那行历史任务,复活前还要做一次变量清洗——把会签计数、上次提交的控制类键剔掉,只带数据类键,否则复活的待办里会显示用户上次填了这次没填的字段,复活的会签节点会从错位的计数序号继续推进。
这三个时刻有个共同点:状态裁决需要的知识,全都住在实例聚合里。撤回要知道"哪些任务还在进行中";退回要知道"血缘上一步是谁";办理要知道"变量合并后会影响哪些判断"。这些知识没有任何一条住在数据库连接里。
二、如果给 Task 配一个仓储,半状态从哪扇门进来
现在做反事实推演:假设我把 ProcessTask 升级成独立聚合根,配一个 ITaskRepository,撤回写成这样:
instance.withdraw(operator); // 内存里:实例 30 + 全部任务 30
for (ProcessTask t : tasks) {
taskRepository.update(t); // 逐个落库
}
instanceRepository.update(instance); // 最后落实例
第一次运行,岁月静好。直到某个下午,第七个任务的 update 抛了连接超时:
- 6 个任务:30(已撤回)
- 1 个任务:10(进行中)——撤回单里多出一张能办的待办
- 实例:10(进行中)——撤回压根没生效
每一行数据都是"合法"的(状态码都存在、外键都对),拼起来是一个业务上不存在的东西。这种半状态最阴险的地方在于它不报错:审批列表照常渲染,业务侧感知到的时候往往已经过了好几天。
jeeflow-core 的实际写法是把这次撤回收成一个动作:
// JdbcProcessRepository(v1.0.1 注释原文)
public void updateInstance(ProcessInstance instance) {
// ... UPDATE wf_process_instance ...
// v1.0.1:级联持久化聚合内任务状态(撤回/挂起/激活等变更随同落库,
// 同连接保证一致)
for (ProcessTask task : instance.getTasks()) {
updateTaskWithConn(conn, task); // 同一条连接 = 同一个事务
}
}
同一条连接,是"同一个事务"的前提:在宿主注册了事务模板的部署里(Spring 场景由自动配置默认注册),这条连接上的所有写入要么全部生效、要么全部回滚,没有第三种。事务模板从哪来,是下一篇的话题——这里先记住:聚合的原子性不靠调用方自觉,靠的是"一个入口搬完整个聚合"这个结构。
三、回到那个质疑:saveTask 明明就在接口上
正面回答开头的问题。IProcessRepository 接口上确实有一批任务级方法:findTaskById、saveTask、updateTask、findDoingTasks、pageTodoTasks……那 Task 为什么还是"没有自己的仓储"?
因为仓储的标志不是"有没有任务级方法",而是"一致性的账本记在谁身上"。看引擎办理路径的真实代码:
// JeeflowEngineImpl:完成任务
instance.completeTask(taskId, operator, args);
// 将 instance 中的已完成 task 状态同步到 task 对象,并持久化
for (ProcessTask t : instance.getTasks()) { /* 找到同 id 的任务行 */ }
task.setTaskState(completedInInstance.getTaskState());
// ...
repository.updateTask(task);
注意顺序:先聚合裁决,再搬运落库。引擎从头到尾没有写死过任何一个状态值——任务该变成几,是聚合根的 completeTask 算出来的;updateTask 搬运的是裁决结果,不是裁决本身。这就是"聚合仓储的搬运通道"和"第二个聚合根的户口"的区别。
裁决有多严?看聚合内部这两道关。第一道,聚合根拒绝办理"不在自己体内"的任务:
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 + "]或不在聚合根中");
}
第二道在子实体自己的命令里,ProcessTask.finish 守卫三连:状态必须是进行中、操作人必须在参与者列表里(系统代执行与超管放行除外)、然后才置 20 写办理人。守卫写在子实体上,但下命令的门只有一扇——都从聚合根开出来。
所以完整的分层是:引擎入口用 findTaskById + isDoing + isAllowed 快速失败(给前端明确的错误码),聚合内部 findDoingTask 权威裁决(不在聚合中的任务直接抛),仓储最后把裁决结果级联搬进库。三层各干各的事,状态值没有第二个写入口。
四、5 张表,2 个聚合,1 个写入口
jeeflow 一共五张表。如果按"一张表一个聚合"去分,会得到五个仓储、五个事务边界——而真实的分法是:
- 流程定义聚合:
wf_process_define一张表,自带 version,是发布产物;执行期以值对象ProcessDefine的形态内嵌在实例上,实例按 id 现查(这是它跟实例聚合的关系:只读内嵌,不共享事务); - 流程实例聚合:
wf_process_instance(聚合根)+wf_process_task(子实体)+wf_process_task_actor(参与者快照行,连值对象都算不上,就是子实体挂的明细)——三张表一个事务边界,整存整取; - 抄送旁路:
wf_process_cc_instance,抄送记录,独立写入,不参与任何状态裁决。
五张表,两个聚合根,一个写入口。task_actor 这张表最能说明问题:它没有自己的领域类、没有自己的生命周期,任务是它的宿主——按表建聚合,它就得是个聚合根;按一致性建聚合,它连值对象都不是。
有人会追问:DDD 教科书说"一个事务只修改一个聚合根",你这里撤回明明改了实例和一堆任务。语义其实是"一个事务只保存一个聚合根"——撤回是实例聚合内部的一次命令,实例和任务自始至终是一个整体,落库也是 updateInstance 一次级联。跨聚合的那次交互(办理时读定义)才是只读、不共享事务的。
五、画边界的三个自查问题
把 jeeflow 的选择提炼成三个可以带走的自查问题,下次画聚合的时候挨个问:
① 这条状态变更,需不需要和别的表原子? 需要,且对方没有独立生命周期——放同一个聚合。撤回对任务就是这种关系;抄送对实例就不是(晚写几毫秒没人受影响)。
② 裁决需要的知识在谁手里? 知识在哪,裁决权在哪,聚合根就在哪。"上一步是谁"的答案在实例的任务行里,所以退回必须是实例的命令——你没法想象一个 TaskRepository.rollback(),它连"上一步"都得现查别家。
③ 换个存储实现,这条规则还成立吗? jeeflow 的仓储接口被八种语言实现过一遍:JDBC、内存、各家语言原生的数据库驱动。换实现从来不动聚合——因为规则写在领域里,不写在 SQL 里。如果某条"业务规则"离开某个 ORM 的特性就写不出来,它多半放错了地方。
三个问题问完,边界自己会浮出来。之后再检查一遍:每个聚合是不是只有一个仓储接口——是,就对了;一个聚合根配了两个仓储,或者一个仓储服务两个聚合根,都值得停下来重新想。
结尾
回到开头那句质疑。saveTask 在接口上,恰恰是因为仓储是聚合的仓库管理员:仓库里当然有任务这一格货架——但进货单、出货单、盘点表,都只认一个主人。ProcessTask 的所有行为都在,finish 的守卫一个不少,只是它签合同的权利,留在了实例聚合手里。
半状态从来不是运气差,是边界画错的必然产物。边界画对的那天你会发现:事务日志干净了,测试好写了,连新人提的问题都变了——从"这里为什么没锁表"变成"这个命令为什么叫这个名字"。后者才是领域模型该回答的问题。
引擎八语言实现全部开源,本文所有代码在 jeeflow-core 仓库逐行可查。
参考资料
- mldong 官网(框架 / 在线演示):www.mldong.com/
- jeeflow 文档站(规范 / 快速上手 / 多语言 demo):jeeflow-doc.mldong.com/
- jeeflow 演示站:jeeflow-demo.mldong.com/
- 系列往篇:《同一个审批流引擎,我写了两次:一次 11226 行,一次 7036 行》(DDD 系列 ①,掘金 jeeflow系列 专栏可查)