软件建模05:UML 类图与交互图
类图里列出 RentalDesk、ReserveRequest 和 Period,仍无法确认取消以后重试会走哪条分支。时序图若把一条 refund() 消息发给根本没有这个操作的回执对象,看起来同样流畅。两张图分别画完还不够,至少要核对消息接收者是否存在、它声明了什么操作,以及取得它的引用是否符合结构关系。
先确定图描述哪个系统
OMG UML 2.5.1 §11.4 描述类及其结构和行为特征,§11.5 描述实例之间的关联;§17 则讨论交互、生命线和消息。本文使用这些概念组织模型,再用 Mermaid 绘制一个受限记法子集。Mermaid 文件通过解析和检查,不表示通过了完整 UML 规范一致性认证。UML 2.5.1 正式规范
图中 RentalDesk 对应真实 Java 类。Receipt 是它的私有嵌套 record,展示它是为了说明内部协作,外部调用者不能直接调用它。Map 表示当前标准库集合接口:实现实际创建两个 HashMap,没有 ReservationRepository 或数据库适配器。把集合画成仓储会暗示额外边界,还会掩盖取消实际只调用一次 remove 的事实。
03 的候选 ER 模型增加了参与方和设备目录,尚未进入这份 Java 代码,所以实现类图也不画出 Party 或完整 Equipment 实体。它只包含非空白的 EquipmentId。一张图可以省略与当前问题无关的内容,但标题、说明和代码映射必须让读者知道它采用哪个视角。
类图同时保留操作和引用
classDiagram
class RentalDesk {
+reserve(ReserveRequest) Outcome
+cancel(RequestId) boolean
}
class ReserveRequest {
+requestId() RequestId
+equipmentId() EquipmentId
+period() Period
+equals(Object) boolean
}
class Receipt {
+request() ReserveRequest
+outcome() Outcome
}
class Period {
+overlaps(Period) boolean
}
class RequestId
class EquipmentId
class Outcome
class Map {
+get(Object) Object
+remove(Object) Object
}
RentalDesk "1" --> "0..*" Receipt : receipts
RentalDesk "0..*" --> "0..*" ReserveRequest : bookings
Receipt "0..*" --> "1" ReserveRequest : request
ReserveRequest "0..*" --> "1" RequestId : requestId
ReserveRequest "0..*" --> "1" EquipmentId : equipmentId
ReserveRequest "0..*" --> "1" Period : period
receipts 和 bookings 的关联表示映射中保存的值:一个柜台可保存零到多个回执和有效请求。每个回执指向一个原请求,每个请求指向恰好一个请求 ID、设备 ID 和租期。构造器拒绝这些必填引用为 null;Period 另行检查开始时刻早于结束时刻。
反向多重性需要按对象引用解释。同一个不可变 ReserveRequest 可以提交给两个独立柜台,也可以由多个回执引用,因此不能把反向端武断地画成唯一所有者。当前 Receipt 由柜台内部创建并一直保留,图中才把它关联到一个柜台。图未使用组合菱形,也没有借此宣称 Java 引用会随对象销毁自动级联删除。
关联上的 0..* 允许一个柜台同时持有很多预留,但不能单独保证设备时间不重叠。这个带设备和区间条件的规则仍由 reserve 与 Period.overlaps 落实。若为了防止双重占用把多重性改成 1,合法的不同设备、不同日期预留都会被错误排除。
操作框是当前讨论所需的投影。Period 的两个时间访问器、record 的通用方法和 Map 的大量操作没有全部列出。Map.get/remove 使用擦除后的 Object 参数和返回值标注,具体值类型由 receipts 与 bookings 字段的泛型参数区分。这个简化使检查器无需承担完整 Java 泛型和 UML 模板系统。
类图没有要求每两条互发消息的生命线都补一条持久关联。柜台调用原请求的 equals,是因为先从回执取得了临时引用;代码没有名为 original 的长期字段。把这次使用画成永久持有关系,会改变模型含义。反过来,已经存在关联也不说明每个用例都会遍历它,例如当前取消过程根本不读取回执集合。
判断关联是否缺失,必须回到代码如何取得目标对象:构造器传入、字段读取、方法返回还是全局查找。当前用例的回执来自 receipts.get,原请求来自 previous.request,所以消息链能够解释对象引用的来源。检查器只核对指定字段类型,尚未自动验证全部局部变量的来源;这部分仍需要逐行对照实现。
时序图中的名字代表参与实例
时序图的前置状态是请求 A 已确认,两个映射中都有 A。A 指向设备 E1,租期为 2026-10-03 UTC 的 [10:00,12:00)。生命线上的 desk 表示一个柜台实例,receipts 和 bookings 是它内部的两个集合实例;类名只说明它们的类型。
sequenceDiagram
actor caller
participant desk as RentalDesk
participant bookings as Map
participant receipts as Map
participant request as ReserveRequest
participant previous as Receipt
participant original as ReserveRequest
Note over caller,desk: 前置:A 已确认,bookings 和 receipts 均有 A
caller->>desk: cancel(RequestId)
desk->>bookings: remove(Object)
bookings-->>desk: 原有效预留
desk-->>caller: true
caller->>desk: reserve(ReserveRequest)
desk->>request: requestId()
request-->>desk: A
desk->>receipts: get(Object)
receipts-->>desk: previous
desk->>previous: request()
previous-->>desk: original
desk->>original: equals(Object)
original-->>desk: true
desk->>previous: outcome()
previous-->>desk: CONFIRMED
desk-->>caller: CONFIRMED
Note over desk,bookings: 重放分支没有 put,当前占用仍为空
这里把调用标签写成方法签名,方便静态核对;具体实参由前置说明和运行实验给出。request 是本次提交的请求,original 是回执保存的原请求。它们按角色分别画出,并不要求是两个不同 Java 对象;实验可以重用同一不可变实例,载荷比较仍按 equals 判断。
取消先删除有效预留,随后重放在 receipts.get 取得旧回执。柜台从回执获得原请求、比较载荷,再读取原结果并返回。这个分支没有重新执行重叠检查,也没有重新写入 bookings。所以原确认结果与当前已经释放资源能够同时成立。
图的范围仅覆盖同 ID 同载荷分支。若载荷不同,代码在比较后抛出 IllegalArgumentException;若找不到回执,则进入新请求分支。省略这些分支不代表它们不存在,更不能从这张顺序示意图推导出并发请求已经串行化。§17 的消息与生命线语义也不把图上两次事件之间的距离解释为实际耗时。UML 2.5.1 PDF,§§17.3–17.4
一致性检查能抓到哪些错误
labs/05/UmlCheck.java 读取的就是 models/05/classes.mmd 和 cancel-replay.mmd。它先识别类图中的操作签名,通过反射查找编译后 Java 类的同名方法和参数类型,并核对返回类型,再检查每条调用的发送者、接收者是否已声明,以及接收者类型是否列出了该操作。
关联检查只接受六条已声明关系,且每条必须恰好出现一次。图中的字段名、多重性必须符合本篇约定,反射结果必须与映射值类型或 record 字段类型一致;额外关系和重复关系都会被拒绝。这里的多重性期望来自源码及构造约束的人工分析,检查器负责检测模型漂移,并没有从 Java 自动推导完整关系语义。新增关联或不同图记法,需要同时扩展明确的检查规则。
| 模型输入 | 必须出现的判断 |
|---|---|
| 当前两个模型文件 | 方法、接收者、六条关联均通过 |
| 删除 previous 生命线声明 | 拒绝缺少接收者 |
| 将 outcome() 改成 refund() | 拒绝 Receipt 不存在该消息操作 |
| 将 bookings 的目标数量改成 1 | 拒绝关联多重性漂移 |
追加关系也必须验证:保留原关联再添加目标数量为 1 的冲突版本,添加涉及未知 Ghost 的关系,或者原样重复 bookings 关联,都应被拒绝。否则六条正确关系虽然各自存在,图中仍可能同时含有相反或多余声明。
负例从实际模型文本复制后修改,不覆盖原文件。检查程序必须发现预期错误类别,意外接受或者因为无关原因拒绝都会使实验失败。它还会真正执行一次 A 的确认、取消、重放,再让新 F 申请同一设备同一租期;F 必须返回 CONFIRMED,验证旧请求没有重新占位。
1 | |
本次 Java 21.0.11 执行退出码为 0,原始输出含有以下结果:
1 | |
静态核对不证明运行时确实按每一条箭头发送了调用;本实验没有插入调用跟踪器。运行探针也只证明受测输入的状态结果。两者结合,可以同时暴露“图引用了不存在的方法”和“取消后重试错误占位”,仍不能证明完整 UML 行为、所有异常路径或线程安全。
例如,把 cancel 和 reserve 两组消息在图上交换,方法名与接收者仍全部存在,当前静态检查可能通过,但图表达的前后条件已经不同。又如,把返回标签从 CONFIRMED 改成“已付款”,该文字不会作为 Java 方法解析,检查器也不会据此确认付款能力。受限规则需要把这些遗漏写出来,防止绿色输出被误读为所有图中文字都有实现支持。
公开方法反射检查还精确比较返回类型,boolean 对应 Java 原始类型,Outcome 对应实际枚举。把 cancel(RequestId) 的返回值从 boolean 改成 Outcome,独立检查必须返回非零。这个检查使用擦除后的 Java 类型,仍不能证明完整泛型签名兼容;若准备把模型用于自动生成接口,异常、泛型和可见性等也需要成为可执行约束。
控制图与代码一起变化的成本
可下载 类图、时序图、检查器与验证记录。解压后按上述仓库相对路径运行;入口也允许传入两份自定义图文件,对不符合声明子集的输入返回非零。语法限制、遗漏项与关联依据保存在 models/05/subset.md,读者可以明确区分检查器接受什么和规范允许什么。
若以后把回执职责移到独立账本,修改类名只是其中一步。时序图中的 get 接收者、回执查询方法、关联字段及检查规则都可能改变,行为实验则继续要求取消后新 F 可以确认。若仅重命名图上的方框,方法核对就会报告已经消失的接收者或操作。
另一个变化是给请求增加参与方。必填还是可选会影响构造器、类图端点数量和输入测试;它是否参与重试载荷比较,则影响原请求重放分支。仅在类图里添加一个属性不会回答后一问题。如果调用者身份可以变化而请求业务载荷不变,还需要先区分身份认证信息与请求事实,避免一次类型补充悄悄改变幂等契约。
当前两张图只围绕一项容易误解的契约,维护范围足够小。无需先画出所有 getter、异常类、容器和未来仓储。需要新增一种消息时,再明确接收者、取得引用的关系及它应改变的状态;这些决定可以直接进入下一次代码与模型核对。
参考资料
- OMG,UML 2.5.1 正式规范入口 与 PDF,§§11.4–11.5 类与关联,§§17.3–17.4 生命线与消息;版本为 2017 年 12 月正式版。
- 实现:
examples/software-modeling/rental-core/RentalDesk.java;模型:examples/software-modeling/models/05/。 - 检查器:
examples/software-modeling/labs/05/UmlCheck.java;逐项输出与退出码:examples/software-modeling/evidence/05/。





