工作流 Flowable 全流程跟踪,是我在上一个项目中接手得最头疼、也收获最大的一块。一开始我以为工作流引擎就是画个图、部署一下、调两个API的事,等真正把审批流、会签、驳回、历史记录全部串起来,才发现事情远没有想象中简单。这篇就把我从零到一跑通Flowable全流程的完整套路写下来,包括为什么选它、怎么设计流程、部署启动要注意什么、运行期怎么查询和归档,以及几个我拿头发换回来的坑。内容有点长,但保证都是实操里用得上的东西。
1. 为什么最终选型Flowable,而不是别的引擎
先说结论:Flowable是Activiti的一个分支演化而来,社区活跃度高、文档相对完善、对Spring Boot的适配做得非常好,而且在流程设计的灵活性、扩展接口、以及性能表现上,都更适合中小团队做企业级审批流的落地。
我在选型的时候其实对比过几类方案。一类是自己写状态机,用一张表记录单据状态,配合if-else把状态流转写死在业务代码里。这种方案在流程极其简单、几乎不变的时候是可行的,但只要审批节点一多、加一个“会签”、加一个“驳回”,代码就变得像意大利面条,改一处崩三处。另一类是商业化的流程平台,功能确实全,但要钱、要部署环境、要学习成本,而且定制能力未必能贴合自家业务。
反观Flowable,它是开源项目,内置了BPMN 2.0标准,流程定义用XML描述,既可以画图生成,也可以手写;运行时引擎负责解析、推进、派发任务,开发者只需要关注自己业务层的回调逻辑。这种“引擎管流转、业务管逻辑”的边界很清晰,和我的团队结构也很匹配——后端同学不用从头研读工作流理论,照着BPMN模型写代码就能落地。
还有一个重要的考量是未来的维护成本。Flowable的流程定义、流程实例、任务表、历史表都是独立的数据结构,运营后台可以直接基于这些表做查询统计,不用自己再造一套流程引擎。哪怕以后流程模型升级换代,只要版本管理做得好,老流程照样能在新引擎上跑完。这个特性在真实项目里特别值钱,因为流程一旦上线,旧数据是不能随便清掉的。
提示:如果你们的流程非常简单、只有三五个节点而且几乎不会变化,建议直接用状态机,没必要为了“工作流”而工作流。Flowable的价值在于流程复杂、变动频繁的场景,它能让你把“流程变化”从代码发布中解脱出来。
2. 环境准备与基础依赖的搭建细节
选型定了,下一步就是搭环境。我当时的项目是Spring Boot微服务架构,版本用了2.7.x,Flowable对应选择了6.7.2,数据库是MySQL 8.0。这里先强调一下版本对应的问题,因为网上很多教程用的老版本,直接复制依赖到新项目里会出现各种不兼容的报错。
2.1 Maven依赖的引入方式与版本坑
Flowable在Maven中央仓库有一系列模块,最核心的是flowable-spring-boot-starter,一个依赖就能把引擎、Spring Boot自动配置、REST API部分拉进来。初期开发阶段可以加上flowable-spring-boot-starter-process,这个模块带了对BPMN流程定义解析、流程实例运行、任务管理、历史记录等核心能力的支持。
<dependency> <groupId>org.flowable</groupId> <artifactId>flowable-spring-boot-starter</artifactId> <version>6.7.2</version> </dependency>这里我要专门提醒一个坑:不要轻易给Flowable加上flowable-spring-boot-starter-rest,除非你确定需要暴露REST接口。这个依赖会把引擎的内部接口以REST方式暴露出去,如果权限控制没做好,等于把流程引擎的管理权限交给了网络攻击者。我第一次搭的时候图省事加上去了,结果扫描的时候直接发现了一堆未授权访问风险,赶紧移除。
2.2 数据库初始化与表结构说明
Flowable启动后会自动创建它需要的表,前提是数据库账号有建表权限。表结构大致分几类:ACT_RE_(流程定义和模型资源)、ACT_RU_(运行时的流程实例、任务、变量、作业等)、ACT_HI_(历史数据)、ACT_ID_(用户和组)。这些表在启动阶段会自动初始化,不需要手工建库,但表会比较多,差不多有七十多张。
当时我把Flowable单独放在一个Schema里,没有跟业务表混在一起,这样数据隔离清楚,备份恢复也方便。连接池和事务需要保证,因为流程引擎的每一步操作都涉及内部表的状态变更,必须和业务操作在同一个事务里才安全。这一点如果不注意,很容易出现流程任务推进了,但业务数据没保存成功,或者反过来业务数据保存了但流程卡在原节点的情况。
spring: datasource: url: jdbc:mysql://localhost:3306/flowable_demo?useUnicode=true&characterEncoding=utf8&nullCatalogMeansCurrent=true username: root password: xxxx flowable: database-schema-update: true async-executor-activate: falsedatabase-schema-update: true表示启动时自动检查和升级表结构,开发期很方便。生产环境建议改为false或使用专门的升级脚本,避免引擎自动改动表结构带来意外。
2.3 流程引擎与服务组件的获取方式
Spring Boot集成Flowable后,容器里会自动注册一堆Bean,最常用的是RepositoryService(流程定义管理)、RuntimeService(流程实例启动与推动)、TaskService(任务查询和处理)、HistoryService(历史数据查询)、IdentityService(用户和组管理)。我习惯把这些服务封装到一个FlowableFacade类里统一使用,而不是在业务代码里到处注入。原因很简单,后续如果要加缓存、加权限过滤、加操作审计,都只需要在Facade层做,不用去改散落在各个Service里的调用。
@Component public class FlowableFacade { private final RepositoryService repositoryService; private final RuntimeService runtimeService; private final TaskService taskService; private final HistoryService historyService; public FlowableFacade(RepositoryService repositoryService, RuntimeService runtimeService, TaskService taskService, HistoryService historyService) { this.repositoryService = repositoryService; this.runtimeService = runtimeService; this.taskService = taskService; this.historyService = historyService; } // 统一封装流程相关操作 }3. 流程定义文件的建模思路与编写要点
Flowable的流程定义是一个BPMN 2.0标准的XML文件,文件里描述了流程的节点、连线、条件和事件。设计这个文件是整个项目中决定成败的关键,因为后续所有运行期行为都取决于这一张图。我建议先在Flowable Modeler或支持BPMN的IDE里把图画出来,再导出XML做微调,纯手写XML不仅效率低,而且连线坐标处理起来要命。
3.1 一个审批流程的BPMN基本结构
拿一个典型的“报销审批”流程举例,它的节点包括:开始事件、填写报销单(用户任务)、部门经理审批(用户任务)、财务审核(用户任务)、结束事件。在Flowable中,用户任务节点用<userTask>表示,连线用<sequenceFlow>,条件用${condition}表达式。
<bpmn2:process id="expenseProcess" name="报销审批流程" isExecutable="true"> <bpmn2:startEvent id="startEvent" name="Start"/> <bpmn2:userTask id="applyTask" name="填写报销单" flowable:assignee="${initiator}"/> <bpmn2:userTask id="managerTask" name="部门经理审批" flowable:assignee="${manager}"/> <bpmn2:userTask id="financeTask" name="财务审核" flowable:assignee="${financer}"/> <bpmn2:endEvent id="endEvent" name="End"/> <bpmn2:sequenceFlow id="flow1" sourceRef="startEvent" targetRef="applyTask"/> <bpmn2:sequenceFlow id="flow2" sourceRef="applyTask" targetRef="managerTask"/> <bpmn2:sequenceFlow id="flow3" sourceRef="managerTask" targetRef="financeTask"/> <bpmn2:sequenceFlow id="flow4" sourceRef="financeTask" targetRef="endEvent"/> </bpmn2:process>这里的flowable:assignee是指定这个用户任务的办理人。实际项目里很少直接在XML里写死一个用户,一般会写成一个变量名,比如${initiator}、${manager},在流程启动或上一节点处理时,通过流程变量动态传入具体的用户ID。
3.2 连线条件的编写与拦截技巧
如果流程是多分支的,比如“金额大于5000走总经理审批,否则直接财务审核”,就需要在连线上加条件表达式。Flowable的条件表达式使用JUEL语法,可以直接读取流程变量。
<bpmn2:sequenceFlow id="flowBig" sourceRef="managerTask" targetRef="financeTask"> <bpmn2:conditionExpression xsi:type="bpmn2:tFormalExpression"> <![CDATA[${amount > 5000}]]> </bpmn2:conditionExpression> </bpmn2:sequenceFlow> <bpmn2:sequenceFlow id="flowSmall" sourceRef="managerTask" targetRef="financeTask"> <bpmn2:conditionExpression xsi:type="bpmn2:tFormalExpression"> <![CDATA[${amount <= 5000}]]> </bpmn2:conditionExpression> </bpmn2:sequenceFlow>有两点必须注意:一是分支连线的条件必须写全,不要只写一个条件让引擎在“满足”和“不满足”之间猜,Flowable对条件匹配的要求是,必须至少有一条连线的条件计算为true,否则会抛异常;二是如果有多条连线同时满足条件,默认只有第一条生效,除非配置了并行网关。这两种情况我在测试阶段都遇到过,前者直接报“No outgoing sequence flow”的错,后者导致节点跳转不符合预期。
3.3 会签、或签与多实例节点的设计
审批流里的会签(多人必须都审批)和或签(一个人审批即可)在Flowable中用多实例节点实现,也就是在<userTask>节点上加multiInstanceLoopCharacteristics。会签代表“全部通过”,或签代表“任一通过即继续”。
<bpmn2:userTask id="multiApproveTask" name="多人会签" flowable:assignee="${assignee}"> <bpmn2:multiInstanceLoopCharacteristics isSequential="false" flowable:collection="${assigneeList}" flowable:elementVariable="assignee"> <bpmn2:completionCondition><![CDATA[${nrOfCompletedInstances == nrOfInstances}]]></bpmn2:completionCondition> </bpmn2:multiInstanceLoopCharacteristics> </bpmn2:userTask>collection字段指定了办理人列表变量,elementVariable指定循环中每个实例对应的办理人变量,completionCondition决定这个节点什么时候算完成。上面的写法是“全部实例都处理完才通过”,如果改成${nrOfCompletedInstances >= 1},就变成或签了。
这里有一个很多新手会忽略的点:多实例节点的assignee变量在任务表里是每个子任务一个值,但在历史表里会记录所有子任务实例。如果办理人列表很大,历史表的数据会膨胀得很快,建议定期做归档清理。
4. 流程部署、版本管理与实例启动的完整链路
流程定义文件写好后,要把它加载到引擎里才能使用。加载的过程叫“部署”,部署完成后引擎会给这个定义分配一个版本号,相同key的流程定义每次部署版本号递增。这个机制允许同一流程线上和线下同时存在,老流程实例继续按老版本跑,新流程实例自然使用最新版本。
4.1 从classpath部署流程定义的代码写法
我把BPMN文件放在resources/processes目录下,项目启动时Flowable会自动扫描并部署目录下以.bpmn20.xml或.bpmn结尾的文件。如果想在程序里手动控制部署时机和文件名,可以用RepositoryService:
repositoryService.createDeployment() .addClasspathResource("processes/expenseProcess.bpmn20.xml") .name("报销审批流程-初始化") .deploy();部署之后立刻查询流程定义:
ProcessDefinition processDefinition = repositoryService.createProcessDefinitionQuery() .processDefinitionKey("expenseProcess") .latestVersion() .singleResult();这里强调一下,processDefinitionKey是流程的唯一业务标识,也就是XML里<bpmn2:process>的id属性。我们在业务上线前一定要把这个key固定下来,流程图可以迭代、显示名可以改,但key不要变,因为所有历史数据和业务关联都以它为锚点。
4.2 启动流程实例并正确设置业务关联
流程定义好比一张图纸,流程实例才是真正跑起来的一条审批流。启动实例的代码很直白,但巧劲在于启动时把业务单据号和流程关联起来。推荐的做法是把业务主键存到流程实例的businessKey字段里,而不是再单独建一张关联表多此一举。
runtimeService.startProcessInstanceByKey("expenseProcess", expenseBillId, Map.of("initiator", userId, "amount", amount, "manager", managerId));这样后续按单据查询流程、按流程查询单据都非常方便。startProcessInstanceByKey的第三个参数是流程变量Map,形如${initiator}的表达式都会从这里取值。启动的瞬间引擎会沿着起始事件自动走到第一个用户任务,任务表里就会多出一条待办记录。
4.3 启动校验与流程不可重复提交
真实业务中,同一张报销单不能无限次启动流程。这个校验不能只做页面按钮级控制,必须在引擎层也拦一道。我的做法是启动前先根据businessKey查历史流程实例,如果已存在则直接抛出业务异常。
long count = historyService.createHistoricProcessInstanceQuery() .processInstanceBusinessKey(expenseBillId) .count(); if (count > 0) { throw new RuntimeException("该单据已发起审批流程,请勿重复提交"); }其实Flowable本身不限制同一businessKey重复启动,所以这层校验一定要自己加。生产环境里我还给流程实例表加了业务主键的唯一索引来兜底,哪怕代码出了并发漏洞,数据库也会挡住重复数据。
5. 任务查询、办结与流转推进的实战细节
流程跑到用户任务节点后,核心操作就变成了“谁能看到什么任务”和“怎么完成任务并推动流程”。这是日常开发中最频繁接触的部分,也是最容易写出低效代码的地方。
5.1 待办任务的分页查询与结果映射
待办查询的常规操作是通过TaskService,携带办理人ID和分页参数。这里有个容易犯的毛病:一次性查出全量任务再在内存里做业务过滤,数据量一大接口就卡死。正确的姿势是把能下推的条件尽量都用查询条件拼进去,先让引擎层过滤掉不相关的任务。
List<Task> tasks = taskService.createTaskQuery() .taskAssignee(userId) .processDefinitionKey("expenseProcess") .orderByTaskCreateTime().desc() .listPage(0, 10);查到Task对象后不要直接返回给前端,因为引擎的任务对象包含很多内部字段,直接序列化会暴露不必要的信息。我在实际项目里都是写一个TaskVO,只筛选任务ID、名称、创建时间、流程实例ID等必要字段,再补充从流程变量里取出来的业务展示字段(比如报销金额、申请人名称)。这也是前后端接口设计的通用规范。
5.2 完成任务时如何设置变量和精准跳转
用户点击“同意”或“驳回”,对应的是taskService.complete(taskId, variables)。这里的变量Map是动态的:如果是驳回,还要附加一个拒绝原因变量;如果是同意,可能还要附带下一节点的办理人。
Map<String, Object> vars = new HashMap<>(); vars.put("approveResult", "pass"); vars.put("financer", financeUserId); taskService.complete(taskId, vars);需要注意的是,complete方法只会让当前任务完成并按连线条件寻找下一个节点,它不会自动判断业务要不要终止。如果想终止整个流程(比如某节点直接点击结束),需要调用runtimeService.deleteProcessInstance(processInstanceId, "reason")。
5.3 驳回到底是怎么实现的
驳回是审批流里一个深坑,因为BPMN标准里并没有“驳回”这个原生概念,它本质上是流程设计上的一种路径安排。常见的两种实现方式:一种是“回到上一节点”,用连线把节点指回目标节点;另一种是“回到发起人”,直接走一条到初始用户任务的连线。我建议在设计阶段就把驳回的粒度定清楚,否则开发期会出现“驳回之后审批人看不清历史记录”“驳回后流程变量残留”之类的各种怪异问题。
我的方案是采用“回退到指定节点”的通用设计:在流程里预埋一条兜底连线到某个公共节点(或者直接到发起人节点),完成当前任务时传入一个“rejectTargetNodeId”变量,任务完成后引擎自动沿兜底连线跳到目标节点。代码上看,其实还是complete加了一个跳转变量,但流程图上要明确画出来,后续维护的人才能看得懂。
6. 流程实例的运行监控与历史数据归档
引擎跑起来之后,运维和运营的需求就来了——流程目前卡在哪个节点、谁在处理、平均耗时多少、已经办结的单据怎么追溯。这些都要靠HistoryService和运行时查询组合完成。
6.1 正在跑的流程实例怎么查
运行中的流程实例存在于ACT_RU_*表中,查询方式如下:
List<ProcessInstance> runningInstances = runtimeService.createProcessInstanceQuery() .processDefinitionKey("expenseProcess") .list();ProcessInstance对象能拿到当前活跃节点ID、流程定义版本等。但要注意,运行库的数据是会话级、临时性的,一旦流程结束,这些记录会被移走。所以做监控页时,不要只查运行库,要同时关联历史库,才能看到完整的“进行中”和“已结束”两类状态。
6.2 历史流程实例与任务历史查询
历史查询是统计报表的主力,我常用的是:
List<HistoricProcessInstance> instances = historyService.createHistoricProcessInstanceQuery() .processDefinitionKey("expenseProcess") .finished() .orderByProcessInstanceEndTime().desc() .listPage(0, 20);查询任务级历史则用HistoricTaskInstanceQuery,可以拿到任务的处理人、处理时间、耗时等信息。这个表特别适合做审批效能分析,比如“哪个节点的平均审批时长最长”,这些数据对优化流程节点配置非常有价值。
6.3 数据膨胀问题与清理策略
Flowable的表设计是按“运行”和“历史”分开的,但历史表不做清理的话,三个月之后几千万条数据不是开玩笑。项目上线稳定后,我写了一个定时任务,每天晚上把超过半年的历史流程实例、历史任务实例、历史活动实例做软归档——先把数据同步到归档表,再从Flowable的历史表物理删除。
归档表自定义结构,保留业务需要的字段和完整的流程XML快照。赔偿注意:清理历史表不要影响还在运行中的流程实例,删除前要按流程实例维度先确认其状态是已结束。跑批时加事务,每批处理1000条,避免一次大事务把数据库锁死。
7. 监听器与扩展接口:在流程事件里安插业务逻辑
审批流不会只有页面操作那么简单,很多业务动作需要在流程推进的瞬间自动触发。比如流程到达某节点时自动发送站内信、流程结束时自动更新单据状态、某个会签全部结束后触发第三方接口回调。这些都能通过监听器实现。
7.1 执行监听器与任务监听器的选择
Flowable提供了两类监听器:执行监听器(ExecutionListener)监听节点/连线的生命周期,任务监听器(TaskListener)监听用户任务的事件(创建、分配、完成)。区别记住一句话:执行监听器绑在节点上,任务监听器绑在用户任务上。
举个例子,报销流程在“财务审核”任务创建后需要给财务人员推送一条待办通知,这时用任务监听器最合适:
<bpmn2:userTask id="financeTask" name="财务审核"> <bpmn2:extensionElements> <flowable:taskListener event="create" class="com.example.FinanceTaskCreateListener"/> </bpmn2:extensionElements> </bpmn2:userTask>对应Java类要实现TaskListener接口,在notify方法里写业务逻辑:
public class FinanceTaskCreateListener implements TaskListener { @Override public void notify(DelegateTask delegateTask) { String taskId = delegateTask.getId(); String assignee = delegateTask.getAssignee(); // 此处实现通知推送逻辑 } }7.2 全局流程事件监听器
除了在XML上按节点绑定监听器,还可以实现引擎级的ActivitiEventListener(Flowable 6下是FlowableEventListener),监听所有流程活动事件。这个适合做全局日志或审计追踪。我在项目里用这种方式把每个任务的接收、完成都记录到自定义审计表,配合业务操作日志形成一条完整的操作链。
事件监听器要在引擎配置里注册,Spring Boot下可以定义一个EngineConfigurationConfigurer来添加:
@Bean public EngineConfigurationConfigurer<SpringProcessEngineConfiguration> engineConfigurer() { return config -> config.setEventListeners(List.of(new GlobalFlowableListener())); }全局监听器的逻辑要精简,只做记录,不要把重量级业务逻辑放进去。亲身教训,我在全局监听器里加过一次推送消息,结果流程操作一多,消息队列直接把引擎所在服务打挂了。
8. 项目落地中的典型报错与排查方法
最后分享几个落地过程中我真实遇到的报错和排查链路,肯定能帮读者省掉不少搜索时间。
8.1No outgoing sequence flow条件分支无匹配
这个报错的根因很简单:节点多条出口连线,没有一条连线条件为true。排查时打开流程定义XML,检查每条连线的conditionExpression,确认覆盖了所有业务分支。如果是金额分支,要特别注意金额等于边界值的情况,>和>=的区别往往会漏掉一条分支。
8.2Task does not exist任务已完成或并发修改
报了“任务不存在”,通常是任务已经完成,再次点击提交导致。这个问题的根因多为前端没有做好按钮幂等控制,或者后端没有对任务ID做状态校验。解决思路统一:在complete前先按任务ID查一次taskService.createTaskQuery().taskId(taskId),如果查不到就提示“任务已处理”。并发场景下还需要考虑悲观锁或幂等表。
8.3 数据库表更新导致的历史数据兼容问题
Flowable版本升级或database-schema-update: true时,偶尔会尝试调整表结构,如果旧表里已有脏数据,就可能升级失败。解决办法是:升级前先备份数据库,然后在测试库跑一遍完整的升级流程,确认全部成功后再动生产环境。永远不要在生产环境上第一次执行升级操作。
8.4 请假流程卡死没有自动到下一步
排查思路分三步走:先查ACT_HI_ACTINST看当前节点是什么;再到历史表查上一个节点有没有正常完成;最后看是否有多实例节点没有满足completionCondition。多实例节点卡死最常见的原因是办理人列表变量为空,导致虽然实例创建了但没有生成任何子任务。
8.5 使用businessKey做唯一索引的注意点
自定义唯一索引要建在ACT_RU_EXECUTION或ACT_HI_PROC_INST的businessKey列上。注意Flowable的businessKey列长度有限,业务主键较长时要用“业务类型+业务ID”拼接后截断,或者额外加一个自定义字段存完整ID。否则流程启动时索引不够长会直接报错,非常坑。
9. 从Demo到生产的一些操作建议
如果我重新把这一整套流程再搭一遍,有几个决定是会坚持的:
第一,流程定义文件一定要做版本管理,并且和代码一起提交、一起走评审。格式尽量缩进规整,加好注释,因为你不知道三个月后谁会接手这张图。
第二,所有流程变量命名要形成规范。用户变量统一用initiator、operator,金额变量用amount,审批结果统一用approveResult,不要今天写approver明天写assignee,变量名混乱是最容易埋雷的地方。
第三,权限模型要提前设计。Flowable自带用户组概念但偏简单,实际项目往往需要对接自己的组织架构。建议在IdentityService上加一层适配器,把系统的用户组同步成Flowable的组,或者干脆不用引擎的用户体系,业务里直接按用户ID下发任务。
第四,流程与业务的隔离要到位。流程引擎的API调用要统一走Facade层,绝对不允许业务模块直接操作RuntimeService和TaskService。这不是代码洁癖,而是后期加缓存、做熔断、加审计日志时的刚需。
从我自己的体会来说,Flowable的复杂度不在于API难用,而在于流程模型和业务模型之间的边界划分。流程图画得越清晰、变量命名越统一、监听器逻辑越克制,后续的维护成本就越低。每次遇到流程卡住或者数据对不上的问题,我最后发现都是模型设计阶段留下的隐患,不是引擎本身的问题。
如果你刚接触Flowable,建议先拿一个最简单的“申请-审批-结束”流程跑通,再逐步加会签、驳回、并行网关这些高级特性。过程中多看看ACT_HI_*表里的数据变化,比只看代码更直观。工作流这个东西,跑起来只是开始,让它跑得稳、跑得可控、跑得可维护,才是真正的全流程。