18.2 映射器
在两个彼此独立的对象之间建立通信,转换表示而不要求任一方了解另一方。
学习目标
- 能解释映射器如何隔离两个模型的字段语义、生命周期和变化方向
- 能编写 TypeScript 双向映射,并用往返校验检测字段遗漏与信息丢失
- 能根据变化轴、信息丢失风险和业务行为边界,判断何时采用或拒绝映射器
为什么 18.2 映射器值得单独学习
订单系统常常同时面对两种说法:业务人员说“客户已暂停”,存储系统却只接受一行列值和一个状态码。如果调用方直接拼出数据库字段,列名、空值规则和业务语义就会沿着每条调用路径扩散;一处改名,许多测试和查询都要一起修改。
这章解决的不是“怎样把对象复制到另一个对象”,而是“谁负责解释两种表示之间的差异”。没有这层责任边界,领域规则会被迫适应存储结构,读回来的数据也可能悄悄丢掉状态、身份或精度。
先建立直觉:两本不同的通讯录
想象团队有一本给人看的通讯录,另一本给快递系统看的通讯录。前者按姓名和联系偏好组织,后者按编号、字段长度和投递代码组织。每次交接都需要一张转换清单,但两本通讯录不必知道对方的页码。
如果转换清单散落在每个调用者手里,新增一列就像让所有人同时改表;如果清单集中且能报告“有一项无法转换”,变化会停在边界上。映射器就是这张可测试、可审查的清单。
概念模型:把表示转换变成一个责任
是什么
↡集中负责两个独立模型之间双向字段转换、校验和缺失报告的对象。 不拥有订单或客户的业务决策,它只把一侧的表示变成另一侧能接受的表示,并把不可转换的情况显式报告出来。映射规则应该集中在一个可命名、可测试的地方,而不是散在控制器、查询和持久化代码中。
为什么需要
↡表达业务身份、状态与行为的对象;它不应被数据库列名决定。 关心客户能否下单、如何变更姓名以及哪些状态转换合法;↡为数据库、消息队列或外部接口组织的字段集合;它优先满足存储或传输约束。 关心列名、编码、长度和索引。两者的变化理由不同,直接让其中一方模仿另一方会把无关变化绑定在一起。
位置与边界
↡把一个模型的字段、编码和嵌套结构改写成另一个模型表示的过程。 位于边界处:它可以拆分姓名、翻译状态码、补齐可选值,但不能替客户决定“是否允许下单”。如果转换前后不能保留必要语义,↡转换后无法恢复、区分或验证的原始语义,例如未知状态被默默改成暂停。 必须成为失败或待人工处理的证据,而不能用默认值掩盖。
目录单元到教学证据
18.2 映射器
本章精确对应 manifest 单元 poeaa24-pattern-42-mapper。案例围绕订单系统中的 Customer 与 CustomerRow:前者表达姓名和客户状态,后者表达数据库列。完成本单元需要交出三样证据:一张双向责任图、一段可测试的 TypeScript 转换、一次往返校验或故障样本。
评审卡记录如下:采用条件是“两种表示有独立变化轴,且转换规则需要集中校验”;观测项包括字段覆盖率、未知编码、往返一致性、业务行为是否越界和替换存储的成本;拒绝条件是“两个模型完全同构,或者映射器开始计算折扣、推进订单状态等业务规则”。
专属可视化实验:追踪一条 Customer 的往返
先预测:把 status_code 从 A 改成未知值,映射器应该把它默认为暂停,还是停止并报告信息丢失?点击阶段按钮观察“读取领域语义 → 转换字段 → 往返校验”的责任如何移动,再打开故障模式验证拒绝条件。
代码实践:显式字段映射与业务解耦
先定义两种模型,让类型本身暴露转换边界。Customer 可以拥有业务方法;CustomerRow 只描述存储形状。第一段代码刻意不把数据库列名带进领域对象。
type Customer = {
id: string;
name: { first: string; last: string };
status: "active" | "suspended";
};
type CustomerRow = {
customer_id: string;
first_name: string;
last_name: string;
status_code: string;
};
const toRow = (customer: Customer): CustomerRow => ({
customer_id: customer.id,
first_name: customer.name.first,
last_name: customer.name.last,
status_code: customer.status === "active" ? "A" : "S",
});toRow 只负责表示转换,不调用数据库,也不决定客户能否变成暂停状态。读回时要反向验证状态码;把未知值静默映射到某个合法状态,会让信息丢失变成错误数据。
const fromRow = (row: CustomerRow): Customer => {
const status =
row.status_code === "A"
? "active"
: row.status_code === "S"
? "suspended"
: null;
if (!status) throw new Error(`Unknown status: ${row.status_code}`);
return {
id: row.customer_id,
name: { first: row.first_name, last: row.last_name },
status,
};
};
const source: Customer = {
id: "c-42",
name: { first: "Lin", last: "Mei" },
status: "active",
};
const restored = fromRow(toRow(source));
if (JSON.stringify(restored) !== JSON.stringify(source)) {
throw new Error("Mapper lost customer meaning");
}这段往返测试验证的是“当前模型能否保留需要的语义”,不是保证所有对象永远逐字相等。若领域模型新增时区、精度或未知状态,测试应先迫使团队补充映射决策,再决定兼容、拒绝还是迁移。
选择与拒绝矩阵
| 评审问题 | 采用映射器的证据 | 应拒绝或换方案的信号 |
|---|---|---|
| 变化 | 两种表示按不同理由演进,转换规则集中 | 两边字段完全同构,没有独立变化轴 |
| 语义 | 状态码、空值和嵌套结构有明确规则 | 默认值掩盖未知值,无法报告丢失 |
| 责任 | 映射器只做转换和校验 | 映射器计算价格、权限或订单状态 |
| 测试 | 可用固定样本覆盖双向和往返 | 只能通过真实数据库才能验证字段规则 |
| 成本 | 替换存储或接口时调用者保持稳定 | 每加一层都只是重复转发,没有边界收益 |
常见误区
本章小结
- 映射器集中处理两个独立模型之间的字段与编码转换。
- 领域模型表达业务语义,持久化模型表达存储约束,两者不互相依赖。
- 双向转换必须校验未知值,并用往返样本暴露信息丢失。
- 映射器不计算业务规则;没有独立变化轴时应拒绝额外间接层。
可验证练习
练习
问题 1:追踪责任。 OrderService 直接把 Customer 拼成数据库参数,并在遇到 status_code = "X" 时改写成暂停。请指出至少两处越界,并说明应该由哪一层负责。
问题 2:改 Demo 代码。 在代码实践中为 CustomerRow 增加 version: number,让 Customer 也保留该值,并修改 toRow、fromRow 与往返断言,使版本不会在映射中丢失。
问题 3:作出选择。 两个对象当前字段完全相同,只有一个调用者,且预计不会更换存储。此时是否应新增 Mapper?如果下季度要迁移到消息队列,请列出两个需要重新评估的证据。
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 映射器
集中把一个模型的字段和编码转换成另一个模型表示,并检查无法转换情况的对象。
- 领域模型
用业务语言表达身份、状态和行为的对象,不应由数据库列名决定形状。
- 持久化模型
为数据库或其他存储组织的字段集合,重点是列、编码、长度和索引等约束。
- 表示转换
把字段、编码和嵌套结构从一种模型改写成另一种模型的过程。
- 信息丢失
转换后无法恢复或验证原始语义,例如把未知状态悄悄改成合法状态。
前后导航
参考资料
- Martin Fowler 作者图书页:核对全书主题、章节范围和模式参考结构。
- Martin Fowler 模式目录:核对映射器模式的公开定位与模式族。
- Pearson 出版社页面:交叉核对英文版出版信息与目录范围。