12.10 继承映射器
以映射器层次组织继承结构的持久化,让公共字段、具体类型和选择策略各有责任。
学习目标
- 能实现父类映射器读取公共字段与鉴别器、子类映射器补齐特有字段的加载链
- 能用类型分发、身份一致性和事务边界定位一次继承映射失败的首个责任点
- 能比较单表继承、类表继承和具体表继承,并为选定方案写出可执行的拒绝条件
为什么 12.10 继承映射器 值得单独学习
↡
解决的不是“如何把一个类转换成一行记录”这么窄的问题,而是:领域里有
Employee、Engineer、Manager
这样的继承关系时,谁读取公共字段,谁决定具体类型,谁负责子类字段,谁在保存失败时收口。若所有责任都挤在一个巨大的
Mapper
里,正常数据也许能加载,但下一次新增子类时,分支、查询和测试会一起膨胀。
本章把一个订单后台中的员工审批切成可观察的 Employee 对象树和数据库映射链。案例、代码、图示、实验和练习均为本课程独立改写;Martin Fowler 的公开图书页与模式目录只用于核对 12.10 的名称、范围及其在对象—关系映射模式族中的位置。
这类映射器尤其值得单独学习,是因为“领域继承”和“存储继承”并不天然同构。对象树可以强调行为复用,关系模式却可能选择一张表、父子多张表或每个具体类一张表。映射器的任务不是强迫两棵树长得一模一样,而是把差异收束在可以测试、替换和回滚的边界内。
先建立直觉:父类知道什么,子类补充什么
先猜一猜:数据库返回 id=7, name="Ada", type="Engineer" 时,应用应该由控制器直接拼出 Engineer,还是让一个拥有类型知识的映射器完成选择?如果这个判断散落在控制器、查询服务和导出任务里,同一条记录就可能在不同入口被还原成不同的对象。可靠的做法是让父类映射器读取公共字段和类型线索,再把子类部分委托给对应的映射器。
这里的↡是关系行中用于表达具体类型的受控值,例如 type = "Engineer"。它不是可以随意拼接的类名,也不能只靠字符串反射决定安全边界:映射器必须维护“允许的类型值 → 具体映射器”的显式表,并在未知值出现时拒绝加载。
对象侧的责任可以先写成一条短规则:父类映射器负责 id、name 和鉴别器,子类映射器负责 skill 或 budget,应用服务只编排查询、事务和返回值。这样一来,新增 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 共享的是业务变化而不是持久化机制,应重新评估领域对象或组合是否更合适。
模式结构图
图中左侧是领域类树,右侧是映射器树;底部把一行关系数据和最终对象之间的验收条件放在同一条线上。注意图示的“镜像”只是责任的可追踪性,不承诺数据库一定要采用与类层次相同的表结构。
三步代码实验:从加载到保存
下面的实验每一步都修改同一条 Employee(id=7) 的证据链。先观察图,再写伪 SQL 或 TypeScript,让每一个结果都能回答“谁做了决定、谁承担失败”。
1. 读取鉴别器并分发到具体 Mapper
先实现父类映射器的 mapperFor(type)。对 type = "Engineer",它应该返回 EngineerMapper;对未知类型,应该在对象创建前抛出错误。不要把未知类型静默当成 Employee,那会把脏数据伪装成合法的基类对象。
const mapper = employeeMapper.mapperFor(row.type);
const employee = mapper.loadFromBase(row);验收问题:日志必须能区分“查不到 id”和“查到但鉴别器未知”这两种失败。前者是数据不存在,后者是映射配置或迁移没有闭合;它们的恢复方式不同。
三种映射策略如何影响 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 行。加载器应该返回一个缺少 skill 的 Engineer,还是失败?请说明失败应发生在哪个边界。
问题 2: 你需要同时支持 Engineer 和 Manager 的多态列表。请写出一个不会把未知鉴别器静默降级为 Employee 的分发策略,并说明如何测试它。
问题 3: EngineerMapper.save() 已写入 skill,随后 EmployeeMapper.saveBase() 因并发冲突失败。如何改造代码,使重试不会留下半个继承对象?
前后导航
出处声明
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 继承映射器
把领域继承结构的公共字段、具体类型和子类字段分配给协作 Mapper 的持久化组织方式。
- 鉴别器
关系行中标识具体子类的受控值,例如 Engineer 或 Manager;它必须经过显式验证。
- 父类映射器
负责主键、公共列和类型分发,并把具体字段装载委托给子类 Mapper 的持久化协作者。
- 子类映射器
负责某个具体类型的特有字段,并复用父 Mapper 的公共字段装载或保存步骤。
- 多态加载
根据持久化记录恢复正确具体对象的过程,包含类型验证和子类字段完整性检查。
- 身份一致性
同一个数据库 id 在父子 Mapper、缓存和工作单元中始终对应同一个受控对象身份的约束。
- 映射策略
把继承结构落到单表、类表或具体表等关系模式时,对查询、约束与写回路径的选择。