第四部分:工作流实战
第十二章 Activiti任意流转——在别人的框架里找"洞"
12.1 政务工作流不是"按图走线"
本章核心:运行时动态改连线,不改流程定义,重启服务就恢复原状。
最开始政务系统没有"流程"这个概念。发养老金的系统只有三个独立功能:申请、审批、拨款。申请是申请,审批是审批,互相不关联。谁审的、什么时候审的,全靠纸质台账。
后来要求越来越严格。下班时间不能干活——不是技术限制,是合规要求,非工作时间提交的公文在审计时有问题。我们加了工作时间校验,但只弹窗提醒不阻止,因为真有急事的时候不能卡死。一个流程里不能有两个人同时做事——防篡改、防冲突,同一个人不能既在申请又在审批同一个事项。同角色的任务要公平自动分配——不能每次都分给同一个科长,否则廉政审查通不过。这个直接用存储过程+定时任务做的,不经过工作流引擎。存储过程直接操作Activiti的 act_ru_task 表,一条UPDATE就改了分配人。用Java做的话要走Activiti API,轮转状态管理、冲突检查、锁定排除、兜底上抛——每一步都是查询加判断,存储过程一个游标循环全搞定。轮转的关键是 t_curr_psn 表记住上次分给谁了,下次分给下一个人。冲突回避是查历史任务表看这个人有没有参与过同一个流程实例,参与过就跳过。所有人都跳过了,就分给上级部门的同角色。
这三个需求哪个BPMN都画不出来。它们不是"流程怎么走"的问题,是"流程能不能走、谁该走、什么时候能走"的合规约束。政务工作流的复杂性不在流程图上,在这些看不见的规则里。
标准的Activiti工作流只能按BPMN定义好的路径走线。科员→科长→处长→局长,一路往下。
但政务审批的现实是:科长要能直接跳到处长(处长正好在旁边,科长顺手就批了)、退回到科员(处长发现材料不齐)、转办到其他科室(这事不归我们管)。
Activiti原生不支持这种"任意跳"。如果每次新增一个跳转需求都要去改BPMN定义,那流程图会变成蜘蛛网,而且改了流程定义会影响所有正在运行的实例。
这些需求还导致了代码的重复。passProcess()是标准审批,passProcessHK()是含税务同步的审批(后置拦截器要异步),passProcessNo()是不保存表单数据的审批,passProcessNo2()是批量审批——每个方法里都有拦截器链、提交流程、分配处理人这套逻辑,但每个都有细微差异。做税务审批的时候,批量发任务是某市税务局的要求——从金税系统导出清单,我们这边批量发给下面各个税务分局。重复的代码都是这些需求逼出来的,当时没抽出公共方法,导致后来出bug的时候改一处忘一处,费了不少劲。这是务实的代价——先跑起来没错,但该抽象的还是要抽象。
实现这一功能的过程并不轻松。由于标准 BPMN 规范未直接支持灵活跳转,我们深入研究了 Activiti 的底层源码,探索其内存模型与执行机制。经过数周的反复验证和踩坑,最终通过动态修改内存连线定义,实现了不破坏原有实例的灵活跳转。
能不能改BPMN定义? 可以,但影响面太大。改了流程定义的XML,所有正在运行的实例都受影响——有些实例可能走了一条被你改掉的路径,直接报错。
能不能用流程变量控制网关? 可以,但流程图会变成蜘蛛网。每个节点之间都要加一个排他网关,用变量判断走哪条线。节点一多,流程图根本看不懂,运维人员无从下手。
能不能在运行时动态改连线? Activiti的ActivityImpl对象(流程定义的内存表示)有一个createOutgoingTransition()方法——可以动态创建一条出线指向任意节点。而且这个修改只在内存中,不会持久化到数据库,不影响其他实例。
这就是Activiti留下的那个"洞"。
12.2 怎么做的
turnTransition()——核心方法,6步操作:
public void turnTransition(String taskId, String activityId,
Map<String, Object> variables) {
// 1. 获取当前任务和流程定义
TaskEntity currentTask = (TaskEntity) taskService
.createTaskQuery().taskId(taskId).singleResult();
ActivityImpl currentActivity = processDefinition
.findActivity(currentTask.getTaskDefinitionKey());
// 2. 保存当前节点的所有出线
List outgoingTransitions =
currentActivity.getOutgoingTransitions();
List backupTransitions =
new ArrayList(outgoingTransitions);
// 3. 清空出线,创建一条临时出线指向目标
currentActivity.getOutgoingTransitions().clear();
ActivityImpl destinationActivity = processDefinition
.findActivity(activityId);
PvmTransition tempTransition = currentActivity
.createOutgoingTransition();
tempTransition.setDestination(destinationActivity);
try {
// 4. complete——任务沿临时线走到目标
taskService.complete(taskId, variables);
} finally {
// 5. 删除临时线
currentActivity.getOutgoingTransitions().clear();
// 6. 恢复原来的出线
currentActivity.getOutgoingTransitions()
.addAll(backupTransitions);
}
}
修改的是内存中的ActivityImpl对象,不会持久化到数据库,不影响其他流程实例。重启服务后ActivityImpl从BPMN重新加载,自动恢复原始定义——这就是"运行时改连线"比"改BPMN定义"安全的原因。
commitProcess()——统一提交入口:
if (activityId == null) {
// 没指定目标,走标准下一步
taskService.complete(taskId, variables);
} else if (isNext(taskId, activityId)) {
// 目标是标准路径的下一个节点,直接complete
taskService.complete(taskId, variables);
} else {
// 目标不是标准路径,动态跳转
turnTransition(taskId, activityId, variables);
}
三个分支覆盖了所有场景。注意第二个判断isNext()——如果用户恰好点的是"标准下一步",不需要动态跳转,直接complete就行。减少不必要的动态操作,降低缓存污染风险。
缓存污染的坑。 一开始没清Activiti的流程定义缓存,跳转完之后偶尔出现其他实例走错路径。排查了很久才发现是Activiti内部缓存了修改后的ActivityImpl对象——在finally里恢复出线后,缓存里的对象虽然恢复了出线,但Activiti的ProcessDefinitionCache可能还在引用旧的临时状态。解决方案是在finally里主动调用Context.getProcessEngineConfiguration().getProcessDefinitionCache().clear()。这个坑花了两天时间才定位——因为不是必现的,只有并发跳转时才偶发。
回退流程——标记历史。 回退比提交多一步:需要把当前任务和目标节点原来的已完成任务都标记为"retract",这样前端展示审批历史时用户能看到"这个环节被回退过"。用Activiti的Command接口实现——不改Activiti源码,写一个UpdateHiTaskReasonCommand直接操作历史表,把ACT_HI_TASKINST中相关记录的delete_reason_字段更新为"retract"。
public class UpdateHiTaskReasonCommand implements Command {
private String taskId;
private String reason;
public Void execute(CommandContext commandContext) {
commandContext.getHistoricTaskInstanceEntityManager()
.changeTaskReason(taskId, reason);
return null;
}
}
参与者规则引擎。 流程的下一步处理人不是写死在代码里的,是配置在数据库中的。getdusers()方法支持四种参与者类型(角色/组织机构/指定用户/原路回退)和十几种限定条件(参保人所在地区、当前经办人所在地区、特定部门等),通过组合可以覆盖所有政务审批场景。
规则配置存在一张专门的表中:
| 规则类型 | 含义 | 示例 |
|---|---|---|
| ROLE | 按角色找 | 下一步是"科长"角色中的人 |
| ORG | 按组织找 | 下一步是参保人所在区社保局 |
| USER | 指定用户 | 直接指定某人 |
| BACK | 原路回退 | 退给上一步的处理人 |
条件限定可以叠加:比如"参保人所在区的科长"——先按参保人地区过滤组织,再在组织内找科长角色的人。
这套规则引擎撑住了两套完全不同的业务体系——某海关的3级垂直管理和某省居民养老保险的5级行政层级,同一个getdusers()通吃。
turnTransition()的运行时修改细节。 代码里注释已经标注了6步。这里只强调一点:getOutgoingTransitions()返回的是引用不是副本,所以必须先new ArrayList<>()做深拷贝。这个坑我踩过——一开始只保存了引用,后面clear()时备份也空了。
会签外挂——为什么Activiti的multiInstance不适用于政务。 Activiti 5.13的multiInstance(多实例)理论上可以做并行审批,但必须改BPMN、参与者写死、规则不灵活——第十二章会展开讲这三个硬伤和我们的解决方案。
我们的"外挂会签"方案绕开了这些限制。会签是一个独立的流程定义,每个会签人启动一个独立的流程实例,用hq关联表记录父子关系。参与者是运行时选择的——科长在审批界面勾选会签部门和人员,系统为每个人创建子流程。会签规则在Java代码里控制——canNext的判断逻辑可以随意定制。会签可以在流程的任何环节发起——因为它是外挂的,不需要在BPMN里预设。
和我们能做的对比,Activiti标准API能做什么? 启动流程、完成任务、查询任务、查询历史——都是"按BPMN定义走"。跳转、跨步退回、任意环节加会签,一样都没有。
我们通过turnTransition()实现的任意跳转,通过UpdateHiTaskReasonCommand实现的历史标记,通过hq表实现的外挂会签——这三样东西,没有改Activiti一行源码,全部通过它暴露的扩展点完成。找扩展点花了几周,但找到之后每个功能的实现代码都不复杂。
本章小结
• Activiti原生不支持任意跳转,但ActivityImpl.createOutgoingTransition()可以在运行时动态创建临时连线。
• 核心方法turnTransition()6步操作:保存出线→清空→创建临时线→complete→删除→恢复。只在内存中,不持久化。
• 踩了缓存污染的坑:Activiti内部缓存了修改后的ActivityImpl,finally里必须clear()。
决策洞察:在别人写的框架里做定制,不要改它的代码,要找它预留的扩展点——找不到就找它的"洞"。
第十三章 流程图追踪与外挂会签——自己画图、自己挂流程
13.1 场景还原
本章核心:自己解析BPMN用Java2D画PNG,Activiti自带的图不支持中文也改不了。
政务审批系统里,用户最常问的问题是:"我们的申请走到哪一步了?"需要一个功能:用户输入申请号,直接看到一张流程图——哪个环节走完了、卡在哪个环节、谁在处理、什么时间处理的,一目了然。
Activiti 5.13的ProcessDiagramGenerator不支持中文标注,高亮逻辑不灵活,图片质量差,而且依赖Activiti自己的渲染引擎,版本升级后可能不兼容。前端SVG/Canvas也不行——2013年IE8还大量存在,兼容性问题一大堆。我们的方案:后端用Java2D画PNG,所有浏览器都能显示。
还有一个隐藏的问题:BPMN分支节点没条件。 业务人员画流程图时经常忘记给分支连线加条件表达式,Activiti走到分支节点就报错。我们的解决方案:流程部署之前,框架自动扫描BPMN文件里所有sequenceFlow,找出同一源节点发出多条连线的情况,自动注入条件表达式${nextid=="连线id"}。业务人员只管画图,条件由框架补齐。
13.2 怎么做的
三步走:查任务历史 → 解析BPMN XML → Java2D画图。
-
用historyService查当前流程实例的所有历史任务——拿到每个环节的处理人、开始时间、结束时间;
-
用repositoryService.getResourceAsStream()拿到BPMN XML的输入流;
-
用dom4j解析BPMN XML,提取节点坐标和连线信息,用Java2D画成PNG图片直接输出到HttpServletResponse。
BPMN XML的布局信息在节点下:提供节点坐标(x、y、width、height),提供连线拐点。解析时通过bpmnElement属性关联process节点的id,画图时同时拿到坐标和名称。
Java2D画图的核心逻辑:
BufferedImage image = new BufferedImage(width, height,
BufferedImage.TYPE_INT_RGB);
Graphics2D g2d = image.createGraphics();
g2d.setBackground(Color.WHITE);
g2d.clearRect(0, 0, width, height);
for (NodeInfo node : nodes) {
int x = node.getX() * 2; // 坐标乘2放大
int y = node.getY() * 2;
if ("userTask".equals(node.getType())) {
g2d.drawRoundRect(x, y, node.getWidth()*2, node.getHeight()*2, 10, 10);
g2d.drawString(node.getName(), x+5, y+20);
if (node.isCompleted()) {
g2d.drawString(node.getAssignee(), x+5, y+40);
g2d.drawString(node.getEndTime(), x+5, y+60);
}
}
}
三种状态三种颜色:已完成的环节黑色边框,标注处理人和完成时间;当前正在处理的环节红色高亮,标注处理人和接收时间;还没走到的环节黑色边框,只标注环节名。坐标乘2放大——BPMN设计器生成的坐标值比较小,乘2后节点之间有足够空间写三行中文。字体统一宋体24号。
连线逐段画直线,最后一段带箭头。连线颜色也根据状态区分——已走过的路径深色,未走过的浅灰色。用户一眼就能看出流程走到了哪里。
前端只需要一个标签:
自己解析BPMN + Java2D画图,虽然代码多了几百行,但完全可控。后来我们加过"已耗时X天"的标注(政务系统有办理时限要求,超时红色警告),这种定制在Activiti自带组件上几乎不可能实现。"笨办法"能解决问题就不是笨办法。
13.3 外挂会签——政务审批在任何节点都能做
政务审批中"会签"是非常普遍的需求——一个申请到了某个环节,需要多个部门同时审批,全部通过才能继续。Activiti的multiInstance理论上能做并行审批,但必须修改BPMN、参与者写死在流程定义里、会签规则不灵活。而政务的现实是:今天三个部门会签明天换四个,会签可能发生在任何环节,有的需要"全部通过"有的需要"一票否决"。
我们的方案:会签是独立的流程定义,每个会签人启动一个独立的流程实例,主流程暂停在当前节点,等所有会签完成后继续。用hq关联表(会签的首拼,不值得学)存父子流程实例的对应关系——preProcInstId关联主流程,procInstId关联子流程。
完整流程:
-
审批人选择"会签" → 为每个会签人启动一个独立流程实例 → 在hq表插入关联记录;
-
主流程停在当前节点不走,前端查询hq表判断是否全部完成;
-
每个会签人在自己的待办里看到会签任务,独立处理/放弃;
-
全部完成后,主流程继续往下走。
canNext的判断逻辑可以随意定制——一票否决、全部通过、多数通过……怎么改都行,不需要改BPMN。不管主流程走到哪个环节都能发起会签,加签/减签就是在hq表里加/删记录。会签人通过pre_proc_inst_id反查主流程实例,加载对应的表单数据。
不修改主流程定义,用外挂的方式实现会签。把"谁参与"从设计时推迟到运行时——会签不是流程图里的一个节点,而是一个随时可以触发的动作。用"外挂"而非"嵌入"来扩展功能,不碰已有的稳定系统,只在边界上做增量。
本章小结
• 流程图追踪:自己解析BPMN XML + Java2D画PNG,三种状态三种颜色,IE8兼容。
• 外挂会签:独立子流程+hq关联表,不改BPMN,任何节点都能发起,参与者运行时决定。
• 两者的共同思路:不碰已有的稳定系统,只在边界上做增量。
决策洞察:不碰已有系统的内部,只在边界上做增量——这是政务系统改造最安全的方式,也是最务实的方式。