18.2 映射器

在两个彼此独立的对象之间建立通信,转换表示而不要求任一方了解另一方。

学习目标

  • 能解释映射器如何隔离两个模型的字段语义、生命周期和变化方向
  • 能编写 TypeScript 双向映射,并用往返校验检测字段遗漏与信息丢失
  • 能根据变化轴、信息丢失风险和业务行为边界,判断何时采用或拒绝映射器

为什么 18.2 映射器值得单独学习

订单系统常常同时面对两种说法:业务人员说“客户已暂停”,存储系统却只接受一行列值和一个状态码。如果调用方直接拼出数据库字段,列名、空值规则和业务语义就会沿着每条调用路径扩散;一处改名,许多测试和查询都要一起修改。

这章解决的不是“怎样把对象复制到另一个对象”,而是“谁负责解释两种表示之间的差异”。没有这层责任边界,领域规则会被迫适应存储结构,读回来的数据也可能悄悄丢掉状态、身份或精度。

先建立直觉:两本不同的通讯录

想象团队有一本给人看的通讯录,另一本给快递系统看的通讯录。前者按姓名和联系偏好组织,后者按编号、字段长度和投递代码组织。每次交接都需要一张转换清单,但两本通讯录不必知道对方的页码。

如果转换清单散落在每个调用者手里,新增一列就像让所有人同时改表;如果清单集中且能报告“有一项无法转换”,变化会停在边界上。映射器就是这张可测试、可审查的清单。

概念模型:把表示转换变成一个责任

是什么

不拥有订单或客户的业务决策,它只把一侧的表示变成另一侧能接受的表示,并把不可转换的情况显式报告出来。映射规则应该集中在一个可命名、可测试的地方,而不是散在控制器、查询和持久化代码中。

为什么需要

关心客户能否下单、如何变更姓名以及哪些状态转换合法; 关心列名、编码、长度和索引。两者的变化理由不同,直接让其中一方模仿另一方会把无关变化绑定在一起。

位置与边界

位于边界处:它可以拆分姓名、翻译状态码、补齐可选值,但不能替客户决定“是否允许下单”。如果转换前后不能保留必要语义, 必须成为失败或待人工处理的证据,而不能用默认值掩盖。

目录单元到教学证据

18.2 映射器

本章精确对应 manifest 单元 poeaa24-pattern-42-mapper。案例围绕订单系统中的 CustomerCustomerRow:前者表达姓名和客户状态,后者表达数据库列。完成本单元需要交出三样证据:一张双向责任图、一段可测试的 TypeScript 转换、一次往返校验或故障样本。

评审卡记录如下:采用条件是“两种表示有独立变化轴,且转换规则需要集中校验”;观测项包括字段覆盖率、未知编码、往返一致性、业务行为是否越界和替换存储的成本;拒绝条件是“两个模型完全同构,或者映射器开始计算折扣、推进订单状态等业务规则”。

专属可视化实验:追踪一条 Customer 的往返

先预测:把 status_codeA 改成未知值,映射器应该把它默认为暂停,还是停止并报告信息丢失?点击阶段按钮观察“读取领域语义 → 转换字段 → 往返校验”的责任如何移动,再打开故障模式验证拒绝条件。

Mapper:两个模型之间的双向桥梁领域模型Customer name: FullName富行为 · 不知 DB 存在toPersistence()fromRow()Mapper持久化模型customers 表 first_name, last_name纯数据 · 不知领域存在关键约束:• 两个模型互不知晓对方 • 映射规则集中在 Mapper 中 • 信息丢失需显式检测Mapper 双向转换两个独立模型,映射规则与业务逻辑分离
Mapper 在两个独立模型之间双向转换数据,映射规则与任一模型的业务逻辑分离, 两个模型互不知晓对方。

代码实践:显式字段映射与业务解耦

先定义两种模型,让类型本身暴露转换边界。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 也保留该值,并修改 toRowfromRow 与往返断言,使版本不会在映射中丢失。

问题 3:作出选择。 两个对象当前字段完全相同,只有一个调用者,且预计不会更换存储。此时是否应新增 Mapper?如果下季度要迁移到消息队列,请列出两个需要重新评估的证据。

名词解释

名词解释

本章出现的专业名词,用大白话再讲一遍。

映射器

集中把一个模型的字段和编码转换成另一个模型表示,并检查无法转换情况的对象。

领域模型

用业务语言表达身份、状态和行为的对象,不应由数据库列名决定形状。

持久化模型

为数据库或其他存储组织的字段集合,重点是列、编码、长度和索引等约束。

表示转换

把字段、编码和嵌套结构从一种模型改写成另一种模型的过程。

信息丢失

转换后无法恢复或验证原始语义,例如把未知状态悄悄改成合法状态。

前后导航

参考资料

资料与写作方式声明

本章以Martin Fowler《企业应用架构模式》与公开模式目录权威目录界定学习范围,并结合正文列出的技术资料独立重写;不宣称复现原书正文,也不沿用原作表述。

原作版权归作者与出版社所有;本站原创教学结构与表述仅供学习交流。

讨论

评论区加载中…