在企业级审批系统的设计中,如何高效地设计和实现工作流是一个关键问题。本文将基于 Camunda 流程引擎,探讨在不同业务需求下,如何选择和设计工作流的发起表单节点,并结合实际场景给出最佳实践建议。
背景与挑战
通常情况下,一个完整的审批流程包括以下几个步骤:
- 用户填写表单并提交
- 审批人进行审批
- 根据审批结果,决定是否驳回给发起人或继续下一步
然而,在实际应用中,不同的业务场景对流程设计有不同的要求。例如:
- 某些场景需要支持“驳回给发起人”重新修改表单。
- 而另一些场景则不需要这种回退机制,仅需直接进入审批阶段。
- 还有些流程是由系统自动发起的,不存在“人工发起人”这一角色。
主要观点与设计原则
1. 常规标准:通用设计是第一个节点为“填写表单” UserTask
在大多数需要人工参与的审批流程中,通用的最佳实践是将第一个节点设计为一个 UserTask,即“填写/修改申请”任务。
这样做有以下优势:
- 支持“驳回后重新提交”:当审批不通过时,流程可以自然地回退到该任务,由发起人修改后再次提交。
- 流程可审计:Camunda 会记录该任务的创建、完成时间,便于追踪整个流程生命周期。
- 用户体验一致:无论是首次提交还是驳回修改,用户都在同一个任务中操作。
<userTask
id="initiatorTask"
name="填写/修改申请"
camunda:assignee="${applicant}"
_nodeType="INITIATOR_FORM_TASK" />
注意: 这段 xml 这是伪代码,表示增加的节点类型设置,实际应用中你可能在属性中设置,也可能是在 extensionElements
中设置,请根据实际情况配置。
2. 特殊场景:系统自动发起的流程不应设置“表单节点”
对于由系统(如定时任务、消息触发、数据同步等)自动发起的流程,情况有所不同。
这类流程的“发起人”是系统本身,显然无法被“驳回”去修改数据。如果在这种流程中仍然设计一个“填写表单”的 UserTask,并允许流程回退到该节点,就会出现逻辑问题:
❌ 问题示例:
系统每天凌晨自动生成10万条财务结算单并启动审批流程。
若某条记录审批失败被“驳回”,流程试图回到“系统填写表单”节点——但系统无法交互,也无法重新“填写”,导致流程卡死或行为异常。
因此,在系统自动发起的流程中,应避免设置“发起人 UserTask”节点,直接进入审批或其他处理环节。
3. 高并发场景:避免不必要的节点以提升性能
在高并发、大数据量的系统中,每一个 taskService.complete()
操作都会带来显著的数据库开销,包括:
-
历史表写入(
ACT_HI_TASKINST
,ACT_HI_VARINST
) -
运行时任务表的增删改(
ACT_RU_TASK
) -
变量表更新(
ACT_RU_VARIABLE
) -
执行流推进(
ACT_RU_EXECUTION
) -
可能得更多相关表
关于 Camunda 表
ACT_RUN_TASK
中PARENT_TASK_ID_
这个字段缺少索引,在大数据量场景下的性能问题,我给官方提交了 issue:https://github.com/camunda/camunda/issues/36824,请根据实际需要自行为这个字段添加索引CREATE INDEX ACT_IDX_TASK_PARENT_TASK_ID ON ACT_RU_TASK(PARENT_TASK_ID_);
。
如果为每一个流程实例都增加一个“自动完成”的 UserTask,即使只是启动时的一次性操作,在百万级流程实例的场景下也会造成:
- 额外的数据库压力
- 更长的流程启动时间
- 更高的资源消耗
因此,在不需要“回退发起人”的场景下,省略第一个 UserTask 节点,不仅能简化流程模型,还能显著提升系统吞吐量。
最佳实践总结
决策树:如何设计第一个节点?
是否需要"驳回给发起人"?
↓
是 → 设计第一个节点为:发起人 UserTask
- 启动流程后自动 complete 该任务
- 支持驳回后重新修改表单
- 提供完整的流程审计记录
↓
否 → 第一个节点应为:审批人或处理人 UserTask
- 启动流程时直接传入表单数据
- 无额外 complete 操作
- 提升系统性能和吞吐量
补充判断:是否为系统自动发起?
在“不需要回退”的前提下,进一步判断:
- 如果是 人工发起 的流程,且未来可能扩展“回退”功能,可预留扩展性。
- 如果是 系统自动发起 的流程,坚决不要添加“表单节点”,避免逻辑混乱和性能浪费。
- 推荐的系统设计逻辑:为流程级增加一个全局变量,在流程发起时可以动态设置这个变量,由这个变量来控制后面的节点进行“回退”时,能否回退到发起人。
重要实践注意事项
⚠️ 发起人节点标识的最佳实践
**关键原则:**永远不要依赖"第一个任务"或"firstTask"来识别发起人节点,而应该为发起人节点设置固定的属性标识。
为什么这样做?
- **流程稳定性:**流程定义可能会变更,第一个任务的位置可能发生变化
- **代码可维护性:**明确的标识使代码更易理解和维护
- **避免歧义:**防止在复杂流程中误判节点类型
- **扩展性:**便于后续添加更多发起人相关的业务逻辑
推荐的实现方式:
- 在BPMN流程定义中,为发起人节点添加自定义属性来标识节点类型,如
_nodeType="INITIATOR_FORM_TASK"
错误示例 vs 正确示例:
// ❌ 错误:依赖firstTask或任务顺序
Task firstTask = taskService.createTaskQuery()
.processInstanceId(instance.getId())
.list().get(0); // 依赖顺序,容易出错
// ✅ 正确:使用节点类型属性
Task initiatorTask = taskService.createTaskQuery()
.processInstanceId(instance.getId())
.taskAttributeEquals("_nodeType", "INITIATOR_FORM_TASK") // 基于业务语义的标识
.singleResult();
代码示例对比
场景一:需要"回退发起人" —— 保留第一个 UserTask
在需要支持"驳回给发起人"的场景下,我们不仅需要启动流程时自动完成发起人任务,还需要提供驳回功能。以下是完整的实现:
/**
* 示例代码
* @author 单红宇
*/
@Service
@Transactional
public class ApprovalService {
@Autowired
private RuntimeService runtimeService;
@Autowired
private TaskService taskService;
/**
* 提交表单并发起流程
*/
public String submitAndStart(String applicant, ApplyForm form) {
Map vars = new HashMap<>();
vars.put("applicant", applicant);
vars.put("approver", "admin");
vars.put("reason", form.getReason());
vars.put("amount", form.getAmount());
ProcessInstance instance = runtimeService.startProcessInstanceByKey("approvalProcess", vars);
// 通过节点类型属性来获取发起人任务,而不是依赖firstTask
Task initiatorTask = taskService.createTaskQuery()
.processInstanceId(instance.getId())
.taskAttributeEquals("_nodeType", "INITIATOR_FORM_TASK") // 基于业务语义的标识
.singleResult();
if (initiatorTask != null) {
taskService.complete(initiatorTask.getId(), vars);
}
return instance.getId();
}
/**
* 退回给发起人重新修改
* @param processInstanceId 流程实例ID
* @param currentTaskId 当前任务ID
* @param rejectReason 驳回原因
* @return 退回后的任务ID
*/
public String rejectToInitiatorFormTask(String processInstanceId, String currentTaskId, String rejectReason) {
// 获取当前任务
Task currentTask = taskService.createTaskQuery()
.taskId(currentTaskId)
.singleResult();
if (currentTask == null) {
throw new RuntimeException("任务不存在: " + currentTaskId);
}
// 设置驳回原因变量
Map variables = new HashMap<>();
variables.put("rejectReason", rejectReason);
variables.put("rejectTime", new Date());
// 完成当前任务,流程会回退到发起人节点
taskService.complete(currentTaskId, variables);
// 获取退回后的发起人任务
Task initiatorTask = getInitiatorFormTask(processInstanceId);
if (initiatorTask == null) {
throw new RuntimeException("未找到发起人任务");
}
return initiatorTask.getId();
}
/**
* 获取流程的发起人任务
* @param processInstanceId 流程实例ID
* @return 发起人任务,如果不存在则返回null
*/
public Task getInitiatorFormTask(String processInstanceId) {
return taskService.createTaskQuery()
.processInstanceId(processInstanceId)
.taskAttributeEquals("_nodeType", "INITIATOR_FORM_TASK")
.singleResult();
}
}
场景二:系统自动发起 + 无需回退 —— 直接进入审批
public String startAutoApproval(AutoFormData data) {
Map variables = new HashMap<>();
variables.put("sourceSystem", data.getSource());
variables.put("batchId", data.getBatchId());
variables.put("totalAmount", data.getTotalAmount());
variables.put("approver", "finance-team");
ProcessInstance instance = runtimeService.startProcessInstanceByKey(
"autoSettlementProcess", variables
);
return instance.getId();
// ✅ 无多余 taskService.complete() 调用
}
结论
通过以上分析,我们可以得出以下核心结论:
- 常规标准:对于需要人工发起且支持“驳回修改”的流程,第一个节点应为“填写表单” UserTask,这是通用且推荐的做法。
- 系统发起场景:由系统自动触发的流程,不应设计“表单节点”,否则“回退到系统”会导致逻辑错误。
- 性能考量:在高并发、大数据量场景下,避免不必要的 UserTask 节点,尤其是那些仅用于"启动即完成"的节点,可显著降低数据库压力,提升系统性能。
- 节点标识规范:为发起人节点设置固定的属性标识(如
_nodeType="INITIATOR_FORM_TASK"
),避免依赖任务顺序或firstTask
,确保流程的稳定性和代码的可维护性。
最终的设计决策应基于业务需求而非“通用模板”。灵活运用 Camunda 的建模能力,在满足功能的前提下,追求简洁、高效、可维护的流程架构。
(END)