Activiti工作流实战:从BPMN部署到动态加签与指定审批人
2026/9/24 23:28:19 网站建设 项目流程

简介:面向流程引擎开发者的功能示例代码包,以动态加签与指定节点审批人为核心,清晰演示流程部署、流程变量传递、任务分配等关键环节的实现思路。压缩包共200个文件、约1.57MB,其中包含63个Java源码、62个class编译文件、56个流程定义文件,并配套XML配置、SQL脚本及properties属性文件,覆盖流程设计、引擎配置到数据初始化的完整链路。已有818人学习浏览,内容适合正在使用主流流程引擎、需要实现复杂审批流的后端工程师。通过源码可掌握流程变量的读取与写入、任务认领与委派等接口的实际调用方式;对照流程定义文件可理解排他网关、包含网关、子流程等建模元素;结合配置文件和数据库脚本,可快速搭起可运行的流程测试工程,并在实际业务中实现动态加签与指定审批人功能。

1. 用 Activity 跑通审批流的日常:先看这批 bpmn 里有什么

拿到这份代码的第一感觉是:东西不多,但每一样都踩在 Activiti 项目最常被问到的点上。exelusive.bpmn 明显是排他网关模型,请假或审批场景里"金额超过阈值走总监、否则直接归档"就是这种结构;sub.bpmn 是子流程,把公共审批步骤抽出来复用;holiday2.bpmn 是请假流程,加签、指定审批人的需求基本都长在请假、报销、采购这类场景里。摘要里提到的部署、动态加签、流程变量、指定节点审批人,正好对应着工作流开发里绕不开的四件事——把流程定义塞进引擎、跑起来、传参数、控制每一步谁来做。这篇笔记就是把这一整套东西从部署讲到跑通,再把最容易出问题的几个点一次性说透,适合刚接触 Activiti 、被网上各种说法绕晕的开发,也适合准备在自己项目里引入审批流的团队。

2. 流程部署与核心 API:从 BPMN 文件到可执行流程实例

2.1 五种流程文件的差异:排他网关、子流程、包含网关怎么选

先看 exelusive.bpmn。Activiti 里排他网关的节点类型是 exclusiveGateway,特征是菱形里面一个 X,分支条件互斥,执行时从上往下找第一个条件为 true 的出口。请假流程里这么写:days > 3走部门经理,否则直接人事归档。这里最容易被忽略的是边界条件——如果所有分支条件都不满足,流程直接抛异常。

include.bpmn 对应包含网关 inclusiveGateway,分支条件允许同时命中多条,满足的出口全部执行。比如审批通过和有会签需求同时触发,两个分支并行跑。它和排他网关的区别是"多选"和"单选",和并行网关的区别是"有条件地并行"而不是无条件并行。sub.bpmn 是子流程,把一段审批逻辑抽出来做成可复用的内嵌或调用子流程,主流程里用一个 callActivity 节点引用它。demo4.bpmn 就是一个演示模型,结构最简单,适合做 API 测试。

实际选型的判断标准就两条:条件之间是否互斥,以及是否需要并行协同。互斥用排他网关,需要并行但带条件用包含网关,公共审批逻辑抽出来用子流程。

2.2 部署代码:RepositoryService 的完整用法

部署在 Activiti 里就是把 BPMN 文件交给 RepositoryService 解析,存进引擎的部署表和流程定义表。项目里最常见的写法是通过 classpath 读取资源:

@Autowired private RepositoryService repositoryService; public String deployProcess(String bpmnResourcePath, String processName) { Deployment deployment = repositoryService.createDeployment() .addClasspathResource(bpmnResourcePath) .name(processName) .enableDuplicateFiltering() .deploy(); return deployment.getId(); }

createDeployment()构建部署对象,addClasspathResource()指定类路径下的 BPMN 文件,name()给部署起个名字方便查,enableDuplicateFiltering()是防重复部署开关——相同资源内容不会重复部署,这个在反复改模型、高频测试时特别有用,不加的话每跑一次就多一条部署记录。deploy()提交后引擎解析 BPMN 并落库,返回的 deployment ID 可以在ACT_RE_DEPLOYMENT表查到。

部署完成后,需要确认流程定义是否生成成功:

List<ProcessDefinition> definitions = repositoryService .createProcessDefinitionQuery() .deploymentId(deploymentId) .list(); for (ProcessDefinition pd : definitions) { String message = String.format( "流程定义: key=%s, version=%d, id=%s", pd.getKey(), pd.getVersion(), pd.getId() ); System.out.println(message); }

这里的 key 来自 BPMN 文件里<process id="holiday">的 id 属性,version 表示同一 key 下的版本号,每次重新部署版本号加一。查询结果为空基本就是文件没被解析到,先去检查 classpath 路径有没有写错、文件名大小写对不对。

2.3 启动流程实例与流程变量的传递

部署只是把模型存好,真正跑起来要启动流程实例,启动的同时把流程变量传进去:

Map<String, Object> variables = new HashMap<>(); variables.put("days", 5); variables.put("applicant", "zhangsan"); variables.put("amount", 12800d); ProcessInstance processInstance = runtimeService .startProcessInstanceByKey("holiday", "BIZ-2024-001", variables);

startProcessInstanceByKey用的是流程定义的 key,不是部署 ID,这样同一个流程模型新版本部署后,业务代码不用改。第二个参数是 businessKey,把业务单号和流程实例绑定,后续从业务表反查流程走到哪一步就靠它。variables 里的数据会被引擎存到ACT_RU_VARIABLE表,排他网关的分支判断就是读这些变量算出来的。

变量类型要注意:days传 Integer,amount传 Double,网关条件里写${amount > 10000}时类型要能对上,字符串 "12800" 和数字 12800 在 SpEL 表达式里的表现完全不同。如果是自定义对象,引擎默认走 Java 序列化,存进数据库的是字节流,后续想跨流程实例查询某个字段就查不了。能传基础类型的就传基础类型,实在要传对象建议转成 JSON 字符串再存,查询的灵活性高很多。

2.4 流程变量的作用域与更新时机

流程变量按作用域分为流程实例级和任务级。流程实例级变量从启动伴随到流程结束,任务级变量的生命周期只到该任务完成。查询的时候要分清用哪个 API:

Map<String, Object> taskVars = taskService.getVariables(taskId); Map<String, Object> processVars = runtimeService.getVariables(processInstanceId);

更新变量也是同样的区别,taskService.setVariable()更新任务级变量,runtimeService.setVariable()更新流程实例级变量。任务完成后任务级变量会被清理,而流程实例级变量会保留到流程结束并归档到历史表。实际项目里,任务监听器中动态改变量的高频写法是delegateTask.getExecution().setVariable("key", value),这是监听器上下文里最稳的更新方式,能确保变量挂在当前执行实例上。

3. 动态加签与指定审批人:两处最实用的扩展点

3.1 加签的本质:向运行中的任务插入审批节点

加签这个词在不同项目里含义不完全一样,但核心诉求是同一个:审批过程中,当前审批人觉得需要多一个人参与意见。Activiti 里没有一键加签的原生 API,常见做法分两类。

一类是在 BPMN 模型层面预留加签分支,用并行网关或者多实例节点实现,运行时通过流程变量控制要不要激活加签子流程。这个方案干净、可追踪,但流程定义复杂。另一类是直接操作运行时任务,往当前任务上追加候选人,代码是这个:

Task task = taskService.createTaskQuery() .processInstanceId(processInstanceId) .singleResult(); taskService.addCandidateUser(task.getId(), "shenpi_wang");

addCandidateUser加的是候选审批人,王工可以在自己的待办列表里看到这个任务,但任务不独占归属,需要他自己 claim(认领)之后才成为办理人。这个语义上的差异必须分清:候选人出现在待办查询里,可认领;办理人是独占归属,其他人在待办里看不到这个任务。

如果业务上要求直接指派给某个人并立刻出现在他的待办里,用setAssignee

taskService.setAssignee(task.getId(), "shenpi_wang");

3.2 指定节点审批人:setAssignee 与流程变量的组合

指定节点审批人是用户任务最核心的配置项,摘要里提到的taskOwnersetAssignee是两种语义不同的设置。owner 是创建人,assignee 是办理人。实际项目里最灵活的做法不是写死 assignee,而是通过流程变量动态指定。

在 BPMN 的 userTask 节点上这样设计:

<userTask id="approveTask" name="审批" activiti:assignee="${approver}"/>

approver 的值在启动流程或任务流转时通过变量传入。启动时指定审批人:

Map<String, Object> variables = new HashMap<>(); variables.put("approver", "lisi"); variables.put("days", 4); runtimeService.startProcessInstanceByKey("holiday", variables);

这样流程定义本身不绑定具体人员,谁审批由运行时变量决定。如果需要在任务创建时动态计算审批人,用监听器更合适:

@Component("approverListener") public class ApproverListener implements TaskListener { @Override public void notify(DelegateTask delegateTask) { if (TASK_CREATE.equals(delegateTask.getEventName())) { String approver = calculateApprover(delegateTask); delegateTask.setAssignee(approver); } } private String calculateApprover(DelegateTask delegateTask) { Integer days = (Integer) delegateTask.getVariable("days"); return days != null && days > 3 ? "manager01" : "hr01"; } }

监听器里setAssignee生效的时机是在用户任务创建事件里,这个时间点拿到的流程变量是完整的。监听器比直接在 BPMN 里写${approver}灵活的地方在于:审批人经过逻辑计算而不是纯变量传递。

3.3 把加签做成完整的业务功能:任务复制方案的取舍

如果业务上要的是"主审批人审批的同时加一个协办人",只addCandidateUser不够,因为候选人被认领后原审批人的任务就没了。一种粗暴但常见的做法是复制当前任务再生成一个新任务,把两个任务指向同一节点,这样两个人都在自己的待办里看到,各批各的。代码上很多人直接往ACT_RU_TASK插数据,但这会带来两个问题:流程实例里存在两个并行任务,后续任务完成时不知道以谁为准;历史表里两个任务没有关联关系,审计追溯困难。

Activiti 社区推荐的做法是在流程设计阶段就预留并行分支。在用户任务后面接一个并行网关,网关出来两个分支,一个走原审批人,一个走协办人,两条分支汇合后再进入下一节点。这个方案叫"静态加签",加签分支可通过变量控制是否激活,模型上看起来复杂一点,但流程数据的完整性和可追溯性是最好的。

实际项目的取舍建议:低频加签场景,用并行网关加变量控制,模型改一次就行;高频且加签人数不固定的场景,考虑多实例节点,Activiti 的activiti:collection属性可以直接指定一个审批人列表实现会签:

<userTask id="multiApproveTask" name="会签审批" activiti:assignee="${approver}" activiti:collection="${assigneeList}" activiti:elementVariable="approver"/>

assigneeList是流程变量,存一个 List ,引擎会为列表里的每个人创建一个任务实例,这就是多实例会签的标准做法。

4. 实战避坑:流程跑不通时先查这五处

4.1 排他网关条件永远命中第一个分支

现象:流程实例流转到网关后,明明 amount 是 8000,设计上应该走经理审批,结果直接走了总监审批分支。

原因:Activiti 的排他网关从上到下匹配条件,第一个条件为 true 就选它,不会做"最优匹配"。如果第一个分支条件写得宽泛,比如${amount >= 0},后面严格的${amount > 10000}永远没机会执行。

解决:给每个出口条件加上显式边界,优先把最严格的条件放在最前面。条件表达式改成${amount > 10000}${amount > 3000 and amount <= 10000}${amount <= 3000}这样逐层收窄,并保证边界值不会同时落入两个分支。

4.2 加签后两个人都审批,流程却提前结束了

现象:给当前任务加了协办人后,主审批人完成任务,流程直接走到了下一个节点,协办人的待办任务还挂着,但已经没有意义。

原因:复制任务或直接在运行时任务表插数据创建的并行任务,不在流程模型的网关控制范围内。主任务完成时,引擎认为当前节点已结束,直接推进到下一步,不会等其他任务。

解决:不要绕过流程模型去插运行时任务。需要在同一节点并行审批的,用并行网关或activiti:collection多实例节点,让引擎自己管理分支汇合。加签需求最好在流程设计阶段就预留分支,而不是运行期改数据。

4.3 流程变量传了自定义对象,历史表查询直接报错

现象:流程跑完后,通过 HistoryService 查历史变量时抛 ClassNotFoundException 或者反序列化异常。

原因:流程变量存了自定义 Java 对象,引擎默认 Java 序列化写入ACT_HI_VARINST。一旦类的包名或结构发生变化,反序列化时找不到原类就炸了。而且历史数据跨版本查询时,老的字节流和新代码不兼容。

解决:流程变量尽量用 String、Integer、Double 等基础类型。必须传对象时,手动转 JSON 字符串存入,取出来再反序列化,这样即使实体类结构变了,JSON 字段也能兼容。历史表里存 JSON 还有一个好处,写 SQL 可以直接按内容模糊查询。

4.4 指定了审批人,待办列表里却查不到

现象:BPMN 里activiti:assignee="${approver}",流程启动时变量里也传了 approver,但用 TaskQuery 按 assignee 查待办,结果是空的。

原因:变量名拼写不一致,或者启动流程时流程变量没传到用户任务所在的执行实例上。如果审批任务在子流程里,父流程设置的变量不一定自动可见,变量作用域是执行实例级的。

解决:先确认 BPMN 文件里${approver}和代码里variables.put("approver", ...)的 key 完全一致,一个字符都不能差。变量在子流程里不可见时,通过ExecutionListenerTaskListener显式把父流程变量拷贝到子流程执行实例上。调试时打印delegateTask.getExecution().getVariables()看实际有哪些变量。

4.5 多次部署后跑的流程还是旧逻辑

现象:改了 BPMN 文件重新部署,新发起的流程实例执行得还是旧的节点顺序,看起来像是没生效。

原因:startProcessInstanceByKey默认启动当前 key 最新版本的定义,但如果部署时加了enableDuplicateFiltering(),并且文件内容哈希没变化,引擎会跳过这次部署,导致流程定义版本没更新。另一种情况是缓存,Activiti 对流程定义有内存缓存,同一 JVM 里修改后立即启动可能命中旧缓存。

解决:部署确认processDefinition.getVersion()确实 +1 了,再去启动流程实例。排查缓存可以先强制重新部署一次,确认版本变化后再启动。如果还是旧逻辑,检查启动代码是不是用了固定的部署 ID 或流程定义 ID,那会绕过 version 逻辑。

5. 把示例流程改成真实业务的完整套路:替换 BPMN 的三个步骤

有了前面的基础,把 holiday2.bpmn 改成真实业务是最快的内化方式。我自己改的话流程固定三个阶段。

第一步,复制模型文件,替换业务节点和网关条件。拿一份 holiday2.bpmn,把用户任务的 name 改成真实业务节点,比如把"部门经理审批"改成"区域经理审批",网关条件从${days > 3}改成${amount > 50000}。改完先别急着部署,把文件拖到浏览器里检查节点 id 有没有重复,条件表达式里的变量名和计划里的流程变量能不能对上。id 重复是新手最高频的错误,HTML 解析器不报错,但引擎部署时直接抛异常。

第二步,写一个最小化测试类覆盖主流程,核心逻辑是三段断言:部署返回的 deployment ID 不为空、启动流程实例成功、网关分支走到预期的用户任务上:

@Test public void testHolidayProcess() { String deploymentId = deployProcess("holiday2.bpmn", "请假流程"); Assert.assertNotNull(deploymentId); Map<String, Object> variables = new HashMap<>(); variables.put("days", 5); variables.put("approver", "lisi"); ProcessInstance processInstance = runtimeService .startProcessInstanceByKey("holiday", variables); Assert.assertNotNull(processInstance); Task task = taskService.createTaskQuery() .processInstanceId(processInstance.getId()) .singleResult(); Assert.assertEquals("部门经理审批", task.getName()); Assert.assertEquals("lisi", task.getAssignee()); }

这段测试的作用是帮你在改流程定义时快速反馈:改了网关条件会不会走到错误分支,改了审批人变量能不能正确落到任务上。开发环境每改一次就跑一遍,比部署到测试环境再手工点流程要快得多。

第三步,验证历史数据的完整性。流程走完一个完整实例后,用 HistoryService 查一遍这个流程实例的完整轨迹,这个习惯能帮你发现运行时进程表和历史进程表中是否一致。比如加签节点的历史任务是否有完整的时间记录,流程变量是否在流程结束后仍然可查。

List<HistoricActivityInstance> activities = historyService .createHistoricActivityInstanceQuery() .processInstanceId(processInstanceId) .orderByHistoricActivityInstanceStartTime() .asc() .list(); for (HistoricActivityInstance activity : activities) { String message = String.format( "%s -> %s (%s)", activity.getActivityName(), activity.getActivityType(), activity.getDurationInMillis() ); System.out.println(message); }

查出来的结果应当和 BPMN 文件里的节点顺序完全一致,如果出现重复节点或缺失节点,说明网关条件的判断有问题或某个边界事件的触发时机不对。

说句实在话,Activiti 项目翻车的场景我见过很多,大部分不是 API 不会调,而是流程模型和代码对不上——变量名拼错、网关条件有缝隙、部署版本没更新,都是在小地方栽跟头。所以我现在每改一个 BPMN 文件,都强制自己先部署确认版本号 +1,再启动流程实例跑一遍完整链路,最后查历史表验证节点序列和变量记录。这套动作看着繁琐,但救回过很多次上线的命。希望这份笔记能让你少走几个弯路,把这些功能一次跑通。

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

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

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

立即咨询