1. 工作流引擎选型与整体设计思路
1.1 为什么在众多方案里挑了Activiti
做过审批流、工单流转、OA系统的人,绕不开一个核心问题:流程引擎到底选谁。市面上常见的有Activiti、Flowable、Camunda、JBPM这几家,底层都脱胎于早期的PVM(Process Virtual Machine)思想,但各自演进路线不同。我当年选Activiti,主要基于三个现实考量。
第一是API的直观程度。Activiti的RuntimeService、TaskService、RepositoryService、HistoryService这几大服务接口划分得非常清晰,基本上看名字就知道该用哪个。新手接手项目时,学习曲线相对平缓,不用一上来就啃一堆抽象概念。
第二是BPMN 2.0规范的贴合度。Activiti对BPMN 2.0的支持相当完整,尤其是BpmnModel这套对象模型,允许我们在代码层面动态构建、修改流程图,而不是只能依赖可视化设计器导出的静态XML。这一点对于需要"运行时动态调整流程"的场景至关重要。
第三是社区沉淀。Activiti在国内的实践案例多,遇到问题搜索时能找到大量真实踩坑记录,这对项目推进的隐性帮助非常大。
需要说明的是,Flowable是Activiti团队分叉出去的后续项目,很多API设计一脉相承。如果你现在新起项目,Flowable在部分特性上更活跃;但如果是维护存量系统,或者团队已有Activiti经验,继续用Activiti完全没问题。本文的讨论以Activiti 6/7这条线为主,核心思路对Flowable同样适用。
1.2 这套方案要解决的核心痛点
标题里点出的几个能力——退回、跳过、强制结束、BpmnModel操作、运行中流程图、流程变量、候选人操作——其实对应了真实业务里最容易被产品经理追问的几个问题:
- "这个审批能不能打回去让上一步重填?"
- "某个节点领导说不用审了,能不能直接跳过去?"
- "流程卡住了,管理员能不能强制终止?"
- "用户想看看现在流程走到哪了,能不能画个图?"
- "这个任务该谁处理?能不能指定多个人抢单?"
这些需求,Activiti原生API并没有全部提供"开箱即用"的方法。比如退回,标准BPMN里没有"回退到上一节点"这种语义,需要我们自己通过RuntimeService的createChangeActivityStateBuilder或者操作BpmnModel来实现。跳过和强制结束同理,都需要对引擎内部机制有理解才能做得干净。
所以这篇文章不是简单的API罗列,而是围绕这些"原生不直接支持但业务必须要"的能力,把实现路径、原理和坑点讲透。
1.3 整体技术架构与依赖准备
先把环境交代清楚,避免读者在版本问题上浪费时间。我实测比较稳的组合是:
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | 8 / 11 | Activiti 6对JDK 8友好,7开始建议11 |
| Activiti | 6.0.0 或 7.x | 6.0.0最经典,7.x API有调整 |
| Spring Boot | 2.x | 配合activiti-spring-boot-starter |
| 数据库 | MySQL 5.7+ | 引擎自带25张表,需提前建库 |
| 连接池 | Druid/HikariCP | 生产环境务必配置 |
Maven依赖的核心坐标(以6.0.0为例):
<dependency> <groupId>org.activiti</groupId> <artifactId>activiti-spring-boot-starter-basic</artifactId> <version>6.0.0</version> </dependency> <dependency> <groupId>org.activiti</groupId> <artifactId>activiti-image-generator</artifactId> <version>6.0.0</version> </dependency>activiti-image-generator这个包单独拎出来说,因为运行中流程图的生成依赖它。很多人只引了starter,结果画图时找不到类,白白排查半天。
提示:Activiti启动时会自动检查并创建表结构(
databaseSchemaUpdate设为true时)。生产环境建议设为false,用官方SQL脚本手动初始化,避免误操作。
配置上,application.yml里至少要配好数据源和async-executor:
spring: datasource: url: jdbc:mysql://localhost:3306/activiti_demo?useUnicode=true&characterEncoding=utf8 username: root password: your_password driver-class-name: com.mysql.cj.jdbc.Driver activiti: database-schema-update: true async-executor-activate: true history-level: fullhistory-level设成full很关键。默认是audit,只记录流程实例和活动,不记录变量和任务细节。一旦你要做"流程变量历史查询"或者"退回时恢复历史变量",audit级别会让你抓瞎。代价是历史表数据量增长快,需要配套清理策略。
2. 核心API能力拆解与实操要点
2.1 七大Service接口的职责边界
Activiti的API设计遵循"单一职责",每个Service管一块。新手最容易混淆的是RuntimeService和TaskService,我见过有人用TaskService去启动流程,结果报错还找不到原因。这里用一张表把边界划清楚:
| Service | 核心职责 | 典型方法 |
|---|---|---|
| RepositoryService | 流程定义(部署、查询、删除) | createDeployment()、createProcessDefinitionQuery() |
| RuntimeService | 流程实例运行态(启动、变量、信号) | startProcessInstanceByKey()、setVariable() |
| TaskService | 用户任务(查询、完成、候选人) | complete()、claim()、addCandidateUser() |
| HistoryService | 历史数据(已结束流程、活动记录) | createHistoricProcessInstanceQuery() |
| ManagementService | 引擎管理(作业、表、定时器) | createJobQuery()、executeJob() |
| IdentityService | 用户与组(可选,很多项目自己管) | createUser()、createGroup() |
| FormService | 表单(用得少,多数项目自研表单) | getTaskFormData() |
理解这张表,后面所有操作都能对号入座。比如获取流程变量,运行态用RuntimeService.getVariables(),历史态用HistoryService.createHistoricVariableInstanceQuery(),两者数据来源不同,别搞混。
2.2 BpmnModel:代码操作流程图的入口
BpmnModel是Activiti对BPMN文件的Java对象映射。一个.bpmn文件解析后,就是一个BpmnModel对象,里面装着Process、FlowElement、SequenceFlow、UserTask等元素。为什么必须掌握它?因为退回、跳过、动态加签这些操作,本质上都是在改流程图的节点和连线关系。
获取BpmnModel的标准姿势:
@Autowired private RepositoryService repositoryService; public BpmnModel getBpmnModel(String processDefinitionId) { return repositoryService.getBpmnModel(processDefinitionId); }拿到之后,你可以遍历所有节点:
BpmnModel model = getBpmnModel(processDefinitionId); Process process = model.getMainProcess(); Collection<FlowElement> elements = process.getFlowElements(); for (FlowElement element : elements) { if (element instanceof UserTask) { UserTask task = (UserTask) element; System.out.println("用户任务:" + task.getId() + " - " + task.getName()); } }这里有个关键细节:getBpmnModel返回的是流程定义的模型,是只读的(严格说是缓存对象,改了会影响后续所有实例)。如果你要基于它做动态修改,必须先深拷贝,否则会污染整个流程定义。我踩过这个坑——改了模型后,所有新启动的实例都跟着变了,排查了很久才发现是缓存被污染。
深拷贝的简单做法是序列化再反序列化,或者用BpmnXMLConverter转成XML字符串再转回来:
BpmnXMLConverter converter = new BpmnXMLConverter(); byte[] xmlBytes = converter.convertToXML(bpmnModel); BpmnModel copy = converter.convertToBpmnModel(new ByteArrayInputStream(xmlBytes), true, false);2.3 流程变量的读写与作用域
流程变量是流程流转的"血液",网关判断、任务分配、表单回显都靠它。Activiti的变量有作用域概念,分全局变量(Execution级别)和本地变量(Task级别)。
设置全局变量:
runtimeService.setVariable(processInstanceId, "amount", 5000); runtimeService.setVariables(processInstanceId, map);设置任务本地变量:
taskService.setVariableLocal(taskId, "approveComment", "同意");区别在于:全局变量在整个流程实例内可见,本地变量只在当前任务可见,任务结束后本地变量会随任务归档到历史表。实操心得:审批意见这类"只属于某个节点"的数据,用本地变量;金额、申请人这类"全程要用"的,用全局变量。混用会导致网关判断时取不到值。
读取时也有讲究:
// 运行态读取 Map<String, Object> vars = runtimeService.getVariables(processInstanceId); // 历史态读取(流程已结束) List<HistoricVariableInstance> list = historyService .createHistoricVariableInstanceQuery() .processInstanceId(processInstanceId) .list();注意:流程结束后,
RuntimeService查不到变量了,必须走HistoryService。很多人在流程结束后还想用runtimeService.getVariable,结果返回null,以为是数据丢了,其实是查错了地方。
3. 退回、跳过、强制结束的完整实现
3.1 退回:从当前节点回到指定历史节点
退回是审批流里最高频的需求。BPMN规范里没有"退回"这个动作,实现思路是:找到目标节点,用changeState把当前执行流"搬"过去。
Activiti提供了RuntimeService.createChangeActivityStateBuilder(),这是退回的核心工具:
public void rollbackToTask(String processInstanceId, String currentActivityId, String targetActivityId) { runtimeService.createChangeActivityStateBuilder() .processInstanceId(processInstanceId) .moveActivityIdTo(currentActivityId, targetActivityId) .changeState(); }moveActivityIdTo的意思是:把当前停在currentActivityId的执行流,移动到targetActivityId。移动后,目标节点会重新生成一个待办任务。
但这里有几个必须处理的细节:
第一,目标节点必须是历史走过的节点。如果目标节点在当前流程路径上从未执行过,changeState会报错或产生不可预期的结果。所以退回前要先查历史活动:
List<HistoricActivityInstance> history = historyService .createHistoricActivityInstanceQuery() .processInstanceId(processInstanceId) .activityType("userTask") .orderByHistoricActivityInstanceStartTime().asc() .list();第二,退回后原任务的变量要处理。如果原任务有本地变量,退回后这些变量不会自动带过去。通常做法是退回前把关键变量提升为全局变量,或者手动复制。
第三,多实例节点退回要特殊处理。如果当前节点是会签(多实例),moveActivityIdTo的行为会比较复杂,可能需要先deleteTask再重建。这块建议单独测试。
我封装过一个通用的退回方法,把历史节点列表返回给前端,让用户选择退回到哪一步:
public List<Map<String, Object>> getRollbackNodes(String processInstanceId) { List<HistoricActivityInstance> list = historyService .createHistoricActivityInstanceQuery() .processInstanceId(processInstanceId) .activityType("userTask") .finished() .orderByHistoricActivityInstanceEndTime().desc() .list(); List<Map<String, Object>> result = new ArrayList<>(); for (HistoricActivityInstance h : list) { Map<String, Object> node = new HashMap<>(); node.put("activityId", h.getActivityId()); node.put("activityName", h.getActivityName()); node.put("assignee", h.getAssignee()); node.put("endTime", h.getEndTime()); result.add(node); } return result; }3.2 跳过:让流程越过当前节点继续走
"跳过"和"退回"方向相反——退回是往回走,跳过是往前跨。典型场景:某个审批节点领导说不用审了,直接进入下一环节。
实现思路有两种,各有适用场景。
方案一:用changeState移动到下一个节点。先通过BpmnModel找到当前节点的出口连线,确定下一个节点ID,然后:
runtimeService.createChangeActivityStateBuilder() .processInstanceId(processInstanceId) .moveActivityIdTo(currentActivityId, nextActivityId) .changeState();方案二:直接complete当前任务,但用变量控制网关跳过。这种方式适合流程设计时就预留了"跳过"分支的情况,通过设置变量让排他网关走另一条路。
方案一更灵活,但需要自己解析流程图找下一节点。找下一节点的代码:
public String findNextActivityId(String processDefinitionId, String currentActivityId) { BpmnModel model = repositoryService.getBpmnModel(processDefinitionId); Process process = model.getMainProcess(); FlowElement current = process.getFlowElement(currentActivityId); if (current instanceof FlowNode) { List<SequenceFlow> outgoing = ((FlowNode) current).getOutgoingFlows(); if (!outgoing.isEmpty()) { return outgoing.get(0).getTargetRef(); } } return null; }提示:如果当前节点后面是网关,直接跳到网关的下一个用户任务会更符合业务预期。实际项目中,我通常会把"跳过"做成"跳到指定节点",让操作人自己选目标,而不是机械地跳下一个。
3.3 强制结束:管理员终止流程实例
流程卡死、业务取消、数据错误时,需要强制结束。Activiti提供了deleteProcessInstance,但直接删会丢失历史数据,不推荐。更好的做法是先记录原因,再删除,或者用changeState把流程导向结束节点。
删除并保留历史:
runtimeService.deleteProcessInstance(processInstanceId, "管理员强制终止:业务取消");第二个参数是删除原因,会记录到历史表。删除后,HistoryService仍能查到这条流程实例,状态是DELETED。
如果业务要求"流程正常结束而不是删除",可以找到结束节点,把执行流移过去:
public void forceEnd(String processInstanceId, String processDefinitionId) { BpmnModel model = repositoryService.getBpmnModel(processDefinitionId); Process process = model.getMainProcess(); String endEventId = null; for (FlowElement element : process.getFlowElements()) { if (element instanceof EndEvent) { endEventId = element.getId(); break; } } if (endEventId != null) { List<Execution> executions = runtimeService.createExecutionQuery() .processInstanceId(processInstanceId).list(); for (Execution execution : executions) { if (execution.getActivityId() != null) { runtimeService.createChangeActivityStateBuilder() .moveActivityIdTo(execution.getActivityId(), endEventId) .changeState(); } } } }两种方式的选择:要留痕、要审计,用删除+原因;要流程状态干净、后续统计方便,用导向结束节点。我个人倾向后者,因为删除的流程在报表里是"异常终止",而导向结束节点是"正常完成",数据更整洁。
4. 运行中流程图与候选人操作实战
4.1 生成带高亮的运行中流程图
用户最想看到的功能之一:打开流程详情,一张图显示当前走到哪了,走过的节点高亮。Activiti的ProcessDiagramGenerator就是干这个的。
核心代码:
@Autowired private ProcessEngine processEngine; @Autowired private RepositoryService repositoryService; @Autowired private RuntimeService runtimeService; @Autowired private HistoryService historyService; public InputStream generateDiagram(String processInstanceId) { HistoricProcessInstance hpi = historyService .createHistoricProcessInstanceQuery() .processInstanceId(processInstanceId).singleResult(); BpmnModel bpmnModel = repositoryService.getBpmnModel(hpi.getProcessDefinitionId()); List<HistoricActivityInstance> history = historyService .createHistoricActivityInstanceQuery() .processInstanceId(processInstanceId) .orderByHistoricActivityInstanceStartTime().asc() .list(); List<String> highLightedActivities = new ArrayList<>(); List<String> highLightedFlows = new ArrayList<>(); for (HistoricActivityInstance h : history) { highLightedActivities.add(h.getActivityId()); } ProcessDiagramGenerator generator = processEngine.getProcessEngineConfiguration() .getProcessDiagramGenerator(); return generator.generateDiagram( bpmnModel, "png", highLightedActivities, highLightedFlows, "宋体", "宋体", "宋体", null, 1.0, true ); }几个实操要点:
- 中文字体必须指定,否则生成的图里中文全是方框。上面代码里的"宋体"就是干这个的,服务器上要确保有这个字体,Linux环境可能需要额外安装。
- 高亮连线需要自己计算。
highLightedFlows要传的是SequenceFlow的ID,不是节点ID。判断一条连线是否"走过",可以看它的源节点和目标节点是否都在历史活动里。 - 性能问题。每次请求都重新生成图,开销不小。生产环境建议加缓存,key用
processInstanceId + 最后活动时间,流程没变化就直接返回缓存的图片流。
高亮连线的计算逻辑:
public List<String> getHighLightedFlows(BpmnModel bpmnModel, List<HistoricActivityInstance> history) { List<String> flows = new ArrayList<>(); Set<String> finishedActivities = new HashSet<>(); for (HistoricActivityInstance h : history) { finishedActivities.add(h.getActivityId()); } Process process = bpmnModel.getMainProcess(); for (FlowElement element : process.getFlowElements()) { if (element instanceof SequenceFlow) { SequenceFlow flow = (SequenceFlow) element; if (finishedActivities.contains(flow.getSourceRef()) && finishedActivities.contains(flow.getTargetRef())) { flows.add(flow.getId()); } } } return flows; }4.2 候选人:认领、查询与动态加签
候选人机制解决的是"这个任务该谁做"的问题。Activiti支持三种分配方式:直接指派(assignee)、候选人(candidateUser)、候选组(candidateGroup)。候选人模式下,任务处于"待认领"状态,谁先claim谁负责。
添加候选人:
taskService.addCandidateUser(taskId, "user001"); taskService.addCandidateUser(taskId, "user002"); taskService.addCandidateGroup(taskId, "managerGroup");查询某用户的待办(包括直接指派和候选人):
List<Task> tasks = taskService.createTaskQuery() .taskCandidateOrAssigned("user001") .list();认领任务:
taskService.claim(taskId, "user001");认领后,assignee变成user001,其他候选人就看不到这个任务了(除非用taskCandidateUser查,但通常业务上认领后就该独占)。
动态加签是候选人机制的进阶用法。场景:审批过程中,当前审批人想拉一个人一起审。实现方式是给任务动态添加候选人:
public void addSigner(String taskId, String userId) { taskService.addCandidateUser(taskId, userId); }如果要实现"加签后必须等加签人处理完才能继续",那就不是简单加候选人,而是要修改流程结构——在BpmnModel里动态插入一个用户任务节点。这种操作复杂度高,我一般建议用**会签(多实例)**在设计阶段就解决,而不是运行时动态改图。
注意:
claim操作有并发问题。两个人同时点"认领",可能都成功。Activiti内部用乐观锁处理,但业务层最好加一层判断:认领前先查assignee是否为空,不为空就提示"已被认领"。
4.3 候选人查询的常见坑
taskCandidateOrAssigned这个方法看起来方便,但有个性能陷阱:它生成的SQL包含OR条件,在任务量大时可能走不了索引。我实测过,任务表超过10万条时,这个查询会明显变慢。
优化思路是拆成两次查询再合并:
List<Task> assigned = taskService.createTaskQuery() .taskAssignee(userId).list(); List<Task> candidate = taskService.createTaskQuery() .taskCandidateUser(userId).list(); // 合并去重另外,候选组查询要注意用户和组的关联关系。Activiti的IdentityService可以管用户组,但很多项目用自己的用户体系。这时需要自己维护"用户-组"映射,查询时把用户所属的所有组传进去:
List<String> groups = getUserGroups(userId); List<Task> tasks = taskService.createTaskQuery() .taskCandidateGroupIn(groups) .list();5. 常见问题与排查技巧实录
5.1 退回后流程走不动了
这是最高频的问题。表现是:退回成功,目标节点生成了任务,但完成这个任务后流程不往下走,或者报"找不到出口连线"。
根因通常是changeState移动执行流时,没有正确处理并行网关或排他网关的分支。比如流程有并行分支,退回时只移动了其中一条执行流,另一条还停在原地,导致流程状态不一致。
排查步骤:
- 查
ACT_RU_EXECUTION表,看这个流程实例下有几条执行流,ACT_ID_分别是什么。 - 对比
BpmnModel里的节点结构,确认执行流数量和位置是否符合预期。 - 如果有多条执行流,退回时要对每条都做
moveActivityIdTo,或者先合并再移动。
避坑技巧:退回操作尽量只在线性流程或简单分支上使用。如果流程里有复杂的并行网关,建议用"作废当前流程、重新发起"代替退回,虽然粗暴但稳定。
5.2 流程变量在退回后丢失
前面提过,本地变量不随退回传递。除此之外,还有一个隐蔽原因:changeState会触发执行流的销毁和重建,某些监听器里的变量设置逻辑可能没被执行。
解决方案:退回前,把需要保留的变量统一提升为全局变量:
Map<String, Object> localVars = taskService.getVariablesLocal(taskId); runtimeService.setVariables(processInstanceId, localVars);退回后,如果需要恢复原任务的本地变量,再手动setVariableLocal回去。
5.3 运行中流程图中文乱码
前面提过字体问题,这里补充一个细节:不同操作系统的字体名称不一样。Windows上是"宋体",Linux上可能是"SimSun"或者需要指定字体文件路径。
更稳妥的做法是加载字体文件:
InputStream fontStream = new ClassPathResource("fonts/simsun.ttf").getInputStream(); Font font = Font.createFont(Font.TRUETYPE_FONT, fontStream);然后在generateDiagram时传入字体名称。这样不依赖服务器环境,部署到哪都一致。
5.4 候选人任务查不到
用户明明被加为候选人了,但taskCandidateUser查不出来。常见原因有三个:
- 候选人加在了错误的表。
addCandidateUser写的是ACT_RU_IDENTITYLINK表,如果直接操作数据库而没走API,可能类型字段TYPE_不对(应该是candidate)。 - 任务已经认领。认领后
assignee有值,taskCandidateUser默认只查未认领的。要查全部,用taskCandidateOrAssigned。 - 用户ID大小写或空格问题。这个最坑,肉眼看不出来,建议查询前
trim一下。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 排查方向 | 解决方式 |
|---|---|---|---|
| 退回后流程卡住 | 并行分支执行流不一致 | 查ACT_RU_EXECUTION | 对每条执行流分别移动 |
| 变量退回后丢失 | 本地变量不传递 | 查变量作用域 | 退回前提升为全局变量 |
| 流程图中文乱码 | 字体未指定 | 检查生成代码 | 指定字体或加载字体文件 |
| 候选人查不到 | 认领状态/类型错误 | 查ACT_RU_IDENTITYLINK | 用taskCandidateOrAssigned |
| 强制结束后历史丢失 | 用了deleteProcessInstance | 查历史表 | 改用导向结束节点 |
| 流程图生成慢 | 无缓存 | 看请求频率 | 加缓存,按最后活动时间失效 |
5.6 几个我踩过的坑
坑一:getBpmnModel返回的对象被意外修改。前面提过,这个对象是缓存的。我有次在代码里直接改了它的节点名称,结果所有实例的流程图都变了。后来养成习惯,拿到BpmnModel第一件事就是深拷贝。
坑二:changeState在事务里调用失败不回滚。changeState内部有自己的事务逻辑,如果外层事务回滚,流程状态可能已经变了。建议把退回操作放在独立事务里,或者用编程式事务精确控制。
坑三:历史级别设成audit导致退回找不到历史节点。audit级别不记录HistoricActivityInstance的详细信息,退回时查历史节点会查不全。这个坑很隐蔽,因为流程能正常跑,只有退回功能出问题。所以前面强调history-level要设full。
坑四:候选人加签后忘记发通知。技术上任务加上了,但被加签的人不知道。业务上要在addCandidateUser之后触发消息通知,这个不是Activiti的职责,但很容易漏。
6. 一些延伸思考与个人体会
6.1 关于"动态改流程"的边界
标题里的BpmnModel操作,本质是"运行时动态改流程"。这个能力很强大,但也很危险。我的经验是:能用设计期解决的,绝不留到运行期。比如加签,优先用多实例会签;比如跳过,优先用网关分支。动态改图只用在真正无法预判的场景,比如"退回任意节点"这种产品硬需求。
原因很简单:动态改图后,流程的实际走向和设计器里画的图不一致,后续维护的人会一脸懵。而且Activiti对动态修改的支持并非无懈可击,复杂场景下容易出诡异问题。
6.2 关于版本升级
Activiti 6到7的API有一些破坏性变更,比如部分changeState相关的方法签名调整了。如果项目要升级,退回、跳过这些功能是重点回归对象。我的建议是:升级前把本文涉及的每个功能都写一个集成测试,升级后跑一遍,比人工点界面靠谱得多。
6.3 关于性能
流程引擎的性能瓶颈通常在历史表和任务查询上。历史表数据量大了之后,HistoricActivityInstanceQuery会变慢。我一般会做两件事:一是定期归档历史数据到冷表,二是给ACT_HI_ACTINST的PROC_INST_ID_和ACT_ID_加索引。
任务查询方面,taskCandidateOrAssigned的优化前面说过了。另外,待办列表分页时,orderByTaskCreateTime().desc()配合索引效果最好,别用orderByTaskId,那个没索引。
最后分享一个小技巧:如果你要频繁生成运行中流程图,可以把生成的PNG存到文件系统或对象存储,数据库里只存URL。流程每次流转时更新一次图,用户请求时直接返回URL,比实时生成快一个数量级。这个优化在流程实例多、用户访问频繁的系统里效果特别明显。