4、流程解析:XML/JSON流程定义解析、流程定义校验、流程定义缓存
好,咱们接着聊。上一章我们把流程定义的数据库设计讲完了,这一章,咱们要真正开始「动刀子」了——把存好的流程定义文件,从磁盘或者数据库里捞出来,解析成引擎能理解的内存对象。
说白了,流程定义解析就是「翻译官」的工作。把XML或JSON这种结构化文本,翻译成Java对象。我见过不少新手,一上来就写一堆if-else去解析,结果代码又臭又长,维护起来想哭。嗯,咱们得讲究点章法。
4.1 流程定义解析:从文本到对象
先说说XML解析。为什么很多老牌工作流引擎(比如Activiti、Flowable)都用XML?因为XML的Schema校验机制非常成熟,而且XPath、XSLT这些配套工具很完善。我个人习惯,如果团队里没有特别偏好,首选XML。
解析的核心思路其实就三步:
- 读取:把XML/JSON文件读成字符串或输入流
- 反序列化:用解析器(比如DOM、SAX、Jackson)转成中间对象
- 模型转换:把中间对象映射成引擎的流程定义模型
举个例子,一个简单的流程定义XML长这样:
<process id="leave_approval" name="请假审批" version="1">
<startEvent id="start" name="开始" />
<userTask id="task1" name="部门经理审批" assignee="manager">
<extensionElements>
<property name="timeout" value="2d" />
</extensionElements>
</userTask>
<exclusiveGateway id="gateway1" name="判断" />
<sequenceFlow id="flow1" sourceRef="start" targetRef="task1" />
<sequenceFlow id="flow2" sourceRef="task1" targetRef="gateway1" />
<endEvent id="end" name="结束" />
</process>
解析时,我会用SAX解析器(事件驱动,内存占用小)逐节点读取。遇到<startEvent>就创建一个StartEvent对象,遇到<userTask>就创建UserTask对象。这里有个坑——节点之间的连线(sequenceFlow)必须在所有节点都解析完后才能建立。为什么?因为连线引用了sourceRef和targetRef,如果目标节点还没解析完,你就没法做引用校验。
JSON解析其实大同小异。用Jackson或Gson反序列化成Map或自定义POJO。我个人更推荐Jackson,性能好,而且支持注解,能直接映射到模型类。比如:
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "type")
@JsonSubTypes({
@JsonSubTypes.Type(value = StartEvent.class, name = "startEvent"),
@JsonSubTypes.Type(value = UserTask.class, name = "userTask")
})
public abstract class FlowNode {
private String id;
private String name;
// getter/setter...
}
这样,JSON里写"type": "userTask",Jackson自动帮你反序列化成UserTask对象。省心。
4.2 流程定义校验:别让错误跑进运行时
解析完了,你以为就完事了?不,校验才是重头戏。我见过太多线上事故,都是因为流程定义里有个小错误,比如连线指向了一个不存在的节点,结果流程跑到一半直接卡死。
校验分两个层面:
- 语法校验:XML格式对不对?JSON结构是否合法?节点属性是否必填?
- 语义校验:连线引用的节点是否存在?网关的分支条件是否覆盖了所有可能?有没有死循环?
语法校验可以用XML Schema(XSD)或JSON Schema。比如,我要求每个<userTask>必须有assignee属性,那就在XSD里定义:
<xs:element name="userTask">
<xs:complexType>
<xs:attribute name="id" type="xs:string" use="required"/>
<xs:attribute name="assignee" type="xs:string" use="required"/>
</xs:complexType>
</xs:element>
语义校验就得自己写逻辑了。我通常会写一个ProcessValidator类,里面包含一系列校验规则:
public class ProcessValidator {
public List<ValidationError> validate(ProcessDefinition processDef) {
List<ValidationError> errors = new ArrayList<>();
// 1. 检查是否有且仅有一个开始节点
if (processDef.getStartEvent() == null) {
errors.add(new ValidationError("流程缺少开始节点"));
}
// 2. 检查所有连线的sourceRef和targetRef是否有效
for (SequenceFlow flow : processDef.getFlows()) {
if (processDef.getNode(flow.getSourceRef()) == null) {
errors.add(new ValidationError("连线 " + flow.getId() + " 引用了不存在的源节点"));
}
}
// 3. 检查网关是否有默认出口
// ... 更多规则
return errors;
}
}
校验不通过怎么办?直接拒绝部署,并返回详细的错误信息给用户。别想着「先部署,运行时再报错」,那是对用户的不负责任。
4.3 流程定义缓存:别每次都去读数据库
流程定义一旦部署,通常不会频繁变更。但每次启动一个流程实例,都要去数据库里查一遍定义,那性能就太差了。所以,缓存是必须的。
我常用的缓存策略是两级缓存:
| 缓存级别 | 存储位置 | 生命周期 | 适用场景 |
|---|---|---|---|
| 一级缓存(本地) | JVM内存(如Caffeine、Guava Cache) | 随应用启动/重启 | 高频访问、数据量小 |
| 二级缓存(分布式) | Redis | 可持久化、可过期 | 多实例部署、共享数据 |
具体做法是:
- 启动时,把所有已部署的流程定义加载到本地缓存。
- 运行时,先从本地缓存查,命中直接返回。
- 本地缓存未命中,去Redis查,查到后回填到本地缓存。
- Redis也没有,去数据库查,查到后依次回填。
这里有个关键点——缓存失效。当用户重新部署或更新流程定义时,必须立即清除相关缓存。我一般用事件机制:部署成功后,发布一个ProcessDeployedEvent,缓存监听器收到事件后,删除对应流程定义的缓存键。
Caffeine.newBuilder().maximumSize(500).expireAfterWrite(1, TimeUnit.HOURS).build()。
另外,流程定义对象本身可能比较大(包含所有节点、连线、表单、脚本等),序列化到Redis时要注意。我建议只缓存核心结构,比如节点列表和连线列表,那些大段的脚本或表单定义,可以按需加载。说白了,就是「懒加载」——用到了再查。
嗯,流程解析这块,核心就是这三板斧:解析、校验、缓存。每一步都有坑,但踩过了,后面就顺了。下一章,咱们聊聊流程实例的运行时管理,那才是真正让流程「跑起来」的地方。