Apache Commons SCXML 0.9 状态机设计示例详解
以红绿灯、订单状态机、秒表、延时闹钟四个由浅入深的 SCXML 0.9 模型为载体,逐行讲解状态、事件、转移守卫、数据模型、Java 对象协作与延时事件的设计取舍,沉淀事件命名、变量放置、可观测性与终态复位的设计规范,全部模型与回显实测通过。
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 >= 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。
八、参考链接
- Apache Commons SCXML 官方主页与示例集:https://commons.apache.org/proper/commons-scxml/
- W3C State Chart XML (SCXML) 规范:https://www.w3.org/TR/scxml/
- Commons JEXL 1.1 语法参考:https://commons.apache.org/proper/commons-jexl/reference/syntax.html
- 本专栏姊妹篇:《Apache Commons SCXML 0.9 快速入门与集成示例》《Apache Commons SCXML 0.9 常用 API 与状态机模型详解》