
审批流项目里接到过这样一个需求表单提交前前端就要展示下一节点的名称和审批人最好还能让发起人在发起时手动指定下一步的审批人。第一次听到这个需求我差点直接调用taskService.complete()跑一下流程再回滚事务好在冷静下来之后意识到这样做等于让引擎真实执行了一遍完整流转回滚操作在 Flowable 的事务模型里会带来一堆历史表、变量表、锁记录上的麻烦属于典型的“用错误工具做正确事情”。Flowable 官方确实没有提供“预测下一节点”的 API任务查询只有“当前待办”和“历史待办”没有“未来待办”。要想在提交前就知道下一步会落到哪个节点、谁来处理只能基于任务ID、流程定义 BPMN 模型和参数把引擎内部那套“从当前活动节点走向下一个节点”的逻辑自己模拟出来。这篇文章把我在 Flowable 6.7.2 Spring Boot 2.7 上落地的这套预测方案完整拆开讲内容包括 BPMN 模型解析、条件表达式评估、候选人解析和参数化覆盖以及生产环境里踩过的几个坑。适合正在做审批类系统、自由流程节点跳转、或需要在前端展示审批路线图的开发者参考。1. 审批页面上的“下一节点”提示为什么Flowable给不了现成答案1.1 一个看似简单的前端需求需求方描述得很轻巧“提交申请之后页面上要显示接下来是哪个节点审批、审批人是谁。” 但仔细一拆这背后有三个技术问题要回答当前任务对应的活动节点是哪个从当前节点沿顺序流走出去会走到哪个节点走到了用户任务节点审批人/候选人如何计算出来前两个问题听起来简单但 Flowable 从设计上就没有把这套计算逻辑暴露成外部可调用的 API。引擎内部处理节点跳转的代码散落在UserTaskActivityBehavior、ExclusiveGatewayActivityBehavior、ParallelGatewayActivityBehavior这些行为类里它们依赖ExecutionEntity的完整运行时上下文属于引擎私有的执行机制。外部如果要复用就只能在模型层自己遍历。Flowable 提供了一些旁路能力RepositoryService.getBpmnModel()能拿到完整的 BPMN 模型SequenceFlow上挂着条件表达式ExpressionManager可以执行 UEL 表达式。把这些组合起来理论上就能在不动流程实例的前提下模拟出节点走向。实际动手之后会发现细节比想象中多得多。1.2 网上流传方案的三个坑我在动手之前也习惯先搜一圈现成方案搜到的结果大致分三类但每一类都有明显的坑。第一类是“解析 BPMN XML 自己遍历”。这种方案用DocumentBuilder加载.bpmn20.xml然后按 XML 节点逐条解析sequenceFlow、conditionExpression、userTask。问题在于Flowable 在运行时对流程模型做了大量的规范化处理包括表达式解析、自定义扩展属性、网关默认流转、甚至flowable:collection这类多实例扩展属性直接解析 XML 等于把所有表达式计算和扩展属性处理重新造一遍轮子一旦流程定义里混入 Spring Bean 表达式或者自定义属性解析逻辑基本就是脆弱的。第二类是“调用complete()走完流程再回滚”。这个方案的危险性前面已经提到引擎的事务上下文、变量作用域、历史记录生成全部依赖真实流转状态回滚并不像数据表操作那样干净彻底。我曾经在测试环境试过一次流程实例确实回滚了但历史表里多出了好几条任务记录业务侧收到重复的待办提醒这种脏数据排查起来让人怀疑人生。第三类是“从历史表查上一节点当成下一节点”。这个思路只适合固定线性流程遇到排他网关、并行网关、条件分支历史里根本查不到要预测的那条路。1.3 预测引擎设计的三条纪律基于这些教训我在设计预测服务时给自己定了三条纪律纪律一只做只读操作。预测过程绝不调用complete()、signal()、trigger()等任何会改变流程状态的 API也尽量避免调用execution.setVariable()去覆盖流程变量。纪律二以引擎模型为准。所有节点和顺序流的遍历基于BpmnModel对象而不是重新解析 XML所有表达式求值通过ExpressionManager完成而不是自己写字符串解析。纪律三建模要贴近引擎真实行为。排他网关的选择逻辑、条件表达式的求值规则、候选人的覆盖策略必须和引擎内部的ActivityBehavior保持一致否则预测结果和真实流转会出现“看起来差不多、实际对不上”的情况。这三条纪律后来帮我避免了很多麻烦。接下来的内容就是在这个框架下一步步实现预测能力的完整过程。2. 基于任务ID定位活动节点从Task到FlowNode的模型解析2.1 任务的TaskDefinitionKey就是BPMN里的节点ID预测的第一步是回答“当前任务在流程模型的哪个节点上”。这个问题的答案其实就藏在 Task 对象本身。Task task taskService.createTaskQuery().taskId(taskId).singleResult(); String activityId task.getTaskDefinitionKey();getTaskDefinitionKey()返回的就是 BPMN 模型中userTask节点的id属性比如userTask idapplyTask name发起申请 flowable:assignee${applyUser}/里的applyTask。这个值在任务创建时由引擎写入ACT_RU_TASK表的TASK_DEF_KEY_字段所以哪怕任务关联的流程实例发生过多实例、子流程等复杂情况它依然能准确指向任务对应的活动节点。拿到activityId之后用RepositoryService加载流程定义模型BpmnModel bpmnModel repositoryService.getBpmnModel(task.getProcessDefinitionId()); FlowElement flowElement bpmnModel.getFlowElement(activityId);这里有个容易忽略的点getFlowElement()返回的是FlowElement抽象类型使用时必须判断具体类型。如果当前任务是普通审批节点它通常是UserTask如果未来把任务绑定到服务任务或者脚本任务上也可能得到ServiceTask。我在代码里加了一个防御性校验if (!(flowElement instanceof FlowNode)) { throw new IllegalStateException(当前节点不是可执行的FlowNode: activityId); }FlowNode是所有可以参与流转节点的基类它作为起始点才能调用getOutgoingFlows()遍历顺序流。2.2 拿到BpmnModel之后要区分节点类型BpmnModel.getFlowElement()返回的元素类型五花八门UserTask、ServiceTask、ExclusiveGateway、ParallelGateway、InclusiveGateway、SubProcess、StartEvent、EndEvent、BoundaryEvent等等。遍历顺序流时如果只处理UserTask其他类型就会被漏掉预测结果自然不完整。我整理了一个简单的分类表作为遍历时的分发依据节点类型处理策略UserTask作为预测结果返回并解析审批人/候选人ExclusiveGateway选择一个条件命中的顺序流继续遍历ParallelGateway所有顺序流全部继续遍历InclusiveGateway条件命中的顺序流继续遍历无命中且存在默认流则走默认流SubProcess进入子流程内部从子流程的 StartEvent 开始遍历ServiceTask / ScriptTask自动执行节点继续往下层遍历EndEvent返回流程结束标记BoundaryEvent默认忽略返回提示信息这看起来是简单分类但实现时最容易翻车的是网关。很多项目把网关当成普通节点统一走“遍历所有 outgoing flows 并计算条件”的逻辑这在排他网关上就会出错排他网关的真实语义是“只选一条满足条件的分支”如果预测时把多条满足条件的分支都返回前端展示出来的节点比实际流转多出好几倍用户就会困惑。2.3 变量容器把运行时变量和表单参数安全合并确定了节点类型之后下一步难点是“条件表达式求值时变量从哪里来”。Flowable 在真实运行环境中条件表达式${days 3}是从当前ExecutionEntity的变量作用域里读days的。预测时流程还没往前走用户填的表单参数还没有提交成流程变量所以我需要把两类变量合并起来当前流程实例已有的运行时变量历史流转过程中产生的变量调用方传入的表单参数尚未持久化只存在于预测请求中为此我实现了一个PredictionVariableContainer它实现了 Flowable 的VariableContainer接口。这个接口是表达式求值时的变量解析根我只需要让它能从 Map 里读变量就行。这里遇到一个很实际的问题很多流程设计者在条件表达式里写的是${execution.getVariable(days)}而不是直接写${days}。如果用纯 Map 做变量根execution这个变量不存在表达式就会求值失败。我在容器里做了一个巧妙的兼容把容器自身注册成execution变量。public class PredictionVariableContainer implements VariableContainer { private final MapString, Object variables; private final String processInstanceId; private final String activityId; public PredictionVariableContainer(String processInstanceId, String activityId, MapString, Object runtimeVariables, MapString, Object overlayVariables) { this.processInstanceId processInstanceId; this.activityId activityId; this.variables new HashMap(); if (runtimeVariables ! null) { this.variables.putAll(runtimeVariables); } if (overlayVariables ! null) { this.variables.putAll(overlayVariables); } // 兼容条件表达式里的 execution.getVariable(xxx) 写法 this.variables.put(execution, this); } Override public Object getVariable(String variableName) { return variables.get(variableName); } Override public SetString getVariableNames() { return variables.keySet(); } Override public void setVariable(String variableName, Object value) { variables.put(variableName, value); } Override public void setTransientVariable(String variableName, Object value) { variables.put(variableName, value); } Override public Object getTransientVariable(String variableName) { return variables.get(variableName); } Override public SetString getTransientVariableNames() { return variables.keySet(); } public String getProcessInstanceId() { return processInstanceId; } public String getActivityId() { return activityId; } }这样${execution.getVariable(days)}和${days}两种写法都能在预测时正常工作。这个细节帮我省去了和流程设计者争论“条件表达式必须统一写法”的麻烦也让预测结果更贴近真实运行行为。3. 条件表达式与网关分支预测的核心计算逻辑3.1 如何安全地“偷看”引擎的判断逻辑Flowable 的条件表达式本质上就是 UEL 表达式存放在SequenceFlow.getConditionExpression()里。引擎在运行时通过ExpressionManager创建表达式对象然后调用getValue()求值。预测服务完全可以复用这套机制。关键代码如下private boolean evaluateSequenceFlow(SequenceFlow sequenceFlow, PredictionVariableContainer variableContainer) { String conditionExpression sequenceFlow.getConditionExpression(); if (conditionExpression null || conditionExpression.trim().isEmpty()) { return true; } try { ExpressionManager expressionManager processEngineConfiguration.getExpressionManager(); Object value expressionManager.createExpression(conditionExpression) .getValue(variableContainer); if (value null) { return false; } if (value instanceof Boolean) { return (Boolean) value; } return Boolean.parseBoolean(String.valueOf(value)); } catch (Exception e) { log.warn(条件表达式求值失败: {}按不通过处理, conditionExpression, e); return false; } }这里有两个细节需要说明。第一个细节是空表达式。在 BPMN 规范中无条件限制的顺序流会被直接通过。所以conditionExpression null或空白字符串时返回true是符合引擎行为的。但有一种特殊情况排他网关中如果非默认流上条件表达式为空Flowable 也会把它当成一条实际可以走的路径这可能会让流程设计者惊讶但引擎确实这么处理。预测服务保持一致即可。第二个细节是表达式求值结果类型。UEL 表达式有的返回Boolean有的返回字符串true、false甚至可能由一个 Spring Bean 方法返回整型1或0。统一做一次Boolean.parseBoolean()转换能提高容错性但我在日志里会记录原始返回类型方便排查异常流程定义。3.2 排他网关、并行网关、包含网关的差异化递归遍历顺序流的算法不能对所有节点一刀切。我按照三种网关类型分别处理。排他网关的运行时语义是“从所有 outgoing flows 中选择第一个条件命中的顺序流”如果所有条件都不命中则走默认流连默认流都没有流程实例会抛出异常。预测时如果遇到这种异常情况不能直接抛异常让前端报错我会在预测结果里追加一个errorNode提示流程定义可能存在问题。private void processExclusiveGateway(ExclusiveGateway gateway, PredictionVariableContainer variableContainer, NodePredictionResult result, int depth, SetString visited) { SequenceFlow defaultFlow null; ListSequenceFlow conditionalFlows new ArrayList(); for (SequenceFlow outgoing : gateway.getOutgoingFlows()) { if (outgoing.getId().equals(gateway.getDefaultFlow())) { defaultFlow outgoing; } else { conditionalFlows.add(outgoing); } } for (SequenceFlow sequenceFlow : conditionalFlows) { if (evaluateSequenceFlow(sequenceFlow, variableContainer)) { handleTargetFlowElement(sequenceFlow.getTargetFlowElement(), variableContainer, result, depth 1, visited); return; } } if (defaultFlow ! null) { handleTargetFlowElement(defaultFlow.getTargetFlowElement(), variableContainer, result, depth 1, visited); } else { result.addErrorNode(gateway.getId(), 排他网关没有匹配条件且未配置默认流); } }并行网关的语义相反所有 outgoing flows 都会并行执行所以无条件全部继续遍历。包含网关介于两者之间所有条件命中的顺序流都会继续如果一条都没命中但有默认流走默认流。这跟排他网关的“只选一条”有本质区别很多人在预测时会把包含网关当成排他网关处理导致预测结果少节点。3.3 顺序流上条件表达式的命中顺序细节Flowable 排他网关在选择顺序流时是按照顺序流在 BPMN 模型中的定义顺序逐条尝试的也就是模型里outgoingFlows列表的先后顺序。这一点看似无关紧要实际会影响流程走向。比如两条条件表达式分别是${type leave}和${type sickLeave}如果用户传入type leave无论顺序如何结果都一样但如果两个条件表达式有重叠比如${days 0}和${days 3}定义顺序就决定了days 5时命中哪条。所以预测逻辑必须严格遍历flowNode.getOutgoingFlows()返回的原始列表不能做排序也不能用HashMap重新组织后再遍历。我在实际代码里始终保留List的顺序语义这个细节也是从一次预测和真实流转结果不一致的排错中总结出来的。4. 候选分配策略解析与参数化覆盖4.1 三种候选人配置方式在模型中的存储形态用户任务节点的候选人配置在 Flowable 里常见有三种方式它们在 BPMN 模型中的存储形态各有不同。配置方式BPMN 示例模型存储形态静态指定flowable:assigneezhangsan字符串常量表达式指定flowable:assignee${nextAssignee}UEL 表达式文本监听器动态分配挂在taskListener上的create事件需要触发监听器逻辑第三种方式对预测服务来说是最麻烦的。监听器里可以写任意 Java 代码比如从外部系统查审批人、根据岗位匹配上级、甚至写库。预测阶段贸然执行监听器不仅可能产生不可预期的副作用还可能导致结果和真实运行不一致——因为某些监听器依赖任务创建后的上下文状态预测时这个状态还没生成。我在预测引擎里对监听器的处理策略是检测UserTask上是否存在create事件的任务监听器如果存在就在预测结果里标记一个提示字段告诉调用方该节点存在动态分配逻辑预测的候选人仅供参考。实现上通过userTask.getTaskListeners()可以拿到监听器集合。4.2 模拟引擎的handleAssignments解析静态值与UEL表达式对于前两种配置方式预测引擎可以较完整地复现引擎的候选人解析逻辑。Flowable 在运行时会把UserTask上的assignee、candidateUsers、candidateGroups属性统一解析成实际值写进任务表。我模拟这个过程时只需要对每个属性值判断是不是 UEL 表达式如果是则用ExpressionManager求值否则原样返回。private String resolveExpressionValue(String rawValue, PredictionVariableContainer variableContainer) { if (rawValue null || rawValue.trim().isEmpty()) { return null; } String trimmed rawValue.trim(); if (trimmed.startsWith(${) || trimmed.startsWith(#{)) { Object value processEngineConfiguration.getExpressionManager() .createExpression(trimmed) .getValue(variableContainer); return value null ? null : String.valueOf(value); } return trimmed; }候选人和候选组是列表所以还需要一个批量解析方法并且要处理表达式求值结果为Collection的情况。比如flowable:candidateUsers${approverList}approverList是一个 List 类型的流程变量解析后需要把列表元素逐个拆出来合并到候选人集合中。private SetString resolveCollectionExpression(ListString rawValues, PredictionVariableContainer variableContainer) { SetString result new LinkedHashSet(); if (rawValues null) { return result; } for (String rawValue : rawValues) { String resolved resolveExpressionValue(rawValue, variableContainer); if (resolved null) { continue; } // 如果解析结果本身是一个集合文本也尝试拆出元素 String[] parts resolved.split(,); for (String part : parts) { if (StringUtils.isNotBlank(part)) { result.add(part.trim()); } } } return result; }这里处理了两种常见情况UEL 表达式返回单个字符串以及返回逗号分隔字符串。更复杂的集合类型比如真实 List 对象可以在调用处先转成字符串再进入这个方法。4.3 通过参数动态指定下一节点审批人的约定预测引擎除了“复现”模型中的候选人还应该支持调用方动态指定下一节点的审批人。这是标题里“基于参数获取候选分配策略”的核心用法发起人在表单里选择“下一节点是部门审批审批人指定为张三”提交前前端调用预测接口时把“下一节点审批人”作为参数传过来预测服务在计算结果里直接反映这次指定。我定义了一套内部参数约定加下划线前缀以避免和业务流程变量冲突参数名含义取值示例_next_assignee指定下一节点办理人zhangsan_next_candidate_users指定下一节点候选人列表zhangsan,lisi_next_candidate_groups指定下一节点候选组dept_manager,hr在构建NodePrediction时优先读取这套参数读不到才使用模型解析值。实现如下private NodePrediction buildUserTaskNode(UserTask userTask, PredictionVariableContainer variableContainer, MapString, Object params) { NodePrediction node new NodePrediction(); node.setNodeKey(userTask.getId()); node.setNodeName(userTask.getName()); node.setNodeType(userTask); node.setMultiInstance(userTask.getLoopCharacteristics() ! null); if (params.containsKey(_next_assignee)) { node.setAssignee(String.valueOf(params.get(_next_assignee))); } else { node.setAssignee(resolveExpressionValue(userTask.getAssignee(), variableContainer)); } if (params.containsKey(_next_candidate_users)) { node.setCandidateUsers(splitCollectionParam(params.get(_next_candidate_users))); } else { node.setCandidateUsers(resolveCollectionExpression(userTask.getCandidateUsers(), variableContainer)); } if (params.containsKey(_next_candidate_groups)) { node.setCandidateGroups(splitCollectionParam(params.get(_next_candidate_groups))); } else { node.setCandidateGroups(resolveCollectionExpression(userTask.getCandidateGroups(), variableContainer)); } return node; }这套约定的价值在于实际提交任务时只需要把同样的参数作为流程变量传入taskService.complete()Flowable 运行时就能通过表达式${nextAssignee}解析到相同的审批人预测结果和真实结果就可以对齐。我在文档中明确要求流程设计者把用户任务的 assignee 配置成${nextAssignee}这类变量名动态指定才不显得别扭。5. 完整可运行的Spring Boot实现代码5.1 核心预测服务类把前面的逻辑串起来就是完整的预测服务。下面是我在项目里使用的核心类骨架去掉了与业务相关的报警逻辑保留了主干。Service public class FlowableNextNodePredictService { private static final int MAX_DEPTH 10; private static final Logger log LoggerFactory.getLogger(FlowableNextNodePredictService.class); private final TaskService taskService; private final RuntimeService runtimeService; private final RepositoryService repositoryService; private final ProcessEngineConfigurationImpl processEngineConfiguration; public FlowableNextNodePredictService(ProcessEngine processEngine) { this.taskService processEngine.getTaskService(); this.runtimeService processEngine.getRuntimeService(); this.repositoryService processEngine.getRepositoryService(); this.processEngineConfiguration (ProcessEngineConfigurationImpl) processEngine.getProcessEngineConfiguration(); } public NodePredictionResult predict(String taskId, MapString, Object params) { Task task taskService.createTaskQuery().taskId(taskId).singleResult(); if (task null) { throw new IllegalArgumentException(任务不存在: taskId); } return predictByActivityId(task.getProcessDefinitionId(), task.getProcessInstanceId(), task.getTaskDefinitionKey(), params); } public NodePredictionResult predictByActivityId(String processDefinitionId, String processInstanceId, String activityId, MapString, Object params) { BpmnModel bpmnModel repositoryService.getBpmnModel(processDefinitionId); FlowElement flowElement bpmnModel.getFlowElement(activityId); if (!(flowElement instanceof FlowNode)) { throw new IllegalStateException(当前节点不是可执行的FlowNode: activityId); } MapString, Object runtimeVariables new HashMap(); if (processInstanceId ! null) { runtimeVariables.putAll(runtimeService.getVariables(processInstanceId)); } PredictionVariableContainer variableContainer new PredictionVariableContainer( processInstanceId, activityId, runtimeVariables, params); NodePredictionResult result new NodePredictionResult(); result.setCurrentNodeKey(activityId); result.setCurrentNodeName(((FlowNode) flowElement).getName()); traverseAndPredict((FlowNode) flowElement, variableContainer, result, 0, new HashSet()); return result; } private void traverseAndPredict(FlowNode flowNode, PredictionVariableContainer variableContainer, NodePredictionResult result, int depth, SetString visited) { if (depth MAX_DEPTH) { result.addErrorNode(flowNode.getId(), 遍历深度超过限制请检查流程定义是否存在循环); return; } if (!visited.add(flowNode.getId())) { return; } if (flowNode instanceof ExclusiveGateway) { processExclusiveGateway((ExclusiveGateway) flowNode, variableContainer, result, depth, visited); } else if (flowNode instanceof ParallelGateway) { for (SequenceFlow outgoing : flowNode.getOutgoingFlows()) { handleTargetFlowElement(outgoing.getTargetFlowElement(), variableContainer, result, depth 1, visited); } } else if (flowNode instanceof InclusiveGateway) { processInclusiveGateway((InclusiveGateway) flowNode, variableContainer, result, depth, visited); } else { for (SequenceFlow outgoing : flowNode.getOutgoingFlows()) { if (evaluateSequenceFlow(outgoing, variableContainer)) { handleTargetFlowElement(outgoing.getTargetFlowElement(), variableContainer, result, depth 1, visited); } } } } private void handleTargetFlowElement(FlowElement target, PredictionVariableContainer variableContainer, NodePredictionResult result, int depth, SetString visited) { if (target instanceof UserTask) { UserTask userTask (UserTask) target; result.addNode(buildUserTaskNode(userTask, variableContainer, result.getRequestParams())); } else if (target instanceof SubProcess) { SubProcess subProcess (SubProcess) target; StartEvent startEvent findStartEvent(subProcess); if (startEvent ! null) { traverseAndPredict(startEvent, variableContainer, result, depth 1, visited); } } else if (target instanceof EndEvent) { result.addEndNode((EndEvent) target); } else if (target instanceof FlowNode) { traverseAndPredict((FlowNode) target, variableContainer, result, depth 1, visited); } } private StartEvent findStartEvent(SubProcess subProcess) { for (FlowElement element : subProcess.getFlowElements()) { if (element instanceof StartEvent) { return (StartEvent) element; } } return null; } }这段代码里继承了前三章的所有逻辑包括排他、并行、包含网关的分流处理、子流程跳转和递归深度限制。NodePredictionResult对象里我额外存了一份requestParams因为在递归过程中还需要读取_next_candidate_users等参数。5.2 节点预测结果对象设计NodePrediction和NodePredictionResult为了方便前端直接使用设计得比较简单直接public class NodePrediction { private String nodeKey; private String nodeName; private String nodeType; // userTask / endEvent / error private boolean multiInstance; private String assignee; private SetString candidateUsers; private SetString candidateGroups; private String warning; // 存在监听器等无法完全预测的情况 }返回给前端的 JSON 大概是这样的{ currentNodeKey: applyTask, currentNodeName: 发起申请, nextNodes: [ { nodeKey: deptApprove, nodeName: 部门审批, nodeType: userTask, multiInstance: false, assignee: wangwu, candidateUsers: [], candidateGroups: [dept_manager] } ], endReached: false, errorNodes: [] }前端拿到这个结构就可以直接渲染出“下一节点部门审批审批人王五”如果有多条分支也能用列表或者流程步骤条展示多条待办路径。5.3 用一条请假流程验证预测效果我设计了一条简单的请假审批流程来做验证process idleaveProcess name请假审批流程 startEvent idstartEvent/ userTask idapplyTask name发起申请 flowable:assignee${applyUser}/ exclusiveGateway iddeptGateway/ userTask iddeptApprove name部门审批 flowable:assignee${deptManager}/ userTask idhrApprove nameHR审批 flowable:assignee${hrManager}/ endEvent idendEvent/ sequenceFlow idflow1 sourceRefstartEvent targetRefapplyTask/ sequenceFlow idflow2 sourceRefapplyTask targetRefdeptGateway/ sequenceFlow idflow3 sourceRefdeptGateway targetRefdeptApprove conditionExpression xsi:typetFormalExpression![CDATA[${days 3}]]/conditionExpression /sequenceFlow sequenceFlow idflow4 sourceRefdeptGateway targetRefhrApprove conditionExpression xsi:typetFormalExpression![CDATA[${days 3}]]/conditionExpression /sequenceFlow sequenceFlow idflow5 sourceRefdeptApprove targetRefendEvent/ sequenceFlow idflow6 sourceRefhrApprove targetRefendEvent/ /process启动流程提交一个 2 天的请假申请任务停留在applyTask。这时调用预测接口MapString, Object params new HashMap(); params.put(days, 2); params.put(applyUser, zhangsan); params.put(deptManager, wangwu); NodePredictionResult result predictService.predict(taskId, params);预测结果是deptApprove节点审批人wangwu。把days改成 5预测结果则切到hrApprove节点审批人从流程变量hrManager解析。此时如果再传入_next_assigneezhaoliu预测结果显示的审批人就会是zhaoliu。这条测试流程验证了三个核心能力从任务ID定位活动节点、条件表达式参与分支选择、参数覆盖候选人。也是我把这套方案提交给测试同学做回归验证时的基线场景。6. 生产环境踩坑记录事务、多实例与边界事件6.1 排他网关无默认出口时的异常处理第一个坑来自排他网关的异常情况。有一条线上流程因为条件判断写漏了一种业务类型导致某个分支既没有条件命中也没有配置默认流。真实运行时Flowable 会抛出FlowableException整个任务提交失败。预测服务在同样的场景下我最初实现是直接返回空列表前端展示“无下一节点”反而把流程定义的问题掩盖了。后来我在预测结果里单独加了errorNodes列表专门记录这类流程定义级别的异常。当前端接到errorNodes不为空的预测结果时会展示一个明显的提示“流程定义可能存在缺陷请联系管理员检查排他网关分支。”这样把问题暴露在设计阶段而不是等用户提交申请后才在后台发现异常日志。6.2 多实例节点与子流程的预测细节多实例节点是我在预测功能上线后遇到最集中的咨询点。比如会签节点配置了flowable:collection${approverList}候选人本身是运行时动态计算出来的集合。预测时approverList这个变量可能根本还没写入流程实例只能依赖调用方传入的表单参数。所以我在预测服务里明确约定涉及多实例节点的预测必须把approverList这类集合参数通过请求参数传进来否则resolveCollectionExpression只能解析出空集合。子流程的情况相对简单遇到SubProcess节点时找到子流程内部的StartEvent然后继续走正常的遍历逻辑。真正需要留意的是嵌套子流程预测服务里的递归深度限制这时候就会生效。我把MAX_DEPTH设置为 10足以覆盖绝大多数公司内部的审批流程同时避免异常流程定义导致栈溢出。6.3 事务边界与缓存性能的三个提醒最后说三个和事务、缓存、性能有关的实践提醒都是在生产环境里真实踩过的。第一个是事务边界。预测接口应该是只读的但 Flowable 的CommandExecutor默认会和 Spring 事务绑定。如果调用方在预测之前开启了一个写事务比如先更新了业务表再调用预测接口有可能因为事务没有提交导致runtimeService.getVariables()读到的还是旧值。我的做法是预测方法上明确标注Transactional(propagation Propagation.REQUIRES_NEW, readOnly true)让预测在独立只读事务里执行避免被调用方事务污染。第二个是 BpmnModel 的缓存。repositoryService.getBpmnModel()每次调用都会做一次定义解析频繁调用会有性能损耗。我经过压测后发现Flowable 内部对已部署的流程定义模型做了进程级缓存首次解析之后的调用走的是缓存耗时基本在 1ms 以内所以在单机部署场景下不需要额外做本地缓存。但如果你的服务是多副本部署并且频繁发起预测建议在应用层加一层按processDefinitionId的Caffeine缓存把BpmnModel这种不可变对象缓存起来能明显降低引擎的解析压力。第三个是请求参数的污染。调用方传入的params会直接进入PredictionVariableContainer也就是说如果前端误传了一个days字符串而流程条件是${days 3}预测结果会与真实运行不一致。我在网关层对预测接口做了一次白名单校验只允许传入流程定义中确实用到的变量和_next_前缀参数。这个校验在两套系统联调时尤其重要能把变量名拼写错误的问题在接口层就挡住。这套预测方案上线运行至今支撑了包括跨部门审批、会签、转办、代理审批在内的多种业务场景。最初困扰我的“前端如何提前知道下一个审批人”的问题在模型层遍历和表达式求值这套组合下得到了比较自然的解决。如果你也在做类似需求建议从最简单的线性流程开始先把用户任务节点的候选人和排他网关分支跑通再逐步加入并行网关、子流程和多实例节点的支持这样排错时会轻松很多。