1. 从一个真实踩坑场景说起:为什么我又回头啃Activiti的事件模型
刚接触工作流引擎那会儿,我对Activiti的理解停留在“画个流程图,部署上去,然后调API往下推”这个层面。直到有一次做一个审批链路的项目,需求里有一条:流程发起后如果三天内没人处理,自动升级通知;流程被强制终止时,要触发一组资源回收动作。我当时的第一反应是在业务代码里写定时任务轮询,结果被带我的老哥一句话点醒——“你为什么不直接用流程引擎自己的事件机制?”
那一刻我才意识到,自己一直在把Activiti当成一个“流程图执行器”在用,而忽略了它本质上是一套事件驱动的状态机。启动事件、结束事件、各种任务节点,它们不只是图上的图形,每一个背后都对应着明确的执行语义和可扩展的监听入口。把这些搞明白之后,很多原本需要外部轮询、硬编码判断的逻辑,都能收敛到流程定义本身里,代码干净了不止一个档次。
这篇内容就是把我重新梳理Activiti事件与任务体系的过程记录下来。核心围绕三块:启动事件、结束事件、任务节点。我会讲清楚它们各自的类型、执行时机、配置方式,以及在实际项目里怎么用、容易踩什么坑。适合已经能跑通一个Hello World流程、但想真正把流程控制权握在自己手里的开发者。如果你还在“流程能跑就行”的阶段,这篇可能会让你对工作流引擎的认知往上提一层。
2. 启动事件:流程的入口远不止一个圆圈
2.1 启动事件的本质与执行时机
很多人以为启动事件就是流程图上那个带绿色三角的圆圈,画上去就完事了。实际上,启动事件是流程实例被创建时的第一个执行语义单元。当你调用runtimeService.startProcessInstanceByKey()或者startProcessInstanceById()时,引擎做的第一件事就是定位流程定义的启动事件,然后从这里开始推进执行。
这里有个容易被忽略的点:启动事件本身不产生任何“待办”或“任务”,它只是一个触发点。但它的类型决定了流程如何被触发、能启动几次、启动时携带什么语义。Activiti支持的启动事件类型主要有:空启动事件、定时启动事件、消息启动事件、信号启动事件、错误启动事件(仅用于子流程)。每一种对应不同的业务场景,选错了要么功能实现不了,要么引入不必要的复杂度。
我见过有团队为了做“每月1号自动发起月度汇报流程”,在外部写了个定时任务去调API启动流程。这当然能跑通,但完全没必要——用定时启动事件,把CRON表达式配在流程定义里,引擎自己就会在指定时间创建实例。少一个外部调度器,就少一个故障点。
2.2 空启动事件:最常用也最容易被低估
空启动事件就是那个没有任何图标的圆圈,它表示流程只能通过API手动启动。这是绝大多数业务审批流的起点——用户点“发起申请”,后端调API创建实例。
配置上没什么花哨的,XML里就是一个<startEvent id="start" name="开始"/>。但这里有个实操细节值得说:启动事件的id命名。我习惯用startEvent或者业务语义化的名字,比如submitStart。为什么强调这个?因为当你在后面写执行监听器或者做流程追溯时,启动事件的id会出现在历史表中,一个清晰的id能让你排查问题时少翻半天文档。
还有一个坑:空启动事件默认只允许通过startProcessInstanceByKey这类API启动。如果你在流程里同时配了定时启动事件,那这个流程就有两个入口。这时候要注意,不同启动事件触发创建的流程实例,走的路径可能完全不同。我建议在流程设计阶段就明确:这个流程到底有几个入口?每个入口的后续路径是否一致?如果不一致,考虑拆成两个流程定义,别硬塞在一个图里。
2.3 定时启动事件:让流程自己“醒过来”
定时启动事件在XML里长这样:
<startEvent id="timerStart" name="定时启动"> <timerEventDefinition> <timeCycle>0 0 9 * * ?</timeCycle> </timerEventDefinition> </startEvent>timeCycle用的是CRON表达式,上面这个表示每天上午9点启动一次。除了timeCycle,还有timeDate(指定某个时间点启动一次)和timeDuration(从部署后延迟多久启动一次)。
这里有个非常关键的注意事项:定时启动事件的调度是基于流程部署时间还是基于引擎启动时间?实测下来,Activiti的定时任务是由JobExecutor统一调度的,timeCycle的周期计算起点是流程定义部署的时间。这意味着如果你在下午3点部署了一个“每天9点启动”的流程,它不会等到明天9点才第一次启动,而是会根据CRON表达式的语义,在下一个匹配的时间点触发。但如果你用的是timeDuration,比如PT5M,那它就是从部署完成那一刻开始算,5分钟后启动第一次。
踩过的坑:有一次做演示环境,部署了一个每分钟启动一次的定时流程,结果忘了关,一晚上创建了几千个实例,把测试库塞满了。所以定时启动事件一定要配合业务上的“是否需要重复启动”来判断,如果只是演示,记得及时挂起流程定义或者删除部署。
2.4 消息与信号启动事件:解耦触发源
消息启动事件和信号启动事件经常被混淆,我用一个类比来解释:消息是点对点的快递,信号是广播。
消息启动事件需要指定一个消息名,启动时通过runtimeService.startProcessInstanceByMessage("消息名")来触发。它要求流程定义中必须有一个唯一匹配的消息启动事件,不能有歧义。适合“收到某个特定业务事件后启动流程”的场景,比如“收到支付成功通知后启动发货流程”。
信号启动事件则是全局广播,通过runtimeService.signalEventReceived("信号名")触发。所有订阅了该信号的流程定义都会被启动。这个特性很强大,但也很危险——如果你在多个流程里用了同一个信号名,一次广播会同时启动多个流程实例。我一般只在明确需要“一对多触发”的场景下用信号,比如“系统维护通知”需要同时触发多个巡检流程。
配置示例:
<!-- 消息启动事件 --> <startEvent id="msgStart" name="消息启动"> <messageEventDefinition messageRef="paymentSuccessMsg"/> </startEvent> <!-- 信号启动事件 --> <startEvent id="signalStart" name="信号启动"> <signalEventDefinition signalRef="maintenanceSignal"/> </startEvent>注意:消息和信号的定义需要在流程定义的
<definitions>根元素下提前声明,否则部署时会报校验错误。这个错误信息有时候不太直观,新手容易卡在这里。
3. 结束事件:流程收尾的多种姿势
3.1 结束事件的执行语义
结束事件是流程实例的终点。当执行流到达结束事件时,引擎会做几件事:结束当前执行分支、判断是否所有分支都已结束、如果是则销毁流程实例、写入历史记录。听起来简单,但结束事件的类型不同,收尾行为差异很大。
Activiti支持的结束事件类型包括:空结束事件、终止结束事件、错误结束事件、取消结束事件、补偿结束事件。日常业务里用得最多的是前两种,后三种属于“高级武器”,用对了能解决大问题,用错了会让人怀疑人生。
3.2 空结束事件与终止结束事件的本质区别
空结束事件就是普通圆圈,执行流到达它就自然结束。如果流程有多个并行分支,空结束事件只结束当前分支,其他分支继续执行,直到所有分支都到达各自的结束事件,流程实例才真正结束。
终止结束事件则完全不同——它是一个“核弹级”的结束。执行流到达终止结束事件时,整个流程实例立即终止,所有还在活动的分支、任务、定时器全部被销毁。XML里这样写:
<endEvent id="terminateEnd" name="强制终止"> <terminateEventDefinition/> </endEvent>我做过一个采购审批流程,正常路径是“部门经理审批→财务审批→结束”。但业务上有个需求:如果金额超过某个阈值,需要“一票否决”,任何一级审批人拒绝后,整个流程立即终止,不需要再走后续节点。这种场景用终止结束事件就非常合适——在拒绝的分支上直接连一个终止结束事件,流程瞬间结束,干净利落。
实操心得:终止结束事件会跳过所有正在等待的任务,这意味着那些任务的审批人不会再看到待办。如果业务上需要“通知所有待办人流程已终止”,得在执行监听器里自己写逻辑,引擎不会自动帮你做这件事。
3.3 错误结束事件:子流程的异常出口
错误结束事件只能用在子流程内部。它的作用是抛出一个业务错误,这个错误会被最近的父级子流程捕获,然后走对应的错误边界事件。如果一直没有被捕获,最终会抛到最外层,流程实例以异常状态结束。
这个机制适合处理“子流程内部出现业务规则不满足”的情况。比如一个“合同审核子流程”里,如果发现合同金额与预算不符,可以抛出一个BUDGET_MISMATCH错误,父流程捕获后走“退回修改”的分支。这样就把异常处理逻辑从业务代码里抽离到了流程定义中。
配置示例:
<endEvent id="errorEnd" name="预算不符"> <errorEventDefinition errorRef="BUDGET_MISMATCH"/> </endEvent>3.4 结束事件与历史数据的关系
这里有一个很多人关心的问题:流程结束后,历史表里会留下什么?答案是:取决于你的历史级别配置。Activiti的历史级别有none、activity、audit、full四档。默认是audit,会记录流程实例、活动实例、任务实例的基本信息,但不记录变量更新细节。
如果你在流程结束后需要做数据分析,比如统计每个节点的平均停留时间,那至少要开到audit级别。如果需要追溯变量的每一次变更,那得开到full。但full级别对性能有影响,生产环境要权衡。
我个人的经验是:核心业务流程开audit,非核心的、量大的流程开activity甚至none。别一刀切全开full,数据库膨胀速度会让你怀疑人生。
4. 任务节点:流程真正干活的地方
4.1 用户任务:最核心也最复杂
用户任务是Activiti里使用频率最高的节点,它代表“需要人来处理”的环节。XML配置看起来简单:
<userTask id="deptApprove" name="部门经理审批" activiti:assignee="${deptManager}"/>但实际项目里,用户任务的复杂度往往不在节点本身,而在分配策略和表单集成上。
分配策略有几种:直接指定assignee(固定人)、用表达式动态指定(${deptManager})、用candidateUsers指定候选人列表、用candidateGroups指定候选组。我一般推荐用candidateGroups配合业务角色,而不是直接绑死到具体人。原因很简单:人员会变动,角色相对稳定。今天张三当部门经理,明天可能换成李四,如果流程里写死了张三,人员变动时就得改流程定义重新部署,这显然不合理。
候选组的方式是:流程里写activiti:candidateGroups="ROLE_DEPT_MANAGER",然后在业务代码里维护“谁拥有这个角色”的映射。查询待办时用taskService.createTaskQuery().taskCandidateGroup("ROLE_DEPT_MANAGER"),这样人员变动只需要改业务侧的映射关系,流程定义不用动。
注意:候选任务和指派任务是两种不同的查询方式。候选任务需要先“认领”(claim)才会变成指派任务。如果你用
taskCandidateGroup查出来的任务,直接调complete会报错,必须先claim。这个坑我见过至少三个同事踩过。
4.2 服务任务:把业务逻辑嵌入流程
服务任务代表自动执行的逻辑,不需要人参与。Activiti支持几种实现方式:Java类、表达式、委托表达式、Web Service调用。最常用的是Java委托类:
<serviceTask id="sendNotify" name="发送通知" activiti:class="com.example.SendNotifyDelegate"/>对应的Java类需要实现JavaDelegate接口:
public class SendNotifyDelegate implements JavaDelegate { @Override public void execute(DelegateExecution execution) { String applicant = (String) execution.getVariable("applicant"); // 发送通知逻辑 } }这里有个设计原则:服务任务里只放“与流程强相关”的逻辑,比如更新流程变量、调用流程相关的业务服务。不要把大段业务计算塞进委托类里,否则流程定义和业务代码会耦合得太紧,后期维护很痛苦。我习惯的做法是:委托类只做参数组装和调用,真正的业务逻辑放在独立的Service里,委托类通过Spring注入来调用。
4.3 脚本任务与业务规则任务
脚本任务允许在流程里直接写脚本(Groovy、JavaScript等),适合做简单的变量计算或条件判断。比如:
<scriptTask id="calcScore" scriptFormat="groovy"> <script> def base = execution.getVariable("baseScore") execution.setVariable("finalScore", base * 1.2) </script> </scriptTask>但脚本任务有个明显的缺点:脚本内容存在数据库里,调试困难,版本管理也不方便。我一般只在原型验证或者非常简单的场景下用,正式项目里更倾向于用服务任务。
业务规则任务则是把规则引擎(如Drools)集成进来,适合规则频繁变动的场景。不过引入规则引擎本身就有学习成本,如果规则不复杂,用排他网关加条件表达式就够了,没必要为了“看起来高级”而引入额外依赖。
4.4 手动任务与接收任务
手动任务表示“需要人工完成,但引擎不跟踪”的环节。它不会产生待办任务,执行流到达后直接通过。适合记录“某个线下动作已完成”这种语义。
接收任务则是“等待外部消息”的节点,执行流到达后会停在那里,直到收到指定的信号或消息才继续。这个在“等待第三方回调”的场景里很有用,比如“等待支付网关回调”可以用接收任务来实现,而不是在业务代码里轮询。
5. 事件与任务的组合实战:一个审批流的完整拆解
5.1 需求描述与流程设计
假设有一个“费用报销审批”流程,需求如下:
- 员工提交报销单后启动流程
- 如果金额小于500,直接由系统自动审批通过
- 如果金额大于等于500,走部门经理审批
- 部门经理审批通过后,如果金额大于5000,还需要财务总监审批
- 任何一级审批拒绝,流程立即终止
- 流程结束时,无论通过还是拒绝,都要记录结束时间和最终状态
这个流程用到了:空启动事件、排他网关、用户任务、服务任务、终止结束事件、空结束事件。
5.2 关键节点配置与参数说明
启动事件用空启动事件,API手动触发。第一个网关判断金额:
<exclusiveGateway id="amountCheck" name="金额判断"/>分支条件用表达式:
<sequenceFlow id="flow1" sourceRef="amountCheck" targetRef="autoApprove"> <conditionExpression xsi:type="tFormalExpression"> ${amount < 500} </conditionExpression> </sequenceFlow>自动审批用服务任务,部门经理审批用用户任务并配置候选组。拒绝分支统一连到终止结束事件。
这里有个参数计算的细节:金额比较用<还是<=,取决于业务规则。我一般会在需求评审时明确边界值,比如“500以下”到底含不含500。这种细节不确认清楚,上线后就是扯皮。
5.3 执行监听器记录流程轨迹
为了记录流程结束时间,我在结束事件上挂了一个执行监听器:
public class ProcessEndListener implements ExecutionListener { @Override public void notify(DelegateExecution execution) { execution.setVariable("endTime", new Date()); execution.setVariable("finalStatus", execution.getVariable("approved") ? "PASSED" : "REJECTED"); } }XML里这样引用:
<endEvent id="end" name="结束"> <extensionElements> <activiti:executionListener event="end" class="com.example.ProcessEndListener"/> </extensionElements> </endEvent>实操心得:执行监听器的
event属性有start和end两个值。对于结束事件,用end才能在流程实例销毁前拿到执行上下文。用start的话,有些变量可能已经被清理了。
6. 常见问题与排查技巧实录
6.1 启动事件相关的高频问题
问题一:定时启动事件不触发。排查顺序:先确认JobExecutor是否启用(activiti.cfg.xml里的jobExecutorActivate配置),再确认流程定义是否处于激活状态(挂起的流程不会触发定时器),最后检查CRON表达式是否正确。我遇到过有人把CRON写成0 0 9 * * *(六位,最后一位是秒),结果每天9点0分0秒触发一次,但业务期望的是9点整,差了一秒导致测试时没观察到。
问题二:消息启动事件报“找不到消息定义”。检查<message>元素是否在<definitions>下声明,且messageRef与startEvent里的引用一致。另外,消息名是全局唯一的,不同流程定义里不能有同名消息。
6.2 结束事件相关的典型异常
异常一:流程结束后还有待办任务。这通常是因为用了空结束事件而不是终止结束事件。空结束事件只结束当前分支,其他分支的任务还在。如果业务上要求“一处结束,处处结束”,必须用终止结束事件。
异常二:错误结束事件抛出的错误没有被捕获。检查错误边界事件是否配置在正确的子流程边界上,以及errorRef是否匹配。错误码是大小写敏感的,BUDGET_MISMATCH和budget_mismatch会被当成两个不同的错误。
6.3 任务节点的排查速查表
| 问题现象 | 可能原因 | 排查方法 |
|---|---|---|
| 查询不到待办任务 | 候选人组配置错误 | 检查candidateGroups值与查询条件是否一致 |
| 任务无法完成 | 任务未认领 | 先调claim再调complete |
| 服务任务不执行 | 委托类路径错误 | 检查activiti:class全限定名,确认类在classpath下 |
| 任务分配给了错误的人 | 表达式变量未设置 | 在启动流程时确认相关变量已传入 |
| 并行任务只完成了一个 | 网关配置问题 | 检查并行网关的汇聚逻辑是否正确 |
6.4 独家避坑技巧
第一个技巧:流程定义部署后,先别急着写业务代码,用单元测试把每个分支都跑一遍。Activiti提供了@Deployment注解和ProcessEngineRule,写测试并不麻烦。我见过太多团队流程图画完直接上业务,结果某个分支的条件表达式写错了,上线后才发现。
第二个技巧:给流程定义加版本注释。Activiti部署时会自动生成版本号,但版本号本身不携带语义。我习惯在流程定义的<documentation>标签里写清楚这个版本改了什么,比如“v3:增加财务总监审批节点”。这样回滚或者排查历史数据时,能快速定位到对应的业务变更。
第三个技巧:慎用全局事件监听器。Activiti支持在引擎级别注册全局监听器,能监听所有流程的所有事件。这个功能很强大,但也很容易造成性能问题——每个事件都触发监听器,如果监听器里有数据库操作,高并发下会成为瓶颈。我一般只在需要做统一审计日志时才用全局监听,且监听器里只做异步写入。
7. 我个人的一些使用体会
把启动事件、结束事件、任务节点这三块吃透之后,我最大的感受是:工作流引擎的价值不在于“能画流程图”,而在于“能把业务规则从代码里抽出来,用声明式的方式表达”。以前写审批逻辑,满屏的if-else和状态判断;现在把这些逻辑收敛到流程定义里,代码只负责“启动流程”和“处理任务”,职责清晰了很多。
当然,这并不意味着所有逻辑都要往流程里塞。我的原则是:流程定义负责“什么时候做什么”,业务代码负责“具体怎么做”。比如“金额大于5000需要财务总监审批”这是流程逻辑,放流程定义里;“财务总监审批时看到的报表怎么生成”这是业务逻辑,放代码里。边界划清楚了,两边都好维护。
还有一个体会是:不要害怕重新部署流程定义。Activiti的版本机制允许同一个key有多个版本,新启动的实例走新版本,老实例继续走老版本。所以流程调整时,直接部署新版本就行,不需要停服务或者做数据迁移。这个特性让流程的迭代变得很轻量,我现在的习惯是:流程有改动就重新部署,版本号自然递增,历史数据也能对应到正确的版本。
最后分享一个排查问题的小习惯:遇到流程行为不符合预期时,先去ACT_HI_ACTINST表里查这个实例的活动轨迹,看看执行流到底走了哪条路径。很多时候问题不是出在引擎,而是出在条件表达式的计算结果上。把轨迹查清楚,比盯着流程图猜要高效得多。