
两年前在评审一个采购审批系统的状态梳理时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 我踩过的坑和现在的做法最后说两个我自己踩过的坑。第一个是早期把工作流引擎的任务节点状态直接透传给前端结果每个审批节点都要写一套按钮逻辑审批链改一次前端跟着改一次。后来改成前端只认业务对象状态节点状态只存在于流程引擎的接口里彻底消停了。第二个坑是活动图更新了分支但业务对象状态机没跟着更新导致流程能跑、单据统计出错。现在我的做法是所有涉及流程的迭代评审时强制要求同时交付流程状态表和业务状态表两张表对照审核完才能进开发。刚开始大家觉得麻烦后来发现这个步骤其实就是把很多隐蔽问题提前暴露出来了。如果你现在也正对着活动图纠结状态字段不妨先停下来把“流程状态”和“业务状态”分开列两张表再对照着走一遍所有分支。大多数状态混乱的问题到这个环节基本就能自己浮出水面了。