写在前面
jeeflow 的文档站有八篇概念文(架构、领域模型、执行引擎、扩展、SPI、契约、门面、元数据),加起来 1,263 行。我刚才在 docs/ 和 SPEC.md 里搜了一遍 classDiagram、sequenceDiagram、erDiagram、mermaid、plantuml------零命中。八篇全是文字和表格。
这不是偷懒。图这东西一旦画出来就得跟着代码走,而这套引擎九个月里从一种语言长到八种,谁也不敢画一张"保证下周还对"的图。
但缺图的代价我最近几个月反复遇到:新人问"任务状态是谁改的",我得带他读三个文件;有新语言接入,我得口头讲一遍"哪层能引哪层";评审一个改动,大家对着 diff 各自想象调用链。
所以这次把六张图画出来。画的过程比结果更有意思------有几处我以为知道答案,图上墙才发现我记的跟码里写的不一样。
一、用例图:整个引擎只有一个入口

这张图回答:谁能对一张审批单做什么。
先说一个数字:JeeflowFacade 这个 2,397 行的类,对外只有一个公开方法------
java
public Map<String, Object> flow(String action, Map<String, Object> args)
其余全是 private。业务侧、HTTP 层、App 端,所有请求都从这一个口子进去,靠 action 字符串分流。我数了一下 switch 里的 case:44 个,分五组------
| 组 | action 数 |
|---|---|
| processInstance | 14 |
| processTask | 11 |
| processDesign | 9 |
| processDefine | 8 |
| processSurrogate | 5 |
这就是为什么用例图值得画:引擎的能力边界不是靠文档里"支持会签、支持退回、支持委托"这些话界定的,是这 44 个字符串界定的。想加一个能力,就得在这张表里多挂一行;不想加的,画图上就根本没有那个椭圆。
图里那条虚线 include 是按委托窗口改派 。它不是一个用户会主动点的功能------审批人点开待办、点同意,压根不知道发生过什么。但 wf_process_surrogate 表里那条"张三在 10 月 1 日到 10 月 7 日把单转给李四"的记录,会在任务创建的那一刻把办理人换掉。所以它挂在"办理待办"下面,而不是挂在某个"委托"菜单下面。这个位置摆对了,才说明它是个横切规则而不是个功能点。
二、包图:96 个类凭什么零依赖

这张图回答:哪一层能引哪一层,以及有没有人抄近路。
jeeflow-core 现在有 96 个 .java ,分 15 个顶层包。零依赖这句话我讲过很多次,这次给它一个可复跑的判据:
bash
cd jeeflow-core/src/main/java/com/mldong/jeeflow
grep -rhoE "^import [a-z0-9.]+" . | sed 's/import //' \
| grep -vE "^(java|javax)\.|^com\.mldong" | sort -u
输出是空的。九十六个文件里,除了 JDK 和自己人,谁都没引。JSON 不引 Jackson,表达式不引 Aviator,落库不引 MyBatis------它们都从 spi 那九个接口出去,由外面的人实现。
spi 包里 9 个接口 (IProcessRepository、IUserProvider、IUserSearchProvider、IOrgUserProvider、IExpressionEvaluator、IIdGenerator、ITransactionTemplate、IActionPermissionProvider、IProcessExtRepository),加上 json 包里的 IJsonProvider。其中最大的是 IProcessRepository,39 个方法------一个接口三十九个方法这事我一直犹豫,但它换来的是"引擎要什么数据,仓储就得给什么数据",不用中途回头加字段。
图里两条虚线回指是这张图我最想让人看见的部分:
model → core:节点模型有个方法签名是execute(Execution),执行上下文在内核包里定义,所以模型层反过来引了内核。event → core:监听器回调要拿引擎句柄。
这两条不是画错,是这套结构的代价。流转逻辑写在节点对象上(TaskModel.execute()、DecisionModel.execute()),代价就是节点必须认识执行上下文。要不要接受这个环,是每次评审都会吵一句的事------图上有环,比文档里写"注意有循环依赖"诚实。
还有一件事:facade 一个人引了 10 个包。门面是这套结构里最大的耦合点,这我认。
三、类图:7 个领域对象,状态藏在命令里

这张图回答:状态到底存在哪个对象上,谁能改它。
九个包、九十六个类,最后落到数据上的只有 domain 里这 7 个对象 :ProcessInstance、ProcessTask、ProcessDefine、ProcessDesign、ProcessDesignHis、ProcessSurrogate、Candidate,加一个 FlowData 装变量。
ProcessInstance 543 行,ProcessTask 199 行。这两个类是我说的"充血"的两个证据点:
java
// ProcessInstance.java
public void completeTask(Long taskId, String operator, FlowData args)
public void abandonTask(Long taskId, String operator)
public void finish() public void reject()
public void withdraw(String operator)
public void interrupt(String operator)
public void pending(String operator) public void resume(String operator)
public void activate(String operator)
// ... 一共 16 条命令与判定
ProcessTask 那半边是六个命令加三个判定:finish / abandon / withdraw / interrupt / pending / resume,isDoing / isFinished / isAllowed。
setter 是有的------ProcessInstance 和 ProcessTask 各带一个 setState / setTaskState,引擎把聚合根的改动同步回要落库的那个对象时确实会调它(JeeflowEngineImpl.java:278)。但流转不经过 setter :要把一张单办完,得调 completeTask(...),它内部去调 task.finish(...),而 finish 一次做两件事------守卫(不是进行中就抛)和改状态。
差别就在能不能绕过守卫 。走命令,非法迁移一定抛;走 setter,随手一写就把历史改了。所以 setter 的定位是持久层回填,不是业务入口。这条区分图上看不出来------我在类图里把命令方法全列出来、setter 一个没画,图的表达能力就到这里,剩下那句得写在正文里。
FlowData 那个类只有 103 行,它 extends LinkedHashMap<String, Object>。流程变量、表单字段、节点过期时间全塞在这一个 map 里,落库时序列化成 variable 那一列。这是"引擎不管你的业务字段"这句话在类图上的样子。
四、时序图:一次"同意"要跳 21 下

这张图回答:点一下同意,到底按什么顺序发生了些什么。
图上的编号就是真实调用顺序。挑几个值得说的:
第 5 步之后有个守卫。 prepareExecution 一上来查任务、查实例、查定义,然后 task.isDoing() 和 task.isAllowed(operator),两个不过分别抛 NOT_FOUND_DOING_PROCESS_TASK 和 NOT_ALLOWED_EXECUTE。这张单已经办完、或者办理人不在参与者列表里,都在这一步挡掉,不会走到后面。
第 6 步才解析流程定义。 define.content 是 LogicFlow JSON 原文(一个 BLOB),ModelParser.parse() 把它变成 ProcessModel------八种节点模型(start / task / decision / fork / join / custom / subprocess / end,各配一个解析器)。也就是说,流程定义不是数据库结构,是一段二进制加一次解析。这是"改流程 JSON 不用改表"的根。
第 8、9 步是聚合根内部的事。 instance.completeTask(...) 里再调 task.finish(...),状态在内存里改完,第 10 步才 updateTask 落库。
第 11、16、18 步是三个事件码 :码 5 任务完成(或码 6 任务拒绝,两条互斥,判据是提交类型)、码 3 新待办产生、码 2 实例终态。它们的位置不是随便放的------全部排在各自那次写库之后 。任务行没落库就发"待您处理",用户点进去查不到;实例行没落库就发终态,下游读到的还是进行中。这条规矩在代码注释里被写成"§11.2 原则 3",图上就是三个箭头永远画在 updateTask / saveNewTask / updateInstance 的右边。
第 19 步抄送排在最后。 handleCcActors 在 persistTasks 之后,跟发起时那支各自"落在自己那次写库之后"。
顺手记一个数字:submitType 现在九个值------0 发起、1 同意、2 拒绝、3 退回上一步、4 跳转、5 重新提交、6 退回发起人、7 转办、20 会签拒绝。我印象里还是八个,"7 转办"是后加的,画这张图才把它补进我的账。
五、状态机图:双层,但不是同一张图复制两遍

这张图回答:一张单能进哪些状态,靠什么命令进去。
实例 7 档 :10 进行中、20 已完成、30 已撤回、40 强行终止、45 已拒绝、50 挂起、99 已废弃。 任务 6 档:同上,少一个 45。
乍看就是"同一套状态码抄了两遍"。画完发现三处不一样,而且每处都有理由:
一、45 已拒绝只在实例层。 任务被拒就是被拒,没有"拒绝"这个独立终态;拒绝这个事实属于整张单。
二、99 已废弃只在任务层被写。 实例枚举里有 99,但引擎从来不往里写------abandonTask 只把那一行任务置 99,实例状态不动。仓里有个测试专门钉这件事(AbandonTaskInstanceState180Test),因为它曾经错过:废弃一个任务把整张单也标成废弃,剩下的待办就再也办不动了。所以图上实例那一侧我干脆没画 99 的入边。
三、resume() 在两层起点不同。 实例层 resume() / activate() 从 50 挂起回到 10;任务层 resume() 只从 40 强行终止回到 10------挂起(50) 的任务行没有回到 10 的那条边。这个不对称我在图上没画(画了反向边,Mermaid 会把它摆成 10 指向 40 的样子,读图的人会当成"interrupt 和 resume 是同一条边"),改成写在旁边的注里。图上不敢画的,正文里就得说清。
还有一条守卫值得单列:withdraw() 只允许进行中(10) 的实例撤回,其他状态进来直接抛 20010009,一行都不改。守卫排在遍历任务行的循环之前------排后面就会先改任务再报错,历史被静默改写。
六、表图:8 张表,0 张业务表单表

这张图回答:这套东西落到 MySQL 上是几张表,字段谁给谁。
5 张核心 + 3 张扩展 。核心是跑流程必须的:define(定义)、instance(实例)、task(任务)、task_actor(参与者)、cc_instance(抄送)。扩展是管理面才用到的:design(设计稿)、design_his(历史版本)、surrogate(委托区间)。
三处形状值得指着图说:
task_actor 单独一张表。 一个任务多个办理人会签,参与者如果存成 task 表里的一个逗号串,"查李四的所有待办"就得靠 LIKE。拆成一张三列表,索引直接打在 actor_id 上。代价是任务与人是两行,一次查要 join。
两条自关联。 instance.parent_id 指回父实例(子流程),task.task_parent_id 指回父任务(会签拆出来的子任务)。图上我没画这两条回边------ER 图里自关联的标签会顶到画布边上被裁掉。它们在字段注释里。
design 和 define 之间没有外键。 部署的时候按 name 把设计稿内容写进定义行,所以图上是"部署时按 name 写入,无外键"。这意味着删了设计稿,正在跑的单照样跑------定义行自己带着那份 JSON。这条链是我画 ER 才逼着自己去 designDeploy 里确认的,之前我一直以为是 design_id 外键。
没有一张业务表单表。 报销金额、请假天数这些字段,全在 instance.variable 和 task.variable 那两列 JSON 里,前缀 f_。表单字段要按节点权限过滤(FlowUtil.filterFieldByPerm,只读和隐藏的字段进不了流程变量),所以引擎不需要知道你的表长什么样。
七、这套图我平时怎么用
画完的第二天就用上了两次。
一次是有栈要接新语言。以前我的讲法是"你照 Java 的 core 抄一遍结构",对方抄完我一看,把仓储的实现细节漏进了领域层。这次我直接把类图和包图丢过去:domain 那七个对象不许引 SPI,SPI 那九个接口不许有实现。图上有没有那条边,就是能不能引。
一次是评审一个改动。有人想给 ProcessTask 加一个 setStatus 方便测试。类图摊开之后这事就不用吵了:状态迁移的入口只有那六个命令,每个命令里带着自己的守卫;测试要造状态,应该造一条真实迁移路径,而不是开后门。
八种语言这件事,图能撑到什么程度我也量了一下。现读八份引擎里都有 ProcessInstance 这个类型:
bash
python jeeflow/model.py:143 class ProcessInstance
go model/types.go:148 type ProcessInstance struct
node src/model.ts:185 export class ProcessInstance
php packages/core/src/Domain/ProcessInstance.php
csharp src/Mldong.Jeeflow.Core/ProcessInstance.cs
C# 那份的方法名跟 Java 一条一条对得上(CompleteTask / AbandonTask / Finish / Reject / Withdraw / Pending / Resume / AddVariable),唯一多出来的是每个命令后面挂一个可选参数 IClock? clock------注入时钟。同一张类图能盖住八种语言,但盖不住"每语言各有一个自己的必要参数"这件事,这个空档我留在正文里,没往图上画。
时钟这件事两种语言走了两条不同的路,顺手也量了一下:C# 把它做成命令的可选参数,Rust 走的是进程级时钟槽(jeeflow-core/src/clock.rs 里的 set_clock 与 ClockScope)。图上那条"命令签名"的边,到 Rust 这里其实是另一套形状------这也是我为什么在正文里写清楚每张图"没画出什么"。
附:六张图是怎么出来的
全程 Mermaid,没有一张手绘。渲染链是:.mmd 源文件 → 本机起一个只监听 127.0.0.1 随机端口的静态服务 → Playwright 里 Chromium 打开页面、mermaid.render() 出 SVG → 按 viewBox 还原固有尺寸 → 2x 截图成 PNG。
三个坑记一下,省得下次再踩:
- Mermaid 12 的 dist 是 ESM 加 chunks ,
file://打开会被 CORS 挡掉动态 import,必须走 http。 - 渲染出来的 SVG 带
width:100%和max-width,落在inline-block容器里会被压回一个很窄的宽度,字小得没法看。要按viewBox把宽高显式写回去。 usecase-beta是这版新加的图种,用例图终于有原生记法了------但椭圆形状只在"作为关系端点首次出现"时才生效,单独声明的用例会画成方框。
六份 .mmd 源跟图一起放在仓库里。图错了改源重渲,比在画图工具里挪框快得多------这也是我这次换 Mermaid 的真实动机:图得能 diff。
jeeflow 全部开源,八种语言实现、前端、文档站、演示站都在下面:
- GitHub:github.com/mldong/jeef... (其余七语言在同组织下)
- 文档站:jeeflow-doc.mldong.com
- 演示站:jeeflow-demo.mldong.com
- 集成演示站:jeeflow-pro.mldong.com