简介Activiti6 中文用户指南面向 Java 工作流开发初学者与需要落地 BPMN 2.0 流程引擎的工程师帮助读者系统掌握流程建模、部署与执行的核心方法。资源包共 1 个 docx 文件约 4.22MB内容为完整中文文档涵盖系统要求、快速入门、ProcessEngine 与数据库配置、Job Executor、邮件服务器、历史记录、事件监听器、Activiti API、Spring 与 Spring Boot 集成、BAR 部署、版本控制及 BPMN 2.0 各类事件与边界事件等章节目录层级清晰便于按模块查阅。已有 1816 人学习下载适合作为日常开发与配置排错的案头参考。读者可借助它快速理解流程引擎的配置项与 API 用法对照示例完成流程定义、任务处理与单元测试并掌握定时任务、信号与消息事件等进阶特性的落地思路。1. Activiti6 中文用户指南从流程定义到会签网关的落地拆解很多团队第一次把 Activiti6 引入业务系统时都会经历一个相似的阶段照着官方示例跑通了请假流程觉得不过如此等到真正要落地一个带会签、驳回、加签、动态路由的审批流才发现文档里没写的细节才是真正吃时间的地方。这份中文用户指南要解决的正是从「能跑通 Demo」到「能扛住业务」之间的那段空白。它适合已经决定用 Activiti6 做工作流引擎的后端开发者也适合正在评估是否要自研审批流的技术负责人。接下来的内容会按流程定义、任务分配、网关路由、监听器扩展、避坑排查的顺序展开每一步都给出可复现的配置和代码而不是停留在概念层面。2. 流程定义与部署BPMN 文件怎么写才不返工2.1 先搞清楚 Activiti6 的运行时表结构在动手写 BPMN 之前有必要先理解 Activiti6 启动后会创建哪些核心表。很多后期排查困难的根源就是一开始没搞清楚数据落在哪张表里。Activiti6 默认使用 25 张表按职责可以分成五类ACT_RE_ 前缀是仓库表存放流程定义和模型资源ACT_RU_ 前缀是运行时表流程实例、任务、变量、作业都在这里ACT_HI_ 前缀是历史表流程结束后数据会从运行时表迁移过来ACT_ID_ 前缀是身份表用户和组的信息ACT_GE_ 前缀是通用表存放属性配置。实际开发中最常打交道的三张表是 ACT_RU_EXECUTION、ACT_RU_TASK 和 ACT_RU_VARIABLE。流程实例每走到一个节点ACT_RU_EXECUTION 里就会多一条执行记录用户任务产生时ACT_RU_TASK 里会插入一条待办流程变量则存在 ACT_RU_VARIABLE 里按变量类型分列存储。理解这个映射关系之后遇到「任务查不到」「变量取不到」这类问题时就能直接去对应的表里定位而不是盲目翻日志。配置数据源时建议在 application.yml 里显式指定数据库类型和建表策略避免默认行为在不同环境产生差异spring: datasource: url: jdbc:mysql://localhost:3306/activiti_demo?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai username: dev_user password: dev_pass driver-class-name: com.mysql.cj.jdbc.Driver activiti: database-schema-update: true db-history-used: true history-level: full check-process-definitions: falsedatabase-schema-update 设为 true 时Activiti 启动会自动比对并创建缺失的表开发阶段方便但生产环境建议改为 false 并用 SQL 脚本手动管理。history-level 设为 full 会记录所有历史细节包括变量变化方便审计但会增加数据量如果只关心流程实例和任务的历史设为 audit 即可。check-process-definitions 关闭后Activiti 不会自动扫描 resources/processes 目录下的 BPMN 文件部署改为手动触发控制更精细。2.2 手写一个带条件分支的 BPMN 流程下面是一个采购申请流程的 BPMN 2.0 定义包含发起、审批、条件网关和结束节点。这个流程的逻辑是申请人提交采购单如果金额小于 5000 直接由部门经理审批否则需要总监审批后再由财务复核。?xml version1.0 encodingUTF-8? definitions xmlnshttp://www.omg.org/spec/BPMN/20100524/MODEL xmlns:activitihttp://activiti.org/bpmn targetNamespacehttp://demo.example.com/process process idpurchaseProcess name采购申请流程 isExecutabletrue startEvent idstartEvent name提交申请/ sequenceFlow idflow1 sourceRefstartEvent targetRefdeptApprove/ userTask iddeptApprove name部门经理审批 activiti:assignee${deptManager}/ sequenceFlow idflow2 sourceRefdeptApprove targetRefamountGateway/ exclusiveGateway idamountGateway name金额判断/ sequenceFlow idflowLow sourceRefamountGateway targetRefendEvent conditionExpression xsi:typetFormalExpression ![CDATA[${amount 5000}]] /conditionExpression /sequenceFlow sequenceFlow idflowHigh sourceRefamountGateway targetRefdirectorApprove conditionExpression xsi:typetFormalExpression ![CDATA[${amount 5000}]] /conditionExpression /sequenceFlow userTask iddirectorApprove name总监审批 activiti:assignee${director}/ sequenceFlow idflow3 sourceRefdirectorApprove targetReffinanceReview/ userTask idfinanceReview name财务复核 activiti:candidateGroupsfinanceGroup/ sequenceFlow idflow4 sourceReffinanceReview targetRefendEvent/ endEvent idendEvent name流程结束/ /process /definitions这段定义里有几个关键点值得展开。exclusiveGateway 是排他网关它会按 sequenceFlow 的定义顺序逐条判断条件第一条为 true 的路径被选中后面的不再判断。所以条件表达式的顺序会影响结果如果两条条件可能同时为 true排在前面的会优先命中。conditionExpression 里用 CDATA 包裹是为了避免 XML 转义问题${amount 5000} 中的小于号在 XML 里是特殊字符不包 CDATA 会导致解析失败。assignee 用 ${deptManager} 这种表达式表示运行时从流程变量里取值。这意味着启动流程时必须传入 deptManager 这个变量否则任务创建会失败。candidateGroups 则是指定候选组组内任意用户都可以认领这个任务。两种分配方式的选择逻辑是确定唯一处理人用 assignee需要多人竞争或按角色分配用 candidateGroups。2.3 部署流程的两种方式与版本控制部署 BPMN 文件有两种常见做法。第一种是通过 RepositoryService 手动部署Autowired private RepositoryService repositoryService; public void deployProcess() { Deployment deployment repositoryService.createDeployment() .name(采购申请流程) .addClasspathResource(processes/purchaseProcess.bpmn20.xml) .category(purchase) .deploy(); System.out.println(部署ID: deployment.getId()); System.out.println(流程定义ID: deployment.getId() :1:1234); }addClasspathResource 从类路径加载文件适合文件打包在 jar 里的场景。如果 BPMN 文件放在外部目录用 addInputStream 或 addString 更灵活。部署成功后ACT_RE_PROCDEF 表里会新增一条记录KEY 是 BPMN 里 process 标签的 idVERSION 从 1 开始递增。第二种方式是自动部署在 application.yml 里把 check-process-definitions 设为 true并配置 process-definition-location-prefix 指向目录。这种方式适合流程文件不常变的场景但每次重启都会扫描流程多时会影响启动速度。版本控制方面同一个 process id 重复部署时Activiti 不会覆盖旧版本而是新增一条 VERSION 加 1 的记录。启动流程实例时默认使用最新版本如果需要指定版本可以用 repositoryService.createProcessDefinitionQuery().processDefinitionKey(purchaseProcess).processDefinitionVersion(1).singleResult() 来获取特定版本的定义。这个机制在流程升级时很有用旧实例继续走旧版本新实例走新版本互不干扰。3. 任务分配与监听器把业务逻辑挂到流程节点上3.1 用户任务的四种分配方式与选择依据Activiti6 的用户任务分配有四种方式每种适用的场景不同。第一种是直接指定 assignee在 BPMN 里写死或通过表达式动态取值。第二种是 candidateUsers指定多个候选人任务出现在所有人的待办列表里谁先认领谁处理。第三种是 candidateGroups指定候选组组内成员都能看到并认领。第四种是 TaskListener 里动态设置在 create 事件触发时用代码指定 assignee 或 candidateUsers。选择依据可以归纳成一张表分配方式适用场景认领机制查询方式assignee处理人唯一确定无需认领taskAssigneecandidateUsers多人竞争先到先得需要 claimtaskCandidateUsercandidateGroups按角色/部门分配需要 claimtaskCandidateGroupTaskListener 动态设置处理人依赖运行时逻辑视设置内容而定视设置内容而定实际项目里我一般会优先用 candidateGroups 做角色分配再用 TaskListener 做特殊情况的兜底。比如一个审批节点默认由「部门经理」角色处理但某个分公司的部门经理空缺时监听器里可以动态把任务转给「分公司负责人」组。这样既保持了配置的简洁又留了扩展口子。3.2 TaskListener 的三种事件与执行时机TaskListener 支持 create、assignment、complete 三种事件。create 在任务创建后触发此时可以修改任务属性assignment 在任务被认领或转办时触发complete 在任务完成前触发此时流程变量已经更新但任务还未删除。下面是一个 create 事件的监听器实现逻辑是如果任务没有 assignee就根据流程变量里的部门编码查找到对应的负责人并设置上去。public class DeptAssignListener implements TaskListener { Override public void notify(DelegateTask delegateTask) { String eventName delegateTask.getEventName(); if (create.equals(eventName)) { String deptCode (String) delegateTask.getVariable(deptCode); String manager lookupManagerByDept(deptCode); if (manager ! null) { delegateTask.setAssignee(manager); } else { // 找不到负责人时挂到默认组避免任务悬空 delegateTask.addCandidateGroup(defaultApproveGroup); } } } private String lookupManagerByDept(String deptCode) { // 实际项目中这里查数据库或调用组织服务 return user_ deptCode _manager; } }在 BPMN 里引用这个监听器有两种方式。一种是在 userTask 标签里加 activiti:taskListeneruserTask iddeptApprove name部门经理审批 extensionElements activiti:taskListener eventcreate classcom.demo.listener.DeptAssignListener/ /extensionElements /userTask另一种是用 delegateExpression 引用 Spring 容器里的 Bean这样监听器里可以直接注入其他服务activiti:taskListener eventcreate delegateExpression${deptAssignListener}/用 delegateExpression 时对应的 Bean 需要实现 TaskListener 接口并在 Spring 配置里注册。这种方式的好处是监听器里可以用 Autowired 注入 Mapper 或 Service不用手动 new 对象。需要注意的是delegateExpression 里的 Bean 名要和 Spring 容器里的名称一致默认是类名首字母小写。3.3 执行监听器与任务监听器的分工除了 TaskListenerActiviti6 还有 ExecutionListener挂在 sequenceFlow 或流程节点上监听 start、end、take 事件。两者的分工是TaskListener 关注用户任务的生命周期ExecutionListener 关注流程执行的路径。一个典型用法是在 sequenceFlow 的 take 事件里记录流转日志public class FlowLogListener implements ExecutionListener { Override public void notify(DelegateExecution execution) { String currentActivity execution.getCurrentActivityId(); String processInstanceId execution.getProcessInstanceId(); // 记录流转轨迹写入自定义日志表 System.out.println(流程实例 processInstanceId 经过节点 currentActivity); } }ExecutionListener 的 take 事件在连线被选中时触发此时流程还没有到达目标节点。如果需要在节点进入或离开时做处理用 start 和 end 事件。需要注意的是ExecutionListener 里不能直接操作任务因为此时可能还没有任务产生。需要操作任务时还是得用 TaskListener。4. 网关与会签多实例、排他与并行网关的实战配置4.1 排他网关的条件表达式写法与优先级排他网关exclusiveGateway的核心是条件表达式的编写。Activiti6 支持 UEL 表达式可以访问流程变量、Spring Bean 和内置对象。常见的写法有几种!-- 数值比较 -- conditionExpression xsi:typetFormalExpression ![CDATA[${amount 1000 amount 5000}]] /conditionExpression !-- 字符串比较 -- conditionExpression xsi:typetFormalExpression ![CDATA[${applyType urgent}]] /conditionExpression !-- 调用 Spring Bean 的方法 -- conditionExpression xsi:typetFormalExpression ![CDATA[${ruleService.needDirectorApprove(amount, deptCode)}]] /conditionExpression字符串比较时要注意Activiti6 的 UEL 实现里 和 eq 都可以用但单引号和双引号在不同版本里行为有差异。稳妥的做法是统一用单引号包裹字符串字面量并且确保流程变量里的值类型一致。如果变量是 String 类型比较时也用字符串如果变量是 Integer比较时不要写成字符串形式否则会触发类型转换异常。排他网关还有一个容易踩的坑如果所有条件都不满足Activiti 会抛出异常。所以至少要留一条默认路径或者确保条件覆盖了所有可能。默认路径的写法是不加 conditionExpression 的 sequenceFlow它会在所有带条件的连线都不满足时被选中。但要注意默认路径必须放在最后因为排他网关是按顺序判断的。4.2 并行网关与多实例会签的配置差异并行网关parallelGateway用于分叉和合并它不判断条件所有出口都会被执行。分叉时每个出口产生一个并发执行分支合并时等待所有分支到达后才继续。并行网关的合并行为是「等待所有」如果某个分支永远不到达流程就会卡住。多实例会签multiInstanceLoopCharacteristics则是另一种机制它让一个用户任务重复执行多次每次由不同的人处理。配置方式如下userTask idcountersignTask name会签审批 activiti:assignee${assignee} multiInstanceLoopCharacteristics isSequentialfalse activiti:collectionassigneeList activiti:elementVariableassignee completionCondition ![CDATA[${nrOfCompletedInstances/nrOfInstances 0.5}]] /completionCondition /multiInstanceLoopCharacteristics /userTaskisSequential 设为 false 表示并行会签所有人同时收到任务设为 true 则是串行前一个人完成后下一个人才收到。activiti:collection 指定参与人列表的流程变量名activiti:elementVariable 是每次迭代时当前元素的变量名assignee 属性用这个变量来动态指定处理人。completionCondition 是完成条件nrOfCompletedInstances 是已完成实例数nrOfInstances 是总实例数。上面的配置表示超过半数人完成就通过。如果不写 completionCondition则默认所有人都完成后才继续。这个条件在会签场景里非常关键因为实际业务中经常有「半数通过即可」或「一票否决」的需求。并行网关和多实例会签的区别在于并行网关分叉出的是不同的任务节点每个节点可以有不同的处理逻辑多实例会签是同一个任务节点重复多次处理逻辑相同但处理人不同。选择时看业务语义如果是「多个部门各自审批」用并行网关如果是「多个人对同一件事投票」用多实例会签。4.3 会签过程中的变量聚合与结果回写会签结束后通常需要把每个人的审批意见汇总起来。Activiti6 的多实例会签里每次任务完成时可以通过 TaskListener 的 complete 事件把意见写入一个共享变量public class CountersignCompleteListener implements TaskListener { Override public void notify(DelegateTask delegateTask) { String assignee delegateTask.getAssignee(); String comment (String) delegateTask.getVariable(comment); // 获取已有的意见列表追加当前意见 ListString comments (ListString) delegateTask.getVariable(allComments); if (comments null) { comments new ArrayList(); } comments.add(assignee : comment); delegateTask.setVariable(allComments, comments); } }这里有一个并发问题需要注意并行会签时多个任务可能同时完成如果都用 getVariable 再 setVariable 的方式更新同一个列表会出现覆盖。稳妥的做法是用 Activiti6 提供的 setVariableLocal 配合执行实例或者改用数据库表来记录意见流程变量只存一个汇总标识。如果坚持用流程变量可以在监听器上加同步锁但这样会影响性能不推荐在高并发场景使用。结果回写方面会签的通过与否可以通过 completionCondition 自动判断也可以在流程结束后用 ExecutionListener 读取 nrOfCompletedInstances 和 nrOfInstances 来计算。如果业务需要记录每个人的具体投票建议单独建一张审批意见表用 processInstanceId 和 taskId 关联而不是全部塞进流程变量。5. 避坑与排查Activiti6 落地时最容易翻车的五个地方5.1 流程变量序列化失败导致任务无法完成现象完成任务时抛出异常提示无法序列化某个变量流程卡在当前节点。原因Activiti6 默认会把流程变量持久化到 ACT_RU_VARIABLE 表如果变量是自定义对象且没有实现 Serializable 接口序列化就会失败。另外如果变量是 List 或 Map里面的元素也必须可序列化。解决自定义对象实现 java.io.Serializable 接口并显式声明 serialVersionUID。如果对象结构复杂考虑只存关键字段的 JSON 字符串而不是整个对象。对于不需要持久化的临时变量用 setVariableLocal 而不是 setVariable前者只在当前执行实例内有效不会写入历史表。5.2 并行网关分支未全部到达导致流程卡死现象流程走到并行网关的合并节点后不再继续ACT_RU_EXECUTION 里有多条执行记录但都不为 0。原因并行网关的合并要求所有分支都到达。如果某个分支因为条件不满足、任务被删除或异常中断而没有到达合并就会一直等待。解决检查每个分支的路径是否都能正常走到合并节点。如果业务上允许某些分支跳过改用排他网关或在分支上设置跳过条件。对于已经卡死的实例可以通过 managementService 手动触发执行或删除实例但根本办法还是在设计时确保分支的完整性。一个实用的习惯是在并行网关的每个分支上都加一个边界定时器超时后自动走异常处理路径。5.3 历史数据膨胀导致查询变慢现象系统运行一段时间后待办列表和历史查询越来越慢ACT_HI_ 开头的表数据量达到百万级。原因history-level 设为 full 时每次变量变化都会写一条历史记录。如果流程变量多、节点多历史表增长会非常快。解决根据审计要求调整 history-level。只需要流程实例和任务历史时用 audit不需要历史时用 none。对于必须保留的历史数据定期归档到冷表用 processInstanceId 和结束时间做分区。另外ACT_HI_DETAIL 表是变量明细数据量最大如果不需要追溯变量变化过程可以在配置里关闭 detail 记录。5.4 表达式里的空指针导致流程启动失败现象启动流程时抛出 PropertyNotFoundException 或 NullPointerException提示某个变量不存在。原因BPMN 里的表达式引用了未传入的流程变量。比如 assignee 写了 ${deptManager}但启动时没有传 deptManager 这个变量。解决启动流程前校验必填变量或者在表达式里用默认值兜底。Activiti6 支持 ${deptManager ! null ? deptManager : defaultUser} 这种三元表达式。更好的做法是在流程定义里用 extensionElements 声明必填变量启动前统一校验。对于 candidateGroups 这类可能为空的属性如果组不存在任务会创建但没有人能看到这种「静默失败」最难排查建议在监听器里加日志。5.5 任务认领后的权限校验缺失现象A 用户认领了任务B 用户通过接口直接完成了任务流程继续流转但审批人记录是 B。原因Activiti6 的 taskService.complete 方法只校验任务是否存在不校验当前用户是否有权完成。权限控制需要业务层自己实现。解决在调用 complete 之前先查询任务的 assignee 或 candidateUsers判断当前登录用户是否在允许范围内。可以封装一个统一的 TaskPermissionChecker在 Controller 层拦截。另外认领操作要用 taskService.claim它会校验当前任务的 assignee 是否为空避免重复认领。对于已认领的任务如果要转办用 taskService.setAssignee 而不是直接改数据库。6. 从能跑到好用流程实例的监控与动态调整技巧流程上线只是开始真正体现功力的是运行期的监控和干预。Activiti6 提供了一组 ManagementService 接口可以在不重启应用的情况下查询和调整流程实例。我常用的一个技巧是写一个诊断接口输入 processInstanceId输出当前实例的所有执行路径、任务、变量和作业。这个接口在排查卡单问题时比翻日志快得多。RestController RequestMapping(/admin/process) public class ProcessDiagnoseController { Autowired private ManagementService managementService; Autowired private RuntimeService runtimeService; Autowired private TaskService taskService; GetMapping(/diagnose/{processInstanceId}) public MapString, Object diagnose(PathVariable String processInstanceId) { MapString, Object result new HashMap(); // 1. 查询所有执行实例看流程走到了哪些分支 ListExecution executions runtimeService.createExecutionQuery() .processInstanceId(processInstanceId) .list(); ListString activityIds new ArrayList(); for (Execution execution : executions) { activityIds.add(execution.getActivityId()); } result.put(activeActivities, activityIds); // 2. 查询当前待办任务 ListTask tasks taskService.createTaskQuery() .processInstanceId(processInstanceId) .list(); ListMapString, String taskInfos new ArrayList(); for (Task task : tasks) { MapString, String info new HashMap(); info.put(taskId, task.getId()); info.put(taskName, task.getName()); info.put(assignee, task.getAssignee()); taskInfos.add(info); } result.put(activeTasks, taskInfos); // 3. 查询流程变量 MapString, Object variables runtimeService.getVariables(processInstanceId); result.put(variables, variables); // 4. 查询定时作业看是否有挂起的定时任务 ListJob jobs managementService.createJobQuery() .processInstanceId(processInstanceId) .list(); result.put(jobs, jobs.size()); return result; } }这个接口的输出里activeActivities 能直接告诉你流程卡在哪个节点。如果某个并行分支的 activityId 一直存在说明那个分支没有走完。activeTasks 里的 assignee 为空时说明任务没有被认领需要检查 candidateGroups 是否正确。variables 里如果缺少某个预期变量说明前面的节点没有正确设置。jobs 数量不为零时说明有定时任务在等待触发比如边界定时器。另一个实用技巧是动态调整流程实例。比如某个审批人离职了任务挂在他名下可以用 managementService 直接修改执行实例的变量或者用 runtimeService 触发信号事件。但要注意这些操作绕过了正常的流程逻辑只适合紧急干预不能作为常规手段。我一般会把这些操作封装成带审计日志的管理接口记录谁在什么时候对哪个实例做了什么调整方便事后追溯。还有一个容易被忽略的点是流程实例的挂起与激活。当系统需要维护或某个流程定义需要下线时可以用 repositoryService.suspendProcessDefinitionById 挂起流程定义此时该定义下的所有实例都会暂停任务不能完成但数据保留。维护结束后用 activateProcessDefinitionById 恢复。这个机制在版本升级时特别有用先挂起旧版本等所有实例走完或手动迁移后再删除旧版本。最后说一个我踩过的坑流程变量的类型在运行期是可以变的但历史表里记录的是第一次设置时的类型。如果第一次设的是 String后来改成 Integer查询历史时会报类型转换异常。所以流程变量的类型一旦确定就不要中途改变。需要存不同类型时用不同的变量名或者在设置前先清理旧变量。这个习惯帮我省了很多排查时间希望帮到你。本文还有配套的精品资源点击获取