软件建模28:深层模型与持续重构
“这台设备可用”至少省略了三个条件:它是否具备出租资格,询问的是哪个时间窗口,那个窗口内是否已有承诺。设备当前没有被拿走,也可能已经预订给明天的顾客;维修完成的设备,也可能尚未通过租赁方检验。一个没有参数的布尔属性难以表达这些差别。
应用用例与仓储 已把资格、租期规则和日历接到同一个预留流程。本篇在这份可运行应用上抽出可用性评估,保留旧实现作对照,逐步比较返回值、快照和事件,检查概念变得清楚以后,已有业务契约是否仍然成立。深层模型来自更准确的问题
Evans 在《DDD Reference》的“Refactoring Toward Deeper Insight”中,将领域理解的深化与模型、代码表达的改进联系起来。深层模型需要捕捉真正影响设计的领域含义,类更多或继承层次更深并不构成证据。2015年摘要,印刷页8
设备租赁场景中,模糊的“可用”同时混合了设备性质和某段时间的承诺。把它改为 available(设备, 租期, 当前条件),首先暴露了查询缺少的输入。本章进一步保留各个条件的判断结果,使一次否定能够说明究竟缺了什么。
这仍是合成教学场景,没有真实租赁人员的访谈,也不声称发现了行业通用模型。它的材料来自前面已经冻结的设备生命周期、维修翻译、半开区间和请求历史。抽出的概念必须能解释这些规则,并在已有程序里承担具体职责。
flowchart LR
S[设备存在且ACTIVE] --> Q[出租资格]
M[维修限制解除] --> Q
P[请求租期与当前时间] --> W[时间窗口合法]
C[日历活跃记录] --> O[已承诺区间]
Q --> A[本次快照可用性评估]
W --> A
O --> A
A --> R[三个条件同时满足才可用]
图中的资格不是一个新的设备状态。设备可能一直是 ACTIVE,但维修评估变为 BLOCKED;也可能维修已经 RELEASED,设备却处于退役状态。评估组合两种来源,不把维修系统的词汇直接写进设备实体枚举。
三个结果分别表达什么
Qualification 保留 UNKNOWN、INELIGIBLE、MAINTENANCE_RESTRICTED、QUALIFIED 四种结果,依次检查设备存在、租赁方资格和维修限制。这一顺序沿用第27篇,避免一次重构意外改变多条件失败时返回的首要原因。
时间窗口复用第26篇 RentalPeriodSpecification.Result。实验仍要求至少提前一小时、租期不超过四十八小时,起止时间使用 Instant。没有新增自然日计费、清洁缓冲或营业时间限制,报价也继续采用已有的小时向上取整规则。
Commitments 从日历快照中筛选同一设备的活跃记录,再判断半开区间是否相交。取消后的确认回执仍然存在,但其 active 为 false,不再占用窗口。只检查“有没有成功记录”,会把历史成功错误地当作永久占用。
Assessment 同时保存这三个结果。只有资格通过、时间规则满足、区间没有承诺,available() 才返回 true。对于不合格设备,时间与承诺维度依然可以作为查询信息呈现;命令侧拒绝原因则保留原有优先级,两者并不要求输出相同形状。
查询解释当前快照,命令作出承诺
新模型的 inspect 是无副作用查询。它既不保存请求回执,也不调用日历的 reserve。把查询结果命名为“预留凭证”会带来新的误解:结果只描述查询所读到的那份快照,并没有锁住之后的时间。
实验先查询 E1 的一个未来窗口,得到可用;随后另一个请求占据同一窗口。原查询对象仍保存 true,但真正提交预留时,日历返回 OVERLAP_REJECTED。这个反例只需要顺序执行就能构造,不必用不稳定的线程时序证明。
sequenceDiagram
participant Q as 查询方
participant A as 累计应用
participant C as 预留日历
Q->>A: inspect请求窗口
A-->>Q: 快照可用
A->>C: 另一个请求reserve
C-->>A: 确认并形成承诺
Q->>A: 使用原窗口提交
A->>C: 在事务内重新判断占用
C-->>A: OVERLAP_REJECTED
A-->>Q: 保存拒绝回执
因此第27篇应用只替换资格与时间的 AdmissionPolicy 实现。承诺判断继续在同一仓储事务内由日历执行。查询里的区间推导用于解释,不能把已经计算过一次当作跳过命令检查的理由。
同一台设备也会对不同窗口给出不同结果。已有承诺是 [10,11) 时,[11,12) 仍可用;设备状态没有变化,变化的是被询问的租期。把 free 写回设备的单一字段,会抹掉这个参数,并迫使其他调用猜测字段针对哪个时间。
评估对象也不应成为第二份可变日历。它保存的是三个推导结果,没有添加或取消承诺的方法;实际活跃记录仍归原日历管理。若让查询对象也维护一份占用集合,两个来源可能各自接受更新,反而制造新的同步问题。
保持行为需要比较完整轨迹
Fowler 对重构的定义要求内部结构变化时保持可观察行为。这里的观察范围不能只包含某一次方法返回值,还要包括后续取消、重放以及仓储重新读取后的内容。Definition of Refactoring
实验保留第27篇真实的 InlineAdmission,另建 AvailabilityModel 实现同一个接口。两条路径使用相同应用、仓储、合同工厂和日历,以相同初始设备与种子预留开始。它们没有分别复制一套“看起来相似”的业务流程。
场景组合覆盖四种设备条件、三种维修结果、四种时间区间,共四十八组。设备条件包括 ACTIVE、待检、退役和不存在;时间区间分别覆盖提前量不足、已有承诺、相邻空闲窗口和超长租期。组合中同时失败的情况也参与比较。
每组依次执行首次预留、推进时钟后的重放、取消种子承诺、再次重放、使用新编号预留、取消候选请求。每一步都保存返回值与完整仓储快照,最后比较事件顺序。任意一步不同都会抛出断言错误,使进程非零退出。
这能捕捉只看最终结果容易漏掉的变化。例如两个版本最终都没有活跃占用,其中一个却删除了历史回执,取消后的旧请求重放就可能重新占用。逐步比较可以在偏离发生的那一步发现差别,而不是等未来调用暴露损失。
比较刻意包含事件集合。资格重构如果在历史回放时再次调用发布端口,返回值与保存状态可能完全相同,订阅者却会多收到一次确认。此处两条路径的事件种类、请求编号与次序也必须相等,才满足选定观察范围内的保持行为要求。
可用性与历史结果不能互换
测试还构造了另一种看似矛盾的情况:请求曾因冲突被拒绝,冲突取消后,窗口现在已经可用,但旧编号重放仍然失败。可用性查询回答当前新请求能否尝试,历史回执回答旧请求已经得到什么结果,两者面向不同的问题。
新编号在同一空闲窗口可以确认。原拒绝没有被更正,也没有被忽略;应用只是识别了新的业务意图。因此不能根据当前评估覆盖回执表,更不能让一个名叫 isAvailable 的方法同时承担历史查询和新预留决策。
额外负例把“设备 ACTIVE”直接当作可用,输出 true;完整评估发现已有未来承诺,输出 false。这个简化方法是专门构造的错误示例,并非声称第27篇曾经如此实现。第27篇已经检查占用,本次改进的是显式表达与可解释性。
命名的收益可以落到调用位置检查:展示窗口信息时使用评估,提交请求时进入应用,修改承诺时调用日历。每个入口只回答自己的问题。代码没有因此获得更高的运行性能,实验也没有测量速度;改进的依据是歧义被分解成了可测试的输入与结果。
规则变化应另立验收
若以后要求每次归还后留出一小时清洁时间,变化的不只是类名。相邻窗口原本可预留,现在可能冲突;需要决定缓冲属于占用区间、出租窗口还是另一种承诺,并更新明确的正反例。这类改变不能藏在“提取可用性对象”的提交里。
类似地,允许退役设备恢复出租会改变生命周期,延期会改变请求负载或引入新用例,维修结果缓存则带来新鲜度问题。现有有限轨迹没有证明这些新行为,也没有证明全部输入空间等价;它提供的是继续修改时可重复执行的对照基线。
本模型仍使用可信同步维修输入与内存快照。查询没有并发版本,仓储没有崩溃恢复,提交后的事件发布仍有第27篇已经测出的缺口。将隐藏概念显式化,可以减少语言歧义,却不会自动补齐基础设施保证。
运行同一应用的两个版本
使用 JDK 21,在仓库或下载包根目录执行:
1 | |
本次 Corretto 21.0.11 严格编译通过;入口先运行原始48项及第27篇36项,再运行本章四十八条轨迹与七项针对性检查,共55项通过。原始输出中的 TRACE 行记录每组返回序列,断言同时核对快照和事件,不靠人工观察日志判定相等。
下载 第28篇源码与证据包。models/28/contracts.md 记录新概念与保持不变的规则;可用性的进一步扩展应先指出哪一个维度变化,再补上能区分旧规则与新规则的场景。






