先讲个我前段时间的真实经历。线上有个请假审批流程,审批人一直是写死的——部门经理审完,HR审,HR审完,总经理审。这个流程上线跑了快半年,一直相安无事。直到有一天部门经理突然离职,新经理还没到岗,流程直接卡在“部门经理审批”这个节点,线上工单瞬间堆了十几条,谁也没法处理。那时候我脑子里只有一个念头:当初怎么就没把审批人做成动态配置?
这就是我今天想聊透的问题——用 Flowable 工作流引擎跑 Spring Boot 项目时,审批人到底怎么配置才能不硬编码。市面上聊 flowable 快速入门的文章很多,但大多数都停留在“把 BPMN 文件画出来、用 Flowable 自带的管理页指定一个固定审批人”这个阶段。一旦进入真实业务,你就会碰到各种绕不开的场景:审批人是由管理员在页面上配的、审批人要按发起人的组织架构动态匹配、特殊情况下要找个代理来审。如果你全凭硬编码处理,那后面每一次人员变动、组织调整,都是灾难。
这篇文章我会以 Spring Boot 集成 Flowable 为背景,把动态审批人配置的几种主流方案全部拆开揉碎,讲清楚各自的原理、适用场景、代码怎么写、实际会遇到什么坑。不管你是刚接触 flowable 工作流,还是已经踩过几个坑正在找解决方案,这篇文章应该都能给你一些可直接落地的参考。
1. 硬编码审批人的“慢性毒药”:先从一次改流程说起
1.1 我是怎么被一份写死的流程逼疯的
很多刚入门 Flowable 的同学,第一次画流程图时大概率会这样配置审批人:在用户任务的属性面板里,直接把Assignee填成一个具体的用户名,比如zhangsan。BPMN 文件里面对应的 XML 大概是这个样子:
<userTask id="managerApprove" name="经理审批" flowable:assignee="zhangsan"/>这种配置在 Demo 里跑起来特别顺,流程发起后任务自动跑到张三头上,看起来一切完美。但稍微想一想就能发现这里面的问题:张三这个人是写死在流程定义里的。一旦张三离职、调岗、请假,或者业务上这个节点的审批人根本不固定,那这套流程就废了。
我线上那个事故就是这么来的——经理离职后,我去改 BPMN,把zhangsan换成lisi,然后部署新版本流程。你以为这就完了?不对。关键是已经发起的、卡在经理审批节点的那些流程实例,还引用着旧版本的流程定义。改 BPMN 只能影响之后新发起的流程,已经跑起来的流程实例根本不会自动套用新配置。最后我只能在数据库里刷任务,手动把那些卡住的任务的ASSIGNEE_字段改掉,一条一条改,改完还要通知相关人。整个过程既没技术含量,又很容易出错。
1.2 当审批人写死在BPMN里,代价是什么
经过这次事故,我把硬编码审批人的问题总结成了四个代价,后面做方案设计时都是按这四条来对照的:
- 人员变动成本极高:每次组织架构调整,都要去改流程文件、重新部署流程,还要考虑存量流程实例怎么办。
- 审批规则无法复用:同一个部门经理审批节点,A 部门和 B 部门的审批人可能完全不同。硬编码等于给每个部门各画一张流程图,维护成本直接翻倍。
- 无法响应临时变化:审批人请假、出差时,需要临时指定代理人。硬编码模式根本没有这个能力。
- 业务和流程耦合太深:理想的工作流设计应该是“流程引擎管流程怎么走,业务系统管审批人是谁”。硬编码把这两件事揉成了一团,后续的每一次业务调整都会牵动流程本身。
想通这四件事之后,我的目标就很明确了:审批人必须能在流程运行过程中动态确定,而且最好能让业务人员在管理界面上配置,而不是靠开发改代码。这才是动态审批人配置这件事的完整意义。
2. 动态审批人的三条技术路线:选型之前先看清楚全局
先说一个反直觉的事实:Flowable 本身并不是不支持动态审批人,恰恰相反,它支持的方式太多、太灵活了,导致很多初学者反而不知道选哪条路。常用的方案整理下来,大致可以分成三条路线。
| 技术路线 | 核心机制 | 配置位置 | 动态程度 | 推荐场景 |
|---|---|---|---|---|
| 表达式方案 | flowable:assignee="${approver}" | BPMN 的 assignee 属性 | 中高,取决于变量来源 | 审批人可通过流程变量传入或由 Bean 动态计算 |
| 监听器方案 | 任务监听器/执行监听器动态修改 assignee | BPMN 的 extensionElements | 高,运行期可完全接管 | 审批逻辑复杂,需要访问数据库、远程接口 |
| 服务任务方案 | 服务任务查库/计算后设置变量或直接改任务 | BPMN 中插入服务任务节点 | 最高,可承担任意业务逻辑 | 多级审批、条件分支多、规则经常变化的场景 |
这三条路线并不是互斥的,实际项目里经常组合使用。但你需要先理解一条核心原则:动态审批人的本质,是把“审批人是谁”这个业务问题,从流程定义中剥离出来。无论用哪种方案,你在 BPMN 文件里都不再写死具体的用户名,而是写一个“怎么获取审批人”的描述,让流程引擎在运行到对应节点时,通过这个描述去找到真正的审批人。
2.1 三条路线分别解决什么问题
表达式方案是门槛最低的。你不需要写 Java 类,只需要在assignee属性里写一个 UEL 表达式,Flowable 会在任务创建时解析这个表达式。表达式的值可以是一个流程变量,也可以是 Spring 容器中某个 Bean 的方法返回值——这一点非常关键,后面我会重点讲。
监听器方案则更“暴力”一些。用户任务在创建时,Flowable 会触发一个create事件,你可以挂一个监听器,在这个事件里直接调用delegateTask.setAssignee(userId)把审批人塞进去。等于说审批人的计算逻辑完全由你自己的 Java 代码决定,自由度最高。
服务任务方案是把“确定审批人”这个动作变成流程里的一个显式步骤。流程走到审批节点之前,先经过一个服务任务,这个服务任务执行一段逻辑(查数据库、调接口、走算法规则),把计算出来的审批人写进流程变量,后面的用户任务引用这个变量。这样做的最大好处是审批人确定的过程是可追溯的——在流程历史里你能清楚看到系统是根据什么规则算出审批人的。
2.2 选型判断:什么样的场景走哪条路
给你一个我实际项目里的选型经验,不一定绝对正确,但能少走弯路:
- 如果审批人可以从流程启动时提交的表单参数里直接拿到(比如发起人自己选了下一个审批人),直接用表达式方案,把审批人作为流程变量传入。
- 如果审批人需要根据发起人、部门、角色等条件动态查库得出,优先考虑表达式方案里的 Bean 方法调用,或者执行监听器。这俩的代码量差不多,但表达式可读性更好。
- 如果审批人确定之前还需要做一系列判断(比如金额超过阈值要自动升级审批)、甚至要调外部系统接口,那就老老实实插一个服务任务节点,把逻辑写在
JavaDelegate里。
这套选型逻辑的底层判断标准其实就一个:审批人计算逻辑的复杂程度。简单到一句话能说清,用表达式;复杂到需要写完整函数,用监听器或服务任务。
3. 表达式方案:让审批人变成可动态解析的变量
3.1 从 ${} 到调用Bean方法
Flowable 的用户任务标签支持 UEL(Unified Expression Language)表达式。最简单的用法是把 assignee 写成变量引用:
<userTask id="managerApprove" name="经理审批" flowable:assignee="${approver}"/>流程启动时,只要往流程变量里塞一个approver,Flowable 在创建这个任务时就会自动把这个表达式的值赋给任务的assignee字段。
Map<String, Object> variables = new HashMap<>(); variables.put("approver", "lisi"); runtimeService.startProcessInstanceByKey("leaveProcess", variables);这套写法比硬编码进了一步,但它有个致命的边界问题:approver 这个变量在流程启动时就得确定。如果“谁审批”这个答案要等流程跑起来之后才能算出来,或者要根据前一个节点的审批结果来定,那这种方式就不够用了。
真正让表达式方案具备生产价值的,是 UEL 表达式可以直接调用 Spring Bean 的方法。因为你的 Flowable 是集成在 Spring Boot 里的,Spring 容器中所有的 Bean 都可以在表达式中被引用。比如我在项目里经常会这样写:
<userTask id="managerApprove" name="经理审批" flowable:assignee="${userQueryService.getManagerByStarter(execution)}"/>注意方法参数里的execution,这是 Flowable 暴露给表达式的一个内置对象,代表当前的执行实例。通过它可以拿到流程变量、当前节点信息等上下文。
3.2 流程变量传审批人:最简单但注意边界
先把最简单的情况说透。有些流程审批人是明确的,比如员工提交请假申请,部门经理是固定的,申请单提交时系统就知道该由谁来审。这种情况下不用搞复杂的动态计算,把审批人塞进流程变量就行。
但这里有一个很容易被忽略的细节:流程变量存的是用户ID还是用户对象?我见过不少新手直接把整个用户对象塞进流程变量,导致序列化性能差、版本升级后反序列化报错。我的建议是流程变量里只存用户ID字符串,需要用户其它信息时再查库。Flowable 的流程变量存储层对字符串的兼容性最好,排查问题时也最直观。
另外要注意,通过变量设置审批人时,变量名必须和 BPMN 里的表达式保持一致,一个字都不能差。我踩过的一个坑是:BPMN 里写的是${manager},代码里塞变量时拼写成了${managerId},结果任务创建时表达式解析结果为 null,任务变成了一个没有审批人的“孤儿任务”。表面上流程还在走,实际上没人能看到这个任务,整个流程就静默卡死了。
3.3 Bean方法动态计算审批人:最常用的生产级方案
如果审批人需要查数据库、调接口来动态确定,表达式里调 Bean 方法是性价比极高的方案。下面是一个典型的例子:根据发起人找到它的部门负责人。
<userTask id="deptApprove" name="部门主管审批" flowable:assignee="${userQueryService.findDepartmentManager(execution)}"/>对应的 Service:
@Service("userQueryService") public class UserQueryService { @Autowired private UserMapper userMapper; public String findDepartmentManager(DelegateExecution execution) { // 获取流程发起人ID String starter = (String) execution.getVariable("starter"); if (starter == null) { throw new FlowableException("流程变量 starter 不能为空"); } // 查询发起人的部门主管 User starterUser = userMapper.selectById(starter); return userMapper.selectDepartmentManager(starterUser.getDeptId()); } }这段代码有几个设计要点值得展开说说:
- 方法参数必须写成
DelegateExecution,这样 Flowable 才能把当前执行上下文注入进来。写Execution接口在某些版本下也能工作,但直接写DelegateExecution类型最保险,方法内部能用的 API 也更丰富。 - 方法返回值必须是字符串,对应审批人的用户ID。Flowable 对这个返回值有一套默认处理逻辑:非 null 时会自动 set 到 task 的 assignee。
- 方法内部如果查不到审批人,我建议主动抛异常而不是返回 null。
FlowableException抛出去后,任务不会创建成功,你会在日志里明显看到错误,避免出现“流程走了但没人审批”的静默故障。
这里有一个值得注意的执行机制:assignee 表达式是在用户任务创建时被解析的,不是流程定义部署时。这也就是为什么表达式方案能支撑动态审批人的根本原因——每次走到这个节点,它都会重新执行一次计算逻辑,得到的结果可以随上下文而变。
4. 监听器方案:在任务诞生的瞬间“劫持”审批人
4.1 任务监听器:直接改assignee
如果你不想把计算逻辑写进 BPMN 的 assignee 表达式里(有些团队的代码规范不允许 BPMN 文件里出现复杂表达式),可以改用任务监听器。监听器挂在用户任务上,在任务创建时被触发,然后你在监听器代码里动态设置审批人。
BPMN 里这样配置:
<userTask id="deptApprove" name="部门主管审批"> <extensionElements> <flowable:taskListener event="create" expression="${userQueryService.setDepartmentManager(task)}"/> </extensionElements> </userTask>注意这里表达式里的参数是task,因为create事件触发时,Flowable 传给监听器的上下文是DelegateTask。对应的 Service 方法:
@Service("userQueryService") public class UserQueryService { public void setDepartmentManager(DelegateTask task) { String starter = (String) task.getVariable("starter"); String managerId = userMapper.selectDepartmentManagerByUserId(starter); task.setAssignee(managerId); } }这个写法和表达式方案的差别在于:表达式方案要求方法有返回值,监听器方案则不需要——你直接在方法内部调用task.setAssignee()就行。这也意味着监听器方案更灵活,你可以在同一个监听器里做更多事情,比如同时设置候选组、设置任务过期时间、给任务加一个业务标记等。
不过要小心一个认知误区:任务监听器在 create 事件里的执行顺序,和 assignee 表达式是有冲突风险的。如果你在 BPMN 的 userTask 上同时配置了flowable:assignee="${approver}"和create事件的任务监听器,Flowable 会先解析 assignee 表达式,再触发监听器。最终任务归属以监听器最后一次setAssignee为准。这种“双重配置”容易让人困惑,我建议团队里明确一个规范:用了监听器方案,就不要在 assignee 属性上写表达式,两条路只走一条。
4.2 执行监听器:先填变量再引用
另一种监听器思路是挂在节点上,利用执行监听器在节点进入时提前把审批人算好,写成流程变量,然后用户任务的 assignee 再引用这个变量。
<userTask id="deptApprove" name="部门主管审批" flowable:assignee="${nextApprover}"> <extensionElements> <flowable:executionListener event="start" expression="${userQueryService.fillNextApprover(execution)}"/> </extensionElements> </userTask>public void fillNextApprover(DelegateExecution execution) { String starter = (String) execution.getVariable("starter"); String managerId = userMapper.selectDepartmentManagerByUserId(starter); execution.setVariable("nextApprover", managerId); }这种写法相当于在到达用户任务之前,“预先把答案写在小黑板上”,用户任务创建时直接从变量里取。它的好处是逻辑更清晰——执行监听器负责准备数据,assignee 只做一次简单的变量读取,两者各司其职。
执行监听器跟任务监听器的选用上,我的判断标准是:如果计算审批人时需要获取“当前节点刚产生的新数据”(比如前一个审批节点的意见),用任务监听器更顺手,因为任务上的数据可以直接通过task.getVariable()获取;如果计算逻辑纯粹依赖流程级变量,那执行监听器就足够了,还省去了 assignee 表达式解析失败的隐患。
5. 服务任务方案:把复杂审批逻辑全部下沉
说到真正复杂、规则经常变化的审批逻辑,监听器和表达式都有点“小打小闹”。比如审批规则是:请假小于等于3天部门经理审,大于3天需要部门经理 + 分管副总审,但最近安全月期间所有请假审批都需要安全总监会签。这种规则你要是全写在表达式里,BPMN 文件会变得没法看,规则调整时还得重新部署流程。
正确做法是把审批人计算彻底下沉到一个服务任务里。在用户审批节点前面插一个服务任务节点,专门负责“算出下一位审批人是谁”。
BPMN 配置:
<serviceTask id="calcNextApprover" name="计算下一审批人" flowable:delegateExpression="${approverCalculateService}"/>@Component("approverCalculateService") public class ApproverCalculateService implements JavaDelegate { @Override public void execute(DelegateExecution execution) { String starter = (String) execution.getVariable("starter"); int days = (int) execution.getVariable("leaveDays"); String currentTime = (String) execution.getVariable("currentTime"); String nextApprover; // 安全月特殊规则 if (isSafetyMonth(currentTime)) { nextApprover = userMapper.selectSafetyDirector(); } else if (days > 3) { nextApprover = userMapper.selectVicePresident(); } else { nextApprover = userMapper.selectDepartmentManagerByUserId(starter); } execution.setVariable("nextApprover", nextApprover); } }后面的用户任务直接引用nextApprover变量:
<userTask id="needApproval" name="审批" flowable:assignee="${nextApprover}"/>服务任务方案最大的优势是审批人计算过程和流程流转完全解耦。流程只在“走审批”这个动作上是固定的,至于审批人是谁,完全由服务任务的逻辑决定。规则变化时你只需要调整ApproverCalculateService里的代码或者它的依赖数据,不用动 BPMN,不用重新部署流程定义,正在运行的流程实例也能通过变量拿到最新的审批人计算结果。
5.1 JavaDelegate是审批规则的“放大器”
也许你会问:服务任务方案看起来跟执行监听器差不多,都是写 Java 代码设置变量,那为什么要多插一个节点?
原因在于服务任务是显式的、可追踪的、有业务含义的。从流程图的视角看,任何看过这张流程图的人都会知道:在用户审批之前系统会经过一个“计算审批人”的环节。就算是不懂技术的业务人员,看图也能大致了解审批人是怎么定的。而监听器挂在节点内部,对使用者来说是个“黑盒”——你只知道有人审,不知道系统为什么指定这个人。
此外,从代码组织上,把复杂的审批人分配规则收敛到一个独立的JavaDelegate实现类里,测试也更好做。你可以针对这个类单独写单元测试,把各种分支条件都覆盖到位。
5.2 服务任务、监听器、表达式三种方案怎么配合
讲了三种方案,但你不需要把它们看成三选一的关系。我实际项目里的做法是:
- 基础的用户任务节点,assignee 统一写成
${nextApprover}变量引用,保持 BPMN 简洁。 - 审批人计算逻辑统一放在服务任务里,通过 JavaDelegate 实现。如果逻辑简单,这个方法只有两三行;如果逻辑复杂,这个方法会调用一个独立的审批人策略类。
- 监听器只负责处理那些“任务产生瞬间需要做的副作用”,比如记录操作日志、通知审批人、设置任务优先级。不再用它来承载审批人计算。
这套“组合拳”的好处是职责清晰:服务任务管审批人是谁,监听器管任务产生后的附加动作,表达式只做最终赋值。代码好维护,出问题也好排查——看一眼流程图就知道审批人是在哪个环节算出来的。
6. Spring Boot完整落地:一个多级审批的请假流程
前面把每种方案的原理讲清楚了,但光有原理不够,我直接给你一个完整的落地案例。这是一个典型的员工请假流程:员工提交申请,部门主管审批,如果请假超过3天还需要总经理审批。重点演示如何在 Spring Boot 工程里通过服务任务 + 变量引用的方式实现动态审批人。
6.1 工程准备与最小依赖
假设你已经有 Spring Boot 工程,引入 Flowable 的 starter 依赖即可:
<dependency> <groupId>org.flowable</groupId> <artifactId>flowable-spring-boot-starter</artifactId> <version>6.7.2</version> </dependency>application.yml里只需要配置数据源即可,Flowable 会自动建表:
spring: datasource: url: jdbc:mysql://localhost:3306/flowable_demo?useSSL=false&characterEncoding=utf8 username: root password: root flowable: database-schema-update: true async-executor-activate: false用 Flowable starter 有个福利:它会自动把classpath:processes/目录下的 BPMN 文件部署成流程定义。你把画好的leave.bpmn20.xml丢进去,项目启动时就会自动部署,省去手动调 API 的麻烦。
6.2 BPMN设计:多级审批+条件分支
这个请假流程的 BPMN 文件我简化了一下关键节点:
<process id="leaveProcess" name="请假审批流程" isExecutable="true"> <startEvent id="startEvent"/> <serviceTask id="calcDeptManager" name="计算部门主管" flowable:delegateExpression="${approverCalculateService}"> </serviceTask> <userTask id="deptManagerApprove" name="部门主管审批" flowable:assignee="${nextApprover}"/> <exclusiveGateway id="needMoreApprove"/> <serviceTask id="calcGeneralManager" name="计算总经理" flowable:delegateExpression="${approverCalculateService}"> </serviceTask> <userTask id="generalManagerApprove" name="总经理审批" flowable:assignee="${nextApprover}"/> <endEvent id="endEvent"/> <sequenceFlow id="flow1" sourceRef="startEvent" targetRef="calcDeptManager"/> <sequenceFlow id="flow2" sourceRef="calcDeptManager" targetRef="deptManagerApprove"/> <sequenceFlow id="flow3" sourceRef="deptManagerApprove" targetRef="needMoreApprove"/> <sequenceFlow id="flow4" sourceRef="needMoreApprove" targetRef="endEvent"> <conditionExpression xsi:type="tFormalExpression"> <![CDATA[${leaveDays <= 3}]]> </conditionExpression> </sequenceFlow> <sequenceFlow id="flow5" sourceRef="needMoreApprove" targetRef="calcGeneralManager"> <conditionExpression xsi:type="tFormalExpression"> <![CDATA[${leaveDays > 3}]]> </conditionExpression> </sequenceFlow> <sequenceFlow id="flow6" sourceRef="calcGeneralManager" targetRef="generalManagerApprove"/> <sequenceFlow id="flow7" sourceRef="generalManagerApprove" targetRef="endEvent"/> </process>这里有个细节需要注意:两个 serviceTask 都指向同一个approverCalculateService,所以这个 Bean 必须在执行时能区分当前该计算部门主管还是总经理。我的做法是用流程变量加一个标记:
@Component("approverCalculateService") public class ApproverCalculateService implements JavaDelegate { @Autowired private UserMapper userMapper; @Override public void execute(DelegateExecution execution) { String starter = (String) execution.getVariable("starter"); int leaveDays = (int) execution.getVariable("leaveDays"); String currentActivityId = execution.getCurrentActivityId(); if ("calcGeneralManager".equals(currentActivityId)) { String generalManagerId = userMapper.selectGeneralManager(); execution.setVariable("nextApprover", generalManagerId); } else { String deptManagerId = userMapper.selectDeptManagerByUserId(starter); execution.setVariable("nextApprover", deptManagerId); } } }利用getCurrentActivityId()区分节点,虽然简单但很实用。更优雅的做法是定义两个不同的 Delegate 实现类,根据场景选择,团队如果分工明确可以这么做。小项目里用当前节点 ID 判断是最省事的方式,后续重构也容易。
6.3 两个核心服务的代码设计
流程启动接口。注意启动时要把业务表单里的信息(发起人、请假天数)作为流程变量传进去:
@Service public class LeaveProcessService { @Autowired private RuntimeService runtimeService; public void startLeaveProcess(String starter, int leaveDays) { Map<String, Object> variables = new HashMap<>(); variables.put("starter", starter); variables.put("leaveDays", leaveDays); runtimeService.startProcessInstanceByKey("leaveProcess", variables); } }审批接口。审批人完成任务时,需要指定任务ID,Flowable 会校验当前任务是否被该审批人持有:
@Service public class ApprovalService { @Autowired private TaskService taskService; public void approve(String taskId, String approverId, boolean approved) { Task task = taskService.createTaskQuery() .taskId(taskId) .taskAssignee(approverId) .singleResult(); if (task == null) { throw new RuntimeException("任务不存在或当前用户无审批权限"); } Map<String, Object> variables = new HashMap<>(); variables.put("approved", approved); taskService.complete(taskId, variables); } }这里有两点值得强调:
taskAssignee(approverId)这个查询条件很关键。它在数据库层面就校验了任务归属,等于把权限控制前置了,避免出现“A 审批人拿着 B 的任务ID也能提交审批”这种越权问题。complete方法传入的流程变量会被后续的流程流转使用,条件网关正是依赖leaveDays和approved这些变量来决定走哪条分支的。
6.4 流程发起与审批的接口层
如果你需要暴露 HTTP 接口给前端页面调用,Controller 层可以这样写:
@RestController @RequestMapping("/leave") public class LeaveController { @Autowired private LeaveProcessService leaveProcessService; @Autowired private ApprovalService approvalService; @PostMapping("/start") public String start(@RequestParam String starter, @RequestParam int leaveDays) { leaveProcessService.startLeaveProcess(starter, leaveDays); return "流程已发起"; } @PostMapping("/approve") public String approve(@RequestParam String taskId, @RequestParam String approverId, @RequestParam boolean approved) { approvalService.approve(taskId, approverId, approved); return "审批完成"; } }查询当前用户待办任务也是流程类项目的标配,用一个查询接口就够:
@GetMapping("/todo") public List<TaskInfo> todo(@RequestParam String userId) { List<Task> tasks = taskService.createTaskQuery() .taskAssignee(userId) .orderByTaskCreateTime().desc() .list(); return tasks.stream() .map(task -> { TaskInfo info = new TaskInfo(); info.setTaskId(task.getId()); info.setName(task.getName()); info.setCreateTime(task.getCreateTime()); return info; }) .collect(Collectors.toList()); }到这里,一个不包含任何硬编码审批人的请假流程就跑通了。部门主管是谁、总经理是谁,全部由approverCalculateService在流程运行过程中实时计算;就算明天组织架构变了,只要数据库里的映射关系更新,新流程实例会自动适配,不用改一行 Java 代码。
7. 这些坑我替你踩过了:动态审批人的实战注意事项
7.1 审批人为空导致流程静默卡死
这是动态审批人配置里最隐蔽、最致命的坑。前面我提到过表达式解析结果为 null 时,任务会被创建出来但没有 assignee,这类任务不会出现在任何人的待办列表里。更麻烦的是 Flowable 本身不会报错,整个流程看起来像是在正常运行,实际上已经卡死了。
我的规避方案有三个层次:
- 在 Bean 方法里主动判空并抛异常,让错误在任务创建阶段就暴露。
- 在服务任务计算完之后检查
nextApprover变量,为空时直接抛FlowableException。 - 在操作层面,每次流程启动时打日志记录关键流程变量,方便事后追溯。
7.2 表达式解析范围:别踩到变量不存在的坑
UEL 表达式解析时,如果引用的变量在流程实例中不存在,不同版本的表现不一样。有些版本返回 null,有些版本直接抛异常。最稳妥的做法是:凡是表达式里要用的变量,在流程启动入口就统一 put 进去,即使初始值是空字符串,也不要出现“变量不存在”的情况。
另外,条件网关里的表达式用的是${leaveDays > 3},这里有个隐晦的坑:如果leaveDays从表单传进来是字符串类型,而条件里用的是数字比较,Flowable 在解析时会做类型转换,但转换失败会直接报错。我建议流程变量尽量用 Java 基本类型的包装类来传递,避免使用字符串再解析。
7.3 监听器里做业务查询的性能隐患
这个坑可能等你的流程量大了之后才会遇到。任务监听器的create事件是在事务内执行的,而且和任务创建处于同一条数据库事务中。如果你在监听器里做了大量业务查询、甚至调用外部接口,会明显拉长任务创建的时间,严重时还会因为事务长时间不提交导致数据库连接池耗尽。
有一次我们线上出现了流程实例创建变慢的现象,排查到最后发现就是执行监听器里调了一个外部审批规则接口,平均耗时 2 秒多。后来我把这个调用改成异步:监听器里只把需要的参数写进流程变量,真正的规则计算放到 MQ 消费端去做,耗时问题就解决了。
另外还要注意:任务监听器内部抛出的所有异常都会导致任务创建失败,进而导致整个流程实例回滚。如果你的监听器要做一些非关键的操作(比如发通知),一定要用 try-catch 包住,避免因为通知失败影响了核心的流程推进。
7.4 候选人配置的“隐形规则”
最后提一嘴候选人的问题。有些业务场景下不指定唯一审批人,而是允许一个角色组的多个成员去认领任务。这种情况下flowable:assignee就不适用了,要改用候选人的方式:
<userTask id="hrApprove" name="HR审批" flowable:candidateUsers="${hrUsers}" flowable:candidateGroups="hrGroup"/>candidateUsers接收逗号分隔的用户ID字符串,candidateGroups接收组标识。任务创建后,这些用户和组里的成员都能在待办列表里看到这个任务,谁先认领谁处理。认领后需要调用taskService.claim(taskId, userId),处理完再complete。
这套机制在动态审批人方案里经常作为兜底策略:查不到唯一审批人的时候,就把任务推给一个候选组,由组内人员自行认领,避免流程卡死。我在项目里是把它和表达式方案搭配使用的——表达式负责算出首选审批人,算不出来时走候选人兜底,两边互补。
回到开头那个事故。如果当时那套请假流程的审批人配置用的是动态方案,部门经理离职这件事根本不会造成线上故障——新经理在系统里一配,新流程自动走新审批人,老流程顶多手动指定一次代理人也能继续跑。这就是动态审批人配置存在的意义,它把“人员变动”这个业务常态,从流程引擎的维护工作中彻底摘了出去。如果你正在规划新项目的工作流模块,或者正打算重构一套写死的审批流程,建议就从今天讲的方案里挑一种先在自己的项目里落地。哪怕先改一个节点,走通之后你会回来感谢当初做这个决定的自己。