☰
Flowable请假流程实战:从BPMN建模到审批流转与踩坑避坑指南
2026/10/12 2:45:09 网站建设 项目流程

简介:这是一份基于Struts2与jBPM的请假审批工作流实例,主要面向Java Web初学者和希望掌握工作流落地方法的开发者。压缩包内共42个文件,整体仅51KB,包含12个XML配置文件、8个JSP页面、3个Java源码与3个class文件,以及properties配置、项目元数据和示意图,覆盖了struts.xml、jbpm.jpdl.xml等关键配置文件,导入Eclipse即可查看完整工程结构。已有252人学习下载。实例完整呈现员工提交、主管审批、老板复核的请假链路,通过Action类处理业务请求,结合jBPM流程定义与部署,并详细演示了Struts2中过滤中文乱码的代码级解决方案;同时附带数据库脚本和测试用例,便于理解各层协作。对想用工作流改造管理系统的开发者来说,这是一个紧凑型参考样例,既能快速跑通流程,又能作为二次改造的起点。读者还可以借此理清工作流引擎与MVC框架的整合思路,掌握从流程设计、Action编写到持久化配置的完整步骤,适合作为课程设计或入职预热的实战素材。

1. 工作流请假实例:看似最简单的流程,为什么总在真实项目里翻车

几乎每个团队第一次接触工作流引擎,都是从工作流请假实例开始的。它看起来足够简单:发起、审批、结束,三张表单就能说清楚。但真正落地时,网关条件、流程变量、会签驳回、多人审批全藏在“审批”两个字后面。我见过不少项目把请假实例跑通后,上线不到两周就被各种边界情况缠住:审批人看不到待办、请假两天走了八天的审批链、同一人身兼两级职位时出现重复待办。这些不是引擎的玄学,而是流程实例的运行机制决定的。

这篇文章用一个可复现的 Flowable 请假流程,把建模、部署、启动、审批到踩坑的完整链路梳理一遍。你会看到请假三天和请假八天在流程图里并不是一条直线;也会看到改完流程定义后,运行中的老实例并不会自动升级。适合正在做选型、或者已经接入了工作流引擎但被流程细节反复折磨的开发者。读完你不仅能跑通一个请假实例,还能避开最常见的几个翻车点。

2. 先把流程画出来:Flowable 选型与请假流程的 BPMN 建模

2.1 三个引擎怎么选:Flowable、Activiti 与 Camunda 的取舍

请假流程本身不复杂,但选错引擎,后面每一步都在还债。国内项目里最常见的三个开源工作流引擎是 Flowable、Activiti 和 Camunda,它们都支持 BPMN 2.0 标准,流程定义文件可以互相迁移,但在集成方式和维护节奏上有明显差异。

引擎血缘社区状态适合场景
Flowable由 Activiti 5/6 分支演化而来发版稳定,Spring Boot 集成资料最多大多数企业系统的默认选择
Activiti早期开源流程引擎7.x 转向云原生路线,社区版更新放缓存量项目维护为主
Camunda核心成员另起炉灶商业产品线完善,社区版功能也开放重视流程可视化和运维治理的团队

我的习惯是:没有特殊诉求就直接用 Flowable。原因不是它比另外两个强多少,而是遇到问题能搜到的案例最多,团队里随便一个后端都能上手。流程引擎这种基础组件,选最主流的那条路本身就是降低风险。Camunda 的可视化确实好,但商用版授权成本需要认真评估;Activiti 老项目里存量很多,新项目没必要赌它的更新节奏。

2.2 请假流程的 BPMN 定义:请假三天和请假八天为什么走不同的节点

先定业务规则,再画流程图。这里我给出一个典型的规则组合:请假天数小于等于 3 天,只需要主管审批;大于 3 天需要主管审批加部门经理审批;大于 7 天再加一道 HR 备案。流程文件名字叫leave-process.bpmn20.xml,放在src/main/resources/processes/下。

<?xml version="1.0" encoding="UTF-8"?> <definitions 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://example.com/leave"> <process id="leaveProcess" name="员工请假流程" isExecutable="true"> <startEvent id="startEvent" name="发起请假"/> <userTask id="applyTask" name="填写请假申请" flowable:assignee="${applyUser}"/> <userTask id="managerApprove" name="主管审批" flowable:assignee="${manager}"/> <exclusiveGateway id="gwDept" name="是否需要部门经理审批"/> <userTask id="deptApprove" name="部门经理审批" flowable:assignee="${deptManager}"/> <exclusiveGateway id="gwHr" name="是否需要HR备案"/> <userTask id="hrRecord" name="HR备案" flowable:assignee="${hrUser}"/> <endEvent id="endEvent" name="结束"/> <sequenceFlow id="sf1" sourceRef="startEvent" targetRef="applyTask"/> <sequenceFlow id="sf2" sourceRef="applyTask" targetRef="managerApprove"/> <sequenceFlow id="sf3" sourceRef="managerApprove" targetRef="gwDept"/> <sequenceFlow id="sf4" sourceRef="gwDept" targetRef="endEvent"> <conditionExpression xsi:type="tFormalExpression">${leaveDays &lt;= 3}</conditionExpression> </sequenceFlow> <sequenceFlow id="sf5" sourceRef="gwDept" targetRef="deptApprove"> <conditionExpression xsi:type="tFormalExpression">${leaveDays &gt; 3}</conditionExpression> </sequenceFlow> <sequenceFlow id="sf6" sourceRef="deptApprove" targetRef="gwHr"/> <sequenceFlow id="sf7" sourceRef="gwHr" targetRef="endEvent"> <conditionExpression xsi:type="tFormalExpression">${leaveDays &lt;= 7}</conditionExpression> </sequenceFlow> <sequenceFlow id="sf8" sourceRef="gwHr" targetRef="hrRecord"> <conditionExpression xsi:type="tFormalExpression">${leaveDays &gt; 7}</conditionExpression> </sequenceFlow> <sequenceFlow id="sf9" sourceRef="hrRecord" targetRef="endEvent"/> </process> </definitions>

这个 BPMN 文件里有几个关键点。process标签的id="leaveProcess"就是后面启动流程用的 key,一旦发布就不能轻易改,因为代码里到处引用它。userTask上的flowable:assignee用的是表达式语法,比如${manager},意思是这个任务由流程变量manager指定的用户来处理。exclusiveGateway叫排他网关,它根据出口条件决定走哪条线,注意条件表达式里的小于号和大于号必须写成&lt;=和&gt;,否则 XML 解析直接报错。

这条链路走下来,2 天假期只经历“填写申请 → 主管审批 → 结束”;5 天假期会经历“主管审批 → 部门经理审批 → 结束”;8 天假期则会在部门经理审批后继续走到 HR 备案。排他网关在这里做的事情,就是把同一个流程实例按业务规则分流到不同的审批链上。

2.3 最小可跑通的部署:Spring Boot 集成与首个部署命令

流程定义文件写好了,接下来要把它部署到引擎里。Flowable 与 Spring Boot 集成的第一步是引入依赖并配置数据源。引擎启动后会自动创建ACT_前缀的一堆表,流程定义、运行中的任务、历史数据都会落到这些表里。

@SpringBootTest class LeaveProcessDeployTests { @Autowired private RepositoryService repositoryService; @Test void deployLeaveProcess() { Deployment deployment = repositoryService.createDeployment() .addClasspathResource("processes/leave-process.bpmn20.xml") .name("员工请假流程-v1") .enableDuplicateFiltering() .deploy(); // 部署完成后,流程定义会落库到 ACT_RE_PROCDEF 表 System.out.println("部署ID: " + deployment.getId()); } }

这段代码把 classpath 下的 BPMN 文件部署进引擎,调用一次就生成一条流程定义记录。addClasspathResource的路径必须和文件实际位置一致;name是给人看的部署名称,建议带上版本号;enableDuplicateFiltering()表示相同内容的资源不重复部署,但它基于资源内容做判断,不是基于流程 key,这一点要留意。

注意:哪怕只改了 BPMN 文件里的一个节点坐标,重新部署也会生成新版本的流程定义。旧版本不会消失,只是新流程实例默认走最新版本。

部署完成后,可以在ACT_RE_PROCDEF表里查到流程定义,KEY_字段是leaveProcess,VERSION_字段是 1。整个部署动作不涉及业务表,引擎只认识你给它的流程定义和变量,这一点在后面排查问题时会反复用到。

3. 发起请假与审批流转:从启动流程到完成任务的核心代码

3.1 启动流程实例:请假表单数据如何放进流程变量

流程定义是模板,每次请假申请都会创建一个流程实例。启动实例时,需要把表单数据放到流程变量里,因为这些变量会驱动网关条件,也会决定每个审批节点的处理人。

// 启动请假流程 Map<String, Object> variables = new HashMap<>(); variables.put("applyUser", currentUserId); // 当前登录人,申请节点处理人 variables.put("leaveDays", 5); // 请假天数,必须是 Integer variables.put("leaveReason", "回老家参加婚礼"); variables.put("manager", managerId); // 主管用户ID variables.put("deptManager", deptManagerId); // 部门经理用户ID variables.put("hrUser", hrUserId); // HR 用户ID ProcessInstance instance = runtimeService.startProcessInstanceByKey( "leaveProcess", businessKey, variables );

startProcessInstanceByKey的三个参数分别是流程定义 key、业务主键和流程变量。businessKey通常放业务表主键,比如请假单 ID,这样以后通过runtimeService.createProcessInstanceQuery().processInstanceBusinessKey()就能直接反查流程实例。leaveDays这里强制用Integer,是因为网关条件里要做数值比较,如果传成字符串,条件判断会得到完全相反的结果。

审批人变量是启动时从组织架构接口查出来的。这里有一个容易被忽略的点:manager、deptManager、hrUser必须和用户表中的用户 ID 完全一致。很多系统的登录用户 ID 是数字主键,但流程表里存的是工号,两边一旦对不上,任务就会变成无人认领的孤儿任务。

3.2 查询待办与完成任务:审批人视角的接口设计

审批人登录系统后,看到的待办列表本质上是查引擎的运行任务表。Flowable 提供了TaskQuery,按当前用户 ID 过滤出所有分配给这个人的任务。

// 查询当前用户的所有待办任务 List<Task> tasks = taskService.createTaskQuery() .processDefinitionKey("leaveProcess") .taskAssignee(currentUserId) .orderByTaskCreateTime() .desc() .list(); // 组装待办卡片时,需要拿到对应的流程变量 for (Task task : tasks) { Map<String, Object> vars = runtimeService.getVariables(task.getProcessInstanceId()); int leaveDays = (Integer) vars.get("leaveDays"); String reason = (String) vars.get("leaveReason"); // 把 reason、leaveDays、task.id 返回给前端渲染 }

这里要小心一个性能问题:getVariables每次都查一次数据库,如果待办列表有几十条,就会产生几十次查询。数据量小的时候无所谓,但到了生产环境,建议一次性查出所有流程实例的变量,或者干脆在业务表里冗余一份请假表单数据,只把processInstanceId留给引擎。

审批人点击“同意”时,调用的核心方法只有一行:

Map<String, Object> completeVars = new HashMap<>(); completeVars.put("approveResult", "agree"); completeVars.put("approveComment", "同意,注意返程安全"); taskService.complete(task.getId(), completeVars);

complete方法会结束当前任务,然后按照 BPMN 的连线规则推动流程继续往下走。completeVars里携带的审批结果会写入流程变量,后续节点和网关条件都可以读取。如果审批人点“不同意”,把approveResult改成"reject"即可,至于这个变量怎么驱动流程走向,取决于流程图里有没有对应的排他网关分支。

3.3 流程变量的边界:哪些数据该进流程,哪些留在业务表

流程变量是引擎里的全局状态,一旦写入,在整个流程实例生命周期内都可以读取。但这不意味着什么数据都往里塞。我见过有人把整个请假表单对象、职级职等 JSON、甚至附件二进制放进去,结果历史变量表迅速膨胀,查一次审批记录慢到无法接受。

我的建议是:流程变量只放驱动流程所需的数据,也就是流程分支条件、节点处理人、业务主键。具体到这个请假实例,核心变量就是leaveDays、applyUser、manager、deptManager、hrUser、approveResult。请假理由、附件、抄送人等业务数据统一存业务表,通过businessKey关联。这样引擎表保持干净,出问题排查也容易。

4. 会签与多级审批:真实组织规则在请假流程里的落地

4.1 部门会签的多实例建模:一个节点让所有人同时审批

请假不一定只有一个人审批。有些公司规定,超过 10 天的长假需要部门全员会签。如果每个审批人画一个用户任务节点,流程图会变得又长又蠢。BPMN 里的多实例(multi-instance)机制就是为这种场景设计的:一个节点,同时为集合里的每个元素生成一个子任务。

<userTask id="deptCountersign" name="部门会签" flowable:assignee="${assignee}"> <multiInstanceLoopCharacteristics isSequential="false" flowable:collection="${assigneeList}" flowable:elementVariable="assignee"> <completionCondition>${nrOfCompletedInstances >= nrOfInstances}</completionCondition> </multiInstanceLoopCharacteristics> </userTask>

这段 XML 的含义是:流程实例到达deptCountersign节点时,引擎读取流程变量assigneeList,它是一个用户 ID 的 List,比如[u001, u002, u003]。引擎会为每个用户创建一个独立的任务,每个任务的处理人分别是u001、u002、u003。completionCondition里的nrOfCompletedInstances表示已完成的任务数,nrOfInstances表示总任务数,当前条件“已完成数大于等于总数”就是所有人都批完,节点才继续往下走。

多实例的参数里,isSequential="false"表示并行会签,所有人同时收到待办;改成true就是串行会签,第一个人批完第二个人才能看到。flowable:collection指向的集合必须在节点创建之前就存在,因为它是在进入节点时一次性快照的,流程跑了一半再往assigneeList里加人不会对当前实例生效。

4.2 多级审批怎么串:规则固化在 BPMN 还是放在外部

多级审批在请假流程里的典型形态是:主管审批完,部门经理审批,再往上可能是总经理。把审批链直接画在 BPMN 里,优点是一目了然、审计方便、每个节点的处理人和规则都固化在流程定义里。缺点也同样明显:公司组织架构一调整,就要改 BPMN 重新部署。

我处理这类问题的习惯是分层:稳定的审批链画进 BPMN,容易变化的审批链放到业务代码里计算。比如“请 3 天以内主管审批,3 到 7 天加部门经理”,这种规则相对稳定,就画成排他网关;而“某些部门需要财务会签”这种临时规则,在启动流程前由 service 层动态决定要经过哪些节点,通过设置跳过变量来控制网关走向。

如果你发现审批链每天变,那说明流程本身还没有稳定下来,这时候应该先和业务方对齐规则,而不是急着改流程定义。流程引擎最怕的不是复杂,而是规则一直动,每次改动都是一次发布,出错的风险会成倍累积。

4.3 或签与一票否决:不同会签完成条件对应的表达式

会签的完成条件不只有“全部通过”这一种。常见的还有两种:或签,也就是一个人同意就继续;一票否决,也就是只要有一个人拒绝,整个流程就终止或驳回。

或签的完成条件改成下面这样:

<completionCondition>${nrOfCompletedInstances >= 1}</completionCondition>

一票否决需要结合每个子任务的审批结果变量来判断:

<completionCondition>${nrOfCompletedInstances >= nrOfInstances || hasReject}</completionCondition>

这里的hasReject是流程变量,需要在每个子任务完成时检测approveResult并动态更新。实现方式通常是在任务监听器里写逻辑:如果审批结果是拒绝,就把hasReject置为true。这样其他还在等待审批的人可能正打开审批页,流程却已经结束了,前端需要做好任务已失效的提示。

注意:多实例节点的完成条件表达式不能写得太复杂,引擎会频繁触发计算。宁可监听器等业务代码多写几行,也别在 XML 里写一长串嵌套条件。

5. 请假流程避坑指南:五个高频翻车点与排查方法

流程引擎的报错往往不会直接告诉你“哪行配置错了”,而是表现为流程静默卡住、任务凭空消失、分支走错。这一章写五个我在请假实例上踩过或者帮别人排查过的坑,每个都按现象、原因、解决的顺序说清楚。

5.1 审批人看不到待办:先查 ACT_RU_TASK 的 ASSIGNEE_

现象:流程实例明明启动了,申请提交成功,但审批人说待办列表是空的,流程像卡住了一样。

原因:最常见的是flowable:assignee表达式里的变量没传,或者传的 key 和 XML 里不一致。比如 XML 里写的是${manager},启动流程时变量名却写成了managerId,引擎解析表达式失败后,任务会进入未分配状态,自然不会有任何人的待办里出现它。还有一种情况是用户 ID 体系不一致,流程表里的ASSIGNEE_存的是工号,而前端传过来的是数据库主键。

解决:直接查运行任务表。用数据库客户端执行SELECT * FROM ACT_RU_TASK WHERE PROC_INST_ID_ = '你的流程实例ID',重点看ASSIGNEE_和TASK_DEF_KEY_两个字段。如果ASSIGNEE_是空,说明表达式没解析成功;再去ACT_RU_VARIABLE表查对应流程实例的变量列表,核对 key。这个排查路径能覆盖绝大多数“看不到待办”的情况。

5.2 网关“静默走错分支”:类型陷阱与缺少 default 流向

现象:请假 2 天,流程却一路走到了 HR 备案节点,审批链完全错乱。

原因:这个坑有两个常见来源。第一,leaveDays在启动时传的是字符串"2",网关条件${leaveDays <= 3}在做比较时,数值和字符串的运算结果不符合预期,条件判断返回 false,但另一个出口条件偏偏返回 true。第二,排他网关的所有出口条件都不满足时,如果配了default流,引擎就会静默走默认出口,不会报错,表现出来就是“走错分支”。

解决:启动流程时把leaveDays强制转成Integer,并且在 service 层入口做类型校验。网关条件写完以后,用单元测试覆盖边界值:1 天、3 天、4 天、7 天、8 天,分别验证走到哪个节点。没有 default 流的话,条件全 false 时引擎会直接抛异常,这样问题反而更容易暴露,不至于在生产环境悄悄跑偏。

5.3 流程变量放太大:一张表被撑爆的经过

现象:系统上线一个月,ACT_HI_VARINST表涨到几百万行,查审批历史接口经常超时。

原因:有人把请假表单的完整对象、附件下载地址列表、甚至前端传来的整段 HTML 都塞进了流程变量。流程变量的设计本意是存储流程运行状态,不是业务数据仓库。每完成一个节点,Flowable 都会把相关变量写入历史表,变量越大、实例越多,表膨胀越快。

解决:流程变量只留业务主键,比如businessKey=201234,表单详情全部在业务表里查。附件上传后拿到存储 URL,存业务表,不碰流程变量。这个原则在任何工作流项目里都适用,别等到表被撑爆了再回头清理,历史数据的清理可比防止写入麻烦得多。

5.4 同一人身兼两级审批:重复待办与跳步问题

现象:某主管同时也是部门经理,他请 5 天假,按理说主管审批和部门经理审批都是他自己。结果系统生成了两个待办任务,他批完主管节点,部门经理节点还在待办列表里,流程看起来像卡住了。

原因:Flowable 对每个 userTask 都是独立创建任务的,它不会因为两个节点的 assignee 是同一个用户,就把任务合并成一个。这是引擎的正常行为,不是 bug,但确实不符合很多公司的实际预期。

解决:在启动流程的 service 层做一次判断,如果manager和deptManager是同一个用户 ID,就在流程变量里设置skipDept=true,然后在deptApprove节点前面加一个排他网关,条件成立时直接跳过部门经理审批。这样既保留了流程图的完整性,又避免重复待办。

5.5 改了流程定义,老实例为什么没跟着变

现象:上线后根据新制度改了一版流程定义,重新部署下去,以为所有运行中的请假单都会按新规则走,结果老单子的审批链完全没变,甚至出现了节点找不到的报错。

原因:Flowable 的版本机制是,新启动的流程实例使用最新版本的流程定义,已经在运行中的实例继续使用它启动那一刻的版本。这是刻意的设计,保证流程执行的确定性。但有人直接在数据库里删了旧版本流程定义,导致运行中的实例引用了不存在的定义,流程直接中断。

解决:发布新流程定义时,永远不要删除旧版本,让版本号自然叠加。老实例如果没必要迁移,就让它按旧规则跑完。如果确实需要迁移,去看看引擎提供的流程实例迁移接口,但迁移前必须把新旧版本的节点映射关系梳理清楚,这个操作远比重新部署一次流程定义要复杂,建议单独评估。

6. 进阶三招:驳回到发起人、转办与超时自动提醒

6.1 驳回到发起人重新提交:一张网关解决“打回修改”

真实请假流程里,审批人不只是同意或不同意,还经常“打回修改”。比如请假理由写得不充分,主管驳回让申请人补充。实现方式最稳妥的是在审批节点后面加一个排他网关,approveResult为reject时,流程回到applyTask节点,发起人重新填写并再次提交流转。流程变量需要保留businessKey,这样第二次提交时不会产生新的业务单,而是继续走同一个流程实例。这个方案在 BPMN 里就完成了闭环,不需要写自由跳转代码。

6.2 转办与委派:审批人不在时任务怎么处理

审批人出差或请假,待办任务不能一直压在头上。最简单的转办一行代码:

taskService.setAssignee(task.getId(), delegateUserId);

setAssignee把任务的处理人改掉,原审批人不再看到这条待办。如果需要保留审计轨迹,建议在转办前把操作记录写到业务表的审批日志里,流程引擎本身不会记录这次转办的业务原因。

6.3 48 小时未审批自动提醒:边界定时事件与 JobExecutor

超时提醒是请假流程里最常见的运维需求。可以用 BPMN 边界定时事件实现:给主管审批节点挂一个定时器,48 小时未完成就触发提醒。关键配置是cancelActivity="false",表示定时器触发后不终止原任务,只是额外走一条提醒分支。要注意 Flowable 的定时器依赖 JobExecutor 组件,测试环境如果没启动这个组件,定时事件永远不会触发。我遇到过多次代码没问题、但提醒一直不发的案例,最后都是这个原因。

我做第一个请假流程实例时,把全部精力都放在画流程图上,以为 BPMN 对了就万事大吉,结果上线后被版本管理、变量设计、组织架构一致性这些“流程图外”的事情折腾得够呛。后来养成的习惯是:先写单测覆盖网关边界,再部署到公共环境联调,最后才敢发布。流程引擎本身是可靠的,绝大多数翻车都来自使用姿势。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询