软件建模33:模型到代码,生成、解释与手写如何分工
同一份设备状态模型,可以生成带详细错误信息的新程序,也可能因为模板的一行修改,把“禁止退役”变成“保持原状态并成功返回”。两份文件都能编译,生成过程也都可以稳定重复。要判断转换是否保持业务含义,必须执行允许和拒绝两类合同。
本篇承接前面的有限状态工具,固定设备模型,改变生成逻辑,比较手写、解释和生成路径。另加一条手写政策:退役必须填写理由。这项新增政策放在生成物之外,检验自定义行为怎样与生命周期规则组合。
三部分分别负责什么
Fowler 的 Semantic Model 条目将其定位为由 DSL 填充的模型。本例相应区分输入文本、解析得到的有限状态模型以及执行该模型的代码;JSON 本身不负责决定某次操作是否合法。Semantic Model,作者目录
输入继续使用第30篇的三个状态、三个操作、四条迁移。parse_model 提供结构与有限状态语义,设备合同再约束准确的迁移集合。第32篇的 audit_model 返回各层检查结果,生成入口据此决定是否继续。
通过检查后,第31篇的 pim_to_psm 补入固定 Python 平台选择,psm_to_python 生成源码。这里直接调用这些已存在的接口,没有另写一份状态校验规则,也没有将 PSM 字段当成不起作用的注释。
flowchart LR
J["设备JSON"] --> A["32分层校验"]
A --> M["30有限状态模型"]
M --> P["31 Python PSM"]
P --> G["33生成逻辑变体"]
G --> F["独立Python文件"]
F --> T["编译、执行九格合同"]
M --> I["解释执行"]
I --> T
H["手写生命周期分支"] --> T
第一张图中的文件是真实写出并读取的工件。手写路径按已冻结的生命周期规则组织分支,不从待测生成物复制答案;合同矩阵也单独保存。四种执行路径得到相同值,才算本次有限比较通过。
基线模板和诊断模板
基线生成物包含初始状态、迁移字典和 step 函数。查询不到状态与操作组合时,它抛出 ValueError,不返回原状态。诊断模板保留这个实现,再增加一层包装,将失败中的当前状态与操作写进错误信息。
两份源码字节不同,但返回目标状态及是否拒绝应保持一致。实验遍历三个状态乘三个操作的九个组合,同时运行手写函数、解释器、基线生成函数和诊断生成函数。四种允许迁移都返回目标,五种禁止组合都拒绝。
退役状态再次调用退役操作时,诊断模板给出 OPERATION_NOT_ALLOWED:RETIRED/retire。这属于生成程序的运行失败位置,不能替代模型校验时的输入路径。前者回答“本次执行为什么失败”,后者回答“输入模型哪里不合法”。
模板变化并不总能对外透明。如果调用方依赖完整错误字符串,新文案也可能构成接口变化。本例合同仅固定异常类别与允许/拒绝语义,详细信息只在诊断模板的专门场景中检查;没有声称所有旧调用方都兼容。
确定性与语义是两项检查
同一模型使用同一诊断模板重复生成,结果逐字节相同,说明这次转换没有混入时钟或随机值。基线与诊断模板的哈希分别记录,使“模型没变、模板变了”能够在工件层区分。
确定性没有保证模板正确。故障模板同样能稳定生成,它把未命中的查询改成返回当前状态。退役设备重新审批时得到 RETIRED 字符串,调用方可能把正常返回误当成审批成功。状态未改变,并不等于操作被明确拒绝。
实验实际执行这份错误程序,发现九格矩阵中的五个负例全部不符。那四条允许迁移仍然通过,因此只测成功路径会漏掉这次回归。检查程序把这五处偏差作为预期反例记录,并不把故障模板列为可以替换基线的实现。
生成器也是需要验收的程序。改变字典表示、错误处理或包装方式之后,旧模型可以继续作为回归输入;若模型规则也同时修改,就需要另外记录哪个合同发生了变化。否则失败可能来自模板,也可能来自需求,难以区分。
保存哈希时需要同时标明输入与转换器。只保存输出摘要,可以发现文件不同,却不能还原差异来自模型还是模板。本章的交付清单另外记录模型、生成入口、平台转换和校验器的源码摘要;它们共同限定这次运行的输入条件。更换任何依赖之后,应重新运行矩阵,旧摘要不能继续证明新版本。
这份矩阵穷举了已声明状态和操作的笛卡尔积,因此可以检查这个小型迁移关系的所有单步组合。它没有穷举任意字符串、异常对象或全部调用历史。若扩展了操作参数,九格也不再充分,例如理由的长度、字符与权限需要各自的边界样本。实验覆盖率的分母随合同变化,不能一直沿用旧数字。
生成源码中的字典采用固定排序,使输入数组顺序不必成为目标程序的书写顺序。不过,输出稳定与业务兼容仍然分别判断;即使未来仅改变格式化方式,也应更新工件哈希,并保留行为结果以解释这次差异。
输入失败发生在写出之前
构建入口先审计模型,再转换、编译生成源码,最后写目标文件。负例把一条迁移的目标写成不存在的状态,真实启动构建子进程后得到退出码 1,错误包含 UNKNOWN_STATE 和 $.transitions[0].to。
实验事先在目标路径放入基线程序,构建失败后再次读取并逐字节比较,内容保持不变。它证明模型校验失败不会覆盖这一个目标文件,不代表文件写入期间断电也有原子性,更没有验证多文件发布的一致提交。
命令行的构建成功目前只保证输入合法、转换成功且源码可编译。九格行为回归由 run.sh 的检查程序另行执行,构建器并不会自动将任意新模板判为语义正确。使用者应把行为检查作为发布前独立步骤。
手写扩展只补自己负责的政策
新增的“退役必须填写理由”没有放入迁移表。当前 DSL 没有参数和守卫表达式,直接增加一个 reasonRequired 字段既不能被解析,也不会自动产生判断。本篇选择在 custom_policy.py 中实现包装函数。
包装函数先检查退役理由是否为空,再把状态和操作交给生成函数。活跃设备缺少理由时被拒绝;提供损坏理由后进入退役;已经退役的设备即使提供理由,也不能重新审批。后一项保证包装层没有用自己的返回值绕过生命周期。
sequenceDiagram
participant C as 调用方
participant H as 手写政策
participant G as 生成生命周期
C->>H: ACTIVE / retire / 空理由
H-->>C: 拒绝:需要理由
C->>H: ACTIVE / retire / 损坏
H->>G: ACTIVE / retire
G-->>H: RETIRED
H-->>C: RETIRED
C->>H: RETIRED / approveInspection
H->>G: 同一操作
G-->>C: 拒绝:迁移不存在
第二张图显示检查与委派的顺序。手写政策没有自己的设备状态,没有保存日志,也没有验证理由是否真实或符合审批权限。它只是本例明确增加的输入规则,不能被解释为完成退役审计。
Fowler 的 Generation Gap 模式通过继承分离生成类与手写类。本例借鉴的是两类代码分别维护的意图,实际采用函数组合,没有实现该模式的继承结构。Generation Gap,作者说明
比较结果还不能覆盖整个实体
九格合同观察的是下一状态或拒绝,不包含设备身份、描述修改、历史快照、仓储或并发行为。生成函数与手写函数在这九格上相同,不能推出它们与第24篇整个 Java 实体等价。
手写理由政策也没有进入语义模型,因此任何只读取模型的工具都不知道这项限制。如果将来需要自动生成表单、规则说明或跨语言实现,政策分散在包装层就会成为新的追踪成本。届时可以扩展语言,但应先定义参数类型、求值顺序和错误语义。
当前分工适合这个有限需求:模型承担迁移,模板承担目标语言表示,包装层承担一个额外输入条件。哪一部分是事实来源,要按具体规则说明,不能把所有手写行为都归结成“模型之外的小补丁”。
复跑生成与回归
1 | |
运行需要 Python 3.10 及以上和 Bash。实际得到 19 条场景记录,全部 pass=true,退出码为 0;其中故障模板的五个偏差被明确检出,非法模型构建子进程按预期退出 1。三份生成源码、错误输出和哈希保存在 evidence/33/。
校验阶段可对照 第30篇:模型、元模型与DSL。本章只输出 Python 生命周期函数,没有修改累计 Java 应用,也没有把生成成功当成部署成功。






