两年前在评审一个采购审批系统的状态梳理时,A同学拿着活动图和状态枚举对照表来找我,说图画完了、状态也列了,但订单表里到底该放几个状态、工作流引擎里流程节点怎么定义,两边始终对不上。这个问题其实很典型——很多人把“工作流的状态机”和“业务对象的状态机”混为一谈,或者干脆只用其中一种。仔细拆开来看,一张活动图完全能同时支撑两套状态机,关键是你得知道什么时候看哪一层、怎么从图里把状态“读”出来。这篇文章就围绕活动图、工作流状态机、业务对象状态机这三者的关系,讲清楚建模方法、判据和落地经验。
1. 两个状态机,一张活动图:先分清“流程在哪”和“业务到哪了”
1.1 工作流状态机盯住“流程实例”的脚下
工作流状态机描述的是“流程实例”这个运行实体自身的状态。什么是流程实例?就是一次具体的流转过程——某张申请单从提交开始,经过各个审批环节,最后到达结束,这整个运行过程就是一个流程实例。它回答的问题是:“这条流程现在跑到哪个位置了?”
典型的工作流状态有:未启动、运行中、等待审批、被挂起、已终止、已完成。更细一层,流程实例在特定节点上还有节点实例状态,比如某个审批节点是“待处理”“处理中”还是“已完成”。这些状态的持有者是流程引擎本身。引擎负责创建流程实例、推进控制流、记录节点执行情况,所以这套状态机往往由工作流引擎来维护。
1.2 业务对象状态机记录“单据/实体”的语义阶段
业务对象状态机描述的是被流程“带着跑”的那份业务数据——采购申请单、订单、合同、报修工单。它回答的问题是:“这份业务数据从业务意义上讲,现在处于什么阶段?”
典型的业务对象状态有:草稿、审批中、已通过、已驳回、执行中、已交付、已关闭。这些状态不关心流程内部经过了多少个节点,只关心业务语义有没有发生变化。持有者是业务系统,也就是操作订单表和申请单表的那个服务。
1.3 两张状态机的本质差别:一个看引擎,一个看数据
| 对比维度 | 工作流状态机 | 业务对象状态机 |
|---|---|---|
| 观察对象 | 流程实例(一次流转过程) | 业务对象(申请单、订单等) |
| 核心问题 | 流程现在执行到什么节点 | 业务数据现在处于什么阶段 |
| 状态粒度 | 细,跟着节点走 | 粗,按业务阶段聚合 |
| 状态数量 | 可能较多 | 通常较少,5到15个 |
| 事件来源 | 引擎调度、人工任务提交 | 业务动作(提交、审批通过、驳回等) |
| 持久化位置 | 引擎的流程实例表 | 业务表的状态字段 |
| 是否随流程定义变化 | 是,加节点就加状态 | 否,相对稳定 |
这里有个很容易踩的误区:以为“未处理”“处理中”“已完成”这些节点状态就是业务对象的状态。它们不是。业务对象状态是从业务语义上抽象出来的,比如一个审批单无论经过部门审批还是财务审批,从业务角度看它都只是“审批中”。
提示:判断一句话描述的是哪类状态,就问自己——“这句话离开了流程引擎还能说通吗?”能说通的,多半是业务对象状态。
2. 活动图里的哪些元素在悄悄定义状态
2.1 动作节点与控制流:从“执行位置”读出流程状态
UML活动图本质上是一个带控制流的流程图,里面有初始节点、动作节点、决策节点、合并节点、分叉与汇合节点。活动图的执行过程有一个“令牌”(token)的概念:流程启动时令牌在初始节点,沿着控制流箭头经过一个个动作节点,最后到达活动终点。令牌当前停在哪个位置,就是流程实例当前所处的节点。
这就直接映射了工作流状态机:流程实例状态可以抽象为“运行中”,而当前正在等待的动作节点就是具体的节点状态。比如令牌停在“财务审批”这个动作节点上,那么流程实例状态就是“运行中,财务审批节点激活中”。所以,动作节点的集合就是工作流状态机可能出现的节点级状态的来源。
2.2 判断节点与合并点:分支守卫本身就是状态转移条件
活动图里的菱形判断节点,会根据条件把令牌引向不同分支。这些条件输出,翻译成状态机语言就是守卫条件(guard)。比如财务审批节点后面有一个判断节点:“审批结果是否为通过”,通过则流向“采购执行”,不通过则流向“修改申请”。从状态机角度看,这就是一个状态转移:当前节点“财务审批”在事件“财务审批提交”发生且守卫条件“结果为通过”满足时,迁移到下一个节点“采购执行”。
合并节点的作用是让多个分支汇合到一起,对应状态机上“多个前置状态都能进入同一个后续状态”的结构。实际建模时要特别注意:合并点之后的状态只有一个,但进入它的路径可能有多条,需要分别给守卫条件。
2.3 分叉与汇合:并发分支把状态机逼成复合状态
活动图里另一类重要节点是分叉(fork)和汇合(join)。分叉节点产生一组并行分支,汇合节点等待所有并行分支完成后再继续。并发对工作流状态机的建模影响非常大——单一的线性状态无法表达“两个分支同时进行”的局面。
这个时候工作流状态机需要引入复合状态,特别是带子状态的正交区域。比如流程里“技术评审”和“商务评审”并行,那么流程实例状态就是“评审中”,内部有两个正交子状态:“技术评审子状态”和“商务评审子状态”。只有当两个子状态都进入完成状态,外层状态才能迁移到下一步。
那业务对象状态机怎么处理并发呢?大多数情况下,业务对象状态机不需要表达并发——因为从业务语义看,并行执行多个动作通常不改变业务对象所处的阶段,“评审中”还是“评审中”。除非并发动作分别改变了同一业务对象的不同维度,才需要单独考虑。这一点在第3章的案例里会具体看到。
2.4 泳道分区与对象流:状态的归属感来自这里
泳道(partition)用来组织动作由谁执行,它回答的是“这个动作属于哪个角色”。但泳道不等于状态归属。比如部门经理在泳道甲、财务专员在泳道乙,他们审批的是同一个申请单,申请单的业务状态就只有一个。
真正决定业务对象状态归属的是对象流(object flow):活动图上如果有一条从动作节点连向某个业务对象的边,或者某个动作节点的输出是一个状态变化后的业务对象,那么这个动作完成后,业务对象就进入了新的状态。换句话说,动作节点对应工作流状态,动作的结果对象对应业务对象状态。一张图里这两种信息都画出来了,只是读图时需要切换视角。
3. 实战拆解:从一张设备采购审批活动图里剥出两套状态机
3.1 先把原始活动图画清楚
拿一个常见的设备采购审批流程举例。角色有:申请人、部门经理、财务专员、总经理、采购员、仓管员。流程如下:
- 申请人填写并提交设备采购申请单。
- 部门经理审批,判断结果:通过则继续,驳回则退回申请人修改。
- 财务专员审批,判断结果:通过则继续,驳回则退回。
- 判断申请金额是否超过阈值(这里设为十万元),超过则总经理审批,未超过则跳过。
- 采购员执行采购。
- 到货后,并发执行两个动作:质量验收、发票核验。两个都完成后再汇合。
- 仓管员入库归档,流程结束。
这张活动图其实很普通,几乎每家做审批系统的公司都画过类似的。但问题是,画完之后你往代码里落状态时,会发现自己至少有两种选择。
3.2 从流程执行视角读出工作流状态机
先看工作流状态机。它的状态就是流程实例在运行过程中可能停留的位置。设备采购流程的工作流状态可以定义成:
未启动 -> 运行中(等待部门审批) -> 运行中(等待财务审批) -> 运行中(等待总经理审批,金额超过阈值时才会出现) -> 运行中(等待采购执行) -> 运行中(等待到货确认,含质量验收与发票核验两个并行子状态) -> 已完成
驳回场景下,流程实例要么回退到“等待申请人修改”,要么直接走到“已终止”。从状态机的严谨角度,这些路径都要表达出来。可见工作流状态机的状态数量几乎和流程节点数成正比,节点一多,状态就很细。
3.3 从采购申请单视角读出业务对象状态机
再来看业务对象状态机。这里的业务对象是《设备采购申请单》。从业务语义看,申请单的状态要简单得多:
草稿 -> 审批中 -> 已通过 / 已驳回 -> 执行中 -> 已交付 -> 已关闭
为什么部门审批、财务审批、可能有的总经理审批在业务对象状态里统统合并成“审批中”?因为从申请单这个业务对象的外部视角看,它只关心自己有没有被批准,不关心具体是哪个环节在批。外部系统、前端页面、下游财务系统读取申请单时,也只需要知道“审批中”这个粗粒度阶段。
这正好体现了第1章强调的差别:工作流状态机要服务于引擎的控制流推进,所以需要细;业务对象状态机要服务于业务语义的一致性,所以应该粗。
3.4 两张状态机放在一起对照
| 时间点 | 工作流状态(流程实例) | 业务对象状态(申请单) |
|---|---|---|
| 申请人刚创建申请单 | 未启动 | 草稿 |
| 部门经理正在审批 | 运行中,等待部门审批 | 审批中 |
| 财务审批通过后等待总经理 | 运行中,等待总经理审批 | 审批中 |
| 总经理审批通过 | 运行中,等待采购执行 | 已通过 |
| 采购员执行采购中 | 运行中,等待采购执行 | 执行中 |
| 质量验收和发票核验并行 | 运行中,等待到货确认(并发) | 已交付 |
| 入库归档完成 | 已完成 | 已关闭 |
这张对照表在很多评审会上可以直接当讨论稿用。只要把每一个业务对象状态的停留区间对照到工作流状态上,就会发现两者其实不是一一对应的,而是一种“一对多”或“多对一”的关系。业务对象状态往往跨越多个工作流节点,一个工作流节点也可能只改变业务对象的某一个方面。
4. 状态爆炸与状态归并:判断某个状态该放流程层还是业务层
4.1 状态爆炸的典型症状
很多系统做久了,订单表或申请单表里的状态字段会越加越多,我见过一张表里状态枚举值超过二十个的系统。症状就是:每在流程图上加一个审批节点,业务表里就多出一个“待XX审批”的状态。
这种做法的直接后果是,前端每个状态都要配一套按钮权限文案,接口层每个状态都要判断能调哪些操作,测试要回归的状态组合数呈指数级上升。更麻烦的是,一旦审批链路上某个环节被砍掉,业务表状态枚举要跟着改,但历史数据里还留着旧状态,数据迁移又是一堆事。
问题的根源在于:把工作流状态机和业务对象状态机合并成了一个状态字段。这不是建模的问题,是分层的问题。
4.2 三条实用判据
那么具体到某个状态,到底该放流程层还是业务层?我一般用三条判据来筛:
- 对外可见性:这个状态除了流程引擎自己,业务流程的上下游系统(比如财务系统、库存系统、前端页面)需要感知吗?如果不需要,它就不该是业务对象状态。
- 操作权限影响:这个状态是否决定了用户能不能执行某个业务操作?比如只有“审批中”状态的申请单才能被审批。决定操作权限的状态,通常是业务对象状态。
- 配置稳定性:这个状态是否随流程定义改动而变化?流程加一个节点它就要跟着加,说明它是流程层的状态,不是业务层的。
一条一条套下来,答案就很清楚了。“待财务审批”对外部不可见、不影响业务规则、流程一改就变,所以它只能算工作流状态。“审批中”则相反,它决定了审批按钮是否可用、下游单据能否生成,所以是业务对象状态。
4.3 最容易迷惑的“驳回重填”:流程回退不等于业务状态回退
驳回重填是个经典场景。部门经理驳回了申请,流程回到申请人那里修改后重新提交。这时候工作流状态从“等待财务审批”回退到“等待申请人修改”,那么申请单的业务状态要不要跟着回退?
有几种建模方式:
| 方案 | 业务对象状态变化 | 优缺点 |
|---|---|---|
| 方案A | 保持“审批中”不动,流程内部回退 | 实现简单,但无法直观统计被驳回的单据 |
| 方案B | 回到“草稿”,再次提交才进入“审批中” | 语义清晰,但丢了“曾经提交过”的信息 |
| 方案C | 增加一个“已驳回”状态,重新提交后变成“审批中” | 状态略多,但业务表达最完整,能统计驳回率 |
我现在的习惯是能用方案C就用方案C,尤其是审批链比较长、需要统计驳回率和修改次数的场景。实在嫌状态多,方案B也比方案A强,因为“草稿”和“审批中”的边界更清楚。
核心原则只有一句:不要让流程引擎的“回退”“跳转”直接改掉业务对象状态字段里由业务语义决定的值。流程的路径变化和业务状态的语义变化,是两个独立的问题。
5. 用状态机反向校验活动图:建模闭环里最容易被跳过的一步
5.1 事件序列是否与控制流完全一致
状态机画完之后,很多人就认为建模完了。其实还差一步,甚至是最重要的一步:用状态机反向校验活动图。方法很简单,把业务对象状态机里的每个转移写成“事件 -> 目标状态”的形式,然后到活动图里找对应路径。
例如申请单状态机里有“审批通过”这个事件,目标状态是“已通过”。那么活动图里必须存在这样一条控制流:从财务审批(或总经理审批)的“通过”分支出来,最终导致业务对象进入“已通过”。如果活动图里找不到这条路径,要么状态机多画了转移,要么活动图漏了分支。
常见的漏项是“取消”事件。状态机里一般都会有“草稿 -> 已取消”“审批中 -> 已取消”这样的转移,但活动图往往只画了主流程,没有画取消边。这时候就需要回到活动图,补充“取消”这个动作节点和相应的控制流。
5.2 异常分支与超时节点:活动图最常见的盲区
活动图最常见的盲区是异常路径。设计评审时大家都习惯把“通过”“驳回”这类正常分支画出来,但超时自动驳回、审批人转办、流程终止这种分支经常被省略。而状态机是讲究“完备性”的,它要求每个非终态都必须有明确的离开事件。
举个例子:申请单在“审批中”状态时,如果规定48小时未审批自动通过,那状态机里必须有“审批超时 -> 已通过”这个转移。回到活动图,就应该画一个定时事件节点,从“财务审批”节点引出超时边。如果活动图和状态机各画各的,开发实现时就会漏掉超时逻辑。
所以校验时不只是对照“状态机里的转移有没有对应的边”,还包括“活动图上画了哪些边,状态机里是否都有对应的转移”。
5.3 实际做法:先活动图后状态机,再回到活动图
完整的建模闭环应该是:先画活动图,再从活动图剥出两套状态机,然后用状态机反向补全活动图。我比较推荐在评审活动图时强制产出两张状态表:
- 流程状态表:列出所有流程节点状态和引擎内部状态,由流程引擎团队核对。
- 业务状态表:列出业务对象的所有状态和合法转移,由业务领域团队核对。
核对业务状态表时,逐条问“这个转移在活动图里对应哪条边”,能找到就打勾,找不到就回活动图补。别小看这一步,它能把活动图里“隐式隐含”的分支全部逼出来,评审会上经常能发现两三个被所有人默认存在但谁都没画出来的分支。
6. 落地到代码和引擎时的几条操作建议
6.1 流程定义与业务状态字段的分层
落到实现层面,我建议明确分层:工作流引擎负责流程定义和实例运行,业务系统负责业务对象状态。流程节点的状态,交给引擎管理,不要把引擎内部状态直接映射成业务表字段。
例如设备采购申请单表里,只放status字段,取值是DRAFT、APPROVING、APPROVED、REJECTED、EXECUTING、DELIVERED、CLOSED。至于当前在哪个审批节点、这个节点是部门审批还是财务审批,存到流程引擎的流程实例表和任务表里就行。这样流程调整审批节点时,业务表结构完全不用动。
6.2 状态转移用事件驱动而不是直接改字段
业务对象状态的变化,我强烈建议通过事件驱动的方式来做,而不是在业务代码里到处直接改status字段。原因是状态机的价值就在于“合法转移”是可枚举的,直接在业务方法里写死if (status == APPROVING) { status = APPROVED; }很容易把校验逻辑散落到各处,改一处漏一处。
更稳的做法是抽出状态机服务,对外暴露submit()、approve()、reject()、close()这类业务动作方法,每个方法内部校验当前状态是否允许执行该动作,然后完成状态转移并记录事件。类似这样:
public void approve(String orderId) { PurchaseOrder order = orderRepository.find(orderId); if (order.getStatus() != APPROVING) { throw new IllegalStateException("当前状态不允许审批操作"); } order.setStatus(APPROVED); eventPublisher.publish(new OrderApprovedEvent(orderId)); }不用复杂的状态机框架,先用最朴素的“枚举 + 方法内校验”守住转移合法性,已经比散落的状态赋值好很多。等状态多了、转移复杂了,再引入专门的状态机模式也不迟。
6.3 我踩过的坑和现在的做法
最后说两个我自己踩过的坑。第一个是早期把工作流引擎的任务节点状态直接透传给前端,结果每个审批节点都要写一套按钮逻辑,审批链改一次前端跟着改一次。后来改成前端只认业务对象状态,节点状态只存在于流程引擎的接口里,彻底消停了。
第二个坑是活动图更新了分支,但业务对象状态机没跟着更新,导致流程能跑、单据统计出错。现在我的做法是:所有涉及流程的迭代,评审时强制要求同时交付流程状态表和业务状态表,两张表对照审核完才能进开发。刚开始大家觉得麻烦,后来发现这个步骤其实就是把很多隐蔽问题提前暴露出来了。
如果你现在也正对着活动图纠结状态字段,不妨先停下来,把“流程状态”和“业务状态”分开列两张表,再对照着走一遍所有分支。大多数状态混乱的问题,到这个环节基本就能自己浮出水面了。