13.1 元数据映射

把对象关系映射规则保存为元数据,由通用机制解释字段、关系和类型转换。

学习目标

  • 能实现一份包含对象、表、字段、关系和类型转换的映射规范,并在启动期拒绝无效列名
  • 能修改通用映射器,使同一份规范分别支持订单装载与写回,同时保留对象身份和事务责任
  • 能回答:给定 total → total_cents 但数据库只有 amount_cents 的配置,失败应在哪一步发生,为什么不能让引擎猜列名

为什么 13.1 元数据映射值得单独学习

订单编辑页里有一份订单对象,数据库里有订单表;两边都叫“订单”,却不一定共享字段名、数据类型或关联方向。系统若把这些差异散落在每个查询和保存函数中,小改一列就会同时触及列表页、后台任务和导入脚本,错误还可能只在某条数据被访问时才出现。

本章解决的是“把转换规则放在哪里、谁来解释、何时拒绝”的问题。没有一个可检查的规则边界,通用机制就会变成黑盒:正常数据看起来能读写,配置写错时却只能等线上暴露。案例、TypeScript 片段、练习和图示均为本课程独立重写;公开资料只用来核对模式名称、目录位置与适用范围。

先建立直觉:把说明书和搬运工分开

把一个订单从仓库搬到工作台,至少要有一张说明卡:订单放在哪个货架、哪些字段要搬、货架标签和工作台字段如何对应。搬运工按照说明卡工作,而不是为每一种货物重新编写一套搬运动作。说明卡写错时,应该先检查说明卡,不能让搬运工凭相似名称猜测。

这条直觉对应本章的责任链:规则作为独立输入,校验器检查它是否能解释真实 schema;通用引擎负责重复的读写机制;领域对象和事务边界仍然负责业务不变量与失败回滚。模式的价值不是“少写几个类”,而是让变化集中在可审计的规则数据上。

目录单元到教学证据

13.1 元数据映射

本章对应 manifest 中的精确单元 poeaa24-pattern-22-metadata-mapping,范围限定为:将对象、表、字段、关系和类型转换写成可解释的规则,再由通用机制驱动装载与写回。订单聚合持久化贯穿全文:同一份规则必须能说明 Order.totalorders.amount_cents 的转换,也必须能说明 Order.customerorders.customer_id 的关系责任。

完成本单元的证据不是“画出一张配置表”,而是能在三个时点留下可追踪结果:无效规则在初始化或测试期被拒绝;合法规则能把一行恢复为完整对象;写回失败时工作单元不留下半次更新。若只能说“ORM 会自动处理”,就还没有完成模式级判断。

元数据、校验与通用机制

规则先于代码分支

是关于映射的描述数据。它可以来自 JSON、YAML、XML、注解或构建期生成物;格式不是模式本身,关键是规则能被读取、验证、版本化和测试。

订单的最小规则可以写成一个独立对象。不包含“这次请求是否允许取消订单”这样的业务判断,它只描述对象与关系行如何互相翻译:

type MappingSpec = {
  className: "Order";
  table: "orders";
  fields: {
    id: { column: "order_id"; type: "number" };
    total: { column: "amount_cents"; type: "money-cents" };
    customerId: { column: "customer_id"; type: "foreign-key" };
  };
};
 
const orderSpec: MappingSpec = {
  className: "Order",
  table: "orders",
  fields: {
    id: { column: "order_id", type: "number" },
    total: { column: "amount_cents", type: "money-cents" },
    customerId: { column: "customer_id", type: "foreign-key" },
  },
};

配置错误要在对象创建前暴露

是第一道边界。它至少要检查目标表存在、列名存在、必需关系有约束、类型转换有实现;校验通过后才允许通用引擎注册这份规范。这样 total → total_cents 而 schema 只有 amount_cents 时,错误会指向配置与迁移,而不是伪装成一个金额为零的 Order。

type Schema = { tables: Record<string, Set<string>> };
 
function validate(spec: MappingSpec, schema: Schema) {
  const columns = schema.tables[spec.table];
  if (!columns) throw new Error(`missing table: ${spec.table}`);
 
  for (const [field, mapping] of Object.entries(spec.fields)) {
    if (!columns.has(mapping.column)) {
      throw new Error(`invalid mapping: ${field} → ${mapping.column}`);
    }
  }
}

校验不等于只检查字符串。它还应检查读写方向是否闭合:一个字段若能从数据库读出,却没有可逆或明确的写回策略,就不能宣称“同一映射支持保存”;一个关系若只声明了对象名称,没有外键或加载策略,也不能把关联对象当作已完成。启动期校验、契约测试和迁移检查可以共享同一份规则,但它们的失败信息要保留阶段与版本。

通用引擎负责解释,不负责猜测

接收规范、行数据和对象,不接收某个页面的业务决定。它可以统一处理列名转换、金额单位、外键解析和空值策略;它不能在规范缺失时根据字段相似度猜一个列,也不能因为一个订单装载失败就把错误吞成默认对象。

type Order = { id: number; total: number; customerId: number | null };
type OrderRow = Record<string, unknown>;
 
function loadOrder(spec: MappingSpec, row: OrderRow): Order {
  return {
    id: Number(row[spec.fields.id.column]),
    total: Number(row[spec.fields.total.column]) / 100,
    customerId:
      row[spec.fields.customerId.column] == null
        ? null
        : Number(row[spec.fields.customerId.column]),
  };
}
 
function saveOrder(spec: MappingSpec, order: Order) {
  return {
    [spec.fields.id.column]: order.id,
    [spec.fields.total.column]: Math.round(order.total * 100),
    [spec.fields.customerId.column]: order.customerId,
  };
}

这里的 loadOrdersaveOrder 是机制的最小切片,不是生产 ORM。真正实现还要处理版本、脏字段、并发冲突和关系加载,但方向必须稳定:规则改变映射,业务对象仍然决定订单是否合法;引擎只负责把可接受的边界翻译成读写动作。

专属可视化实验:从规则到可回滚写回

主图先显示完整的五段关系:映射规范、元数据校验、通用映射器、Order 对象和 orders 表。点击三个阶段按钮,观察责任如何从“规则是否可接受”推进到“对象如何恢复”再推进到“写回如何封口”;点击“注入错误元数据”,让 total_cents 故意指向不存在的列,确认失败停在对象创建之前。

专属元数据映射图 · 元数据可被接受
Metadata Mapping:规则是数据,映射器是引擎先把映射规则当作输入,检查它能否解释真实表结构1. 校验2. 装载3. 写回映射规范(MappingSpec)class: Ordertable: ordersid → order_idtotal → amount_centscustomer → customer_id字段、关系、类型都可检查读规范元数据校验class ✓columns relations ✓types ✓accept spec驱动通用映射器load(spec, row)save(spec, object)convert typesresolve relation一套机制,多种对象恢复对象 ↔ 表Order { id, total }ordersorder_id · amount_centscustomer_id同一规范读 / 写阶段 1 · 元数据可被接受映射规范同时声明对象、表、字段和类型;任何一个名字对不上,都应在创建通用引擎前失败。验收问题:若换 ORM 或数据库,是否仍能用这份映射规范回答同样的责任、类型和失败问题?元数据映射的边界:规则可替换,校验与事务责任不可隐身
元数据映射把对象、字段、关系和类型转换保存为可校验的规则,由通用映射器解释;它不替团队决定事务、身份和迁移边界。

三个阶段快照

下面的 Stepper 把同一条链拆成三张证据快照。每一步都有图,而不是只用文字描述状态;阅读时先预测阶段的产物,再对照图中高亮边界。

先预测:如果映射规范把 total 指向不存在的 total_cents,你认为错误会在“查询返回空值”“对象构造失败”还是“元数据校验”阶段出现?

分步1 / 3

1. 校验:先拒绝无法解释的规则

先让真实 schema 与 MappingSpec 做逐项比对。表、列、类型和关系都能找到时才登记规范;任何一项不匹配,都保留字段名、目标列和规范版本,供迁移或配置修复使用。

专属元数据映射图 · 元数据可被接受
Metadata Mapping:规则是数据,映射器是引擎先把映射规则当作输入,检查它能否解释真实表结构1. 校验2. 装载3. 写回映射规范(MappingSpec)class: Ordertable: ordersid → order_idtotal → amount_centscustomer → customer_id字段、关系、类型都可检查读规范元数据校验class ✓columns relations ✓types ✓accept spec驱动通用映射器load(spec, row)save(spec, object)convert typesresolve relation一套机制,多种对象恢复对象 ↔ 表Order { id, total }ordersorder_id · amount_centscustomer_id同一规范读 / 写阶段 1 · 元数据可被接受映射规范同时声明对象、表、字段和类型;任何一个名字对不上,都应在创建通用引擎前失败。验收问题:若换 ORM 或数据库,是否仍能用这份映射规范回答同样的责任、类型和失败问题?元数据映射的边界:规则可替换,校验与事务责任不可隐身
元数据映射把对象、字段、关系和类型转换保存为可校验的规则,由通用映射器解释;它不替团队决定事务、身份和迁移边界。

读写合同:身份、关系与事务不能外包

装载后仍要维护同一身份

通用引擎把一行恢复为对象,却没有自动保证同一请求中两次读取 orders.order_id = 42 得到同一实例。可以用请求级缓存或受控工厂实现,重点是后续修改、脏状态和关系引用不能各自指向不同副本。

例如先加载 Order,再通过订单的客户外键加载 Customer,身份映射应让重复出现的 Customer(7) 回到同一个实例。它解决的是对象身份一致性,不等于缓存永不过期;版本检查、失效策略和事务隔离仍要由系统明确选择。

写回不是“把对象整体序列化”

元数据映射可以告诉引擎 Order.customerId 对应 orders.customer_id,却不应因此把 Customer 的姓名、地址和订单集合一起塞进订单行。关联的拥有方、空值语义、删除策略和加载范围都需要独立记录。规则清楚时,列表查询可以只取订单列,详情查询再按外键加载客户;这比一个自动展开的对象图更容易测量和回滚。

用工作单元封住失败

把多个映射动作放进同一提交范围:先校验对象版本与身份,再按照规范生成待写列,最后提交订单和关联更新。若 amount_cents 写入成功而 customer_id 写入因约束失败,工作单元必须让前者也回滚,不能用“稍后补写”掩盖部分成功。

function saveOrderUnit(order: Order, spec: MappingSpec, db: Database) {
  return db.transaction(() => {
    validate(spec, db.schema());
    const row = saveOrder(spec, order);
    db.update(spec.table, row, { order_id: order.id });
  });
}

这段伪代码的重点是顺序与边界,而不是某个数据库 API:校验不能只在部署时运行,写回前也要保证当前 schema 和规范仍匹配;事务不能只包住最后一条 SQL,而要覆盖规范验证后产生的整组变更。

配置演进与替代方案

元数据映射适合规则变化频繁、对象种类较多、字段转换可描述且希望统一测试的边界。它不意味着所有对象都应该交给一个万能引擎。领域规则复杂、对象生命周期差异大或查询需要完全不同的聚合策略时,显式 Mapper、数据访问对象或手写查询可能更容易审计。

评审问题选择元数据映射的证据应拒绝或换方案的信号
变化新增对象主要增加规范和契约测试每个对象都有独特的业务分支,配置开始承载流程判断
失败无效列名、类型和关系能在启动或测试期失败引擎只能到运行时猜列名,错误只能显示为空对象
查询多个对象共享稳定的字段转换与关系策略查询计划必须手写,通用引擎反而隐藏索引与联接成本
身份规范与身份映射、版本条件有明确边界同一 id 在多个缓存和 Mapper 中产生互相覆盖的副本
写回工作单元能让整组映射更新一起提交或回滚保存被拆成异步片段,失败后只能人工修复半个聚合

配置文件也需要演进纪律。添加一列时先让校验器认识兼容的 schema,再部署能读取旧列和新列的版本,最后切换写入;删除列时先停止写入、观察读流量,再移除规则和约束。每次规则变更都应有一个失败样本:旧服务遇到新列或新类型时应明确拒绝,而不是静默丢失字段。

常见误区

本章小结

  • 元数据把对象、表、字段、关系和类型转换写成可检查的规则。
  • 元数据校验应在通用映射器创建对象前拒绝无效配置。
  • 通用映射器解释规则,不猜列名,也不承载业务决定。
  • 身份映射和工作单元分别守住对象实例一致性与整组写回的回滚语义。
  • 当配置开始隐藏查询计划或业务流程时,应改用显式 Mapper、查询对象或手写 SQL。

本章练习

练习

问题 1: MappingSpecOrder.total 指向 total_cents,但真实的 orders 表只有 amount_cents。请指出失败阶段,并写出测试至少要断言什么。

问题 2: 同一份规范要支持订单列表读取和订单编辑保存。你会如何证明金额与客户外键的读写方向闭合?

问题 3: 保存订单时金额列已生成,但客户外键因约束失败。请设计一个最小故障注入,证明工作单元保护了聚合。

前后导航

出处声明与独立改写

正文参考 Martin Fowler 作者图书页Martin Fowler 企业应用架构模式目录Pearson 出版社页面,用于核对原书主题、模式名称和公开目录范围。中文出版信息沿用 frontmatter 所示 2024 年中文版;本文为独立改写,不复现原书正文、插图或代码,CC BY-NC 4.0 仅适用于本站原创教学表述与结构。

资料与写作方式声明

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

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

名词解释

名词解释

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

元数据

描述对象怎样对应数据库结构的数据;它是可读取、可验证的规则,不是某次请求的业务决定。

映射规范

一份把对象字段、数据库列、关系和类型转换写在一起的映射合同。

元数据校验

把映射规范逐项和真实表、列、关系及转换实现比对,提前找出不能执行的配置。

通用映射器

按映射规范执行装载、类型转换和写回的可复用引擎;它不负责猜测或决定业务规则。

身份映射

让同一个持久化 id 在一次工作范围内对应同一个对象实例的机制,避免两个副本互相覆盖。

工作单元

收集一次操作中的对象变化,并在同一事务语义下统一提交或回滚的边界。

讨论

评论区加载中…