12.10 继承映射器

以映射器层次组织继承结构的持久化,让公共字段、具体类型和选择策略各有责任。

学习目标

  • 能实现父类映射器读取公共字段与鉴别器、子类映射器补齐特有字段的加载链
  • 能用类型分发、身份一致性和事务边界定位一次继承映射失败的首个责任点
  • 能比较单表继承、类表继承和具体表继承,并为选定方案写出可执行的拒绝条件

为什么 12.10 继承映射器 值得单独学习

解决的不是“如何把一个类转换成一行记录”这么窄的问题,而是:领域里有 EmployeeEngineerManager 这样的继承关系时,谁读取公共字段,谁决定具体类型,谁负责子类字段,谁在保存失败时收口。若所有责任都挤在一个巨大的 Mapper 里,正常数据也许能加载,但下一次新增子类时,分支、查询和测试会一起膨胀。

本章把一个订单后台中的员工审批切成可观察的 Employee 对象树和数据库映射链。案例、代码、图示、实验和练习均为本课程独立改写;Martin Fowler 的公开图书页与模式目录只用于核对 12.10 的名称、范围及其在对象—关系映射模式族中的位置。

这类映射器尤其值得单独学习,是因为“领域继承”和“存储继承”并不天然同构。对象树可以强调行为复用,关系模式却可能选择一张表、父子多张表或每个具体类一张表。映射器的任务不是强迫两棵树长得一模一样,而是把差异收束在可以测试、替换和回滚的边界内。

先建立直觉:父类知道什么,子类补充什么

先猜一猜:数据库返回 id=7, name="Ada", type="Engineer" 时,应用应该由控制器直接拼出 Engineer,还是让一个拥有类型知识的映射器完成选择?如果这个判断散落在控制器、查询服务和导出任务里,同一条记录就可能在不同入口被还原成不同的对象。可靠的做法是让父类映射器读取公共字段和类型线索,再把子类部分委托给对应的映射器。

这里的是关系行中用于表达具体类型的受控值,例如 type = "Engineer"。它不是可以随意拼接的类名,也不能只靠字符串反射决定安全边界:映射器必须维护“允许的类型值 → 具体映射器”的显式表,并在未知值出现时拒绝加载。

对象侧的责任可以先写成一条短规则:父类映射器负责 idname 和鉴别器,子类映射器负责 skillbudget,应用服务只编排查询、事务和返回值。这样一来,新增 Contractor 时,变化主要落在类型注册、ContractorMapper 和对应测试,而不是每个调用入口都复制一遍 if

目录单元到教学证据

12.10 继承映射器

12.10 继承映射器 的边界内,学习者要能把“领域类树”与“Mapper 树”同时画出来,并说明每一条委托边的输入、输出和失败语义。单元键为 poeaa24-pattern-21-inheritance-mappers;本章用员工审批记录验证这一点:加载一个 Engineer 必须同时得到公共字段和 skill,保存它必须在同一个身份和事务语义下完成。

评审记录至少回答五个问题:鉴别器由谁验证,公共字段由谁拥有,子类字段由谁加载,数据库行如何恢复为同一身份,以及哪一段失败时整个对象是否应该暴露给业务层。只要答案仍然是“ORM 默认会处理”,就还没有把模式变成可检验的代码设计。

专属代码案例:员工审批记录

我们先用一个很小的类型集合说明合同。EmployeeRow 是关系查询返回的形状;Employee 是业务对象;两个子类 Mapper 只拥有各自的额外字段。代码刻意把“选择类型”和“填充字段”拆成两步,便于测试每条责任链。

type EmployeeType = "Engineer" | "Manager";
 
type EmployeeRow = {
  id: number;
  name: string;
  type: EmployeeType;
};
 
type Employee = {
  id: number;
  name: string;
  type: EmployeeType;
};
 
type Engineer = Employee & { type: "Engineer"; skill: string };
type Manager = Employee & { type: "Manager"; budget: number };
 
interface EmployeeMapper<T extends Employee> {
  load(id: number): T;
  save(employee: T): void;
}

真正的父类映射器不应该把所有子类字段都塞进返回值。它只做三件事:读取公共行、验证鉴别器、选择一个具体 Mapper。具体 Mapper 再调用父类的 loadBase,最后把自己的字段映射到对象上。这个顺序让每一步都能在日志和单元测试中留下证据。

class EmployeeMapper {
  constructor(private readonly db: Database) {}
 
  load(id: number): Employee {
    const row = this.db.one(
      "select id, name, type from employees where id = ?",
      id,
    );
    const mapper = this.mapperFor(row.type);
    return mapper.loadFromBase(row);
  }
 
  loadBase(row: EmployeeRow): Employee {
    return { id: row.id, name: row.name, type: row.type };
  }
 
  private mapperFor(type: EmployeeType) {
    if (type === "Engineer") return new EngineerMapper(this.db, this);
    if (type === "Manager") return new ManagerMapper(this.db, this);
    throw new UnknownEmployeeType(type);
  }
}

这个例子中的父类映射器不是领域父类的替身,而是持久化协作者。它可以共享查询、身份和错误翻译的机制,但不能因此接管工程师技能或经理预算的业务规则。子类 Mapper 的继承只应该复用稳定的映射步骤;如果两个 Mapper 共享的是业务变化而不是持久化机制,应重新评估领域对象或组合是否更合适。

模式结构图

Inheritance Mappers:领域继承 ↔ 映射委托先读鉴别器,再把请求分发给具体 Mapper1. 分发2. 装载3. 保存领域类树Mapper 树Employeeid · name · typeEngineerskillManagerbudget映射EmployeeMapperfind(id) → read type → choose childEngineerMappertype = EngineerManagerMappertype = ManagerEngineerMapper.load(id)super.loadBase(id) → id, nameloadSubtype(id) → skill两段都完成,才返回 Engineer 对象EngineerMapper.save(engineer)saveSubtype(skill) → engineerssuper.saveBase(id) → employeestransaction:任一段失败,整体回滚employees 行id: 7name: "Ada"type: Engineer先得到 id、name、type;不要在父 Mapper 里猜子类。find(id) → dispatch(type)验收状态type = Engineer → EngineerMapper此时只完成类型选择,还没有声称子类字段已加载。责任链可追踪 · 失败可回滚父 Mapper 复用公共映射,子 Mapper 承担类型特有字段;加载与保存都必须保住身份和事务边界
Inheritance Mappers 让 Mapper 继承树与领域类树保持可解释的对应关系:父 Mapper 负责公共字段和类型分发,子 Mapper 负责特有字段,委托链由事务与身份规则收口。

图中左侧是领域类树,右侧是映射器树;底部把一行关系数据和最终对象之间的验收条件放在同一条线上。注意图示的“镜像”只是责任的可追踪性,不承诺数据库一定要采用与类层次相同的表结构。

三步代码实验:从加载到保存

下面的实验每一步都修改同一条 Employee(id=7) 的证据链。先观察图,再写伪 SQL 或 TypeScript,让每一个结果都能回答“谁做了决定、谁承担失败”。

分步1 / 3

1. 读取鉴别器并分发到具体 Mapper

先实现父类映射器的 mapperFor(type)。对 type = "Engineer",它应该返回 EngineerMapper;对未知类型,应该在对象创建前抛出错误。不要把未知类型静默当成 Employee,那会把脏数据伪装成合法的基类对象。

const mapper = employeeMapper.mapperFor(row.type);
const employee = mapper.loadFromBase(row);

验收问题:日志必须能区分“查不到 id”和“查到但鉴别器未知”这两种失败。前者是数据不存在,后者是映射配置或迁移没有闭合;它们的恢复方式不同。

Inheritance Mappers:领域继承 ↔ 映射委托先读鉴别器,再把请求分发给具体 Mapper1. 分发2. 装载3. 保存领域类树Mapper 树Employeeid · name · typeEngineerskillManagerbudget映射EmployeeMapperfind(id) → read type → choose childEngineerMappertype = EngineerManagerMappertype = ManagerEngineerMapper.load(id)super.loadBase(id) → id, nameloadSubtype(id) → skill两段都完成,才返回 Engineer 对象EngineerMapper.save(engineer)saveSubtype(skill) → engineerssuper.saveBase(id) → employeestransaction:任一段失败,整体回滚employees 行id: 7name: "Ada"type: Engineer先得到 id、name、type;不要在父 Mapper 里猜子类。find(id) → dispatch(type)验收状态type = Engineer → EngineerMapper此时只完成类型选择,还没有声称子类字段已加载。责任链可追踪 · 失败可回滚父 Mapper 复用公共映射,子 Mapper 承担类型特有字段;加载与保存都必须保住身份和事务边界
Inheritance Mappers 让 Mapper 继承树与领域类树保持可解释的对应关系:父 Mapper 负责公共字段和类型分发,子 Mapper 负责特有字段,委托链由事务与身份规则收口。

三种映射策略如何影响 Mapper

继承映射器解决责任分配,不能替代数据库表设计。常见的三种会改变查询和保存的成本,但都可以由同一组父子 Mapper 合同承接:

存储策略读取路径主要收益主要代价
单表继承一张表读公共列与子类列多态读取直接,事务简单子类列稀疏,约束容易变弱
类表继承父表加子表,按身份 JOIN字段归属清晰,重复少多态查询与保存需要多段 SQL
具体表继承每个具体类一张完整表单类型读取简单公共字段重复,多态查询要合并

选择策略时,先固定 Mapper 的可观察合同:给定合法 id,加载结果的运行时类型正确;保存后公共字段和子类字段一起可读;任一段失败时没有半个对象或半次写回泄漏。然后再用查询计划、字段约束和迁移成本比较表结构,而不是让 ORM 的默认继承选项替你作架构决定。

继承映射器的两个关键边界

多态加载不等于类型猜测

是“根据持久化记录恢复正确的具体对象”,不是看到一个类名字符串就调用反射。类型注册表应该是显式的、可审计的,并能在启动测试中检查每个鉴别器值都有对应的 Mapper。对外部输入或旧数据,未知类型必须成为明确的拒绝结果。

身份在父子 Mapper 之间只能有一个来源

要求父 Mapper、子 Mapper、缓存和工作单元对同一个数据库 id 只承认同一对象身份。若父 Mapper 创建了 Employee(7),子 Mapper 又无条件创建一个 Engineer(7),对象图中就会出现两个互相覆盖的实例。可以用身份映射、请求级缓存或受控工厂避免重复创建,但不能把“对象相等”误当成“对象身份相同”。

继承关系不应该复制业务规则

适合集中公共列、主键和鉴别器的持久化机制; 适合补齐特有列和保存委托。它们不应该复制 Engineer.approve()Manager.approve() 的业务判断。若子类 Mapper 开始决定审批额度、权限或状态转换,应该把规则移回领域对象或领域服务,并让 Mapper 只翻译状态。

常见误区

选择与拒绝矩阵

评审问题选择继承映射器的证据应拒绝或改用其他方案的信号
责任公共字段、鉴别器和子类字段分别有明确拥有者一个 Mapper 既做类型分发又执行全部业务决定
变化新增子类主要增加一个具体 Mapper 和注册测试新增一个字段需要改动所有查询入口和所有保存分支
一致性同一 id 的加载、保存和缓存共享身份与事务边界父子表可以独立提交,失败后只能人工修复
查询已用实际查询计划比较多态列表与单类型读取只因 ORM 默认开启继承就跳过表结构和索引评审
拒绝未知鉴别器、缺失子行和类型不匹配都有显式错误为了“尽量返回数据”而静默降级成基类对象

本章小结

继承映射器的价值不在于让 Mapper 类也形成一棵漂亮的继承树,而在于把继承持久化中的三个决定拆开:父类映射器验证鉴别器并处理公共字段,子类映射器加载和保存特有字段,事务与身份规则保证两段责任最后仍然描述同一个对象。表结构可以是单表、类表或具体表,但加载、保存、未知类型和失败回滚都必须有可运行的证据。

如果一次新增子类仍然要求所有入口复制分支,说明分发边界没有收口;如果父 Mapper 不断吸收业务规则,说明复用已经越过了持久化职责。用类型测试、身份测试、故障注入和真实查询计划复核,才能决定 12.10 继承映射器是否值得保留。

本章练习

练习

问题 1: employees 返回 type = "Engineer",但 engineers 没有对应的 employee_id 行。加载器应该返回一个缺少 skillEngineer,还是失败?请说明失败应发生在哪个边界。

问题 2: 你需要同时支持 EngineerManager 的多态列表。请写出一个不会把未知鉴别器静默降级为 Employee 的分发策略,并说明如何测试它。

问题 3: EngineerMapper.save() 已写入 skill,随后 EmployeeMapper.saveBase() 因并发冲突失败。如何改造代码,使重试不会留下半个继承对象?

前后导航

出处声明

名词解释

名词解释

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

继承映射器

把领域继承结构的公共字段、具体类型和子类字段分配给协作 Mapper 的持久化组织方式。

鉴别器

关系行中标识具体子类的受控值,例如 Engineer 或 Manager;它必须经过显式验证。

父类映射器

负责主键、公共列和类型分发,并把具体字段装载委托给子类 Mapper 的持久化协作者。

子类映射器

负责某个具体类型的特有字段,并复用父 Mapper 的公共字段装载或保存步骤。

多态加载

根据持久化记录恢复正确具体对象的过程,包含类型验证和子类字段完整性检查。

身份一致性

同一个数据库 id 在父子 Mapper、缓存和工作单元中始终对应同一个受控对象身份的约束。

映射策略

把继承结构落到单表、类表或具体表等关系模式时,对查询、约束与写回路径的选择。

讨论

评论区加载中…