10.4 数据映射器
以独立映射层在对象和数据库之间搬运数据,使领域对象、数据库模式和映射器彼此解耦。
学习目标
- 能画出订单从领域对象到数据库行、再从数据库行回到对象的映射边界,并指出每一步的责任者
- 能用映射契约、对象身份和变更集解释数据映射器如何保持读写一致,而不把数据库细节带进领域对象
- 能根据关联复杂度、查询成本和失败语义选择数据映射器,并用测试验证加载、保存与并发冲突
为什么 10.4 数据映射器 值得单独学习
↡ 是一种把对象与数据源之间的转换放在独立层中的模式:领域对象不需要知道表名、列名或连接细节,映射器负责把查询结果构造成对象,也负责把对象变化写回数据源。它解决的不是“如何调用 ORM”,而是“谁拥有转换责任以及何时允许状态离开边界”。
在订单聚合持久化中,订单可能包含客户、订单项和状态行为,而数据库可能把它拆成多张表。若让领域对象直接拼接 SQL,表结构变化会沿调用链扩散;若让通用转换器无条件加载所有关联,查询次数和内存成本又会失控。数据映射器把这两个方向的变化集中在可审查的转换层,同时保留领域对象的业务行为。
本章的订单案例、映射实验、练习和判断标准均为课程原创;公开图书页与模式目录只用于核对 10.4 的模式名称、模式族和公开范围。这里不复现原书正文、插图或代码,而是用新的观察点验证模式是否成立。
先建立直觉:一条记录怎样成为一个对象
先预测:orders 表只有 id、customer_id、status 和 amount_cents,但订单对象还需要金额值对象和订单项集合。哪些转换应该留在映射器,哪些行为必须留在领域对象?一个可靠的分界是:结构转换交给映射器,业务决定交给对象或领域服务。
↡
应表达订单状态、金额规则和可执行的业务行为,而不是暴露数据库列。映射器可以读取一行或一组行,组装领域对象,再把对象的持久化状态拆回目标列;它不应因为方便就调用
approve()、决定折扣,或把数据库异常伪装成业务成功。
独立层还需要一个可测试的↡:列的类型、空值、默认值、主键、关联加载范围和写回条件都必须有明确约定。契约变化时,应先更新映射测试,再评估领域对象是否真的需要变化。
目录单元到教学证据
10.4 数据映射器
在 10.4 数据映射器 的学习边界里,数据映射器不是待背诵的目录词,而是用来检查“领域调用”是否把结构转换责任交给正确对象。对订单聚合持久化,学习者要记录对象身份、关联加载和写回结果的可观察变化,并说明这些证据何时支持或否定数据映射器。
单元键为 poeaa24-pattern-08-data-mapper;模式族为 mapping;本章的裁决是“能在不修改领域对象的情况下替换存储结构,并用映射测试证明身份、关系和更新正确”。观测项包括对象身份、关联查询次数、映射成本、版本条件和测试替身;拒绝条件是“映射器开始拥有业务规则,或加载/写回范围无法被测试固定”。
专属设计案例:订单聚合持久化
把一次 loadOrder(id) 切成“查询订单行 → 查询订单项 → 组装对象 → 执行业务行为 → 拆分写回”五个观察点。映射器拥有行与对象之间的翻译,订单对象拥有状态变化,应用服务拥有事务与用例顺序;三者的职责不能靠某个框架默认行为猜测。
在第一次加载时,映射器根据订单 ID 建立↡,并决定同一工作单元里重复请求相同 ID 是否复用同一个实例。身份复用可以避免两个副本互相覆盖,但也会带来缓存失效、内存增长和跨请求泄漏的风险,所以必须明确范围,而不能把全局缓存当成默认答案。
保存时,映射器只处理结构变化和持久化条件。领域对象产生的↡应经过验证,再由映射器转换成订单表和订单项表的更新;如果版本条件不匹配,映射器报告冲突,应用流程决定重载、合并还是放弃。这样,数据库的失败语义不会被隐藏在对象的属性访问中。
模式结构图
图中三段边界分别表示领域对象、独立映射器和关系结构。读路径是“行 → 对象”,写路径是“对象 → 行”;两条路径都应经过同一份映射契约,而不是让领域对象偶然依赖列名。
用订单映射走一遍读写生命周期
先划出对象与行的边界
先预测:如果订单对象新增一个金额值对象,而数据库仍以整数分为单位保存,哪一层负责把两种表示互相转换?先写出领域字段、数据库字段和主键,再把结构转换集中到 OrderMapper,检查订单对象是否完全不知道表名与连接。
常见误区
映射契约与可替换实现
下面的伪代码展示边界,而不是某个 ORM 的生产 API:
type OrderRow = {
id: string;
customer_id: string;
status: string;
amount_cents: number;
version: number;
};
type Order = {
id: string;
customerId: string;
status: "draft" | "approved";
amountCents: number;
};
class OrderMapper {
toDomain(row: OrderRow): Order {
return {
id: row.id,
customerId: row.customer_id,
status: row.status as Order["status"],
amountCents: row.amount_cents,
};
}
toRow(
order: Order,
version: number,
): Pick<OrderRow, "id" | "status" | "amount_cents" | "version"> {
return {
id: order.id,
status: order.status,
amount_cents: order.amountCents,
version,
};
}
}这段代码刻意没有把 approve() 放进映射器,也没有把数据库连接暴露给 Order。生产实现还要决定关联的批量查询、事务隔离、删除语义和版本冲突,但这些决定都可以沿映射契约逐项测试。
选择与拒绝矩阵
| 评审问题 | 选择数据映射器的证据 | 应拒绝或准备迁移的信号 |
|---|---|---|
| 责任 | 领域对象与表结构分别演化,翻译责任集中 | 领域对象直接依赖列名、连接或 ORM 生命周期 |
| 关联 | 加载范围和查询次数有明确测试 | 每次读取都隐式加载完整对象图 |
| 身份 | 工作单元内同一主键的实例策略可解释 | 全局缓存或多个副本导致旧快照覆盖新状态 |
| 写回 | 变更集、版本条件和失败结果可观察 | 映射器直接执行业务规则或吞掉数据库异常 |
| 替代 | 领域复杂且对象/表结构差异值得独立层 | 对象与表天然同构、映射规则少到不值得维护 |
本章小结
掌握 10.4 数据映射器的标志,不是记住“对象和表之间有一层代码”,而是能说明领域对象、映射器和应用服务各自拥有的责任。读路径要验证类型、空值、关联与对象身份,写路径要验证变更集、事务边界、版本冲突和失败传播;只有这些证据可测试,独立映射层才值得承担额外间接成本。
本章练习
练习
问题 1: 订单对象的 amountCents 需要改成金额值对象,但数据库列暂时不变。应该修改领域对象、映射器,还是两者都修改?请说明测试重点。
问题 2: 同一工作单元两次加载订单返回两个实例,第二个实例保存后覆盖了第一个实例刚完成的状态变化。这个问题属于查询、对象身份还是写回契约?如何修复?
问题 3: 映射器为了“方便”在加载订单时调用 approve(),并把库存服务失败转换成空订单返回。应如何拆分?
前后导航
出处声明
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 数据映射器
位于领域对象与数据源之间、负责双向结构转换和持久化结果翻译的独立层。
- 领域对象
承载业务状态、规则和行为的对象,不依赖数据库表、列或连接细节。
- 映射契约
规定字段类型、空值、主键、关联范围、版本条件和读写转换结果的可测试约定。
- 对象身份
在一个明确生命周期内,同一持久化标识如何对应内存对象实例的策略。
- 变更集
领域对象经过业务验证后,需要由映射器转换并写回数据源的状态变化集合。