简介:一套基于Struts2与jBPM的请假审批工作流入门实例,面向Java Web初学者与流程管理学习者,完整演示员工提交请假申请、主管审批、老板终审的多角色闭环,帮助理解工作流任务分配与状态流转。资源共42个文件,约51KB,包含12个XML配置文件(jBPM流程定义leave.jpdl.xml、struts.xml映射、jbpm.cfg.xml、Hibernate配置等)、8个JSP页面(登录、请假申请、各级审批视图)、3个Java源文件与3个class(处理业务逻辑和数据库交互),以及properties属性文件、项目工程描述与.classpath等,目录结构完整、便于按模块查阅。已有252人学习下载。通过该实例可掌握jBPM流程节点和转移的配置方法、Struts2 Action与拦截器的配合使用,并解决常见中文乱码过滤问题,也可借鉴其角色登录与权限区分思路,适合作为课程设计或企业流程开发前的练手项目。
1. 工作流请假实例:从一个审批乱象说起
某公司行政把请假流程在邮件里来回转发了五轮,月底核对考勤时发现三个人的假单根本没走完——主管批了,经理没看到;经理批了,人事没备案。后来我把这套流程抽成了一个工作流请假实例,用工作流引擎把"员工提交→部门主管审批→总监审批→人事备案"固化成流程定义,每个节点谁处理、处理多久、走哪个分支都变成可查询的数据。它解决的不只是请假审批,而是所有"多人多级、按条件跳转"的审批场景。适合两类人:刚接触工作流引擎的开发者,和要在内部系统里快速落地审批模块的从业者,拿到后直接改节点、改表单就能跑。
2. 流程定义拆解:BPMN文件里的节点、网关与变量约定
2.1 请假流程的BPMN骨架:从提交到备案的五个必备节点
一个能跑起来的请假流程,流程定义文件是第一关。这个资源的核心就是那份BPMN 2.0的XML定义,它描述的不是代码逻辑,而是流程本身:从哪里开始、哪些人审批、条件满足走哪条线、最终在哪里结束。下面这段是一个典型的请假流程骨架,按照这个资源的原始结构做了精简:
<?xml version="1.0" encoding="UTF-8"?> <definitions id="leaveProcess" xmlns="http://www.omg.org/spec/BPMN/20100524/MODEL" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:flowable="http://flowable.org/bpmn" targetNamespace="http://flowable.org/bpmn20"> <process id="leaveProcess" name="请假流程" isExecutable="true"> <startEvent id="startLeave" name="提交申请"> <extensionElements> <flowable:formProperty id="employeeName" name="员工姓名" type="string" required="true"/> <flowable:formProperty id="startDate" name="开始日期" type="date" required="true"/> <flowable:formProperty id="days" name="请假天数" type="long" required="true"/> <flowable:formProperty id="reason" name="请假原因" type="string"/> </extensionElements> </startEvent> <userTask id="deptManagerApprove" name="部门主管审批" flowable:assignee="deptManager"/> <userTask id="directorApprove" name="总监审批" flowable:assignee="director"/> <userTask id="hrConfirm" name="人事备案" flowable:assignee="hrUser"/> <exclusiveGateway id="needDirectorGate" name="是否总监审批"/> <exclusiveGateway id="endGate" name="流程结束"/> <sequenceFlow id="flowStartToDept" sourceRef="startLeave" targetRef="deptManagerApprove"/> <sequenceFlow id="flowDeptToGate" sourceRef="deptManagerApprove" targetRef="needDirectorGate"> <conditionExpression xsi:type="tFormalExpression"> <![CDATA[${passed == true}]]> </conditionExpression> </sequenceFlow> <sequenceFlow id="flowGateToEnd" sourceRef="needDirectorGate" targetRef="endGate"> <conditionExpression xsi:type="tFormalExpression"> <![CDATA[${needDirector == false}]]> </conditionExpression> </sequenceFlow> <sequenceFlow id="flowGateToDirector" sourceRef="needDirectorGate" targetRef="directorApprove"> <conditionExpression xsi:type="tFormalExpression"> <![CDATA[${needDirector == true}]]> </conditionExpression> </sequenceFlow> <sequenceFlow id="flowDirectorToHr" sourceRef="directorApprove" targetRef="hrConfirm"/> <endEvent id="endLeave" name="结束"/> <sequenceFlow id="flowHrToEnd" sourceRef="hrConfirm" targetRef="endLeave"/> </process> </definitions>注意,这里的endGate到endLeave的线没有画全,实际使用时需要把endGate接到endLeave,或者干脆省掉endGate直接用条件流汇聚到hrConfirm。这个资源里采用的是排他网关汇聚再结束的写法,保留这个结构是为了让新手看清排他网关既是分支也是汇聚的用法。
参数说明:
id必须保持唯一且稳定,部署到引擎之后,后续版本升级、流程实例追溯都依赖这个id。踩过把id改了导致新老流程对不上的坑。flowable:assignee指定的是任务的办理人,可以写死,也支持${deptManager}这种表达式取值,后面会讲到动态办理人。isExecutable="true"不能漏,漏掉之后流程部署不会报错,但启动时引擎会找不到可执行的流程定义,这是最容易翻车的一个默认定值。formProperty定义了启动表单的字段,但它只作用于开始事件,启动后这些字段不会自动变成流程变量,需要手动传递到运行时变量里,很多初学者在这里卡住。
2.2 网关与条件表达式:用流程变量控制分支走向
请假流程最核心的业务规则是"请假天数不同,审批链不同"。天数小于3天时,主管审批完直接人事备案;大于等于3天,还要总监审批。这个规则在流程定义里不是用代码写的,而是用排他网关加条件表达式实现的。条件表达式必须返回布尔值,放在sequenceFlow上:
<sequenceFlow id="flowDeptToGate" sourceRef="deptManagerApprove" targetRef="needDirectorGate"> <conditionExpression xsi:type="tFormalExpression"> <![CDATA[${passed == true}]]> </conditionExpression> </sequenceFlow>每个条件分支都要写独立的条件。我一般会把所有可能的分支条件都列全,并在排他网关上设置default流向兜底,防止变量缺失时流程走进死胡同。default的写法是在exclusiveGateway标签上加default="flowGateToEnd",这样即使needDirector变量没有设置,也会走默认分支。实际资源里的示例没有default,复现时我都会补上,这是一个很实用的防御习惯。
流程变量怎么来?启动实例时通过Map<String, Object>传入,审批时也能追加或覆盖。关键约定是:条件表达式里引用的变量名必须和Map里的key完全一致,大小写敏感,而且类型要匹配——${days >= 3}要求days是数字类型,如果传进来的是字符串"3",条件判断会直接抛异常。这个坑在后面避坑章节会展开。
2.3 会签与驳回的建模:多实例与并行网关的差异
请假超过七天,某些公司会要求部门内全员会签或几名负责人共同审批。这种场景要用会签,BPMN里的标准做法是多实例活动。多实例分串行和并行两种,请假场景通常用并行,表示几个人不分先后同时审。多实例配置的核心是集合来源和完成条件:
<userTask id="deptMultiApprove" name="多人会签" flowable:assigneeList="${assigneeList}"> <multiInstanceLoopCharacteristics isSequential="false" flowable:collection="${assigneeList}" flowable:elementVariable="assignee"> <completionCondition> ${nrOfCompletedInstances == nrOfInstances} </completionCondition> </multiInstanceLoopCharacteristics> </userTask>flowable:collection指定参与会签的人员列表,来自流程变量assigneeList,必须是Collection类型。flowable:elementVariable是循环变量名,每个实例进入时会自动赋值,任务办理人可以用flowable:assignee="${assignee}"来绑定当前实例的办理人。completionCondition是完成条件,最常用的是所有实例都完成,也可以写成80%的人同意就通过。isSequential="false"表示并行,true表示串行——串行会签适合"多人依次审批"的场景,比如逐级审批。
会签节点之后的汇聚逻辑经常被搞错。默认情况下,多实例节点会对所有实例做隐式汇聚,也就是说所有人都完成之后,token才继续往下走。如果后面再接一个排他网关,网关的流入会来自多个实例,需要保证同一时刻只有一个token进入网关,否则会触发"无法执行"的报错。我的做法是:会签节点之后尽量直接接普通任务或结束事件,非要接网关的话,在网关上做显式汇聚校验。
驳回重做也是请假流程里的高频需求。官方推荐的做法不是回退任务,而是用ChangeActivityStateBuilder把执行实例迁移回之前的某个userTask。我会在流程定义里专门放一个"重新提交"的userTask,通过监听器把驳回原因写入流程变量,再迁移执行实例到该节点。这样做的好处是历史记录里保留了完整的审批轨迹,而不是把任务删掉重来。
2.4 排他网关与并行网关的选型差异
请假场景里,"总监是否审批"是互斥分支,用排他网关;如果存在"主管审批的同时,人事同步做考勤预检"这种并发需求,才用并行网关。选型的差别主要在token的处理上:排他网关只激活满足条件的第一个分支;并行网关会同时激活所有分支,且必须等所有分支都到达汇聚点才能继续。下表可以帮你快速决定用哪个:
| 网关类型 | 适用场景 | token行为 | 汇聚要求 |
|---|---|---|---|
| 排他网关 | 按条件选择一条路径 | 只走第一个满足条件的分支 | 一个token进入即可 |
| 并行网关 | 多个环节同时发生 | 所有分支同时激活 | 所有分支token到齐才继续 |
| 包含网关 | 满足条件的多个分支都走 | 激活所有满足条件的分支 | 所有已激活分支到齐才继续 |
这个资源里的请假流程只用到了排他网关,但代码包中补了一个包含网关的案例,用来演示"主管审批通过且人事预检通过才继续"的复杂分支场景。新手容易把"会签"和"并行网关"搞混:会签解决的是多人审同一件事,并行网关解决的是多件事同时推进,本质是两码事。
3. 把流程定义跑起来:引擎配置与核心API调用
3.1 依赖引入与数据源配置:让引擎先长出表结构
流程定义文件只是图纸,真正让流程跑起来的是引擎。这个资源基于Flowable引擎构建,整个工程是标准的Spring Boot项目。第一步是引入引擎依赖,我的做法是在pom里加上以下核心依赖:
<dependency> <groupId>org.flowable</groupId> <artifactId>flowable-spring-boot-starter</artifactId> <version>6.x</version> </dependency>如果不使用Spring Boot,可以改用flowable-engine包,但需要自己管理事务和配置类,我一般不建议新手这么干,Spring Boot Starter把引擎配置和事务集成都做好了,省去很多初始化代码。
数据源配置决定了引擎的表建在哪里。Flowable引擎启动时会检查数据库结构,如果database-schema-update配置得当,它会自动创建ACT_开头的一系列表。下面是这个资源里用的application.yml配置:
spring: datasource: url: jdbc:mysql://localhost:3306/flowable_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver flowable: database-schema-update: true async-executor-activate: falsedatabase-schema-update: true:开发期自动建表和升级表结构,生产环境要改成false或使用专门的迁移脚本,否则引擎版本升级时可能自动改表,影响线上稳定性。async-executor-activate: false:关闭异步执行器。这个开关很重要,如果项目里没有用到定时任务或异步消息,开着反而会让后台线程空转,遇到过它把数据库连接池占满的情况。- 数据库字符集务必用utf8mb4,不然流程变量里写入中文名称时,某些MySQL版本会报"Incorrect string value"错误。
3.2 部署、启动、审批三个核心动作的代码与参数
引擎建好之后,代码侧只需要和几个核心Service打交道:RepositoryService管流程定义,RuntimeService管流程实例,TaskService管任务。以下三段代码是这个资源里最核心的业务操作:部署、启动、完成审批。
@Autowired private RepositoryService repositoryService; public void deployLeaveProcess() { Deployment deployment = repositoryService.createDeployment() .addClasspathResource("processes/leave.bpmn20.xml") .name("请假流程v1") .category("oa") .deploy(); String deploymentId = deployment.getId(); System.out.println("部署成功,部署ID: " + deploymentId); }部署动作把BPMN文件解析成引擎内部可执行的流程定义,category用于按业务域分类,查流程定义时可以用repositoryService.createProcessDefinitionQuery().processDefinitionCategory("oa")过滤。部署一次生成一个版本,同id的流程定义重复部署,版本号会自增,这个版本管理机制是工作流引擎的天然优势,后面进阶章节会用它做灰度。
@Autowired private RuntimeService runtimeService; public ProcessInstance startLeave(String employeeId, String startDate, long days, String reason) { Map<String, Object> variables = new HashMap<>(); variables.put("employeeId", employeeId); variables.put("startDate", startDate); variables.put("days", days); variables.put("reason", reason); variables.put("passed", false); variables.put("needDirector", days >= 3); return runtimeService.startProcessInstanceByKey( "leaveProcess", employeeId + ":" + startDate, variables ); }启动流程时传的businessKey我习惯把员工工号和日期拼在一起,这样之后按业务Key反查流程实例非常快,runtimeService.createProcessInstanceQuery().processInstanceBusinessKey(employeeId + ":" + startDate)就能精确命中。流程变量全部塞在这个Map里,网关条件表达式读的就是这批变量。注意needDirector这个变量在启动那一刻就根据请假天数算好了,后续审批环节不会再重新计算——如果你希望审批人修改后再决定走哪个分支,就不要在启动时设置,而是放到complete方法里传。
@Autowired private TaskService taskService; public void completeDeptTask(String taskId, boolean approved) { Map<String, Object> variables = new HashMap<>(); variables.put("passed", approved); variables.put("needDirector", false); taskService.complete(taskId, variables); }complete是审批动作的本质:给当前任务传入变量,然后把任务结束,token顺着连线往下走。这里有个非常隐蔽的行为逻辑:传入的变量不仅用于本节点的网关判断,还会存进流程实例级变量池,也就是说后续所有节点都能读到这份变量。所以needDirector在这个审批动作里被重新赋值,是为了让流程真正往人事备案而不是总监审批走,这是演示代码里刻意设置的"二次决策"效果。
3.3 查询待办与历史:TaskService和HistoryService的常用调用
日常使用中,除了"发起"和"审批",最常被调用的就是"我的待办"和"流程历史"。
@Autowired private HistoryService historyService; public List<HistoricTaskInstance> myTodo(String assignee) { List<Task> todoTasks = taskService.createTaskQuery() .taskAssignee(assignee) .active() .orderByTaskCreateTime().desc() .list(); List<HistoricTaskInstance> historyTasks = historyService.createHistoricTaskInstanceQuery() .taskAssignee(assignee) .finished() .orderByHistoricTaskInstanceEndTime().desc() .list(); // todoTasks是待办,historyTasks是已办 return historyTasks; }taskQuery查的是运行态任务,只对未完成节点有效;historicTaskInstanceQuery查的是历史任务,已完成节点的处理人、开始时间、结束时间、审批结论都在里面。待办查询必须加active()过滤,否则会把候选组任务也带出来,导致"不是我的活却显示在我的待办里"的问题。这个资源里把待办和已办分成了两个查询方法,改造时我加了分页参数,因为数据量上来后全量list会撑爆内存。
3.4 用户与组的挂接:IdentityService的演示配置
流程定义里写的是flowable:assignee="deptManager",但真实系统里deptManager不是一个具体的人名。引擎不负责管理业务用户,只负责把用户标识关联到任务上。演示环境里最简单的方案是用IdentityService直接造用户和组:
@Autowired private IdentityService identityService; public void initUsers() { User deptManager = identityService.newUser("deptManager"); deptManager.setEmail("dept.manager@example.com"); identityService.saveUser(deptManager); User hrUser = identityService.newUser("hrUser"); hrUser.setFirstName("人事"); hrUser.setLastName("专员"); identityService.saveUser(hrUser); Group deptGroup = identityService.newGroup("deptLeader"); deptGroup.setName("部门负责人组"); identityService.saveGroup(deptGroup); }注意identityService.saveUser只是把用户记录存进引擎的ACT_ID_表,并不会和项目自身的用户体系打通。实际生产项目中,我的做法是不用引擎的用户表,而是把业务用户ID直接作为assignee的值,这样流程里的assignee和业务系统的用户主键一一对应,省掉同步数据的额外维护工作。这个资源自带的演示脚本用的是后者——直接把业务用户工号放进assignee,简单直接。
4. 完整跑通一次请假:从接口封装到按角色操作
4.1 用REST接口封装流程入口
当流程定义、引擎配置、核心Service都就绪后,下一步是把流程能力暴露成REST接口。这个资源没有把前端页面一并打包,而是提供了接口层的完整封装,前端只需要对着接口调。按资源里的接口设计,整理了一个最简Controller:
@RestController @RequestMapping("/leave") public class LeaveController { @Autowired private RuntimeService runtimeService; @Autowired private TaskService taskService; @PostMapping("/start") public ProcessInstance start(@RequestBody LeaveRequest request) { Map<String, Object> variables = new HashMap<>(); variables.put("employeeId", request.getEmployeeId()); variables.put("startDate", request.getStartDate()); variables.put("days", request.getDays()); variables.put("reason", request.getReason()); variables.put("needDirector", request.getDays() >= 3); return runtimeService.startProcessInstanceByKey( "leaveProcess", request.getEmployeeId() + ":" + request.getStartDate(), variables ); } @GetMapping("/tasks") public List<Map<String, Object>> tasks(@RequestParam String assignee) { return taskService.createTaskQuery() .taskAssignee(assignee) .active() .orderByTaskCreateTime().desc() .list() .stream() .map(task -> { Map<String, Object> map = new HashMap<>(); map.put("taskId", task.getId()); map.put("taskName", task.getName()); map.put("createTime", task.getCreateTime()); map.put("processInstanceId", task.getProcessInstanceId()); return map; }) .collect(Collectors.toList()); } @PostMapping("/complete") public void complete(@RequestBody CompleteRequest request) { Map<String, Object> variables = request.getVariables(); taskService.complete(request.getTaskId(), variables); } }接口设计上,/start接收前端表单数据并组装流程变量;/tasks按办理人回传待办列表;/complete接收任务ID和动态变量集合。变量集合用Map<String, Object>接,让前端可以在审批时追加"审批意见""是否通过"等自定义字段,而不用改后端代码。这种设计在流程引擎的落地方案中非常常见,因为它把流程定义的重心放在了BPMN文件层,业务字段的扩展全部交给变量池,代码层保持稳定。
需要特别注意前端传参的类型:很多表单组件会把数字字段提交成字符串,比如days="4"而不是days=4。如果后端不做类型转换就塞进流程变量,网关表达式${days >= 3}会直接报类型不匹配异常。我在改造这个资源时,专门在/start接口里加了一步Long.valueOf(request.getDays())转换,这是前端协作中很常见的一个翻车点。
4.2 按角色完整走一遍请假链路
资源自带的演示脚本,按角色顺序整理了一张操作清单,照着调就能把整个流程从头走到尾。假设员工工号是EMP001,请假4天,走"需总监审批"的分支:
| 顺序 | 角色 | 操作 | 关键参数 | 预期结果 |
|---|---|---|---|---|
| 1 | 员工 | 调用/leave/start | employeeId=EMP001, days=4 | 流程实例启动,主管收到待办 |
| 2 | 主管 | 调用/leave/tasks?assignee=deptManager | 查到taskId | 待办列表出现"部门主管审批" |
| 3 | 主管 | 调用/leave/complete | taskId=上一步值, passed=true | 流程走到总监审批节点 |
| 4 | 总监 | 调用/leave/tasks?assignee=director | 查到taskId | 待办列表出现"总监审批" |
| 5 | 总监 | 调用/leave/complete | taskId=上一步值 | 流程走到人事备案节点 |
| 6 | 人事 | 调用/leave/tasks?assignee=hrUser | 查到taskId | 待办列表出现"人事备案" |
| 7 | 人事 | 调用/leave/complete | taskId=上一步值 | 流程实例结束 |
注意第3步传passed=true时,流程定义的网关判断会读取到passed变量,同时我在complete请求里把needDirector也传成了true(因为days=4),所以流程会走到总监审批;如果days改成2,就跳过总监直接到人事备案。整个过程可以在数据库的ACT_HI_PROCINST里查询到完整的生命周期,END_TIME_有值时表示流程已结束。
提示:上面步骤序号对应流程定义的节点顺序,如果改了BPMN文件里的网关条件或节点id,步骤顺序和参数也要同步调整,否则接口层会出现"查不到任务""走错分支"的连锁问题。
4.3 观察点:流程实例、活动节点与变量变化
跑通流程之后,建议用以下查询来验证流程行为是否符合预期。复现这个资源时,每完成一步审批都会执行一次这个查询,用来确认token的走向和变量变化:
-- 查询流程实例当前状态 SELECT ID_, PROCESS_DEF_ID_, BUSINESS_KEY_, ACTIVITY_ID_, IS_ACTIVE_ FROM ACT_RU_EXECUTION WHERE PROCESS_INSTANCE_ID_ = '2501'; -- 查询完成过的历史任务节点 SELECT ID_, NAME_, ASSIGNEE_, START_TIME_, END_TIME_ FROM ACT_HI_TASKINST WHERE PROC_INST_ID_ = '2501' ORDER BY START_TIME_;ACT_RU_EXECUTION里的ACTIVITY_ID_表示当前流程执行到哪个节点,分支和汇聚都会生成新的执行实例,所以看到多行不要慌,要结合IS_ACTIVE_来区分活跃token。历史任务表则记录了完整的人事轨迹,ASSIGNEE_字段可以回溯每一步是谁处理的。这是排查"流程卡住"或"某节点没触发"的最直接入口,比看日志快得多。
变量排查也有对应的SQL:SELECT ID_, NAME_, TEXT_, LONG_ FROM ACT_RU_VARIABLE WHERE PROC_INST_ID_ = '2501',用来确认变量名是否拼错、类型是否正确。我之前遇到过函数里写的是needDirector,SQL查出来的却是need-director,这种隐藏的拼写差异很难用肉眼发现,全靠变量表兜底。
5. 请假流程运行中的五个常见坑:现象、原因与处理
5.1 部署成功但启动报"找不到流程定义"
现象:部署接口返回成功,流程定义列表也能查到,但一调用startProcessInstanceByKey就抛ProcessDefinitionNotFoundException。
原因:启动时用的流程Key和部署的BPMN文件中<process id="...">的值不一致,或者isExecutable="true"缺失导致定义被解析成非可执行模型。另一个高频原因是指定了错误的资源路径,addClasspathResource("processes/leave.bpmn20.xml")路径写错时部署会静默失败,但流程定义查询接口不会报错。
解决:用repositoryService.createProcessDefinitionQuery().latestVersion().list()打印出所有定义,确认key字段和启动参数严格一致。部署后立刻查ACT_RE_PROCDEF表,重点看KEY_和VERSION_字段。从那以后每次部署完都会顺手跑一遍这个查询,从不例外。
5.2 流程走到网关后纹丝不动
现象:主管审批通过后,流程实例没有继续前进,ACT_RU_EXECUTION里看到节点停在网关,日志无异常但也没有生成新任务。
原因:排他网关的所有流出线条件都不满足,且网关没有配置default流向。最常见的情况是条件表达式写的${passed == true},但complete时传入的变量名是pass或isPass,变量名不匹配导致表达式得到null,null参与布尔判断时引擎会认为条件不成立。
解决:设置default流向兜底,并保证变量名大小写和拼接逻辑完全一致。建议在同一个事务里先taskService.getVariable(taskId, "passed")确认变量已写入,再执行complete,这个小小的检查能省掉大量排错时间。
5.3 审批人换岗后,待办成了"黑洞"
现象:流程定义里assignee写死为某个用户ID,该用户离职后,他名下的待办任务没有任何人能处理,管理员手动改assignee又找不到批量修改的入口。
原因:assignee是静态绑定,写死在BPMN文件里。如果业务系统的人员变动频繁,静态assignee必然产生孤儿任务。
解决:把办理人从静态值改成动态表达式,比如flowable:assignee="${deptManager}",在启动流程时从业务系统查询当前部门负责人并放入流程变量。如果需要多人可处理,还要配合候选组flowable:candidateGroups="${deptGroup}"并用taskService.claim(taskId, userId)签收。这个改造在实际项目中几乎必做,该资源里的演示是静态值,不等于生产环境可以照抄。
5.4 会签任务卡死或提前"放行"
现象:配置了多实例会签后,第一个审批人完成任务,整个会签节点就直接通过了,后面的人根本不需要审批;或者反过来,所有人都完成了,流程还停在会签节点。
原因:会签的完成条件completionCondition配置有误。提前放行是因为写成了${nrOfActiveInstances == 0},而该值在第一个实例完成时就已经是0;卡死是因为完成条件引用了不存在的变量,导致引擎每次实例完成时都判断条件为false。
解决:统一使用${nrOfCompletedInstances == nrOfInstances}作为默认完成条件,表示"全部完成才通过"。如果有"多数通过"的需求,写成${nrOfCompletedInstances >= (nrOfInstances / 2) + 1}。同时确认flowable:collection关联的变量确实是List类型,而不是单个字符串,否则引擎会抛FlowableIllegalArgumentException。
5.5 流程历史越查越慢,表数据膨胀
现象:运行几个月后,ACT_HI_TASKINST和ACT_HI_PROCINST达到百万行级别,待办查询和历史查询响应时间从毫秒级退化到秒级。
原因:Flowable默认把所有历史数据都写进历史表,没有清理策略。历史表对线上查询是只读的,但表数据量过大会拖慢所有带历史表join的查询。
解决:根据业务合规要求配置历史清理策略。这个资源里补了一份flowable.app.cleanInstances清理任务,用HistoryService按结束时间分批删除历史实例。注意删除历史前要确认业务系统没有引用这些流程数据,实操中都是先备份再删除,而且删除操作放到凌晨低峰期执行。一次删除的批次大小建议控制在500条以内,避免一次性加载过多实例导致内存溢出。
6. 进阶:流程实例的退回重做、版本升级与验证方法
6.1 用ChangeActivityStateBuilder实现驳回重做
流程走到总监审批,总监发现请假天数算错,需要退回给员工修改。传统做法是直接删除当前任务,但这会丢历史记录,审计时说不清。引擎提供的正确姿势是迁移执行实例:
@Autowired private RuntimeService runtimeService; public void rejectToStart(String processInstanceId) { runtimeService.createChangeActivityStateBuilder() .processInstanceId(processInstanceId) .moveActivityIdTo("directorApprove", "startLeave") .changeState(); }moveActivityIdTo把执行实例从directorApprove迁移回startLeave,流程会重新创建提交申请的任务。迁移前要清理当前节点产生的局部变量,否则回到起点后,提交新表单会带着旧数据。在跑这个资源时我加了一段清理逻辑:迁移前先runtimeService.getVariables(processInstanceId)过滤掉审批意见类字段再重新设置,避免员工重新提交时看到"总监已通过"这种过期数据。
6.2 版本升级后,新旧实例分道扬镳
流程规则调整是常态,比如"请假3天以上需要总监审批"改成"5天以上"。直接修改BPMN重新部署后,引擎会生成新版本,但正在运行的旧实例不会自动切到新版本——这是引擎的安全机制,也是很多新手误解的地方:以为改了流程定义,所有在途流程都会按新规则走。实际上,在途流程实例依旧按旧版本执行,只有新发起的流程才会用新版本。
验证版本行为的方法很简单:部署新版后,分别用businessKey查询新旧两个流程实例的PROC_DEF_ID_,确认它们指向的版本号不同;再用历史数据把两个实例的节点轨迹拉出来对比,就能看到旧实例走的还是旧网关条件。需要让在途实例也按新规则跑,只能迁移:把旧实例迁移到新版本的当前活动节点,或者在接受业务数据不一致的前提下对在途实例做特殊处理。我的实践是:规则变更时优先让新老并存,给业务方一个缓冲期,而不是一刀切地迁移所有旧实例。
6.3 一套轻量级的流程校验习惯
因为这个资源的流程定义是可以随意修改的BPMN,改模型容易改错逻辑,所以我养成了一个习惯:每次改完流程定义,不急着部署,先跑一段模型校验代码:
@Autowired private RepositoryService repositoryService; public void validateModel() { List<ProcessDefinition> definitions = repositoryService .createProcessDefinitionQuery() .latestVersion() .list(); for (ProcessDefinition pd : definitions) { BpmnModel model = repositoryService.getBpmnModel(pd.getId()); List<ValidationError> errors = repositoryService .validateProcess(model); if (!errors.isEmpty()) { for (ValidationError error : errors) { System.out.println(error.getActivityName() + " - " + error.getProblem()); } } } }validateProcess会检查XML结构、节点连线、表达式语法,但不会校验业务规则本身,比如网关分支条件是否会被变量覆盖。所以我在校验之外还加了一条自查线:把每个网关的所有分支条件写成单元测试,用不同的流程变量组合去启动流程,断言走到的节点是否符合预期。从那以后我每次重构流程定义,都强制走一遍这两个步骤——模型校验加分支覆盖测试,这个习惯帮我挡掉了不少生产翻车。希望帮到你。
本文还有配套的精品资源,点击获取