引擎里没有 setStatus:状态迁移收口,不用状态机框架

上一篇评论区最常见的问题其实是个"的反面":大家不是问"任务状态谁能改",而是问------"你不用状态机框架,迁移规则写在哪?"

这篇文章回答它。先给一个可以自己验证的结论:在 jeeflow-core 的主代码里 grep setState,你只会找到 3 处调用点。运行时的流程实例、流程任务,想找一个"直接把状态写成某个值"的地方,找不到------不是被禁止了,是不存在。

一、旧版长什么样:25 个写入口

先交代旧版的账。内置工作流模块里,setState/setTaskState 的调用点一共 25 处,散在 4 个类里:实例 Service 15 处、任务 Service 6 处、定义 Service 3 处、引擎 1 处。随手摘三行真实代码:

java 复制代码
// ProcessInstanceServiceImpl
processInstance.setState(ProcessInstanceStateEnum.FINISHED.getCode());  // L125
processInstance.setState(ProcessInstanceStateEnum.REJECT.getCode());    // L133
processInstance.setState(ProcessInstanceStateEnum.DOING.getCode());     // L149

注意一个细节:旧版并不裸写数字------枚举是有的,10/20/30 各有名字。那问题出在哪?

出在写入口没收口 。状态机隐含的规则是"完成动作只能让状态从 10 到 20",但在这 25 个调用点上,每个 Service 方法自己决定"这次把状态写成什么":这个方法管完成,就写 FINISHED;那个方法管拒绝,就写 REJECT。语义靠方法名暗示,约束靠 code review 兜底------迁移规则存在于团队的默契里,不存在于结构里。

这种写法的维护成本是复利的:想知道"实例能不能从挂起直接到已废弃",你得通读 15 处调用点;想加一条守卫,你得找到所有该加的地方------漏一处,就是一个新入口。

二、新版只有 3 处 set,每一处都有名字

jeeflow-core 的 3 处,逐处交代,因为每处都是刻意留下的:

第一处,搬运。 引擎完成任务后,把聚合裁决出的任务状态同步到独立加载的任务行再落库(task.setTaskState(completedInInstance.getTaskState()))。它不产生任何裁决------写进任务的值来自聚合,这是上一篇讲过的"聚合裁决、仓储搬运"。

第二处,聚合内部。 聚合根创建自定义节点的历史任务行时,直接把新行置为已完成(createHistoryTask 里)。它发生在聚合自己的命令方法内部,属于"聚合在组装自己的组成部分",和外部直写是两回事。

第三处,定义聚合。 门面层的流程定义启用/停用写 def.setState(1)。这是设计器操作,操作的对象是流程定义的可用性开关------它压根不在运行时实例/任务的状态机里。

除掉这三处,运行时实例和任务的状态写入是 0 处直写 。引擎侧下达聚合命令的地方也只有寥寥几处:流程走到终点的处理器下达 finish() 或 reject(),门面的撤回入口下达 withdraw(operator)。状态值的每一次变化,都能回答"是谁在哪个命令里写的"。

以办理为例,一次状态写入要过三道关:引擎入口快速失败(给前端明确错误码)→ 聚合内部权威裁决(findDoingTask 对不在聚合中的任务直接抛异常,finish 再过守卫)→ 仓储搬运落库(同连接级联)。状态值在全链路只被裁决决定一次。

三、命令面就是白名单:非法迁移"不可表达"

通用做法是给状态机配一张 from/to 迁移矩阵:10→20 ✓、10→50 ✓、20→50 ✗,引擎每次写入前查表拦截。这是"非法迁移可表达、运行时来拦"的路线。

jeeflow 走的是另一条:聚合的命令面就是合法迁移的白名单 。看 ProcessTask.finish 的完整守卫:

java 复制代码
public void finish(String operator, FlowData args) {
    // ① 状态必须是进行中
    if (!ProcessTaskStateEnum.DOING.getCode().equals(this.taskState)) {
        throw new RuntimeException("任务[" + taskName + "]不是进行中状态,无法完成");
    }
    // ② 操作人必须在参与者列表里(系统代执行/超管放行)
    if (!isAllowed(operator)) {
        throw new RuntimeException("操作人[" + operator + "]不在任务参与者列表中");
    }
    // ③ 裁决:置 20 + 写办理人 + 变量并入 + 完成时间
    this.taskState = ProcessTaskStateEnum.FINISHED.getCode();
    this.actorId = operator;
    if (args != null) { this.variables.putAll(args); }
    this.finishTime = LocalDateTime.now();
    this.updateTime = LocalDateTime.now();
    this.updateUser = operator;
}

想"完成一个已完成的任务"?没有这个方法 。不是有个配置表拦着你说不行,是这个动词在已完成的状态上根本不存在------abandon 只对进行中任务生效、resume 只认强行终止的任务,每个命令自带前置条件,非法迁移在方法签名层面就写不出来。

这条路线还有个矩阵给不了的东西:迁移伴随的副作用和迁移住在同一个方法里。完成任务要同时写办理人、并入变量、记完成时间------这些东西塞不进 from/to 表格,用矩阵路线你还是得在"动作"里再写一遍,规则从此裂成两半(一半在配置里、一半在代码里)。命令面路线里,它们从来就没分开过。

四、为什么不用状态机框架

三条理由,按分量排序:

① 引擎零依赖是铁律。 jeeflow-core 的编译期依赖只有日志门面一个,这是它能被八个语言栈、十几个框架复用的前提。状态机框架是一个实打实的依赖------Java 版引了,Go/Python/Rust 版还得各找各的等价物,八份生态位,只为解决一个七状态状态机的问题。

② 状态太少,迁移太"厚"。 实例 7 个状态、任务 6 个状态,矩阵的规模小到一张图就画完;而每条迁移都驮着自己的副作用(写办理人、变量合并、时间戳、撤回人回写)。框架擅长的是"迁移多而薄"的场景,这里是"迁移少而厚"------工具和问题错配。

③ 守卫跟知识必须住一起。 "操作人能不能完成这个任务"的答案在参与者快照里,在任务的变量里,在聚合的记忆里。守卫写进外部框架,就得把这些知识都喂出去;写在命令里,知识不用挪窝------上一个问题的变量作用域,就是这一个问题的权限边界。

五、诚实账:收口 ≠ 完备守卫

按本系列的惯例,说说不完美的地方。

命令面收口解决的是"谁能写 "------状态写入收窄到了聚合的命令里。但"什么时候能写 "的守卫,目前并没有在所有命令上配齐:实例级的 finish()、reject() 等命令目前是无条件置状态的,它们的安全性靠的是调用时机------引擎只在"全部任务完成/收到拒绝指令"这两个确定的上下文里下达它们,子实体守卫再兜一层底。

这不是疏漏,是顺序:先收口,后装闸。入口唯一之后,给某个命令加 from 状态校验是一行改动,且所有调用方零修改;入口散落时,加守卫是一场"找全 25 处"的打地鼠。收口本身就是为未来的守卫铺路------这也是我不愿意为七状态引入一整套框架的深层原因:框架买的是"现在就完备",而工程常常需要的是"现在就正确,且永远留得进下一刀"。

六、带走的三步

把 jeeflow 的路径压缩成三步,任何项目都能照着走:

第一步,grep 出所有写入口。 搜 setState / setStatus / 对状态字段的赋值,数一下分布在几个类。超过两三个类,你的迁移规则就已经不在结构里了。

第二步,把每个入口换成动词。 不是把 set 挪个位置,是按业务动作重新起名:完成、拒绝、撤回、挂起。这一步做完,命令面就是白名单的雏形。

第三步,在动词里装第一道闸。 挑最高频的命令,把"什么状态下允许"写成它的前置判断。有了唯一入口,这道闸一行代码,调用方零改动------然后按需逐个补齐。

状态机框架不是不好,它只是解决"迁移多、规则薄、独立于业务副作用"的问题。审批流恰好相反:迁移少、每条都驮着业务、规则和领域知识长在一起。工具错配比没有工具更贵。

结尾

三篇看下来,jeeflow 的选择其实一以贯之:让知识住进领域,让结构替人守规矩。第一篇是把状态机从注释里搬进实体,第二篇是把一致性的账本交给聚合,这一篇是把状态写入收进命令面------三件事合起来,grep 出来的那 3 处 set 才有了它们各自的道理。

状态值沉默地躺在表里,但每一个值都能告诉你它是谁写的、经过了哪道闸、为什么合法。这就是收口的全部意义。

引擎八语言实现全部开源,本文数字可用同样的 grep 在自己机器上复现。

参考资料

  • mldong 官网(框架 / 在线演示):www.mldong.com/
  • jeeflow 文档站(规范 / 快速上手 / 多语言 demo):jeeflow-doc.mldong.com/
  • jeeflow 演示站:jeeflow-demo.mldong.com/
  • 系列往篇:《同一个审批流引擎,我写了两次》《聚合边界:为什么 ProcessTask 没有自己的 Repository》(DDD 系列 ①②,掘金 jeeflow系列 专栏可查)
相关推荐
farerboy1 小时前
WEB 项目如何禁用 F12 等功能
前端·vue.js·架构
136096757231 小时前
1Panel 部署 ThinkPHP8 踩坑实录
后端
用户788477316341 小时前
源码交付清单:做完一个项目,你到底该从开发商手里拿到什么
后端
美好世界1 小时前
Codex 源码导读:第六部分——模型请求与流式网络层
架构
thissai1 小时前
Laravel 12 日志配置详解:config/logging.php 逐项说明
后端
美好世界1 小时前
Codex 源码导读:第三部分——沙箱真实执行
架构
程序猿DD1 小时前
OctaFuse Gateway 2.12.0:供应商账号管理、路由工作区与多模态入口发现
后端
她的男孩1 小时前
开放接口限流从 20 改到 200 还是每分钟 20 次:拆完防重放+幂等+限流,我找到 5 个静默失效的坑
java·后端·架构
lizhongxuan1 小时前
Agent Sandbox 怎么选
后端