news 2026/9/30 7:44:28

Spring Boot 3 + Flowable 7 工作流引擎实战:BPMN部署与任务流转

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Spring Boot 3 + Flowable 7 工作流引擎实战:BPMN部署与任务流转

1. Spring Boot 3.x 与 Flowable 7.x 的版本配对:先跨过 javax 到 jakarta 这道坎

如果你最近正想把老项目从 Spring Boot 2.x 升到 3.x,同时又把工作流组件换成 Flowable 7.x,那你多半会撞上第一个拦路虎:包名迁移。Spring Boot 3.0 底层基于 Spring Framework 6,全面切换到 Jakarta EE 9+,所有javax.servlet、javax.persistence、javax.validation这类包名都变成了jakarta.*。而 Flowable 6.x 还是基于旧的javax.*规范编译的,直接和 Spring Boot 3.x 配套使用,启动时大概率会遇到类找不到、Bean 初始化失败这类问题。这不是你代码写错了,是两代技术栈的底层约定不一致。

1.1 为什么 Spring Boot 3.0 之后不能继续用 Flowable 6

很多做后端的老哥看到项目升级第一反应是“先跑起来再说”,于是把 Spring Boot 从 2.7 升到 3.2,然后顺手引入了flowable-spring-boot-starter-process的 6.8.0 版本。看起来依赖没报错,但应用一启动,日志里会出现类似ClassNotFoundException: javax.xml.bind.JAXBException或者持久层相关的jakarta.persistence异常。原因就在于 Flowable 6 内部很多类直接引用了javax.persistence、javax.xml.bind这些旧包,而 JDK 17 加 Spring Boot 3 的环境里,这些包既不在 JDK 中,也不再被 Spring Boot 的管理 BOM 统一替换。

那是不是加一个javax.xml.bind:jaxb-api依赖就能解决?我试过,能解决一部分编译问题,但治标不治本。因为 Flowable 6 的整个注解体系、MyBatis 映射、Servlet 相关过滤器都是围绕javax.*写的,Spring Boot 3 的自动配置在扫描这些类时依然会半残。最干净的办法就是直接升到 Flowable 7.x,这是官方为了适配 Spring Boot 3 而做的重大版本调整。Flowable 7 把底层依赖的包名全部换成了jakarta.*,并且最低要求 JDK 17,和 Spring Boot 3 属于同一代技术底座。

1.2 我这边实测稳定的版本组合与依赖清单

我在生产环境验证过一套比较省心的组合:JDK 17 + Spring Boot 3.2.x + Flowable 7.x + MySQL 8.0。Flowable 7 的小版本还在快速迭代,建议先用 7.0.x 或 7.1.x 的最新补丁版,不要一上来就追最新大版本,免得遇到社区还没填平的坑。下面是完整的 Maven 依赖,核心只需要一个 starter:

<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.flowable</groupId> <artifactId>flowable-spring-boot-starter-process</artifactId> <version>7.0.1</version> </dependency> <dependency> <groupId>com.mysql</groupId> <artifactId>mysql-connector-j</artifactId> <scope>runtime</scope> </dependency>

注意,flowable-spring-boot-starter-process会自动把 Spring Boot 的DataSource接到 Flowable 引擎上,不需要额外单独配置数据源给 Flowable 用。也不建议再引入flowable-ui那套东西,那是官方的控制台工程,包含了一堆前端资源和用户体系,和我们自己集成后端 API 的诉求完全不在一个量级。

1.3 启动前必调的三个配置项

第一次启动 Flowable 7,你会在控制台看到它默默建了一大批以ACT_开头的表。这个行为由database-schema-update控制,默认情况下 starter 会做自动升级。虽然它很方便,但我在本地调试时会主动把配置写明白,避免同事 clone 代码后因为数据库账号权限不足导致启动失败。下面是application.yml里的关键配置:

spring: datasource: url: jdbc:mysql://localhost:3306/flowable_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&nullCatalogMeansCurrent=true username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver flowable: database-schema-update: true async-executor-activate: false process-definition-location-prefix: classpath*:/processes/ process-definition-location-suffixes: - "**.bpmn20.xml" - "**.bpmn"

这几个配置里,async-executor-activate最容易被忽略。它的作用是启动 Flowable 的异步任务执行器。如果我们只是跑流程的审批闭环,不涉及定时器事件、异步消息、异步 continuation,那完全可以把这一步关掉,省掉一批后台线程的资源占用,也让本地日志干净不少。等你后面真的用到TimerEventDefinition之类的能力,再把它打开就行。

2. 从零写出一条“最小闭环”BPMN:请假审批流程的建模思路

Flowable 7 做流程编排,核心载体是 BPMN 2.0 文件。很多第一次接触的人会怕这个 XML,总觉得工作流建模必须用可视化的流程设计器拖拽出来,其实完全不是这样。BPMN 文件本质上就是一段描述节点和连线的 XML,手写完全可行,而且更能理解引擎的执行逻辑。我建议所有入门阶段的项目都先手写一个最小流程,而不是直接上 Flowable Modeler 去画图。

2.1 流程文件放哪、怎么命名才会被自动扫描

Flowable 的 Spring Boot starter 默认会去 classpath 下的/processes/目录扫描流程定义文件。也就是说,你新建src/main/resources/processes/目录,把.bpmn20.xml或.bpmn文件放进去,应用启动时就会自动部署。这个机制对单体应用非常友好,改流程文件重启一次就能生效,但也会带来一些需要注意的问题,后面我会专门讲。

文件命名我建议用“业务场景-版本”的格式,比如leave-process.bpmn20.xml。如果你在同一个目录放多个流程文件,千万别给它们起相近的名字,Flowable 是按文件内容里的process id去区分流程定义的,文件名只是资源标识。两个文件里如果出现了相同的process id,后扫描到的会把前面的流程定义当成新版本部署,轻则版本号混乱,重则启动时直接报重复定义。

2.2 手写 BPMN 的节点、连线和条件表达式

下面是一个请假审批流程的完整 XML,它包含开始事件、用户任务、排他网关、结束事件,以及一条按请假天数分流的路由。这段 XML 可以直接复制到你的项目里跑:

<?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://flowable.org/bpmn20"> <process id="leaveProcess" name="请假审批流程" isExecutable="true"> <startEvent id="leaveStart" name="发起请假" /> <userTask id="applyTask" name="提交请假申请" flowable:assignee="${starter}" /> <exclusiveGateway id="gatewayJudge" name="请假天数判断" default="flowToManager" /> <userTask id="managerTask" name="直属主管审批" flowable:assignee="${manager}" /> <userTask id="deptManagerTask" name="部门经理审批" flowable:assignee="${deptManager}" /> <endEvent id="endNode" name="流程结束" /> <sequenceFlow id="flowStart" sourceRef="leaveStart" targetRef="applyTask" /> <sequenceFlow id="flowApply" sourceRef="applyTask" targetRef="gatewayJudge" /> <sequenceFlow id="flowToManager" sourceRef="gatewayJudge" targetRef="managerTask"> <conditionExpression xsi:type="tFormalExpression"> <![CDATA[${days <= 3}]]> </conditionExpression> </sequenceFlow> <sequenceFlow id="flowToDept" sourceRef="gatewayJudge" targetRef="deptManagerTask"> <conditionExpression xsi:type="tFormalExpression"> <![CDATA[${days > 3}]]> </conditionExpression> </sequenceFlow> <sequenceFlow id="flowManagerEnd" sourceRef="managerTask" targetRef="endNode" /> <sequenceFlow id="flowDeptEnd" sourceRef="deptManagerTask" targetRef="endNode" /> </process> </definitions>

这里需要特别说明几个字段。flowable:assignee="${starter}"表示当流程走到这个用户任务时,引擎会根据流程变量里的starter值,动态决定这个任务分配给谁。我实际用下来强烈推荐这种动态指定的方式,因为真正上线后,节点的负责人基本都是根据表单提交人、组织架构查询出来的,很少有人把用户名硬编码在 XML 里。

exclusiveGateway是排他网关,它会从上到下依次判断出口连线的conditionExpression,一旦某个条件为 true 就顺着那条线走下去。我这段 XML 里还配了default="flowToManager",意思是如果所有条件都不满足,就默认走主管审批这一条。这个兜底非常关键,否则条件引擎找不到可走的分支时会直接抛异常。

2.3 先想清楚变量作用域再建模

很多初学者画完流程图就开始写启动接口,结果流程启动后,任务停在第一个用户节点上,去查数据库的ACT_RU_VARIABLE表才发现,starter、manager这些变量根本不在。原因很好理解:flowable:assignee="${starter}"是流程引擎在创建任务时用表达式解析出来的,表达式依赖流程实例级别的变量。也就是说,你在启动流程时传进去的 Map,会进入流程实例的变量作用域,而不是任务局部作用域。后面查任务、完成任务时,传入的变量也需要根据业务需求决定是放流程级还是任务级。

我的建议是:像“谁发起”“当前审批人”“请假天数”“审批结果”这类贯穿整个流程的数据,都放到流程变量里。像“某一个节点上传的附件 ID”“某个审批节点的补充意见”这种每个节点需要独立记录的数据,用任务局部变量更合适,否则所有审批意见都互相覆盖,后面想追溯历史就很被动了。

3. 部署流程定义的三种姿势:自动扫描、RepositoryService 与部署后校验

流程定义文件写好了,下一步就是让引擎认识它。Flowable 7 里部署流程定义的方式不少,但很多人会因为“能用就行”而忽略不同方式之间的差异。我建议把这三种都掌握,因为它们分别对应开发、测试和生产的不同场景。

3.1 自动部署:把 BPMN 丢进 processes 目录就算完事吗

自动部署是最省事的方式。前面说的process-definition-location-prefix: classpath*:/processes/配置一旦生效,应用启动时 Flowable 会把目录下所有合法流程定义文件部署进引擎。在本地开发阶段,我用得最多。

但要注意,自动部署并不等于“每次启动都重新部署一份”。Flowable 的部署机制会按资源文件的字节码去比对,如果同一个部署包里的流程定义没有变化,重新启动时不会重复生成部署记录。只有当文件内容变了,才会生成新的部署记录和新的流程版本号。这个设计很关键,后面排查“为什么重启之后流程版本变多了”的时候,第一时间要去检查有没有人改过 BPMN 文件,而不是怀疑引擎有 bug。

3.2 编程部署:动态指定流程名和分类

复杂场景下,我们并不想让流程定义全部随应用启动自动部署,尤其是上生产之后,流程文件往往由运维或流程管理员在数据库里手动维护。这时就需要用RepositoryService编程部署。

我有一个实际案例:客户的多租户系统里,每个租户要使用不同版本的审批流程,同一个leaveProcess流程定义,在 A 租户和 B 租户下的节点配置不同。如果依赖自动部署,就只能在启动时按一套文件来。改成编程部署后,管理端上传 BPMN 文件,后端解析文件内容,动态调用createDeployment()接口完成部署,租户 ID 存到部署的分类字段里,非常灵活。

@Resource private RepositoryService repositoryService; public String deployBpmn(MultipartFile file, String category) throws IOException { Deployment deployment = repositoryService.createDeployment() .name(file.getOriginalFilename()) .category(category) .addBytes(file.getOriginalFilename(), file.getBytes()) .deploy(); return deployment.getId(); }

编程部署的另一个好处是可以在一个部署包里塞多个流程定义文件,然后统一命名。比如“2024年Q1所有审批流程”这样的一批文件,只要调用一次部署接口,ACT_RE_DEPLOYMENT表里就是一条完整记录,后续回滚、排查看起来非常清晰。

3.3 部署后确认流程定义与版本

部署完成不等于万事大吉。我见过太多人部署结束后就直接去ACT_RE_PROCDEF表里查数据,结果发现流程定义 key 对不上,半天找不到问题。正确做法是部署后用ProcessDefinitionQuery按 key 查最新版本:

List<ProcessDefinition> list = repositoryService.createProcessDefinitionQuery() .processDefinitionKey("leaveProcess") .orderByProcessDefinitionVersion() .desc() .list();

这里有个细节:.latestVersion()方法很方便,但如果你在同一个流程定义 key 下部署了停用的旧版本,查询结果里会把旧版本一并查出来。生产环境我通常还会加.active()条件过滤,只保留状态为 active 的定义。Flowable 部署时会自动把同 key 的旧版本设置为挂起状态,但这只在同一个 deployment 里才完全可靠,跨部署包的情况建议自己校验一遍。

4. 发起流程、查询待办、完成任务:RuntimeService 与 TaskService 的实战用法

流程定义部署好之后,就到了整个系列里最核心的环节:让流程真正跑起来。这一步涉及两个 Service:RuntimeService负责任务流转和流程实例管理,TaskService负责待办任务的查询和完成。我把它们拆开讲,因为太多人在这一步把变量作用域搞混了。

4.1 启动流程实例:key、businessKey 与变量

发起流程的标准动作是调用runtimeService.startProcessInstanceByKey()。第一个参数是流程定义 key,也就是 BPMN 文件里<process id="leaveProcess">的值。Flowable 会自动选择这个 key 下最新版本的流程定义来启动实例。

第二个参数businessKey是业务主键,强烈建议传入自己业务系统的单据号。比如请假单号LEAVE-20240001。这样一来,流程表和业务表可以通过这个字段关联,将来查“这条流程对应哪张请假单”“这张请假单走到哪一步了”都非常方便。

@Resource private RuntimeService runtimeService; public String startLeaveProcess(LeaveApplyDTO dto) { Map<String, Object> variables = new HashMap<>(); variables.put("starter", dto.getStarter()); variables.put("manager", dto.getManager()); variables.put("deptManager", dto.getDeptManager()); variables.put("days", dto.getDays()); ProcessInstance processInstance = runtimeService .startProcessInstanceByKey("leaveProcess", dto.getBizNo(), variables); return processInstance.getId(); }

这里最容易踩的坑:启动流程时传人的变量一定要保证 key 与 BPMN 里的表达式变量名完全一致。如果你在 XML 里写的是${manager},传的却是manger,引擎解析表达式时拿不到值,流程实例虽然能启动,但创建任务时会因为 assignee 表达式无法解析而直接报错。这是新手报错的高发地。

4.2 查询当前用户待办并按需完成任务

流程启动后,会按照 BPMN 定义自动执行到第一个用户任务,也就是applyTask,待办任务的 assignee 会被设成流程变量starter的值。这时候就要用TaskService来查待办了。

@Resource private TaskService taskService; public List<TaskVO> queryTodoList(String userId) { List<Task> tasks = taskService.createTaskQuery() .taskAssignee(userId) .orderByTaskCreateTime() .desc() .list(); return tasks.stream().map(task -> { TaskVO vo = new TaskVO(); vo.setTaskId(task.getId()); vo.setName(task.getName()); vo.setProcessInstanceId(task.getProcessInstanceId()); vo.setCreateTime(task.getCreateTime()); return vo; }).collect(Collectors.toList()); }

完成任务的 API 是taskService.complete(),它接收任务 ID 和一个变量 Map。这里要说一个很容易犯的错误:很多人以为完成任务就是点个“通过”,不需要传变量。但在我们的请假流程里,如果审批人在直属主管节点填了审批意见,意见要传给后续的部门经理节点查看,就必须在 complete 的时候作为变量传进去。

public void completeTask(String taskId, Integer approveResult, String comment) { Map<String, Object> variables = new HashMap<>(); variables.put("approveResult", approveResult); variables.put("comment", comment); taskService.complete(taskId, variables); }

complete 之后,引擎会自动判断当前节点是否有出口连线需要继续走。如果下一个节点是用户任务,变量会影响 assignee 的解析;如果是排他网关,变量会参与条件表达式的计算。所以你会发现,发起流程时和完成任务时两个变量 Map 的 key 集合往往不一样,这是正常的。

4.3 完成任务时变量回填的两种作用域

完成任务时的变量可以放在流程实例作用域,也可以放在任务局部作用域。complete(taskId, variables)里传的变量默认是流程实例级别的,全局可见,方便后续所有节点读取。如果你只想让某个变量对当前任务生效,不影响其他节点,可以用taskService.setVariableLocal(taskId, key, value)先设置局部变量,再调用 complete。

举个真实例子:两个审批节点都需要记录“审批意见”,如果用流程变量,后一个节点的审批意见就会覆盖前一个。用任务局部变量就能各自独立,历史记录里后来排查时也能分清哪个节点写了什么内容。刚开始写代码时我图省事全程用流程变量,等到要追溯审批链路上的历史意见时,才发现设计上的问题。工作流这种场景,数据追溯的价值远大于写代码的便利。

5. 跑通后的验证手段与最容易踩的坑

一套“部署-发起-查询-完成”的最小闭环跑通之后,接下来要做的不是急着写更多复杂流程,而是把验证和排错的方法论建立起来,否则后面流程一变复杂,你会被各种隐藏问题淹没。

5.1 验证闭环:从历史活动与流程实例状态看结果

流程走到结束事件后,运行时的实例数据会从ACT_RU_*表里消失,此时如果用runtimeService.createProcessInstanceQuery().processInstanceId(id).singleResult()去查,会返回 null。这不是流程丢了,而是它已经从运行表归档到了历史表。

要看流程到底走了哪些节点,应该用HistoryService:

@Resource private HistoryService historyService; public void showExecutePath(String processInstanceId) { List<HistoricActivityInstance> activities = historyService .createHistoricActivityInstanceQuery() .processInstanceId(processInstanceId) .orderByHistoricActivityInstanceStartTime() .asc() .list(); for (HistoricActivityInstance activity : activities) { System.out.println(activity.getActivityId() + " - " + activity.getActivityName() + " - " + activity.getEndTime()); } }

如果输出列表最后一个是endNode - 流程结束 - 有结束时间,说明整个闭环是通的。如果中间某节点只有开始时间,没有结束时间,说明流程卡在了那个节点,多半是该节点的用户任务没人处理,或者条件网关没有可匹配的分支。

5.2 常见异常的排查链路

我把在实际集成过程中遇见过的高频问题整理成了一张排查表,虽然不算全面,但覆盖了绝大多数第一次跑 Spring Boot 3 + Flowable 7 的人会遇到的状况。

现象可能原因排查方法
启动报错,提示找不到javax.*类Spring Boot 3 配了 Flowable 6换 Flowable 7.x,检查依赖版本
启动后项目里没有ACT_*表database-schema-update为 false,且数据库账号无建表权限手动执行官方 SQL 脚本,或放开 DDL 权限
部署提示流程定义重复多个 BPMN 文件用了同一个process id全局搜索id="xxx",确保唯一
用户任务没有负责人,待办查不到flowable:assignee表达式变量没传进去查ACT_RU_VARIABLE确认变量存在
排他网关走了默认分支条件表达式变量名写错逐个对比变量 Map 的 key 与 XML 表达式
流程结束后RuntimeService查不到实例正常归档行为改用HistoryService或查ACT_HI_*表

这里面最浪费时间的是第二个。我刚开始接到一个 Spring Boot 3 项目,启动时发现一堆ACT_*表全是空的,当时还以为是表没有初始化,后来发现是数据库账号权限不够,自动建表被静默忽略了。这个坑在开发机不明显,因为 root 用户权限够,但一上测试环境就暴露了。

5.3 一点更深入的扩展思考

把这套最小闭环跑熟之后,你可以开始把注意力放到更复杂的工作流能力上。比如排他网关和并行网关的组合、会签任务、驳回与撤回、子流程,以及通过flowable:formKey把表单定义挂到任务上。但这些都是后话,我建议你先刻意练习一件事:每次写完一个流程定义,先在本地起应用,用 Postman 或 curl 走一遍“部署到发起、发起到完成”的完整链路,再去看ACT_HI_*表确认节点足迹。这个肌肉记忆一旦建立,之后排查任何问题都会快很多。

最后分享一个我自己调试时的小技巧:Flowable 7 的应用日志里,只要把org.flowable这个包的日志级别调到 DEBUG,就能看到引擎原生的 SQL 和执行逻辑输出。这个开关对第一次集成的人非常有帮助,一开始觉得日志刷屏很烦,但真到排查变量没传进去、条件判断不对的问题时,你会感谢每一行 DEBUG 输出。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/30 7:44:02

Redis Cluster数据分片机制详解:从哈希槽到扩缩容避坑指南

在实际业务里&#xff0c;Redis 单机撑不住的时候&#xff0c;绝大多数团队的第一反应就是上 Redis Cluster。但很多人对 Cluster 的使用只停留在“redis-cli --cluster create 一把梭”的层面&#xff0c;一旦遇到槽位迁移、CROSSSLOT 报错、CLUSTERDOWN&#xff0c;就完全不知…

作者头像 李华
网站建设 2026/9/30 7:42:59

手机中框在线三维检测如何落地?从上料定位到结果输出

手机中框为屏幕、主板、电池和按键等部件提供安装基础。随着结构趋于轻薄&#xff0c;表面同时存在平面、台阶、孔槽和胶路&#xff0c;检测任务也从少量点位抽检&#xff0c;逐步转向对多区域高度信息的在线获取。激光三维轮廓测量仪可以连续获得可见表面的高度轮廓&#xff0…

作者头像 李华
网站建设 2026/9/30 7:42:33

GNN与GCN百页深讲PPT:从消息传递原理到PyG落地避坑指南

简介&#xff1a;面向AI、深度学习初学者与进阶者的图神经网络专题讲解PPT&#xff0c;系统梳理GNN的基础概念与主流变体。内容从欧式/非欧式数据说起&#xff0c;解释CNN为何难以直接处理图数据&#xff0c;进而引入图神经网络的信息聚合与更新机制&#xff1b;随后重点展开图…

作者头像 李华
网站建设 2026/9/30 7:41:17

纯CSS3实现双半圆进度条:从渐变到遮罩的完整实战

1. 双半圆进度条到底是什么&#xff0c;为什么2026年还要拿它当考题 先给没做过这个组件的朋友描述一下画面&#xff1a;页面顶部是一块240像素宽的半圆盘&#xff0c;弧线从左侧9点钟方向起步&#xff0c;像转速表一样沿着上沿往右爬&#xff0c;爬到右侧3点钟方向就是100%。有…

作者头像 李华
网站建设 2026/9/30 7:40:02

OSPF与IS-IS双点双向路由引入:路由回馈成因与Route Tag根治方案

前两天一位做网络集成的朋友给我发消息&#xff0c;说他在实验环境里做了一个OSPF与IS-IS双点双向路由引入的验证&#xff0c;结果发现OSPF域里所有路由器的外部LSA数量几乎翻了一倍。更诡异的是&#xff0c;明明只有两台ASBR&#xff0c;可路由表里同一个前缀却出现了两条外部…

作者头像 李华
网站建设 2026/9/30 7:39:00

Windows内核启动早期ACPI PCI枚举调试:断点组合拳实战

内核调试的朋友应该都有过这种体验&#xff1a;启动早期想看的东西就那么一瞬间&#xff0c;断点没打好&#xff0c;要么进不了现场&#xff0c;要么被无关调用刷屏。最近我在梳理系统引导阶段PCI设备枚举流程时&#xff0c;用了“在ACPI!GetPciAddressWorker函数内的hal!HalGe…

作者头像 李华