Activiti工作流引擎实战:从核心概念到企业级流程开发

1. 项目概述:为什么我们需要一个工作流引擎?

如果你在开发企业级应用,尤其是涉及审批、报销、请假这类流程化业务时,肯定遇到过这样的场景:业务逻辑和流程逻辑高度耦合,改一个审批节点,代码要动一大片;流程状态全靠数据库字段手动维护,今天加个“加签”,明天加个“驳回”,后天产品经理又说要支持“并行审批”,代码越写越乱,维护成本指数级上升。这时候,一个成熟的工作流引擎就显得至关重要了。

Activiti,作为一款轻量级、开源且功能强大的工作流与业务流程管理(BPM)引擎,就是为了解决这些问题而生的。它基于BPMN 2.0(业务流程模型与标注)标准,允许我们通过可视化的流程图来定义复杂的业务流程,然后由引擎来驱动这些流程的自动执行。简单来说,你把业务流程图画出来,剩下的流转、任务分配、状态管理、历史追踪这些“脏活累活”,都交给Activiti。开发者只需要关注业务节点的具体实现(比如“部门经理审批”这个节点要做什么操作),而不用再操心“这个任务完成后该流转给谁”、“如何记录每一步的操作日志”这类流程控制问题。

我最早接触Activiti是在一个大型的OA系统重构项目中,当时手动维护的审批状态机已经变成了一个近千行的“屎山”switch-case。引入Activiti后,流程定义变得清晰可视,代码职责分离,开发和运维效率都得到了质的提升。这个系列,我就结合自己踩过的坑和积累的经验,带你从零开始,彻底搞懂Activiti的核心概念、实战用法以及那些官方文档里不会写的“潜规则”。

2. 核心概念与架构拆解:理解Activiti的“世界观”

在动手写代码之前,我们必须先理解Activiti的几个核心概念。这些概念是理解其运行机制的基础,很多初学者遇到的困惑,根源都在于对这些概念的理解偏差。

2.1 BPMN 2.0:统一的流程“语言”

Activiti的核心是BPMN 2.0。你可以把它理解为流程图的“国际通用语言”。就像建筑工程师用CAD图纸沟通一样,BPMN 2.0提供了一套标准化的图形符号和XML格式,来描述业务流程。

  • 事件(Event):流程中发生的事情,用圆圈表示。例如,开始事件(一个细圆圈)、结束事件(一个粗圆圈)、中间捕获事件(比如定时器事件)。
  • 活动(Activity):流程中需要执行的工作,用圆角矩形表示。这是最常用的元素,比如“用户任务”、“服务任务”。
  • 网关(Gateway):控制流程的分支与合并,用菱形表示。例如,排他网关(XOR,带“X”的菱形)用于决策(if-else);并行网关(AND,带“+”的菱形)用于同时发起多个分支。
  • 顺序流(Sequence Flow):连接其他元素的箭头,表示执行顺序。
  • 泳道(Swimlane):用池(Pool)和道(Lane)来区分流程的不同参与者,比如不同的部门或角色。

使用标准BPMN的好处是,业务分析师可以用专业的流程设计工具(如Activiti官方提供的Activiti Modeler,或者开源的bpmn-js)画出流程图,生成标准的.bpmn20.xml文件。开发者拿到这个文件,几乎不需要修改就能直接部署到Activiti引擎中运行。这种“设计即开发”的模式,极大地提升了业务与研发的协作效率。

2.2 Activiti的核心服务与数据库表

Activiti通过一组定义清晰的服务接口与数据库交互,驱动流程运行。理解这些服务是进行API编程的关键。Activiti 7.x之后,更推荐使用ProcessRuntimeTaskRuntime等更现代的API,但其底层思想与传统的服务接口一脉相承。我们先从经典的服务模型入手,它更利于理解原理。

Activiti引擎在启动时,会创建一系列服务实例,我们可以通过ProcessEngine对象获取它们:

  1. RepositoryService流程仓库服务。负责管理流程定义(即BPMN XML文件)的部署、查询、删除。你可以把它想象成流程定义的“版本管理器”。
  2. RuntimeService运行时服务。负责启动流程实例、管理流程变量、查询和执行流程实例。一个流程定义(模板)可以启动多个流程实例(具体的审批单)。
  3. TaskService任务服务。这是最常用的服务,负责管理“用户任务”(User Task),例如查询待办任务、完成任务、拾取任务、设置任务代理人等。
  4. HistoryService历史服务。查询已经执行完毕的流程实例、任务、活动节点等信息,用于生成报表或追溯历史。
  5. IdentityService身份服务。管理用户、组以及它们之间的关系。但在实际项目中,我们通常会将Activiti与公司现有的用户体系(如LDAP、自研用户中心)集成,所以这个服务使用频率不高。
  6. FormService表单服务。用于处理动态表单,但实际项目中,前后端分离架构下,前端表单通常自研,此服务也较少直接使用。
  7. ManagementService引擎管理服务。主要用于作业管理和数据库维护,日常开发接触较少。

这些服务背后,对应着Activiti自动创建的数十张数据库表,表名都以ACT_为前缀,后跟两位分类标识:

  • ACT_RE_*:RE表示repository,存储流程定义和部署信息的静态资源。
  • ACT_RU_*:RU表示runtime,存储运行时的流程实例、任务、变量等数据。这些表是引擎运行的核心,记录当前正在进行的流程。流程结束后,相应的记录会被删除。
  • ACT_HI_*:HI表示history,存储所有历史数据,包括已结束的流程实例、任务、变量详情等。
  • ACT_GE_*:GE表示general,通用数据,如二进制资源(流程图片)。
  • ACT_ID_*:ID表示identity,身份信息,如果使用自带身份服务的话。

实操心得:排查生产环境流程卡住的问题时,ACT_RU_TASK(运行时任务表)和ACT_RU_EXECUTION(运行时执行流表)是你最需要关注的两张表。通过它们,你可以清晰地看到当前所有活跃的任务停留在哪个节点,以及流程的精确执行路径。

2.3 流程实例、执行流与任务的关系

这是最容易混淆的一组概念,我用一个简单的请假流程来解释:

  1. 流程定义:你画好的“请假流程图.bpmn”,定义了从“提交申请”到“审批结束”的完整规则。这是一个模板
  2. 流程实例:张三今天提交了一张请假单,引擎根据“请假流程图”这个模板创建了一个具体的流程。这个具体的请假审批过程,就是一个流程实例。一个定义可以产生无数个实例。
  3. 执行流:一个流程实例在运行时,引擎会创建一个或多个执行流来推进流程。通常,开始事件会创建一个主执行流。当流程走到并行网关时,一个执行流会分裂成多个子执行流,分别走不同的分支。执行流是引擎驱动流程前进的“指针”。
  4. 任务:当执行流到达一个“用户任务”节点时,引擎会在ACT_RU_TASK表中创建一条任务记录。这个任务会分配给具体的用户或组(如“部门经理”)。张三的部门经理李四登录系统,就能在待办列表里看到这个任务。

简单比喻:流程定义是乐高图纸,流程实例是按图纸拼好的一艘具体飞船,执行流是拼装工人的手(可能有多只),任务则是图纸上标注的“现在请把这块积木(某个具体操作)拼上去”的指令。

3. 环境搭建与第一个流程实战

理论讲得再多,不如动手跑一遍。我们从一个最简单的“员工请假流程”开始,搭建环境并实现流程的完整生命周期:部署 -> 启动 -> 办理任务 -> 结束。

3.1 项目初始化与依赖引入

我们创建一个Spring Boot项目。现在Activiti 7.x已深度集成Spring Boot,使用起来非常方便。在pom.xml中添加依赖:

<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> <!-- 使用H2内存数据库方便演示 --> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency>

application.yml中配置基本属性:

spring: datasource: url: jdbc:h2:mem:testdb;DB_CLOSE_DELAY=-1 driver-class-name: org.h2.Driver username: sa password: h2: console: enabled: true # 开启H2控制台,方便查看数据库 activiti: database-schema-update: true # 自动更新数据库结构 db-history-used: true # 使用历史表 history-level: audit # 历史记录级别:audit记录所有节点信息 check-process-definitions: false # 不自动部署resources/processes下的bpmn文件

注意database-schema-update: true在开发环境很方便,引擎启动时会自动检查并创建缺失的表。但在生产环境,务必设置为false,并建议使用Flyway或Liquibase等工具来严格管理数据库脚本的版本,避免不可预知的表结构变更。

3.2 绘制并部署第一个BPMN流程图

src/main/resources/processes目录下,新建一个leave-application.bpmn20.xml文件。你可以用任何文本编辑器写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:xsd="http://www.w3.org/2001/XMLSchema" xmlns:activiti="http://activiti.org/bpmn" targetNamespace="http://www.activiti.org/processdef"> <!-- 一个简单的请假流程 --> <process id="leaveApplication" name="员工请假流程" isExecutable="true"> <!-- 1. 开始事件 --> <startEvent id="startEvent" name="开始请假"></startEvent> <!-- 2. 员工提交申请(用户任务) --> <userTask id="submitApply" name="提交请假申请" activiti:assignee="${applicant}"> <documentation>员工填写请假单并提交</documentation> </userTask> <!-- 3. 部门经理审批(用户任务) --> <userTask id="deptManagerAudit" name="部门经理审批" activiti:candidateGroups="deptManager"> <documentation>部门经理审批请假申请</documentation> </userTask> <!-- 4. 排他网关:根据审批结果决定流向 --> <exclusiveGateway id="decisionGateway" name="审批决定"></exclusiveGateway> <!-- 5. 网关出口1:审批通过 --> <sequenceFlow id="flowToApprove" sourceRef="decisionGateway" targetRef="approveEnd"> <conditionExpression xsi:type="tFormalExpression"> <!-- 这里判断流程变量 auditResult 是否为 'approve' --> <![CDATA[${auditResult == 'approve'}]]> </conditionExpression> </sequenceFlow> <!-- 6. 网关出口2:审批驳回 --> <sequenceFlow id="flowToReject" sourceRef="decisionGateway" targetRef="modifyApply"> <conditionExpression xsi:type="tFormalExpression"> <![CDATA[${auditResult == 'reject'}]]> </conditionExpression> </sequenceFlow> <!-- 7. 驳回后,回到提交节点(修改申请) --> <userTask id="modifyApply" name="修改申请" activiti:assignee="${applicant}"> <documentation>根据经理意见修改请假申请</documentation> </userTask> <!-- 8. 修改后再次流向经理审批 --> <sequenceFlow id="flowToReAudit" sourceRef="modifyApply" targetRef="deptManagerAudit"></sequenceFlow> <!-- 9. 审批通过,流程结束 --> <endEvent id="approveEnd" name="审批通过结束"></endEvent> <!-- 连接线 --> <sequenceFlow id="flow1" sourceRef="startEvent" targetRef="submitApply"></sequenceFlow> <sequenceFlow id="flow2" sourceRef="submitApply" targetRef="deptManagerAudit"></sequenceFlow> <sequenceFlow id="flow3" sourceRef="deptManagerAudit" targetRef="decisionGateway"></sequenceFlow> </process> </definitions>

关键点解析

  • process id:流程的唯一标识,在代码中通过这个ID来启动流程。
  • userTask:用户任务节点,需要人工处理。
    • activiti:assignee="${applicant}":任务指派给具体的人,${}表示这是一个表达式,运行时从流程变量applicant中取值。
    • activiti:candidateGroups="deptManager":任务指派给一个组(角色),组内任何人都可以拾取并处理。
  • exclusiveGateway:排他网关,相当于if-else。出口的顺序流上可以定义条件表达式。
  • conditionExpression:条件表达式,使用JUEL(Java Unified Expression Language)语法。这里判断流程变量auditResult的值。

接下来,我们编写一个简单的REST Controller来部署这个流程定义:

@RestController @RequestMapping("/process") public class ProcessController { @Autowired private RepositoryService repositoryService; @PostMapping("/deploy") public String deploy() { Deployment deployment = repositoryService.createDeployment() .addClasspathResource("processes/leave-application.bpmn20.xml") .name("员工请假流程部署") .deploy(); // 执行部署 return "流程部署成功,部署ID: " + deployment.getId(); } }

启动Spring Boot应用,调用这个接口,流程定义就部署到数据库(ACT_RE_*表)中了。你可以通过http://localhost:8080/h2-console登录H2数据库,查看ACT_RE_PROCDEF表,里面应该有一条leaveApplication的记录。

3.3 启动流程实例并办理任务

流程部署好了,现在模拟员工“张三”发起一个请假申请。

@RestController @RequestMapping("/runtime") public class RuntimeController { @Autowired private RuntimeService runtimeService; @Autowired private TaskService taskService; /** * 张三发起请假 */ @PostMapping("/start-leave") public String startLeaveProcess() { // 设置流程变量:申请人 Map<String, Object> variables = new HashMap<>(); variables.put("applicant", "zhangsan"); // 通过流程定义KEY启动一个流程实例 ProcessInstance instance = runtimeService.startProcessInstanceByKey("leaveApplication", variables); return "流程实例启动成功,实例ID: " + instance.getId(); } /** * 查询张三的待办任务 */ @GetMapping("/tasks/{assignee}") public List<Map<String, Object>> getTasks(@PathVariable String assignee) { List<Task> tasks = taskService.createTaskQuery() .taskAssignee(assignee) // 根据办理人查询 .orderByTaskCreateTime().desc() .list(); return tasks.stream().map(task -> { Map<String, Object> map = new HashMap<>(); map.put("taskId", task.getId()); map.put("taskName", task.getName()); map.put("processInstanceId", task.getProcessInstanceId()); map.put("createTime", task.getCreateTime()); return map; }).collect(Collectors.toList()); } /** * 办理任务(提交申请) */ @PostMapping("/complete-submit") public String completeSubmitTask(@RequestParam String taskId) { // 完成任务,并可以设置下一步需要的流程变量 Map<String, Object> variables = new HashMap<>(); variables.put("leaveDays", 3); // 请假天数 variables.put("reason", "回家探亲"); // 请假原因 taskService.complete(taskId, variables); return "任务[提交请假申请]已完成,流程已流转至[部门经理审批]"; } }

操作步骤:

  1. 调用/runtime/start-leave,启动流程。此时,第一个节点“提交请假申请”的任务已经产生,并且分配给了zhangsan
  2. 调用/runtime/tasks/zhangsan,可以查询到张三的待办任务。
  3. 张三填写表单后,调用/runtime/complete-submit?taskId=xxx,完成“提交申请”任务。引擎会自动推进到下一个节点“部门经理审批”。

此时,任务到了“部门经理审批”节点,并且这个节点是候选组deptManager。我们需要模拟部门经理“李四”来拾取并处理这个任务。这里涉及到一个重要概念:拾取(Claim)。对于候选组任务,需要组内成员先“认领”到自己名下,才能办理。

/** * 李四查询组任务并拾取 */ @GetMapping("/group-tasks/{groupId}") public List<Map<String, Object>> getGroupTasks(@PathVariable String groupId) { List<Task> tasks = taskService.createTaskQuery() .taskCandidateGroup(groupId) // 查询候选组任务 .list(); // ... 返回任务列表(同上) } @PostMapping("/claim-task") public String claimTask(@RequestParam String taskId, @RequestParam String userId) { // 李四拾取任务,将组任务变为他的个人待办 taskService.claim(taskId, userId); return "用户[" + userId + "]已成功拾取任务[" + taskId + "]"; } /** * 李四审批(通过或驳回) */ @PostMapping("/complete-audit") public String completeAuditTask(@RequestParam String taskId, @RequestParam String result, // 'approve' or 'reject' @RequestParam(required = false) String comment) { Map<String, Object> variables = new HashMap<>(); variables.put("auditResult", result); // 设置审批结果变量,用于网关判断 // 添加审批意见(会保存到历史记录中) if (StringUtils.hasText(comment)) { taskService.addComment(taskId, null, comment); } taskService.complete(taskId, variables); return "审批任务完成,结果: " + result; }

操作步骤:

  1. 李四调用/runtime/group-tasks/deptManager,看到待审批的组任务。
  2. 李四决定处理其中一个,调用/runtime/claim-task?taskId=xxx&userId=lisi,将任务认领。
  3. 李四审批后,调用/runtime/complete-audit,传入resultapprovereject
    • 如果result='approve',流程变量auditResult被设置为approve,流经网关时匹配flowToApprove线,直接到达结束事件,流程结束。
    • 如果result='reject',流程变量auditResult被设置为reject,匹配flowToReject线,流转到“修改申请”节点,任务再次分配给申请人zhangsan。这就形成了一个驳回循环

至此,一个包含基本元素(开始事件、用户任务、排他网关、条件分支、循环)的流程就完整地跑通了。你可以通过HistoryService查询这个流程实例的完整执行轨迹。

4. 流程变量详解:流程的“记忆”与“血液”

流程变量(Process Variable)是Activiti中极其重要的概念,它是流程在流转过程中携带和传递数据的载体,决定了流程的走向,也承载了业务数据。你可以把它理解为流程的“记忆”和“血液”。

4.1 变量的作用域与生命周期

变量是有作用域的,主要分为两种:

  1. 流程实例变量(Process Instance Variable):作用域是整个流程实例,在实例启动时或运行中设置,所有该实例下的执行流和任务都能访问。通常用于存储全局业务数据,如applicant(申请人)、leaveDays(请假天数)。
  2. 任务变量(Task Variable):作用域仅限于单个任务。任务创建时设置,任务完成后,如果未显式提升为流程变量,则通常会被清除。适用于该任务特有的临时数据。

变量设置的最佳实践

  • 启动时注入:在runtimeService.startProcessInstanceByKey()时,通过Map传入初始变量。
  • 任务办理时传递:在taskService.complete(taskId, variables)时传入变量,这些变量默认会提升为流程实例变量(除非指定作用域)。这是最常用的传递数据的方式。
  • 运行时显式设置:通过runtimeService.setVariable()taskService.setVariable()在流程中间任何需要的地方设置。
// 设置流程实例变量 runtimeService.setVariable(processInstanceId, "key", "value"); // 设置任务局部变量(仅该任务可见) taskService.setVariableLocal(taskId, "localKey", "localValue"); // 获取变量 Object value = runtimeService.getVariable(processInstanceId, "key");

4.2 变量的序列化与存储

Activiti会将变量值序列化后存入ACT_RU_VARIABLE(运行时)和ACT_HI_VARINST(历史)表。支持的类型包括:

  • 基本类型及包装类:String, Integer, Long, Double, Boolean, Date, Short, Byte...
  • 可序列化的对象:任何实现了java.io.Serializable接口的POJO。
  • 集合类型:List, Map等(其元素也需可序列化)。

重要避坑指南:存储自定义对象(如LeaveInfo)时,务必确保该类实现了Serializable接口,并且保持serialVersionUID的一致性。如果流程实例运行期间,你修改了实体类并重新部署了应用,可能会导致反序列化失败,流程无法继续。一种更稳健的做法是,流程变量中只存储实体ID(如leaveId: 123),业务数据通过ID从业务表中实时查询。

4.3 在表达式中使用变量

变量最强大的地方在于可以在BPMN元素的表达式(EL表达式)中直接使用,实现动态逻辑。

  • 任务分配activiti:assignee="${applicant}"
  • 网关条件${auditResult == 'approve'}
  • 服务任务调用:在JavaDelegate实现类中,可以通过DelegateExecution对象获取变量。
  • 监听器:在事件监听器中,可以读取和修改变量。

表达式语言默认是JUEL,功能强大,但也需注意安全,避免在表达式中执行危险操作。

5. 用户任务(User Task)的深入配置

用户任务是流程与“人”交互的核心节点,其配置灵活性直接决定了流程的易用性。

5.1 任务分配策略

除了上面用到的assignee(指定具体用户)和candidateGroups(候选组),还有更多分配方式:

  • 候选用户(Candidate Users)activiti:candidateUsers="user1, user2"。多个用户都可以看到并拾取该任务。
  • 表达式动态分配
    <userTask id="hrAudit" name="HR备案" activiti:assignee="${hrManager}"/>
    在流程变量中设置hrManagerlisiwangwu
  • 通过监听器分配:实现org.activiti.engine.delegate.TaskListener接口,在create事件中编程式分配。
    public class ManagerTaskListener implements TaskListener { @Override public void notify(DelegateTask delegateTask) { String dept = (String) delegateTask.getVariable("department"); // 根据部门信息,从数据库或缓存查询部门经理 String manager = findManagerByDept(dept); delegateTask.setAssignee(manager); } }
    然后在XML中配置:
    <userTask id="deptAudit" name="部门审批"> <extensionElements> <activiti:taskListener event="create" class="com.example.listener.ManagerTaskListener"/> </extensionElements> </userTask>
    这种方式最灵活,可以实现复杂的分配逻辑,如轮询、负载均衡、根据职务动态查找等。

5.2 任务办理人与候选人的查询

在业务系统中,我们经常需要构建“我的待办”、“组待办”列表。Activiti提供了强大的TaskQueryAPI。

// 1. 查询个人待办(指定办理人) List<Task> myTasks = taskService.createTaskQuery() .taskAssignee(currentUserId) .orderByTaskCreateTime().desc() .list(); // 2. 查询候选组待办 List<Task> groupTasks = taskService.createTaskQuery() .taskCandidateGroup("deptManager") // 精确组 // .taskCandidateGroupIn(Arrays.asList("deptManager", "hr")) // 多组 .list(); // 3. 查询候选用户待办(用户可能在多个候选组中,或直接被设为候选人) List<Task> candidateTasks = taskService.createTaskQuery() .taskCandidateUser(currentUserId) // 这个API会查询用户作为候选人(用户或组)的所有任务 .list(); // 4. 复杂查询:结合流程变量 List<Task> tasks = taskService.createTaskQuery() .processVariableValueEquals("applicant", "zhangsan") // 流程变量applicant等于zhangsan .taskCandidateOrAssigned(currentUserId) // 当前用户是办理人或候选人 .list();

性能提示taskCandidateUser查询虽然方便,但在用户组关系复杂时可能性能不佳。如果系统用户量大,建议在业务层维护用户-组关系,然后使用taskCandidateGroupIn进行查询,或者对Activiti的身份表(ACT_ID_*)进行定制化改造和缓存。

5.3 任务附加操作:委托、转办、加签

实际业务中,任务处理人可能无法及时处理,需要委托或转交给他人。Activiti原生支持部分操作,但更复杂的逻辑通常需要自己基于API封装。

  • 委托(Delegate):将任务暂时交给他人处理,处理完后任务会回到原负责人这里。使用taskService.delegateTask(taskId, delegateUserId)
  • 转办(Transfer):将任务完全转交给他人,原负责人不再参与。使用taskService.setAssignee(taskId, newUserId)
  • 加签:这不是一个原生操作,而是一种业务模式。例如,部门经理审批时,觉得需要财务会签。实现方式通常有两种:
    1. 动态增加候选人:通过taskService.addCandidateUser(taskId, extraUserId)为当前任务添加一个候选人。这样原经理和财务都能看到并处理这个任务,谁先完成即结束(需业务规则约定)。
    2. 动态创建子任务:通过编程方式,复制或创建一个新的任务给财务,并建立父子关系。这更复杂,但能实现更精细的控制(如并行加签、顺序加签)。

这些操作都会产生相应的历史记录,便于审计。在设计流程时,需要提前考虑这些业务场景,并在流程节点上预留足够的灵活性,或者通过额外的“管理功能”来支持。