简介:一份面向SpringBoot开发者的Activiti工作流引擎集成学习包,基于BPMN 2.0标准,覆盖流程建模、部署、启动、任务处理与历史监控的完整链路,适合正在做审批流、工单系统或需要快速上手Activiti的Java工程师。包体共67个文件,含27个Java示例代码、13个BPMN流程模型、配套PNG预览图以及pom.xml、application.yml/properties配置、SQL初始化脚本等;同时收录Activiti Designer插件及EMF相关jar包,便于本地搭建建模环境。整体压缩包约23.92MB,结构清晰,按source资源、doc文档说明和插件区分层组织。目前已有250人学习浏览。学习后可获取可直接参考的SpringBoot集成方法、流程定义文件、数据库脚本和推荐插件版本,对照文档与代码能减少自行摸索的时间,快速完成工作流模块的落地与二次开发。
1. 为什么是 Spring Boot + Activiti 的工作流入门组合
工作流引擎落到 Spring Boot 里,最常被问的不是 BPMN 图怎么画,而是“Activiti 中设计的流程怎么直接集成到应用程序中”。标题里的“文档代码”,我理解为一种能跑、能查、能改的示例工程:代码本身就是文档,依赖选择、引擎启动、流程部署、任务处理都留下可复现的痕迹,而不是散落一地的截图和配置片段。这篇按我实际搭这套组合的顺序写:先定版本和数据源,让引擎在 Spring Boot 里把表建好;再让一个请假流程从 BPMN 文件变成可查询的任务;最后把验证手段补上。主线是 Activiti 7.x 配 Spring Boot 2.x,Boot 3 的场景会给替代方案。适合刚接手工作流模块的人,也能让有经验的同事少踩两个版本和参数的坑。
2. 在 Spring Boot 里装好 Activiti 引擎的三个前置决定
Activiti 在 Spring Boot 里的集成方式,和 MyBatis、Redis 这类 starter 不太一样:它不只注册一个客户端,而是启动时拉起整个流程引擎,包括流程定义解析、命令执行器、历史归档和定时任务。所以装错版本或漏参数,现象往往在第一次部署流程时才暴露。先把三个前置决定做对,后面所有文档代码才有复现基础。
2.1 版本对应:先锁 Spring Boot 主干,再选 Activiti 分支
常见做法是直接在 Maven 里引入org.activiti:activiti-spring-boot-starter,版本跟随 Activiti 主干。以 7 系列为例,7.1.0.M6 在这套组合里用得最多,它内部对齐的是 Spring Boot 2.7.x 的自动配置机制。若工程已经升到 Spring Boot 3.x,也就是常说的“springboot 版本太高”的情况,7 系的 starter 会因为自动配置加载方式的变化启动失败,报的多是NoSuchBeanDefinitionException或ClassNotFoundException,别在这种组合上花时间排错。
Spring Boot 3 场景下,我会优先看 Activiti 8 的依赖或直接评估 Flowable。Flowable 是从 Activiti 5 分叉出去的社区分支,接口风格很接近,activiti:assignee这类 BPMN 扩展大部分兼容,迁移成本主要体现在包名前缀从org.activiti换成org.flowable,这也是“springboot 整合 flowable”这类问题反复出现的原因。选择的顺序是:先确定 Boot 版本,再决定引擎分支,反过来一定会遇到兼容性补丁追不完的局面。
<dependency> <groupId>org.activiti</groupId> <artifactId>activiti-spring-boot-starter</artifactId> <version>7.1.0.M6</version> </dependency> <dependency> <groupId>com.h2database</groupId> <artifactId>h2</artifactId> <scope>runtime</scope> </dependency>这段依赖说明两点:starter 会带过来 Spring Boot 的自动配置与 Activiti 引擎包;h2是本地复现最省事的库,不需要装任何数据库就能看到建表过程。生产一般换成 MySQL 或 PostgreSQL,连接驱动和这个 starter 互不干扰。
2.2 数据源与自动建表:25 张表的分工决定排查方向
starter 在只有一个数据源时会直接复用 Spring 的DataSource,不需要额外声明ProcessEngineConfiguration。启动时引擎根据配置决定是否执行建表脚本,这个开关就是database-schema-update。共约 25 张表,按前缀分成五个职责域,排查问题时先看前缀再决定查哪张表。
| 表前缀 | 职责 | 典型表 | 常见误用 |
|---|---|---|---|
| ACT_RE_ | 流程定义的静态资源 | ACT_RE_PROCDEF、ACT_RE_DEPLOYMENT | 删了部署记录以为能清理流程定义 |
| ACT_RU_ | 运行中的执行与任务 | ACT_RU_TASK、ACT_RU_EXECUTION | 重启后直接清空,导致审批中断 |
| ACT_HI_ | 已结束实例的历史归档 | ACT_HI_PROCINST、ACT_HI_TASKINST | 只知道查 RU,拿不到历史轨迹 |
| ACT_GE_ | 字节流与通用数据 | ACT_GE_BYTEARRAY | 只翻这里找 BPMN 源文件原型 |
| ACT_ID_ | 用户、组、关系 | ACT_ID_USER、ACT_ID_GROUP | 生产直接用内置身份表 |
这张表在排错时有个实际用法:任务列表空了,先查ACT_RU_TASK是不是真的没有记录;如果这里有记录但接口查不到,问题在 TaskQuery 参数而不是数据;如果这里没有记录而流程实例还在,说明流程停在网关或事件上,继续往ACT_RU_EXECUTION查。ACT_HI_是历史数据大头,开了history-level: full之后,每次任务办结都会多若干行,上线前要做归档策略,否则一年后这张表会比业务表还大。
2.3 一份能启动的 application.yml:四个参数说明
spring: datasource: url: jdbc:mysql://localhost:3306/activiti_demo?useUnicode=true&characterEncoding=utf8&nullCatalogMeansCurrent=true username: root password: root driver-class-name: com.mysql.cj.jdbc.Driver activiti: database-schema-update: true history-level: full db-history-used: true check-process-definitions: true process-definition-location-prefix: classpath:/processes/database-schema-update: true表示启动时按引擎版本自动建表或升级表结构,开发环境最省事,生产环境建议改回false,由发布流程去执行升级 SQL,避免引擎版本切换时自动改表出意外。history-level: full记录完整的历史活动轨迹,排查“流程到底走了哪条线”全靠它;如果只关心结果,audit也能用,但别设成none,否则后面的回归断言全失效。check-process-definitions决定启动时是否解析并校验类路径下的所有流程文件,文件不合法会直接启动失败,这反而是好事,CI 里靠它能提前暴露 BPMN 写错的问题。最后一项是自动部署路径的前缀,默认就是它,下一章展开。
MySQL 8 的驱动对useInformationSchema的处理有差异,连接串里加nullCatalogMeansCurrent=true是常见做法,否则引擎拿不到正确的表目录,建表脚本可能落到陌生库或直接报 table not found。本地想快速起,把驱动换成 H2,URL 写成jdbc:h2:mem:activiti;DB_CLOSE_DELAY=-1即可,其余配置不动。
注意:定时器边界事件依赖引擎的任务执行器,7 系 starter 里
spring.activiti.async-executor-activate默认不开启。流程里画了到期自动提醒这类节点,记得显式打开。
3. 在 Spring Boot 里把 BPMN 部署成 Activiti 流程实例
依赖和配置就位后,下一步就是把设计好的流程“塞”进引擎。很多教程到这里就一句“自动部署”,但实际工作中,自动部署的路径、重复部署的去重、发起实例时的变量,三件事分开理解才不会在报表里看到一堆重复版本。这一章用请假流程把整条链路走一遍。
3.1 resources/processes 目录:自动部署的约定与修改方式
starter 默认扫描类路径下的processes目录,凡是后缀是.bpmn20.xml或.bpmn的文件,启动时都会自动部署。你不需要写任何部署代码就能跑通最小案例,这也是“Activiti 中设计的流程怎么直接集成到应用程序中”最直接的答案:把设计器导出的 BPMN 文件放进src/main/resources/processes/,启动应用即可。
目录前缀和文件名后缀都可以改:前缀由process-definition-location-prefix控制,后缀由process-definition-location-suffixes控制,多个后缀用逗号分隔。常见做法是保持默认,不轻易改,因为团队已经习惯从这个目录找流程文件。三种部署方式各有适用场景:
| 部署方式 | 触发时机 | 适用场景 | 注意点 |
|---|---|---|---|
| 自动扫描 | Spring Boot 启动时 | 本地开发、部署包发布 | 文件路径或 XML 解析错误会拖垮启动 |
| 代码部署 | 运行时调用 DeploymentBuilder | 需要动态注册流程 | 要做好重复部署的去重判断 |
| 引擎管理接口 | 运行时调用 | 流程设计器后台 | 生产环境要加权限控制 |
自动扫描看起来简单,有个隐含风险:只要文件内容变了,重启就会生成一次新部署。同一份请假流程迭代十次,ACT_RE_DEPLOYMENT里出现十条记录是正常现象,别当故障去处理。真正需要关心的是流程定义版本,这个在 3.3 小节讲。
3.2 一个能跑的最小 BPMN:用户任务和排他网关
<?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:activiti="http://activiti.org/bpmn" targetNamespace="LeaveProcess"> <process id="leaveProcess" name="请假审批流程" isExecutable="true"> <startEvent id="start" name="发起"/> <userTask id="managerApprove" name="经理审批" activiti:candidateGroups="managers"/> <exclusiveGateway id="gateway" name="审批结果"/> <endEvent id="end" name="结束"/> <sequenceFlow id="flow1" sourceRef="start" targetRef="managerApprove"/> <sequenceFlow id="flow2" sourceRef="managerApprove" targetRef="gateway"/> <sequenceFlow id="passFlow" name="通过" sourceRef="gateway" targetRef="end"> <conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == true}]]></conditionExpression> </sequenceFlow> <sequenceFlow id="rejectFlow" name="驳回" sourceRef="gateway" targetRef="end"> <conditionExpression xsi:type="tFormalExpression"><![CDATA[${approved == false}]]></conditionExpression> </sequenceFlow> </process> </definitions>这个文件是手写的最简形态:process 的id是流程定义 key,发起实例时要用;candidateGroups让任务进入“经理组”的候选池;排他网关按出线顺序逐条判断条件,表达式里的变量来自发起或办理时传入的上下文。实际设计器导出的文件会多出bpmndi图形信息和一堆扩展节点,跑引擎不需要它们,解析时也不报错,代码文档里保留核心节点就够了。
条件表达式有个容易踩的写法:${approved}和${approved == true}在布尔变量下等价,但如果传进来的approved是字符串"true",前者会直接判为假,后者在表达式中也会得到不预期结果。所以文档代码里约定:所有布尔变量一律用 Java 的Boolean类型传递,字符串只在显示层用。
3.3 部署与发起:DeploymentBuilder 和 RuntimeService 的最小代码
@Service public class LeaveProcessService { private final RepositoryService repositoryService; private final RuntimeService runtimeService; public LeaveProcessService(RepositoryService repositoryService, RuntimeService runtimeService) { this.repositoryService = repositoryService; this.runtimeService = runtimeService; } /** 手动部署一份类路径下的 BPMN,返回部署 ID */ public String deployFromClasspath(String bpmnPath) { Deployment deployment = repositoryService.createDeployment() .name("文档代码示例-请假流程") .addClasspathResource(bpmnPath) .enableDuplicateFiltering() // 资源内容未变时不重复部署 .deploy(); return deployment.getId(); } /** 发起请假实例:businessKey 关联业务单号,变量进入启动上下文 */ public String startLeave(String businessKey, String applicant, String manager, int days) { Map<String, Object> vars = new HashMap<>(); vars.put("applicant", applicant); vars.put("manager", manager); vars.put("days", days); vars.put("approved", false); ProcessInstance instance = runtimeService .startProcessInstanceByKey("leaveProcess", businessKey, vars); return instance.getId(); } }createDeployment()返回部署构建器,addClasspathResource的路径是classpath:之后的部分,例如processes/leave.bpmn20.xml。enableDuplicateFiltering对比的是资源名和内容,内容没变就不产生新部署记录,适合代码部署方式;但注意它不能代替版本管理,流程逻辑变化时仍要主动处理新旧版本并存的问题。startProcessInstanceByKey取得是该 key 的最新已部署版本,所以线上改了流程后再次发起,新单走新逻辑;已经跑着的旧实例继续按旧定义执行,直到结束,这个行为符合大多数业务预期。
businessKey建议直接放业务主键或业务单号,它在ACT_HI_PROCINST里可查,不用专门建映射表就能定位“某张请假单对应哪个流程实例”。变量vars会进入实例的启动上下文,BPMN 网关和任务监听器都能读到;把approved初始化为false是个小技巧,防止某个分支条件读到null时行为不可预期。
4. Activiti 任务处理的参数边界:查询、审批、驳回
流程跑起来之后,主要工作全在任务这一层:候选人怎么看到任务、审批变量怎么影响走向、驳回时怎么让任务回到正确的人手里。这三件事对应 TaskQuery 的入参、complete 的变量、网关的条件,拆开讲才不会被“任务不见了”这类问题卡住。
4.1 TaskQuery 的三个身份参数:assignee、candidateUser、candidateGroup
// 某人的直接待办 List<Task> todo = taskService.createTaskQuery() .taskAssignee("zhangsan") .processDefinitionKey("leaveProcess") .orderByTaskCreateTime().desc() .list(); // 经理组可见的候选任务 List<Task> candidates = taskService.createTaskQuery() .taskCandidateGroup("managers") .orderByTaskCreateTime().asc() .list(); // 签收:候选人认领后,任务归属到具体办理人 taskService.claim(candidates.get(0).getId(), "zhangsan"); taskService.complete(candidates.get(0).getId(), Collections.singletonMap("approved", true));三个查询参数的语义有重叠也有边界,列成表最容易看清:
| 参数 | 查出来的任务 | 常见坑 |
|---|---|---|
| taskAssignee | assignee 等于该用户的任务 | 候选任务必须 claim 后才查得到 |
| taskCandidateUser | 该用户属于其候选组或候选人的任务 | 不含已认领走但没办完的 |
| taskCandidateGroup | 指定候选组下的任务 | 组名拼写错误时静默返回空 |
| taskInvolvedUser | 参与过该实例的用户 | 容易和 assignee 混用,语义不同 |
代码里的claim是把候选任务变成个人任务的动作,之后taskAssignee才能命中该用户。实际产品里常见做法是:候选人点“办理”时自动 claim,或者列表页直接显示候选任务并允许抢办,两种交互对应不同参数组合,前端展示层要区分开。
4.2 审批通过与驳回:complete 变量进入网关的求值顺序
public void approve(String taskId, boolean approved, String comment) { Map<String, Object> vars = new HashMap<>(); vars.put("approved", approved); vars.put("comment", comment); taskService.addComment(taskId, null, comment); // 审批意见写入历史表 taskService.complete(taskId, vars); // 变量先进入上下文,再触发网关判断 }complete是事务性命令:提交的变量会先写进执行上下文,再触发当前节点之后的流转。排他网关从第一条出线开始逐条求值,命中即走,没命中就继续下一条。这就是为什么案例里把approved == false显式写出:只有两条出线时,第二条会被兜住,但一旦后续加了一条“转办”出线而忘记调条件,业务就会报没有匹配的出线。建议给网关配一条default出线兜底;文档代码里把出线顺序写成“先驳回后通过”,理解上更贴近审批常规。
addComment的第二个参数传null,表示这是任务级意见而非流程实例级;意见本身进ACT_HI_COMMENT,办结后也能查询,别把意见塞进业务变量表。办理驳回时,如果希望任务回到上一节点而不是直接结束,需要给每个 userTask 补相应的回退流转和变量标记,例如传approved=false时走回退线并重新设置 assignee,这部分逻辑在网关上用表达式处理,也可以用监听器统一实现。
4.3 卡在网关的报错:查 Execution 而不是猜 BPMN
// 定位流程实例当前停在哪 List<Execution> executions = runtimeService.createExecutionQuery() .processInstanceId(instanceId) .list(); for (Execution e : executions) { // activityId 为 null 的是根执行,不为空的才是当前等待节点 if (e.getActivityId() != null) { System.out.println("停留节点: " + e.getActivityId()); } }网关没有匹配出线时,complete会抛异常,很多人第一反应是去改 BPMN 重发,但更快的路径是先查ACT_RU_EXECUTION:等在那里的执行实例会告诉你它停在哪一步。结合 2.2 的表,RU_EXECUTION中有记录而RU_TASK为空,基本可以断定问题出在网关条件或事件上,而不是用户身份。
另一个容易忽略的性质是事务回滚:complete内部是引擎命令,任何一条路径抛错,整个命令回滚,任务、变量、评论都不会留下半截状态。所以“审批失败但任务不见了”在原生 Api 里不会发生,真出现这种想象,先查是不是有自定义监听器在命令边界外做了额外写入。
5. 用 @SpringBootTest 验证 Activiti 文档代码的三条路径
5.1 测试基类:部署、发起、办结一条命令跑完
@SpringBootTest class LeaveProcessTest { @Autowired RuntimeService runtimeService; @Autowired TaskService taskService; @Autowired HistoryService historyService; @Test void passPathShouldEnd() { ProcessInstance pi = runtimeService.startProcessInstanceByKey( "leaveProcess", "BIZ-001", Collections.singletonMap("approved", false)); Task task = taskService.createTaskQuery() .processInstanceId(pi.getId()).singleResult(); taskService.complete(task.getId(), Collections.singletonMap("approved", true)); assertEquals(1L, historyService.createHistoricProcessInstanceQuery() .processInstanceId(pi.getId()).finished().count()); } }这个测试复用了 starter 的自动部署机制:@SpringBootTest一启动,processes/下的 BPMN 就已注册进引擎,所以它同时覆盖了“部署、发起、办结”三段链路。用历史表finished().count()做断言而不是查运行表,是因为它直接证明流程走到了结束节点,不受运行时残留数据干扰。失败时把org.activiti.engine日志调到 DEBUG 重跑,最有用的三个输出位置是:启动时的建表与 schema 版本、部署时的 resource registered、办结时的任务命令轨迹。
最后补一个把验证固化的技巧:断言别只停留在“能办结”,再用HistoryService取这个实例的历史活动列表,按开始时间升序,断言第一个活动是start、最后一个是end。这样每次 CI 跑测试,既验证了“能发起”,也验证了“能走完”;以后修改 BPMN 或升级 Activiti 版本,流程有没有被改坏,两条断言就能看出来。把这里的流程 key 和任务路径替换成你们自己的业务,这份 Activiti 文档代码就真正集成进了 Spring Boot 工程。
本文还有配套的精品资源,点击获取