合同对象里有设备身份、租期、金额和取消行为,关系数据库里则有主键、外键、列类型与事务。把对象放进 Map,只能检查对象如何复制;只有真实执行 SQL,再换连接读回,才能发现列精度、关联约束和提交边界造成的差异。企业应用架构04:模块化单体与上下文边界 已限制模块入口,本篇比较两种保存职责的安排。

同一张表,两种保存入口

Fowler 的 Active Record 条目把数据访问和领域行为放在代表数据库行的对象中。Data Mapper 则单独设置对象与表之间的转换层,让领域对象不需要知道表结构。两种方式可以处理相同数据,差异首先在职责依赖,不在是否执行了某一种 SQL。

本例使用受限的 ActiveContract:对象持有合同数据,提供取消、插入和保存状态的方法。它知道数据库操作;取消仍复用相同的合同规则。Data Mapper 路径的 Contract 只有数据和取消行为,ContractMapper 负责插入、读取和更新。两条路径访问同一个 rental_contract 表,使用相同设备和租期输入。

flowchart LR
  A[ActiveContract:行为与保存方法] --> T[同一合同表]
  C[Contract:数据与行为] --> M[ContractMapper:对象与行转换]
  M --> T
  T --> E[设备表:复合外键]

为避免把比较变成两套不同规则,金额仍使用冻结核心的 Money,设备使用 EquipmentId,区间使用 Period。新增 Contract 是持久化教学模型,尚未实现冻结应用的完整 Repository 端口,也未把所有请求回放、维修输入和事件历史写入数据库。不能因合同保存成功就声称原应用已经整体持久化。

先冻结数据库与列语义

实验固定 H2 2.1.214,驱动和数据库来自同一个 Maven 构件,Java 使用 Corretto 21.0.11。sql-support/v1 保存版本、官方下载地址和 SHA-256。读取覆盖路径或缓存时仍校验散列,错误文件在启动 JVM 前被拒绝;jar 留在工具缓存,实验包不携带二进制依赖。

设备表以租户和设备身份为复合主键。合同表包含相同租户、合同身份、设备关联、开始结束时间、金额、币种、状态和版本。关联使用复合外键,因此另一个租户下同名设备不能满足当前合同的关联。租户列是隔离条件的教学输入,还没有认证主体或数据库权限策略。

金额使用 DECIMAL(19,2),币种单独保存。Money 可以表示负值,但本章合同总额边界拒绝负数和超出列精度的值;数据库另有金额非负 CHECK。不能把“所有 Money 必须非负”倒写成旧核心规则,退款或差额可能需要带符号金额,具体约束应由使用金额的业务对象决定。

时间列使用带时区的 TIMESTAMP(9),映射时从 Instant 转成 UTC 偏移时间,读回再转成 Instant。样本特意含九位小数,用完整对象相等检查确认纳秒没有在本版本往返中丢失。区间仍是半开区间,表上的开始小于结束只检查非空区间,没有保证任意两份合同不重叠。

状态仅支持 RESERVED 与 CANCELLED。取消保留合同、金额和租期,只改变当前状态;它不代表退款已经完成。数据库预留版本列尚未参与本章更新条件,因此当前保存方法没有乐观并发保护。两个客户同时改同一行的结论不能从顺序测试中获得。

换连接读回才检查到提交

正常实验先创建设备,再分别通过两条路径插入 AR 与 DM 两份合同,在一个数据事务中提交。关闭写连接后,打开新的 JDBC 连接读取,两条路径都与原样本比较。断言覆盖金额、币种、设备身份、时间精度、状态和版本,而不是只检查主键存在。

随后分别执行取消并保存,再次换连接验证两份合同仍然存在且状态均为 CANCELLED。两条路径在同一契约下得到相同结果,才有条件讨论职责安排。如果其中一条保存时重新计算价格,另一条保留原价格,这首先是业务行为不一致,不能归因于映射模式。

sequenceDiagram
  participant U as 用例
  participant W as 写连接
  participant D as H2文件数据库
  participant R as 新读连接
  U->>W: 插入设备与两份合同
  W->>D: 参数化SQL
  W->>D: commit
  U->>W: close
  U->>R: 重新建立连接
  R->>D: SELECT合同
  D-->>R: 金额、身份、租期与状态
  R-->>U: 与原输入逐项相等

新连接排除了“只是返回同一个内存对象”的情况。它依然不是进程崩溃、断电或磁盘故障恢复实验;本章在单 JVM 内使用真实文件数据库,并通过正常连接关闭再打开验证读回。重启恢复需要另设进程和失败点。

故障要落在真正的数据库语句上

两条路径分别运行无效关联场景。事务先插入一台暂存设备,再尝试保存引用不存在设备的合同。H2 报外键错误,SQLSTATE 为 23506;代码显式 rollback,随后新连接确认设备仍只有最初一台,合同仍只有原来的两份。暂存设备没有成为半份业务记录。

非法金额有两道检查。构造合同总额为负数时,领域边界直接拒绝;另一个场景绕过对象验证,先插入合法合同,再用 SQL 把金额改成负数,数据库 CHECK 报 23513。回滚整个事务后,新连接看不到刚才插入的合同。这个故障确实发生在第一次写入之后,能检查回滚范围。

不能把语句失败自动等同于整个用例已经回滚。代码在捕获 SQLException 后明确调用 rollback;若遗漏这一步,调用方之后仍有机会提交其他已经成功的语句。数据库约束阻止了坏行,并不替应用决定哪些合法写入应当一起撤销。

迁移脚本在业务事务之前单独执行。H2 的版本文档说明多数 DDL 会提交当前事务,因此不能把建表、业务插入和错误回滚混在一段代码里,再声称它们属于同一个原子单元。实验的提交计数也分别记录初始化连接和数据连接,没有把迁移提交冒充业务提交。

变更会影响哪里

若合同取消新增资格条件,共同的 Contract 行为需要变化;ActiveContract 暴露该行为,Mapper 路径调用该行为后保存。若数据库列名变化,两条路径的数据访问代码都要改,而不依赖 SQL 的 Contract 可以保持原样。若一个对象开始跨多表,Mapper 的组装责任通常会更明显。

本例为了共享完全一致的行解码,Active Record 的静态读取复用 Mapper 的解码函数;它仍拥有读取入口和写 SQL,因而只是受限对照,没有宣称构造了两个完整框架。复制一整套解码规则不会让比较更公平,反而可能让精度错误只出现在其中一条路径。

Active Record 对简单且贴近表结构的对象比较直接,业务修改和存储操作也更容易一起出现。Data Mapper 要维护额外的转换代码,适合需要保护领域对象独立性或对象结构与表结构差别较大的情况。类更多不代表设计更好,比较应落在实际变更位置与约束是否清楚。

数据库约束与领域校验也不能互相替代。对象校验可以更早给出有含义的拒绝,外键与 CHECK 可以挡住绕过对象的直接 SQL;两边仍可能随业务变化发生漂移。本章只检查已列出的关联、金额和区间约束,未实现价格版本有效期、全局请求回放和合同完整生命周期。

当前持久化接口没有承诺什么

两个保存入口都接收调用者提供的数据库会话,没有在每个对象方法里偷偷提交。因此,保存两份合同可以由外部用例协调一次提交;若保存方法各自提交,第二份失败时就无法通过一次回滚撤销第一份。这种提交所有权比方法叫 save 还是 insert 更影响故障结果。

当前映射返回不可变合同值,每次查询都重新组装对象,没有会话级身份表,也不追踪脏对象。先改对象再调用保存是显式动作。两个对象引用同一数据库主键时如何避免互相覆盖,需要后续工作单元与并发协议处理,本章不会把对象相等误写成实例唯一。

字段新增也有迁移边界。修改 Java 构造器并不会给已经存在的表补列,修改建表脚本也不会自动升级旧数据库。本次数据库由临时目录新建,schema-v1 是可复跑的初始结构,没有提供线上增量迁移方案。

运行与证据

准备 JDK 21、Python 3 和可访问 Maven Central 的网络,在仓库或解压根目录执行:

1
bash examples/enterprise-application-architecture/labs/05/run.sh

下载入口为 本章实验包。首次缺少 jar 时按固定地址下载;可用 H2_JAR 指向已有同散列文件,或用 ARCHITECTURE_TOOL_CACHE、MODELING_TOOL_CACHE 指定缓存目录。读者命令不依赖作者机器的 Maven 路径。

正常入口执行十三项断言并退出零。SQL 文本、参数、连接编号、受影响行数、提交回滚以及错误码均来自实际 JDBC 调用,保存在 evidence/05。每个语句和结果集使用资源管理语句关闭,连接结束日志记录未关闭语句计数。

本例固定 H2 的语法、类型和错误码,未使用 PostgreSQL 兼容模式,也不能代表其他数据库的隔离级别或锁行为。替换数据库后,需要重新验证时间精度、约束行为、事务和驱动,而不能只替换 JDBC URL。

参考资料