第1章:流程定义解析——从XML到可执行模型

各位同学,咱们今天聊聊工作流引擎里最基础、也最关键的一环——流程定义解析。

说白了,就是怎么把一张画好的流程图,变成计算机能理解、能执行的数据结构。我做了这么多年低代码平台,发现很多新手一上来就扎进画界面、拖组件,结果到了流程引擎这块就卡住了。嗯,今天咱们就把这块硬骨头啃下来。

1.1 XML解析基础:流程定义的“通用语言”

为什么选XML?你想想看,工作流引擎要跨平台、跨语言,总得有个大家都认的格式吧。XML就是那个“通用语言”。

我在项目中遇到过,有人非要用JSON存流程定义,结果到了要加命名空间、要扩展属性的时候,改得那叫一个痛苦。XML的树形结构天然适合描述流程——节点是活动,边是流转,清清楚楚。

一个典型的流程定义XML长这样:

<process id="leave_approval" name="请假审批流程">
    <startEvent id="start" name="开始" />
    <userTask id="apply" name="提交申请" assignee="employee">
        <extensionElements>
            <property name="formKey" value="leave_form" />
        </extensionElements>
    </userTask>
    <sequenceFlow id="flow1" sourceRef="start" targetRef="apply" />
    <userTask id="manager_approve" name="经理审批" assignee="manager" />
    <sequenceFlow id="flow2" sourceRef="apply" targetRef="manager_approve" />
    <endEvent id="end" name="结束" />
    <sequenceFlow id="flow3" sourceRef="manager_approve" targetRef="end" />
</process>

看到没?每个元素都有id,这是流程引擎识别节点的唯一标识。我建议你从一开始就养成好习惯——id用有意义的命名,别用uuid,否则调试的时候你会想哭。

小提示:XML解析时,记得处理命名空间。很多新手忽略这个,结果解析出来的节点名全是带前缀的,匹配不上。我曾经因为这个bug排查了整整一个下午。

1.2 DOM与SAX解析对比:选对工具,事半功倍

好,XML有了,怎么读?Java里主流就两种:DOM和SAX。咱们来掰扯掰扯。

对比维度 DOM解析 SAX解析
读取方式 一次性加载到内存,构建树 顺序扫描,事件驱动
内存占用 高(整个文档都在内存里) 低(只处理当前节点)
访问方式 随机访问,任意节点 只能顺序访问,不能回头
修改能力 支持增删改 只读
适用场景 小文件、需要频繁操作 大文件、只需读取

我个人习惯是:流程定义文件一般不大(几百KB顶天了),用DOM解析完全够用。而且你想想看,解析完我们还要构建模型、做校验,DOM的树结构直接映射到流程模型,多顺手。

但如果你要解析的是流程日志、历史数据这种动不动几十MB的文件,那还是老老实实用SAX吧。我记得有一次,一个客户导入了三年的流程日志,用DOM解析直接OOM了……嗯,从那以后我养成了先评估文件大小的习惯。

1.3 流程定义模型构建:从XML到Java对象

XML解析完了,拿到的是DOM节点或者SAX事件。接下来要干的事,就是把这些零散的信息组装成我们想要的流程模型。

我一般会设计这么几个核心类:

public class ProcessDefinition {
    private String id;
    private String name;
    private List<FlowNode> nodes;      // 所有节点
    private List<SequenceFlow> flows;  // 所有流转
    private Map<String, FlowNode> nodeMap; // id到节点的映射,方便查找
}

public abstract class FlowNode {
    private String id;
    private String name;
    private String type; // startEvent, userTask, endEvent等
}

public class SequenceFlow {
    private String id;
    private String sourceRef; // 来源节点id
    private String targetRef; // 目标节点id
    private String conditionExpression; // 条件表达式,用于排他网关
}

构建的过程其实就是一个遍历+映射的过程。用DOM解析的话,大概是这样:

Document doc = DocumentBuilderFactory.newInstance()
        .newDocumentBuilder()
        .parse(xmlFile);

Element root = doc.getDocumentElement();
NodeList nodeList = root.getChildNodes();

ProcessDefinition processDef = new ProcessDefinition();
processDef.setId(root.getAttribute("id"));
processDef.setName(root.getAttribute("name"));

for (int i = 0; i < nodeList.getLength(); i++) {
    Node node = nodeList.item(i);
    if (node instanceof Element) {
        Element elem = (Element) node;
        String tagName = elem.getNodeName();
        
        if ("startEvent".equals(tagName)) {
            // 构建开始节点
        } else if ("userTask".equals(tagName)) {
            // 构建用户任务节点
        } else if ("sequenceFlow".equals(tagName)) {
            // 构建流转
        }
    }
}
核心要点:构建模型时,一定要建立id到对象的映射。后续做流程流转、查找下一个节点的时候,没有这个映射,每次都要遍历列表,性能会非常差。

1.4 流程定义校验:别让错误跑上线

模型构建完了,别急着用。先校验!我在生产环境见过太多因为流程定义错误导致的诡异bug了。

校验什么?我总结了几条:

  • 结构完整性:有没有开始节点?有没有结束节点?没有开始节点,流程怎么启动?没有结束节点,流程永远跑不完。
  • 引用正确性:sequenceFlow的sourceRef和targetRef引用的节点id,在节点列表里是否存在?我曾经见过一个case,sourceRef写错了,结果流程走到一半就断了。
  • 连通性:从开始节点出发,能不能到达所有节点?有没有死胡同?有没有永远走不到的节点?
  • 网关约束:排他网关的分支条件是否互斥?并行网关的汇聚节点数量是否匹配?

校验代码大概长这样:

public class ProcessValidator {
    
    public ValidationResult validate(ProcessDefinition processDef) {
        ValidationResult result = new ValidationResult();
        
        // 1. 检查开始和结束节点
        if (processDef.getStartEvent() == null) {
            result.addError("缺少开始节点");
        }
        if (processDef.getEndEvent() == null) {
            result.addError("缺少结束节点");
        }
        
        // 2. 检查引用完整性
        for (SequenceFlow flow : processDef.getFlows()) {
            if (!processDef.containsNode(flow.getSourceRef())) {
                result.addError("流转 " + flow.getId() + " 引用了不存在的源节点");
            }
            if (!processDef.containsNode(flow.getTargetRef())) {
                result.addError("流转 " + flow.getId() + " 引用了不存在的目标节点");
            }
        }
        
        // 3. 检查连通性(BFS/DFS遍历)
        Set<String> reachableNodes = findReachableNodes(processDef);
        for (FlowNode node : processDef.getNodes()) {
            if (!reachableNodes.contains(node.getId())) {
                result.addWarning("节点 " + node.getId() + " 不可达");
            }
        }
        
        return result;
    }
}
避坑指南:我曾经遇到过一个问题——校验通过了,但流程跑起来就是不对。后来发现是XML里有个隐藏字符(BOM头),导致解析出来的id多了个不可见字符。所以,解析XML之前,最好先做一次字符编码的清洗。

好了,这一章的内容就到这里。流程定义解析是整个工作流引擎的基石,这块做扎实了,后面的流程流转、任务分配、历史记录,都会顺很多。下一章咱们聊聊流程实例的创建和执行,到时候你会看到,今天构建的这个模型是怎么“活”起来的。