同一个审批流引擎,我写了两次:一次 11226 行,一次 7036 行

0 阅读1分钟

几年前,我给自己的快速开发框架写了一套内置工作流模块:流程定义、流程实例、任务、会签、退回,审批流该有的都有,用得也挺好。后来我把引擎从业务工程里抽出来,做成独立的 SDK,就是现在这个多语言工作流引擎 jeeflow。

同一个作者,同一套审批流语义,两种架构各写了一遍。第一版连皮带瓤 11,226 行;第二版引擎核心 7,036 行,95 个类。

把两个数字放在一起,你大概会期待一个"重构之后代码少了三分之一"的故事。不是。7,036 比 3,518——第一版里真正属于引擎的那部分——还多了 3,518 行。这篇文章想讲清楚的就是这件事:少的 4,190 行是什么,多出来的行又是什么。这笔账算完,"贫血"和"充血"这两个被用滥的词,会变成两个可以用行数结账的东西。

一、先交代两次各写了什么

第一次的宿主,是一个普通的 Spring Boot 单体。工作流模块长在业务工程里:19 个 Service、8 个 Controller、18 个 Mapper 与 XML、31 个 VO 与 DTO、19 个实体与枚举,外面围一圈 engine 包——引擎和它的数据访问层、接口皮住在同一个 Maven 模块里,跟着业务工程一起编译、一起出包。ORM、JSON 工具、接口文档注解,一样不少。

第二次换了宿主:jeeflow-core,一个独立的引擎 SDK。95 个类、7,036 行,编译期依赖只有 slf4j-api 一个。目录里没有 service、没有 controller、没有 mapper、也没有 vo——不是忘了写,是这些层不该属于引擎。

第一次 · 内置模块第二次 · 独立引擎
宿主业务工程里的一个 Maven 模块独立 SDK(jeeflow-core)
体量180 文件 / 11,226 行95 类 / 7,036 行
编译期依赖ORM、JSON 工具、接口文档注解一应俱全仅 slf4j-api
数据访问自带 18 个 Mapper 与 XML只声明仓储接口,实现由宿主注入
接口皮谁来做引擎自己长框架各自接一层集成薄壳(422~1,798 行)

二、第一笔账:11,226 行里,引擎只占 31%

同一个引擎,两次写的行数构成

这笔账我在《一套审批流要写多少代码》里记过一次,当时回答的问题是"接一个现成引擎要写多少代码";这次把它翻出来,是为了问另一个问题:那 11,226 行里,有多少是"工作流",有多少只是围着工作流的水泥。

后来换引擎的时候,这一整块被删掉了,180 个文件、11,226 行,每一行都留在了 git 里。按职责拆开:

删掉的类别文件行数
Service 层193,494
引擎内核与拦截271,732
模型与解析器271,233
实体与枚举191,034
VO 与 DTO311,006
Controller8945
Mapper 与 XML18649
其它(工具、调度)22580
处理人解析器9553

把九类按"要不要懂流程语义"重新分组,答案就出来了:

  • 引擎本体 3,518 行(31%):引擎内核与拦截 1,732、模型与解析器 1,233、处理人解析器 553——这些代码必须懂"什么是流程";
  • 脚手架 7,708 行(69%):Service、实体、VO、DTO、Controller、Mapper、其它——这些代码只需要懂"什么是增删改查",把流程表当普通表伺候。

自研一套审批流为什么贵?贵的就是那 69%。更难受的是这份贵还要按份数付:boot2 一份,boot3 按新框架规范重写一份(那边的删除账是 −11,265 行),同一套语义写两遍,还得保证行为一致、两边一起修 bug。

三、贫血版长什么样:状态机活在注释里

贫血 vs 充血:完成任务的两个写法

空口说"贫血"没意思,看三段第一版的真实代码。

第一段,实体。 流程实例实体是一个标准的数据袋:

/** 流程实例状态(10:进行中;20:已完成;30:已撤回;
 *  40:强行中止;50:挂起;99:已废弃) */
private Integer state;

状态机在哪?在这行注释里。10 是什么、能变成什么、谁能改——注释说得很全,代码什么都不管。任何一个拿到这个实体的人都可以 setState(50),编译器既不知道 50 意味着挂起,也不知道"已完成"压根就不该走到"挂起"。

第二段,引擎。 任务完成时,引擎这样写:

// FlowEngineImpl:完成任务
processTask.setTaskState(
    ProcessTaskStateEnum.FINISHED.getCode());

实体上有 set 方法,引擎就用——穿透实体直接改状态。注意"完成一个任务"牵动的远不止这一个字段:实例变量要合并、所有任务是否都完成了要判断、实例要不要跟着结束……这些动作全部住在引擎方法里,靠一串 service 调用串起来。

第三段,变量。 实例变量在数据库里是一个字符串:

// 引擎现场拆包
Dict processInstanceVariable = JSONUtil.toBean(
    processInstance.getVariable(), Dict.class);
// 合并完再整包回写
processInstanceService.addVariable(
    processInstance.getId(), addArgs);

变量没有行为,只有形态:存的时候是 JSON 字符串,用的时候现场 toBean,改完再序列化回去。拆包、合并、回包,每一次都发生在实体外面。

这三段代码没有任何一处"错"。它们能跑,跑了很久。问题是一种慢性的:关于流程的知识,一半在注释里,一半在引擎里,实体本身什么都不记得。想回答"实例从挂起能不能直接废弃",你得去读引擎;想回答"变量合并有没有并发问题",你还是得去读引擎。

四、充血版长什么样:把状态机领养回家

第二次写,我先把一句话写进了类注释,当成这个类的宪法:

/**
 * 流程实例——DDD 聚合根(充血模型)
 *
 * 流程实例是一个独立的事务边界,包含所有子任务。
 * 所有状态修改都通过聚合根的方法完成,
 * 外部不直接操作子实体。
 */
public class ProcessInstance {
    private Integer state;
    private List<ProcessTask> tasks = new ArrayList<>();
    // ...
}

然后是这个类的公开方法清单——注意,这就是它的全部对内行为:

方法说的是哪句人话
completeTask(taskId, operator, args)完成这个任务:子实体状态转换+实例变量合并(f_ 表单字段沉淀为流程变量),一次做完
finish() / reject()实例结束 / 实例拒绝
abandonTask(...) / abandonAllDoing()废弃一个 / 全部进行中任务
withdraw(...) / interrupt(...) / pending(...) / resume(...)撤回 / 强制中止 / 挂起 / 恢复
createTask(...) / createCountersignTasks(...)建普通任务 / 建会签任务
getDoingTasks() / isAllTasksFinished()查询,不修改状态

和第三节的三段代码一一对照:

  • 状态不再需要注释解释自己——finish() 就是结束,pending() 就是挂起;想改状态,只能通过一个说人话的命令,而不是对着一个整数写魔法数字。改状态这件事的入口,从"任何拿到实体的人"收窄到了"聚合的方法";
  • 引擎不再穿透实体——它调用聚合根的命令,然后保存聚合根;任务状态、变量合并这些"必须同时发生"的事,在同一个方法里完成;"是否全部完成"的裁决权也在聚合手里,isAllTasksFinished() 是它的查询;
  • 变量不再是实体外的字符串杂技——addVariable(args) 是聚合根自己的方法,怎么拆包、什么时候序列化,是它和仓储之间的私事。

方法内部怎么处理并发与边界,是这个系列后面几篇的正餐,这里先不展开。这一节只需要记住一个观感上的差别:第一版要读完实体再读引擎;第二版读完一个类,规则基本读完。

五、第二笔账:多的 3,518 行不是复发,是长大

现在可以回答开头那道减法题了。

7,036(第二版)减 3,518(第一版引擎本体)等于 3,518——正好翻倍。这个对称是巧合,但多出来的行花在哪不是:事件监听、流程代理、子流程、枚举字典注册表、持久化扩展点……全都是这些年引擎该长出来的领域功能,每一行都住在引擎该住的地方(_event、Surrogate、SubProcess、EnumDict、ExtRepository,类清单里个个对得上号)。

真正的减法发生在引擎外面:

  • 脚手架 7,708 行 → 0 行。 没有 Service、没有 Controller、没有 Mapper、没有 VO。这些不是删了不管,是归位了:十几个框架要接引擎,各自写一层 422~1,798 行的集成薄壳(最薄的四百来行,最厚的不到一千八),壳归框架,引擎归引擎;
  • 按份数付费 → 一份核心。 第一版那会儿 boot2 一份、boot3 一份;第二版是一份核心语义,八个语言栈各自原生实现,所有框架共用。

所以两笔账要合起来读:行数几乎没变,但第一版的 11,226 行里只有 31% 是领域,第二版的 7,036 行里领域是 100%。行数从来不是 DDD 的卖点,占比才是。

六、贫血体检:三条判据,中一条就该警惕

贫血体检

把两次写法放在一起看,"贫血"其实就三个症状:

① 状态机活在注释里。 状态的含义只出现在字段注释、文档和枚举类里;真正的赋值动作是 setState(魔法数字),改对改错,编译器不管。

② 有人穿透实体改状态。 Service 甚至引擎层拿着实体直接 set,实体只当数据袋;"这个状态能不能改、改了算什么"的规则散落在各个调用方。

③ 一个动作要敲好几次库才能对上。 完成任务要改任务表、再改实例表、再合并变量回写,几次访问之间就是"任务完成了、实例还停在进行中"的半状态窗口,靠约定和 review 不出事——而不是靠结构不出事。

体检完了想动一动的话,第一步不需要任何架构调整,一次重命名就够:把 updateState(20) 改名成 finish()。名字先说人话,方法的归属就会开始自己变对——哪个 Service 里的状态修改最密集,哪个类就该是聚合根的候选。当年我从第一版走到第二版,第一锹就是这么挖下去的。

结尾

回到开头那两个数字。11,226 和 7,036 之间,差的从来不是"代码变少了"——一个把状态机养在注释里、把一致性托付给调用方自觉的模块,写得再长也长不成引擎。第二版真正换掉的是知识的居住地:状态机搬进了实体,一致性搬进了聚合根,引擎只剩一件事——把流程图上的箭头,一步一步走对。

引擎现在跑在八个语言栈上,全套源码和文档都是公开的,感兴趣可以到下面的文档站和演示站自己走一遍。

参考资料

  • mldong 官网(框架 / 在线演示):www.mldong.com/
  • jeeflow 文档站(规范 / 快速上手 / 多语言 demo):jeeflow-doc.mldong.com/
  • jeeflow 演示站:jeeflow-demo.mldong.com/
  • 系列往篇:《一套审批流要写多少代码:13 个框架的接入 diff 我数了一遍》(本文第二笔账的数据来源,掘金 jeeflow系列 专栏可查)