4、流程解析:XML/JSON流程定义解析、流程定义校验、流程定义缓存

好,咱们接着聊。上一章我们把流程定义的数据库设计讲完了,这一章,咱们要真正开始「动刀子」了——把存好的流程定义文件,从磁盘或者数据库里捞出来,解析成引擎能理解的内存对象。

说白了,流程定义解析就是「翻译官」的工作。把XML或JSON这种结构化文本,翻译成Java对象。我见过不少新手,一上来就写一堆if-else去解析,结果代码又臭又长,维护起来想哭。嗯,咱们得讲究点章法。

4.1 流程定义解析:从文本到对象

先说说XML解析。为什么很多老牌工作流引擎(比如Activiti、Flowable)都用XML?因为XML的Schema校验机制非常成熟,而且XPath、XSLT这些配套工具很完善。我个人习惯,如果团队里没有特别偏好,首选XML。

解析的核心思路其实就三步:

  1. 读取:把XML/JSON文件读成字符串或输入流
  2. 反序列化:用解析器(比如DOM、SAX、Jackson)转成中间对象
  3. 模型转换:把中间对象映射成引擎的流程定义模型

举个例子,一个简单的流程定义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,如果目标节点还没解析完,你就没法做引用校验。

我的经验: 解析时先过两遍。第一遍只解析节点(Node),第二遍再解析连线(Edge)。这样逻辑清晰,不容易出bug。我曾经在一个项目里图省事,一遍过,结果遇到循环引用时直接栈溢出,排查了一下午。

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;
    }
}
注意: 我曾经遇到过一个案例,用户上传了一个流程定义,里面有个排他网关,但只配了一条出口连线。结果流程走到网关时,条件不满足,直接抛异常。从那以后,我强制要求排他网关必须有一个默认出口(default flow),否则不予通过。

校验不通过怎么办?直接拒绝部署,并返回详细的错误信息给用户。别想着「先部署,运行时再报错」,那是对用户的不负责任。

4.3 流程定义缓存:别每次都去读数据库

流程定义一旦部署,通常不会频繁变更。但每次启动一个流程实例,都要去数据库里查一遍定义,那性能就太差了。所以,缓存是必须的。

我常用的缓存策略是两级缓存:

缓存级别 存储位置 生命周期 适用场景
一级缓存(本地) JVM内存(如Caffeine、Guava Cache) 随应用启动/重启 高频访问、数据量小
二级缓存(分布式) Redis 可持久化、可过期 多实例部署、共享数据

具体做法是:

  1. 启动时,把所有已部署的流程定义加载到本地缓存。
  2. 运行时,先从本地缓存查,命中直接返回。
  3. 本地缓存未命中,去Redis查,查到后回填到本地缓存。
  4. Redis也没有,去数据库查,查到后依次回填。

这里有个关键点——缓存失效。当用户重新部署或更新流程定义时,必须立即清除相关缓存。我一般用事件机制:部署成功后,发布一个ProcessDeployedEvent,缓存监听器收到事件后,删除对应流程定义的缓存键。

避坑指南: 本地缓存一定要设置最大容量和过期时间。我曾经在一个项目里没设上限,结果用户部署了上千个流程定义,直接把堆内存撑爆了。用Caffeine的话,可以这么配:Caffeine.newBuilder().maximumSize(500).expireAfterWrite(1, TimeUnit.HOURS).build()

另外,流程定义对象本身可能比较大(包含所有节点、连线、表单、脚本等),序列化到Redis时要注意。我建议只缓存核心结构,比如节点列表和连线列表,那些大段的脚本或表单定义,可以按需加载。说白了,就是「懒加载」——用到了再查。

嗯,流程解析这块,核心就是这三板斧:解析、校验、缓存。每一步都有坑,但踩过了,后面就顺了。下一章,咱们聊聊流程实例的运行时管理,那才是真正让流程「跑起来」的地方。