工作流中的子流程节点可以驳回到父流程,但必须先判断它是哪一种子流程。嵌入式 SubProcess 与父流程共享同一个流程实例,只是增加了一层 Execution 作用域,可以使用 Flowable 的普通状态变更 API,把子流程内的执行实例移动到外层节点;Call Activity 调用的是独立流程定义,会创建新的子流程实例,必须以子流程实例 ID 为入口,使用 moveActivityIdToParentActivityId,由引擎终止子流程实例并把父流程的 Super Execution 移到目标节点。
真正的边界不是“能不能跳”,而是子流程里是否还有并行分支、多实例、边界事件和异步作业。Flowable 8.0.0 源码会校验 Call Activity 子流程实例中的所有活动 Execution 是否都进入迁移池,只迁移一个分支而遗漏其他分支会直接抛出异常,避免删除半个子流程后留下孤儿状态。
一句话结论:嵌入式子流程回到外层节点,用同一流程实例内的普通 Change State;Call Activity 子流程回到父流程,用
moveActivityIdToParentActivityId,并确保子流程实例的全部活动执行都被覆盖。
一、核心结论与问题边界
先给答案:能驳回,但两类子流程走两套路线
BPMN 图上,嵌入式子流程和 Call Activity 都像一个带“+”号的圆角矩形,但运行时完全不同。
| 对比项 | 嵌入式 SubProcess | Call Activity |
|---|---|---|
| 流程定义 | 定义在父流程 BPMN 内 | 引用外部流程定义 |
| 流程实例 | 与父流程共用同一个 processInstanceId | 新建独立子流程实例 |
| 运行时关系 | 父子 Execution 作用域 | child process instance + superExecution |
| 返回外层 API | moveExecutionToActivityId / moveActivityIdTo | moveActivityIdToParentActivityId |
| 子流程结束 | 删除嵌入作用域 | 终止整个子流程实例 |
| 主要风险 | 漏删嵌套作用域、边界资源 | 漏掉子流程活动 Execution、输出变量丢失 |
图 1:两种子流程的图形相似,但一个是同实例内跨作用域,另一个是跨流程实例迁移。
Flowable 官方文档也明确区分两者:嵌入式 SubProcess 完全定义在父流程中,并创建事件作用域;Call Activity 引用外部流程定义,父流程的 Super Execution 在调用活动处等待,直到被调用流程结束。
先弄清“父流程”到底指什么
业务人员说“退回父流程”,通常可能包含三种不同目标:
- 退回子流程之前的父流程审批节点;
- 退出子流程,直接进入父流程后续节点;
- 退回父流程中的某个并行分支或会签节点。
第一种会在重新完成父节点后再次进入子流程,形成新一轮子流程;第二种等于跳过子流程剩余步骤;第三种还涉及父流程并行令牌的收拢。三者的历史、变量和业务含义都不同,不能共用一个没有上下文的“驳回到父流程”按钮。
还要区分“正常结束子流程”和“异常迁移到父流程”。正常结束会沿 BPMN 顺序流离开 Call Activity,并按正常完成语义处理输出参数、监听器和后续节点;状态迁移则是直接终止当前运行状态并激活指定父节点,不会自动补跑中间路径。
二、关键概念与能力差异
嵌入式 SubProcess 怎样驳回到外层节点
嵌入式 SubProcess 与父流程处在同一个流程实例中。Flowable 官方文档说明,顺序流不能跨越 SubProcess 边界;因此在 BPMN 模型上不能直接画一条从子流程内部任务连到外层节点的线,但运行时可以通过 Change State 执行受控迁移。
以子流程内任务 subReview 驳回到外层 applyTask 为例:
runtimeService.createChangeActivityStateBuilder()
.processInstanceId(processInstanceId)
.moveActivityIdTo("subReview", "applyTask")
.changeState();
如果活动 ID 可能因为并行、循环或多实例同时出现多次,优先使用任务的精确 executionId:
runtimeService.createChangeActivityStateBuilder()
.moveExecutionToActivityId(task.getExecutionId(), "applyTask")
.changeState();
Flowable 8.0.0 的 ChangeStateTest.testSetCurrentActivityOutOfSubProcess 就覆盖了这一场景:测试把 subTask 移动到外层 taskBefore,随后断言子任务和 SubProcess 都产生取消事件,外层任务重新启动,运行时 Execution 数量恢复到外层结构。
图 2:嵌入式子流程没有第二个流程实例,引擎删除子流程作用域后继续使用外层 Execution。
嵌入式子流程的源码如何折叠作用域
普通 moveActivityIdTo 最终进入 AbstractDynamicStateManager.doMoveExecutionState。引擎解析当前活动与目标活动后,会判断目标是否仍处于当前 SubProcess 容器内。
如果目标已经在子流程外,deleteParentExecutions 会沿 Execution 父链向上处理:
- 找到当前 Execution 的 SubProcess 父作用域;
- 判断目标 FlowElement 是否仍是该 SubProcess 的后代;
- 如果不是,删除该 SubProcess 下的子 Execution 和关联运行时数据;
- 继续向外寻找能够承载目标节点的父 Execution;
- 在目标活动上创建新的任务、作业或事件订阅。
源码中的 isSubProcessAncestorOfAnyNewFlowElements 与父 Execution 删除逻辑,共同决定需要折叠多少层嵌套作用域。这也是嵌套 SubProcess 可以一次跨出多层的原因,但每多一层都会增加变量、边界事件和多实例根 Execution 的处理复杂度。
Flowable 测试还验证了带边界定时器的子流程迁移:从 subTask 移到外层 taskBefore 后,原边界 Timer Job 被取消;重新进入子流程时,引擎再按模型创建新的定时器。由此可见,驳回不是只更新活动 ID,而是一整套作用域销毁与目标行为重建。
三、模型架构与运行机制
Call Activity 为什么必须使用专门的父流程 API
Call Activity 到达时,Flowable 会启动一个新的子流程实例。父流程在 Call Activity 对应的 Super Execution 上等待,子流程任务的 processInstanceId 与父流程不同。此时普通 moveActivityIdTo 只能在子流程自己的 BPMN 模型中解析目标,找不到父流程节点。
正确入口是:
runtimeService.createChangeActivityStateBuilder()
.processInstanceId(childProcessInstanceId)
.moveActivityIdToParentActivityId(
"childReviewTask",
"parentApplyTask"
)
.changeState();
这里有三个容易写错的参数:
processInstanceId必须是被调用的子流程实例 ID,不是父流程实例 ID;currentActivityId属于子流程定义;newActivityId属于父流程定义。
Flowable 8.0.0 官方测试 ChangeStateForCallActivityTest.testSetCurrentActivityInParentProcess 使用的就是这条调用链。测试先从父流程查询出 Call Activity 创建的子流程实例,再把子流程 theTask 移到父流程 secondTask;执行后,子流程实例消失,父流程只保留一个活动 Execution,并在目标节点生成新任务。
Flowable 8.0.0 从 Builder 到父流程目标的源码链
moveActivityIdToParentActivityId 并不是普通跳转方法的别名。Flowable 8.0.0 源码会在 ChangeActivityStateBuilderImpl 中创建 MoveActivityIdContainer,并显式设置 moveToParentProcess=true。
随后 AbstractDynamicStateManager 执行以下步骤:
- 按子流程实例 ID 和当前活动 ID解析活动 Execution;
- 通过子流程实例的
getSuperExecution()找到父流程在 Call Activity 处等待的 Execution; - 使用父流程的 processDefinitionId 加载父流程 BPMN 模型;
- 在父模型中解析目标
newActivityId; - 调用
safeDeleteSubProcessInstance校验并删除子流程实例; - 将待迁移对象切换为父流程的 Super Execution;
- 删除 Call Activity 下的旧子结构并激活父流程目标节点。
图 3:真正被移动到父节点的是 Super Execution,子流程实例在迁移前会被完整校验并终止。
ChangeActivityStateCmd 负责在 CommandContext 内校验 Builder 参数,再调用 DynamicStateManager.moveExecutionState。因此整个状态变更处在同一引擎命令事务中:目标解析失败、子流程 Execution 不完整或父流程不存在时,事务会回滚,不会留下“子流程删了但父任务没创建”的半成品。
四、核心场景与处理策略
为什么源码要求子流程的全部 Execution 都必须纳入迁移
ChangeActivityStateBuilder 的 Javadoc 对 moveActivityIdToParentActivityId 有一句非常关键的说明:子流程实例会被终止,因此所有子流程实例 Execution 都需要被移动。
源码中的 safeDeleteSubProcessInstance 会读取该子流程实例下的全部子 Execution,再逐个检查它们是否位于迁移池中:
- 普通活动 Execution 未被包含,抛出
Following execution of sub process instance is not moved; - 边界事件 Execution 如果没有绑定到已迁移的父活动 Execution,抛出
Unbound boundary event execution prevents...; - 全部通过后,才调用
deleteProcessInstance删除子流程实例。
这项校验尤其影响并行网关、多实例和事件子流程。如果子流程同时停在 legalTask 与 financeTask,只传 legalTask 无法代表整个子流程。引擎拒绝迁移,是为了防止财务分支仍在运行、子流程实例却已经被父流程回退删除。
图 4:只有迁移池覆盖全部活动 Execution,子流程实例才允许被安全删除。
并行、多实例和多层 Call Activity 应该怎样处理
1. 子流程内有并行分支
公开接口 moveActivityIdToParentActivityId 以活动 ID 为源。如果并行分支都停在同一个活动 ID,它会解析出该活动的多个 Execution;如果不同分支停在不同活动 ID,单次公开 API 不能自然表达“多个不同源活动合并到父流程一个节点”。
ChangeActivityStateBuilderImpl 内部存在接收多个活动 ID 的父流程迁移方法,但它不属于公开 ChangeActivityStateBuilder 接口。业务代码不应直接依赖内部实现类,否则小版本升级也可能破坏兼容性。更稳妥的选择是:限制跨父流程驳回只在单一活动状态使用,或在工作流平台层实现经过目标版本回归测试的专用 Command。
2. Call Activity 本身是多实例
多实例 Call Activity 会创建多个子流程实例,每个实例都有自己的 Super Execution。只从其中一个子流程实例驳回,只会处理该实例对应的父执行分支。若业务语义是“任一子流程驳回,全部实例作废”,必须先收集同一多实例体下的所有子流程实例和父执行,再整体取消或收拢,不能循环调用 API 并让每个子实例都创建一个父任务。
3. 子流程又调用下一层子流程
moveActivityIdToParentActivityId 的“Parent”是直接父流程实例,不是任意祖先。如果孙流程要直接回到根流程,建议逐层设计清晰的退出协议,或者由平台解析完整 Super Execution 链后执行一次经过严格校验的专用状态变更。直接在业务服务里连续调用两次,容易在第一次提交后产生中间待办和监听器副作用。
五、数据、规则与状态设计
目标选在 Call Activity 前后,业务含义完全不同
| 父流程目标位置 | 运行结果 | 典型用途 | 风险 |
|---|---|---|---|
| Call Activity 之前 | 完成父节点后可能再次创建新子流程 | 退回申请人重新修改 | 重复创建子流程轮次 |
| Call Activity 本身 | 重新进入调用活动并启动子流程 | 整轮子流程重做 | 版本选择和变量输入需重算 |
| Call Activity 之后 | 跳过子流程剩余步骤 | 管理员修复、强制放行 | 输出变量和完成监听器可能未执行 |
| 父流程并行域内 | 恢复到某条父分支 | 复杂联合审批 | 需要同步其他父分支令牌 |
如果目标在 Call Activity 之前,旧子流程实例已经终止;父节点再次通过后启动的是新子流程实例,不应复用旧任务 ID。审批意见需要按 roundNo 记录新旧轮次。如果目标在 Call Activity 之后,则必须明确哪些子流程结果仍有效,哪些变量由平台补齐。
变量、监听器和历史为什么容易不一致
Call Activity 正常完成时,Flowable 可以通过 flowable:out 把子流程变量复制回父流程。异常迁移到父流程不是正常完成路径,不应假设所有输出映射、结束监听器和业务回调都会按原顺序执行。
建议把数据分成三类:
- 审批意见、材料版本和操作原因写入业务数据库,不能依赖临时流程变量;
- 父流程继续执行必需的变量,在状态变更前由同一业务事务或可靠 Outbox 显式准备;
- 子流程局部临时变量随子流程终止,不再向父流程传播。
历史记录也不应被物理删除。旧子任务应保留“因驳回父流程而取消”的删除原因,新父任务使用新的 taskId,并通过 operationId、roundNo、sourceTaskId 和 targetActivityId 关联。这样审计人员才能看清一次跨流程实例的状态变更。
六、工程实现与系统集成
为什么不建议先删除子流程,再单独启动父任务
有些实现会先调用 deleteProcessInstance(childId, reason),再用自定义 Command 修改父流程。这种拆分有三个问题:
- 两次调用可能不在同一个事务中,第二步失败后子流程已经丢失;
- 父流程的 Super Execution、Call Activity 订阅和实体链接可能没有按引擎规则清理;
- 历史删除原因与父任务创建之间缺少统一操作标识。
Flowable 的专用 API 已经把“校验子实例、删除子实例、切换 Super Execution、激活父目标”放在同一个 Dynamic State 命令内。企业二开应围绕公开 API 增加权限、幂等和业务同步,而不是绕过引擎直接改 ACT_RU_EXECUTION、ACT_RU_TASK 或手工删除子流程实例。
七、安全、性能与治理要求
工作流平台如何把跨子流程驳回产品化
低代码流程设计器应在建模阶段识别 SubProcess 与 Call Activity,并给出不同配置项:允许退回的父级范围、是否允许跨多层、子流程并行状态处理方式、父目标位置、变量映射策略和意见失效规则。
云程低代码开发平台可以在发布校验中加入几条硬约束:Call Activity 子流程存在多个活动分支时,禁止使用简单单节点父流程驳回;目标位于 Call Activity 之后但缺少必需父变量时,禁止发布;跨过父流程并行 Fork 时,要求配置整域收拢策略;子流程包含不可逆服务任务时,要求配置补偿或人工处理方案。
操作界面也不应只显示节点名称。管理员至少要看到父流程实例、子流程实例、直接 Super Execution、当前活动 Execution 数量、目标流程定义版本以及即将取消的任务和作业清单,确认后再提交。
八、案例实践与迁移路线
上线前必须覆盖的测试场景
- 嵌入式子流程单任务退回外层前置任务,验证 SubProcess 作用域被删除;
- 嵌入式子流程带边界定时器,验证原 Timer Job 取消且重入后重新创建;
- Call Activity 单任务退回父流程,验证子流程实例消失、父目标只创建一个任务;
- 错把父流程实例 ID 传给 Builder,验证请求被拒绝且状态不变;
- 子流程存在两个并行活动,遗漏一个 Execution 时验证事务整体回滚;
- 子流程活动绑定边界事件,验证未绑定边界 Execution 不会遗留;
- 多实例 Call Activity 中一个实例驳回,验证其他实例按产品策略保留或整体取消;
- 孙流程退回直接父流程和根流程,分别验证 Super Execution 链;
- 目标位于 Call Activity 前后两侧,验证子流程轮次和变量映射;
- 状态变更与业务单据同步失败,验证幂等、Outbox 和人工修复通道;
- 两名管理员并发执行跨流程驳回,验证乐观锁和重复提交保护;
- Flowable 升级后复跑官方测试拓扑及企业自定义并行、多实例用例。
测试断言不能只看“父任务出现了”。还要检查子流程实例数量、Execution 树、活动任务、定时器与事件订阅、历史删除原因、审批轮次、父变量以及外部副作用次数。
九、平台落地、测试与选型
工作流中的子流程节点能否驳回到父流程?
可以,但必须区分嵌入式 SubProcess 与 Call Activity。嵌入式子流程和父流程属于同一个流程实例,可以使用 moveActivityIdTo 或基于精确 Execution 的 moveExecutionToActivityId,Flowable 会删除不再需要的 SubProcess 父作用域并在外层目标节点重建运行状态。Call Activity 会创建独立子流程实例,应以子流程实例 ID 调用 moveActivityIdToParentActivityId;引擎先通过 Super Execution 找到父流程,再完整删除子流程实例并激活父流程目标。
Call Activity 子流程存在并行、多实例或边界事件时,不能只迁移当前一个任务。Flowable 8.0.0 的 safeDeleteSubProcessInstance 会校验子流程实例的所有活动 Execution 是否都纳入迁移池,遗漏任何普通活动 Execution 或未绑定的边界事件 Execution 都会抛出异常并回滚。企业实现还必须同步处理审批轮次、历史意见、父子变量、外部副作用和父流程并行令牌,不能直接修改运行时数据库,也不能把删除子流程与启动父任务拆成两个独立事务。