一份 JSON 可以正常解析,却把退役设备重新审批成活跃设备。另一份 JSON 把 ACTIVE 写了两次,程序若直接放进集合,重复项就会悄悄消失。格式正确、结构符合约定和业务正确,是三个不同的检查问题。

本篇为设备生命周期建立一个受限 DSL,提供真实解析器、模型校验器与执行入口。输入只有状态和迁移,不接受任意表达式。它将为后续转换和生成实验提供稳定接口,而不是扩展成通用建模平台。

语言需要表达什么

Fowler 将 DSL 描述为聚焦软件某一方面的小语言,并区分独立解析的外部 DSL 与宿主语言中的内部 DSL。JSON 只是本例选用的外部表示;状态、操作和迁移的含义由本例自己的规则定义。Domain Specific Languages,作者说明

模型是具体的设备生命周期:有哪些状态、从哪里开始、哪些操作可以改变状态。元模型在这里规定允许出现哪些概念及关系:一个模型有状态与操作,一条迁移引用来源状态、操作和目标状态,并受确定性等约束。Schmidt 对领域专用建模语言的讨论也将元模型与概念关系、语义约束联系起来。Model-Driven Engineering,页27

本例用 Python 数据类和校验函数实现这组定义,没有建立 MOF 仓库或读取 UML 模型。元模型不是“模型文件外面再套一层 JSON”,也不因字段名叫 metamodel 就成立;它必须实际约束哪些模型可被接收。

flowchart TB
    MM["元模型与约束<br/>State / Operation / Transition"] -->|约束| MODEL["具体模型:设备生命周期"]
    JSON["JSON文本:外部语法"] -->|解析与校验| MODEL
    MODEL --> I["解释执行下一状态"]
    MODEL --> G["生成Python迁移表"]
    RULE["24设备领域合同"] -->|另外核对| MODEL

第一张图区分语法、模型与领域合同。它是教学工具的关系图,没有声称对应完整的 OMG 分层实现,也没有把文本解析器当成业务规则的唯一来源。

版本一的输入结构

顶层必须且只能有七个字段:版本、名称、状态集合、初始状态、终态集合、操作集合、迁移列表。版本必须是整数 1,不能以布尔值代替。名称和引用使用受限的 ASCII 标识符,避免引入引号转义、自由文本与表达式语言的额外语义。

下面是一条迁移,完整模型保存在 models/30/equipment.json:

1
{"from":"INSPECTION_REQUIRED","operation":"approveInspection","to":"ACTIVE"}

这段文本只有在两个状态和操作都已经声明时才可使用。每条迁移也只允许这三个字段,多写 guard 会被拒绝,不能指望工具忽略它之后仍然表达原来的条件。当前语言没有参数、守卫、动作副作用、层次状态或并行区域。

JSON 标准定义通用数据语法,并建议对象成员名称唯一;重复成员可能导致不同实现产生不同结果。本例选择更严格的入口政策:重复键直接失败,非 JSON 常量 NaN 也拒绝。RFC 8259,§4、§6

状态数组中的重复名称与 JSON 对象的重复键是两种问题。前者属于本语言的命名约束,后者发生在文本解析阶段。它们分别返回 DUPLICATE_NAME 和 DUPLICATE_KEY,不会先覆盖旧值再继续处理。

解析之后检查关系

parse_model 先调用标准库解析器,再检查顶层与迁移字段、类型和名称格式。随后核对初始状态、终态与所有迁移引用。不存在的目标状态和未声明的操作分别报告错误,定位到具体迁移及字段。

接下来检查同一“来源状态、操作”是否重复。即使两条边写了相同目标,也会拒绝,因为语言不允许通过重复声明表达额外含义。否则后续解释器取第一条、生成字典保留最后一条,就可能在错误模型上给出不同结果。

终态不能存在出边,因此从 RETIRED 审批回 ACTIVE 在当前模型中立即失败。程序还从初始状态遍历迁移,检查所有声明状态能否到达;单独加入 LOST 却没有到达路径,也会被拒绝。

这些都是当前语言选择的语义约束,并非所有状态机语言都必须如此。例如版本一允许声明暂未使用的操作,也允许非终态没有出边。工具没有把这种不完整自动解释为错误,领域需要哪些操作仍由下一层合同决定。

合法状态机仍可能违反设备规则

wrong-policy.json 故意交换待检设备的审批和退役目标:审批进入退役,退役反而进入活跃。状态、引用、确定性、终态和可达性都满足通用检查,因此 parse_model 接受它。实际调用解释器执行审批,结果确实是 RETIRED。

这个模型必须在设备领域合同处失败。validate_equipment_contract 另外核对第24篇的三个状态、初始与终态、三个操作和四条准确迁移;交换目标以后返回 EQUIPMENT_CONTRACT。通用模型合法性与设备规则由此得到不同的运行结果。

flowchart LR
    T["输入文本"] --> J["JSON解析"]
    J --> S["字段与类型"]
    S --> R["引用与有限状态约束"]
    R --> D["设备领域合同"]
    D --> E["允许进入设备实验"]
    J --> F1["缺逗号/重复键"]
    S --> F2["多余guard/错误类型"]
    R --> F3["未知目标/退役出边"]
    D --> F4["审批错误地进入退役"]

第二张图对应实际检查顺序。图中的“允许进入”只指生命周期实验,不是租赁准入。设备活跃仍不等于某个租期可用,也不能替代维修评估或占用判断。

把失败位置交给调用方

ModelError 继承 ValueError,保存稳定的 code 和 path。未知目标的路径类似 $.transitions[0].to;JSON 语法错误保留行列;运行时禁止操作使用 $.runtime。测试比较代码和位置,避免把一段随时可能调整的说明文案当成协议。

当前校验遇到第一个错误就停止,没有聚合全部问题。重复对象键在标准库回调中被发现,只能报告根路径,尚未保留嵌套位置。错误信息能指出问题类别,但不能据此声称已具备 IDE 的精确源码定位。

生成器和执行器接收已经解析的不可变 Model。直接调用数据类构造器能够绕过检查,因此不是受支持的不可信输入入口。冻结数据类主要防止正常调用中意外改写字段,不是安全隔离机制。

step 是纯函数:成功返回目标状态,失败抛错,不负责修改设备或保存仓储。实验从初始状态执行审批、要求检查、再次审批、退役,再尝试非法审批;失败后调用方保留的状态仍是退役。这个结果不能代替 Java 实体并发更新或事务回滚验证。

反例也要保留原始文本

models/30/invalid/ 保存各个失败输入,清单单独写出预期错误代码和路径。重复状态、未知初始状态、未知目标与未知操作各有独立文件;布尔版本、多余字段和非数组状态也分别运行。修改解析器以后,同一份文本必须仍然在约定的位置被拒绝。

这组负例没有在捕获任意异常后就报告成功。测试只接受 ModelError,并比较明确的错误类型;若程序因为缺少字典键而意外抛出 KeyError,运行会直接失败。由此可以区分“模型被有意拒绝”与“校验器自身崩溃”。

当前覆盖的是固定样本,不是对全部 JSON 文本进行穷举。文本大小、深度限制与大量状态带来的资源消耗没有被评估,工具也未暴露为网络入口。将其用于外部批量导入之前,应在保持业务语义的同时补上资源边界,而不能把这些本地样本当成解析服务的安全证明。

后续章节依赖的稳定接口

contracts.md 固定了 parse_model、load_model、step、generate_python 和设备合同校验入口。模型可以通过 as_dict 导出为新的字典,再解析回来;这次往返后的要求检查操作仍返回待检状态。

生成器输出独立 Python 文件,只有初始状态、迁移表和查询函数。实验真实编译执行生成源码,检查审批成功与重复退役拒绝。生成文件运行时不依赖工具模块,但生成过程依赖经过验证的模型以及固定转换代码。

后续增加平台映射时,应另外说明从哪里补入类型、存储和异常策略。增加业务约束时,也应区分语言不接受什么、某个领域不允许什么。版本一没有将所有设备规则写死在解析器内,因此同一受限语法可以承载别的有限状态模型。

若未来加入守卫表达式,不能继续悄悄沿用版本一。表达式的变量、类型、求值时机与失败行为都需要定义,并补充能够区分错误的场景。保留一个未知字段再交给运行时代码猜测,会使稳定接口失去意义。

复跑校验与行为

1
bash examples/software-modeling/labs/30/run.sh

环境只需 Python 3.10 及以上和 Bash。实际运行产生 23 条场景记录,全部 pass=true,退出码为 0;其中包括 13 个解析或模型校验反例,以及合法模型执行、领域合同拒绝、生成物执行和往返对照。原始输入、输出与生成源码均保留在本章目录。

第30篇实验包 保留仓库路径前缀。既有设备语义可对照 第24篇:Entity、Value Object与身份。工具不依赖累计 Java 应用,没有实现通用 UML 解释器、MOF 兼容或自动无损往返;后续转换只能承诺它实际表达的这一小段行为。