Apache Commons SCXML 0.9 状态机设计示例详解

以红绿灯、订单状态机、秒表、延时闹钟四个由浅入深的 SCXML 0.9 模型为载体,逐行讲解状态、事件、转移守卫、数据模型、Java 对象协作与延时事件的设计取舍,沉淀事件命名、变量放置、可观测性与终态复位的设计规范,全部模型与回显实测通过。

Apache Commons SCXMLApache Commons SCXML2026/10/15

Apache Commons SCXML 0.9 状态机设计示例详解

目标:用四个由浅入深的状态机模型(红绿灯 → 订单 → 秒表 → 延时闹钟)把 SCXML 模型设计的核心手法讲透:事件驱动循环、数据模型与条件守卫、层级事件命名与 Java 对象协作、延时事件,最后沉淀一套可直接落地的事件命名与变量放置规范;本文全部模型与回显均已在 Ubuntu 22.04 + JDK 17 + Maven 3.9 实测通过。

SCXML 模型设计的本质是回答四个问题:有哪些状态、谁触发状态切换、切换时要做什么、数据住在哪里。本文四个示例分别把这四个问题的典型形态走了一遍,示例工程沿用本专栏统一结构:模型在 /opt/scxml-demo/scxml-demo/models/,Java 驱动代码在 /opt/scxml-demo/scxml-demo/src/main/java/demo/。驱动模板与集成坑位不再重复展开,见第 3 篇;API 签名细节见第 4 篇。

一、环境信息

  • 操作系统:Ubuntu 22.04 LTS
  • 架构:x86_64
  • JDK:Temurin 17.0.20.1(OpenJDK 1.8.0_312 同样实测通过)
  • 构建工具:Apache Maven 3.9.16
  • 组件版本:commons-scxml 0.9(2008-11-22 发布)
  • Maven 坐标:commons-scxml:commons-scxml:0.9 + 显式补充 commons-jexl:1.1、xalan:2.7.2
  • 模型目录:/opt/scxml-demo/scxml-demo/models/
  • 示例源码目录:/opt/scxml-demo/scxml-demo/src/main/java/demo/

💡 技术拓展:本文四个模型刻意避开了同一类题材的重复堆砌,各自负责一个设计维度——

模型状态数核心设计维度
红绿灯3无数据的纯事件循环、模型最小骨架
订单状态机5<datamodel>、cond 守卫、onentry 动作、终态
秒表3事件命名空间(watch.*)、调用 Java 对象、状态相关事件
延时闹钟2<send delay>、事件调度器装配

另有层级状态 + In() 谓词的组合已在第 4 篇 5.5 节实测,可作第五个素材。

二、示例总览

四个模型的公共驱动骨架完全一致(五步:解析 → 组装 → 挂载 → go() → triggerEvent),差异只在模型文件与 Java 侧交互深度。所有示例的运行方式统一为:

cd /opt/scxml-demo/scxml-demo
mvn -q -B compile exec:java -Dexec.mainClass=demo.示例类 -Dexec.args='/opt/scxml-demo/scxml-demo/models/模型文件'

工程依赖与 pom.xml 全文见第 1 篇 4.3 节,此处不再重复。

三、示例一:红绿灯——事件驱动的循环状态机

3.1 模型全文与结构解读

文件 models/traffic-light.scxml:

<?xml version="1.0" encoding="UTF-8"?>
<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="red">

  <state id="red">
    <transition event="timer" target="green"/>
  </state>

  <state id="green">
    <transition event="timer" target="yellow"/>
  </state>

  <state id="yellow">
    <transition event="timer" target="red"/>
  </state>

</scxml>

结构解读:根元素 <scxml> 上 xmlns 是 W3C 标准命名空间、initial="red" 声明初始状态;每个 <state> 用 id 唯一命名,<transition> 声明「收到 event 则迁移到 target」。三个转移构成闭环——这是状态机最朴素的形态:无数据、无动作、无守卫,只回答「有哪些状态、谁触发切换」两个问题。

3.2 驱动代码

文件 src/main/java/demo/TrafficLightDemo.java:

package demo;

import java.net.URL;
import java.util.Set;
import org.apache.commons.scxml.SCXMLExecutor;
import org.apache.commons.scxml.TriggerEvent;
import org.apache.commons.scxml.env.SimpleErrorHandler;
import org.apache.commons.scxml.env.SimpleErrorReporter;
import org.apache.commons.scxml.env.jexl.JexlEvaluator;
import org.apache.commons.scxml.io.SCXMLParser;
import org.apache.commons.scxml.model.SCXML;
import org.apache.commons.scxml.model.TransitionTarget;

public class TrafficLightDemo {
    public static void main(String[] args) throws Exception {
        URL model = new java.io.File(args[0]).toURI().toURL();
        SCXML scxml = SCXMLParser.parse(model, new SimpleErrorHandler());
        System.out.println("模型解析成功, 根状态个数=" + scxml.getChildren().size());

        SCXMLExecutor exec = new SCXMLExecutor(new JexlEvaluator(), null, new SimpleErrorReporter());
        exec.setStateMachine(scxml);
        exec.go();
        printStates("初始配置", exec);

        exec.triggerEvent(new TriggerEvent("timer", TriggerEvent.SIGNAL_EVENT));
        printStates("发出第 1 个 timer 后", exec);

        exec.triggerEvent(new TriggerEvent("timer", TriggerEvent.SIGNAL_EVENT));
        printStates("发出第 2 个 timer 后", exec);

        exec.triggerEvent(new TriggerEvent("timer", TriggerEvent.SIGNAL_EVENT));
        printStates("发出第 3 个 timer 后", exec);
    }

    static void printStates(String tag, SCXMLExecutor exec) {
        Set<TransitionTarget> states = exec.getCurrentStatus().getStates();
        for (TransitionTarget t : states) {
            System.out.println(tag + " -> " + t.getId());
        }
    }
}

3.3 实测流转

模型解析成功, 根状态个数=3
初始配置 -> red
发出第 1 个 timer 后 -> green
发出第 2 个 timer 后 -> yellow
发出第 3 个 timer 后 -> red

3.4 设计要点

  • initial 属性必填,引擎 go() 时自动进入初始态——初始态本身就是一次状态进入,onEntry 动作会执行。
  • 同一状态可以声明多条同名 event 的转移做「同事件多去向」(订单示例会用到),引擎按文档顺序取第一条 cond 为真(或无 cond)的转移。
  • 无匹配转移的事件被静默忽略(秒表示例会实测这个语义),这是 SCXML 的标准容错行为。

四、示例二:订单状态机——数据模型与条件转移

4.1 模型全文与结构解读

文件 models/order.scxml:

<?xml version="1.0" encoding="UTF-8"?>
<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="created">

  <datamodel>
    <data name="payCount" expr="0"/>
    <data name="amount" expr="100"/>
  </datamodel>

  <state id="created">
    <transition event="pay" cond="amount &gt;= 100" target="paid"/>
    <transition event="pay" target="failed"/>
  </state>

  <state id="paid">
    <onentry>
      <assign name="payCount" expr="payCount + 1"/>
    </onentry>
    <transition event="ship" target="shipped"/>
  </state>

  <state id="shipped">
    <transition event="confirm" target="done"/>
  </state>

  <state id="failed"/>

  <final id="done"/>

</scxml>

结构解读,四个新元素各司其职:

  • <datamodel> 声明状态机级变量(payCount、amount),进入根上下文,全模型可见,Java 侧可读写。
  • cond="amount >= 100" 是转移守卫:事件到达后先求值,为真才迁移。created 状态的同名事件 pay 有两条转移,第一条带守卫、第二条无守卫作兜底——「主路径 + 兜底路径」是业务状态机的标准写法。
  • <onentry>/<assign> 组成进入动作:进入 paid 即把 payCount 加一。<assign> 要求变量已存在(由 <data> 声明),否则求值阶段报错。
  • <final id="done"/> 是终态声明,进入后 isFinal() 翻转为 true;failed 则是普通状态——业务上「失败待人工处理」并不等于流程终态。

4.2 Java 侧与上下文交互

文件 src/main/java/demo/OrderDemo.java,三个场景:正常履约、Java 改写数据后走兜底、复位:

package demo;

import java.net.URL;
import java.util.Set;
import org.apache.commons.scxml.Context;
import org.apache.commons.scxml.SCXMLExecutor;
import org.apache.commons.scxml.TriggerEvent;
import org.apache.commons.scxml.env.SimpleErrorHandler;
import org.apache.commons.scxml.env.SimpleErrorReporter;
import org.apache.commons.scxml.env.jexl.JexlEvaluator;
import org.apache.commons.scxml.io.SCXMLParser;
import org.apache.commons.scxml.model.SCXML;
import org.apache.commons.scxml.model.TransitionTarget;

public class OrderDemo {

    public static void main(String[] args) throws Exception {
        URL model = new java.io.File(args[0]).toURI().toURL();

        System.out.println("======== 场景一: 正常支付履约 (amount=100) ========");
        SCXMLExecutor exec = newExecutor(model);
        Context ctx = exec.getRootContext();
        System.out.println("根上下文初始变量: amount=" + ctx.get("amount") + ", payCount=" + ctx.get("payCount"));

        exec.triggerEvent(new TriggerEvent("pay", TriggerEvent.SIGNAL_EVENT));
        print(exec);
        System.out.println("onentry 执行 assign 后: payCount=" + ctx.get("payCount"));

        exec.triggerEvent(new TriggerEvent("ship", TriggerEvent.SIGNAL_EVENT));
        print(exec);

        exec.triggerEvent(new TriggerEvent("confirm", TriggerEvent.SIGNAL_EVENT));
        print(exec);
        System.out.println("isFinal()=" + exec.getCurrentStatus().isFinal());

        System.out.println("======== 场景二: 条件不满足走兜底转移 (amount=50) ========");
        SCXMLExecutor exec2 = newExecutor(model);
        Context ctx2 = exec2.getRootContext();
        ctx2.set("amount", Integer.valueOf(50));
        System.out.println("Java 侧改写变量: amount=" + ctx2.get("amount"));
        exec2.triggerEvent(new TriggerEvent("pay", TriggerEvent.SIGNAL_EVENT));
        print(exec2);
        System.out.println("isFinal()=" + exec2.getCurrentStatus().isFinal());

        System.out.println("======== 场景三: reset() 复位 ========");
        exec.reset();
        System.out.println("reset 后回到: " + onlyIds(exec) + ", payCount=" + exec.getRootContext().get("payCount"));
    }

    static SCXMLExecutor newExecutor(URL model) throws Exception {
        SCXML scxml = SCXMLParser.parse(model, new SimpleErrorHandler());
        SCXMLExecutor exec = new SCXMLExecutor(new JexlEvaluator(), null, new SimpleErrorReporter());
        exec.setStateMachine(scxml);
        exec.go();
        return exec;
    }

    static void print(SCXMLExecutor exec) {
        System.out.println("当前状态: " + onlyIds(exec));
    }

    static String onlyIds(SCXMLExecutor exec) {
        Set<TransitionTarget> states = exec.getCurrentStatus().getStates();
        StringBuilder sb = new StringBuilder("[");
        for (TransitionTarget t : states) {
            if (sb.length() > 1) {
                sb.append(", ");
            }
            sb.append(t.getId());
        }
        return sb.append("]").toString();
    }
}

4.3 实测三场景

======== 场景一: 正常支付履约 (amount=100) ========
根上下文初始变量: amount=100, payCount=0
当前状态: [paid]
onentry 执行 assign 后: payCount=1
当前状态: [shipped]
当前状态: [done]
isFinal()=true
======== 场景二: 条件不满足走兜底转移 (amount=50) ========
Java 侧改写变量: amount=50
当前状态: [failed]
isFinal()=false
======== 场景三: reset() 复位 ========
reset 后回到: [created], payCount=0

4.4 payload 路由扩展

事件自带 payload 时(TriggerEvent 第三个参数),模型里用 _eventdata 参与守卫,可替代「为每个分支定义一个事件」的做法。模型 models/payload.scxml:

<state id="wait">
  <transition event="msg" cond="_eventdata == 'vip'" target="vipLane"/>
  <transition event="msg" target="normalLane"/>
</state>

Java 侧 demo/PayloadDemo.java 携带不同 payload 触发同一事件,实测回显:

---- 携带 payload='vip' 触发 msg 事件 ----
当前状态: vipLane
---- 携带 payload='basic' 触发 msg 事件 ----
当前状态: normalLane

4.5 设计要点

  • 数据放 <datamodel>、变化靠 <assign>、路由靠 cond:三件套构成了「带数据流转」的最小完备组合。
  • Java 侧与模型的交互通道就是对根上下文的读写:预置业务对象、改写决策变量、读回统计值(如 payCount),不需要给引擎写适配层。
  • reset() 同时复位状态与 <datamodel> 初值(实测 payCount 回 0),适合「同一执行器跑多轮业务」的场景。

五、示例三:秒表——层级事件命名与 Java 对象协作

5.1 模型全文与结构解读

文件 models/stopwatch.scxml,取材自官方 stopwatch 示例的形态:秒表有 reset(归零)、running(计时中)、stopped(暂停)三态,事件按 watch.* 命名:

<?xml version="1.0" encoding="UTF-8"?>
<scxml xmlns="http://www.w3.org/2005/07/scxml"
       xmlns:cs="http://commons.apache.org/scxml"
       version="1.0" initial="reset">

  <datamodel>
    <data name="laps" expr="0"/>
    <data name="elapsed" expr="0"/>
  </datamodel>

  <state id="reset">
    <onentry>
      <cs:var name="noop" expr="watch.reset()"/>
      <assign name="elapsed" expr="watch.getElapsed()"/>
      <assign name="laps" expr="0"/>
    </onentry>
    <transition event="watch.start" target="running"/>
  </state>

  <state id="running">
    <onentry>
      <cs:var name="noop" expr="watch.start()"/>
    </onentry>
    <transition event="watch.split">
      <assign name="laps" expr="laps + 1"/>
    </transition>
    <transition event="watch.stop" target="stopped"/>
  </state>

  <state id="stopped">
    <onentry>
      <cs:var name="noop" expr="watch.stop()"/>
      <assign name="elapsed" expr="watch.getElapsed()"/>
    </onentry>
    <transition event="watch.start" target="running"/>
    <transition event="watch.reset" target="reset"/>
  </state>

</scxml>

结构解读,四个设计手法值得注意:

  • 事件命名空间:watch.start、watch.stop、watch.split、watch.reset 用点号分层,事件多了以后按前缀归类,监听器与日志里一眼可辨。事件名只是字符串,引擎不做任何解析。
  • 模型调用 Java 对象:cs:var(Commons 自定义命名空间动作)的表达式 watch.start() 直接调用上下文中 watch 对象的方法——秒表的计时逻辑全部在 Java POJO 里,模型只负责「何时调用哪个方法」的编排,这正是模型/逻辑分离的教科书形态。
  • 无 target 的转移:running 的 watch.split 转移没有 target,只执行 <assign>——事件被消费、状态不变化、计数动作执行,这是「自循环动作」的标准写法。
  • 状态相关事件:watch.reset 只在 reset 与 stopped 两个状态有转移,running 态收到它会被静默忽略(3.4 节所述标准语义),不需要模型里写「禁止归零」的守卫。

5.2 秒表 POJO

文件 src/main/java/demo/StopwatchDemo.java 内的静态内部类,承担全部计时逻辑:

public static class Stopwatch {
    private long base;
    private long saved;
    private boolean running;

    public long start() {
        base = System.currentTimeMillis();
        running = true;
        return saved;
    }

    public long stop() {
        if (running) {
            saved += System.currentTimeMillis() - base;
            running = false;
        }
        return saved;
    }

    public long reset() {
        long old = saved;
        saved = 0L;
        running = false;
        return old;
    }

    public long getElapsed() {
        return running ? saved + (System.currentTimeMillis() - base) : saved;
    }
}

主流程把 POJO 预置进根上下文(注意用 JexlContext 配套 JexlEvaluator):

Context rootCtx = new JexlContext();
rootCtx.set("watch", new StopwatchDemo.Stopwatch());

SCXMLExecutor exec = new SCXMLExecutor(evaluator, null, new SimpleErrorReporter());
exec.setRootContext(rootCtx);
exec.setStateMachine(scxml);
exec.go();

5.3 实测七步

驱动脚本依次验证:初始归零、无转移事件被忽略、打点计数、运行中归零被拒、两段计时累加、终态归零(demo/StopwatchDemo.java,运行回显,实测;毫秒数随机器浮动):

1) go() 初始: [reset], elapsed=0, laps=0
2) reset 态发 watch.reset(无此转移): [reset]
3) 计时中打点(split): [running], laps=1
4) running 态发 watch.reset(无此转移): [running] (事件被忽略)
5) 停表: [stopped], elapsed=273
6) 第二段计时再停表: elapsed=354 (两段累加)
7) stopped 态发 watch.reset: [reset], elapsed=0, laps=0

5.4 设计要点

  • 把「秒」级领域逻辑(时间累计)放进 POJO、把「何时做什么」放进模型,POJO 可独立单测,模型可独立演进——这是 SCXML 集成最常见的目标形态。
  • onentry 是「进入状态即副作用」的挂载点:调 POJO、快照数据都在这里,模型里不写任何 Java 代码细节。
  • 第 5、6 步证明 POJO 的 saved 累计逻辑跨状态保持(273 + 81 = 354),上下文中的对象实例与 Java 侧是同一个。

六、示例四:延时事件——闹钟模型

6.1 模型全文与结构解读

文件 models/alarm.scxml:idle 状态一进入就登记一个 1 秒后自触发的 wake 事件,到期后自动迁移到 awake:

<?xml version="1.0" encoding="UTF-8"?>
<scxml xmlns="http://www.w3.org/2005/07/scxml" version="1.0" initial="idle">

  <state id="idle">
    <onentry>
      <send event="'wake'" delay="'1s'"/>
    </onentry>
    <transition event="wake" target="awake"/>
  </state>

  <state id="awake"/>

</scxml>

结构解读:<send> 是动作不是状态——它把事件「发出去」,event 与 delay 属性都是表达式(第 3 篇 3.3 节实测),字符串字面量必须写成 "'wake'" 与 "'1s'"。延时事件由事件派发器调度:Java 侧必须装配 SimpleScheduler,否则延时事件不会回投引擎。

6.2 实测

文件 src/main/java/demo/SendDelayDemo.java,关键装配与监听计时:

final long[] enteredAt = new long[1];
final long startedAt = System.currentTimeMillis();
exec.addListener(scxml, new SCXMLListener() {
    public void onEntry(TransitionTarget state) {
        if (state.getId().equals("awake")) {
            enteredAt[0] = System.currentTimeMillis();
            System.out.println("[监听] 进入 awake, 距 go() 已 " + (enteredAt[0] - startedAt) + " ms");
        }
    }

    public void onExit(TransitionTarget state) {
    }

    public void onTransition(org.apache.commons.scxml.model.TransitionTarget from,
            org.apache.commons.scxml.model.TransitionTarget to,
            org.apache.commons.scxml.model.Transition transition) {
    }
});

SCXMLExecutor execRef = exec;
execRef.setEventdispatcher(new SimpleScheduler(execRef));
execRef.setStateMachine(scxml);
execRef.go();
System.out.println("[主线程] go() 完成, 初始状态=" + ids(execRef) + " (onentry 已登记 delay=1s 的 send)");

Thread.sleep(1800L);
System.out.println("[主线程] 等待 1800 ms 后状态=" + ids(execRef));

实测回显(毫秒数随机器浮动):

[主线程] go() 完成, 初始状态=[idle] (onentry 已登记 delay=1s 的 send)
[监听] 进入 awake, 距 go() 已 1030 ms
[主线程] 等待 1800 ms 后状态=[awake]

6.3 设计要点

  • 延时事件的调度发生在事件派发器内部:go() 立即返回,主线程不被阻塞,到期由派发器回调 triggerEvents 完成迁移——注意回调线程不是主线程,监听器里的业务代码要考虑线程安全。
  • delay 支持毫秒('500')、秒('1s')、分钟('1m')三种后缀写法(派发器解析逻辑实测支持)。
  • 超时重试、心跳、锁过期这类「时间驱动的状态迁移」,本质都是本示例的模式:onentry 登记延时事件 + 对应转移。同一状态被反复进入时,<send> 可带 id 属性配合 SimpleScheduler.cancel() 取消未到期的事件,避免定时器堆积。

七、设计方法论沉淀

7.1 事件命名与守卫规范

  • 事件名用「域.动作」点号分层(watch.start),监听器按前缀过滤,日志检索友好。
  • 同名事件多去向时,把带 cond 的主路径写在前面、无 cond 的兜底写在最后——引擎按文档顺序取第一条满足条件的转移。
  • 携带业务参数的分支优先用 _eventdata 守卫路由(4.4 节),而不是为每个分支发明一个事件名。
  • 状态相关的事件约束(某状态下事件无效)优先靠「不声明转移」实现,让引擎静默忽略(5.3 节第 4 步实测),比写反向守卫更简洁。

7.2 数据放哪:三处变量的分工

放置方式语义适用
<datamodel> + <assign>根上下文变量,跨状态共享、reset() 随之复位业务计数、决策参数(订单示例)
<var>(Commons 命名空间)状态局部上下文,setLocal 语义状态内临时值(秒表示例的 noop)
_eventdata当前事件 payload,随事件派发刷新事件路由参数(payload 示例)

判定口诀:跨状态共享进 <datamodel>,只在进入动作里用的临时值用 <var>,随事件来的参数读 _eventdata。

7.3 可观测性:监听器与 REPL 调试

  • 生产埋点:根级注册自定义 SCXMLListener(三回调实测序列见第 4 篇 7.1 节),把进入/退出/转移写入审计日志。
  • 开发调试:setSuperStep(false) 微步推进 + 空事件驱动,逐级观察链式内部事件(第 4 篇 3.3 节实测)。
  • 模型联调:官方 REPL org.apache.commons.scxml.test.StandaloneJexlExpressions 免编译验证模型,标准输入喂事件即可(第 3 篇 6.2 节实测)。
  • 层级模型判断「是否处于某状态」用 In() 谓词(第 4 篇 5.5 节实测)。

7.4 终态与复位

  • <final> 只给「流程真正结束」的状态;失败但需人工介入的状态用普通 <state>(订单示例的 failed),避免 isFinal() 语义被污染。
  • 复位一律用 exec.reset():状态回初始、<datamodel> 回初值,一行完成(4.3 节场景三实测)。
  • 引擎实例不是线程安全的:多轮业务用 reset() 复用单实例,并发业务每轮各建一个 SCXMLExecutor。

八、参考链接

  1. Apache Commons SCXML 官方主页与示例集:https://commons.apache.org/proper/commons-scxml/
  2. W3C State Chart XML (SCXML) 规范:https://www.w3.org/TR/scxml/
  3. Commons JEXL 1.1 语法参考:https://commons.apache.org/proper/commons-jexl/reference/syntax.html
  4. 本专栏姊妹篇:《Apache Commons SCXML 0.9 快速入门与集成示例》《Apache Commons SCXML 0.9 常用 API 与状态机模型详解》