12.8 类表继承

为继承层次中的每个类建立一张表,以共享主键连接父类和子类数据,并用 JOIN 重建对象。

学习目标

  • 能把一个继承层次拆成父类表、子类表和共享主键外键,并解释每列的身份语义
  • 能编写插入、加载和删除类表继承对象的最小代码,明确 JOIN、事务与参照完整性的责任边界
  • 能依据多态查询、字段演化和写入事务成本,在类表继承、单表继承与具体表继承之间作出可验证的选择

为什么 12.8 类表继承值得单独学习

设想一个订单系统把员工分成 EmployeeEngineerManager。对象侧希望复用 Employee.idname 等公共状态,又希望工程师有 skill、经理有 budget。如果把所有字段塞进一张宽表,会得到很多与类型无关的空列;如果每个子类完全复制公共字段,又会把一次公共字段改名扩散到多张表。

本章解决的不是“ORM 怎样声明继承”,而是一个更小、更硬的问题:怎样让数据库中的多行仍然表示同一个对象身份。把公共状态放在父类表,把专属状态放在子类表;子类行通过同一个 ID 找回父类行。它用 JOIN 换取字段边界清晰,用事务和约束换取身份不被拆散。

本页依据 Martin Fowler 的作者图书页、模式目录和出版社目录独立重写,不复现原书正文、插图或代码。目录只限定学习范围;员工继承层次、SQL、TypeScript 映射、评审矩阵和交互图均为本课程原创教学材料。

先建立直觉:一份身份如何分布在三张表里

先猜一猜:如果 Engineer 对象的 id 是 42,employees 表里有 (42, "Lin"),那么 engineers 表应该使用什么值来指向它?答案不是再生成一个 9001,而是继续使用 42。这个值就是:它同时回答“这是哪个对象”和“它属于哪一行父类数据”。

数据库中的三个层次可以这样读:employees 保存所有员工都有的字段,engineersmanagers 只保存差异字段;当应用要恢复一个工程师时,映射器先按同一个 ID 连接两张表,再构造一个对象。这里的不是数据库的替代品:它负责翻译,数据库仍负责主键、外键和事务约束。

如果父类还有一层抽象中间类,连接链会继续变长。例如 StaffMember → Employee → Engineer 对应三张表,最深的查询需要沿着共享 ID 逐层 JOIN。先观察图中的 ID 是否保持不变,再回答:你愿意为每次读取支付几次 JOIN,取决于这个继承层次是否真的有稳定的公共身份,而不是取决于 ORM 的默认配置。

核心模型:父类表保存共性,子类表保存差异

共享身份比字段复制更重要

类表继承的最小模式是一组同名身份列:

CREATE TABLE employees (
  id   BIGINT PRIMARY KEY,
  name VARCHAR(120) NOT NULL
);
 
CREATE TABLE engineers (
  id    BIGINT PRIMARY KEY REFERENCES employees(id),
  skill VARCHAR(120) NOT NULL
);
 
CREATE TABLE managers (
  id     BIGINT PRIMARY KEY REFERENCES employees(id),
  budget DECIMAL(12, 2) NOT NULL
);

子类表的 id 同时是主键和指向 employees.id 的外键。因此拒绝两种错误:孤立的工程师行,以及没有对应子类行却被应用误构造成工程师的对象。公共字段不会在子类表重复,专属字段也不会污染不属于该类型的行。

写入与删除必须有明确顺序

创建工程师时,先插入父类行,再插入子类行;删除工程师时,先删除子类行,再删除父类行。两步必须位于同一个事务中,否则中途失败会留下只有父类、只有子类,或已经失去完整对象的半成品。数据库的外键会挡住非法顺序,但它不能替你决定事务何时开始、错误怎样返回。

type Engineer = { id: number; name: string; skill: string };
 
async function insertEngineer(db: Db, engineer: Engineer) {
  await db.transaction(async (tx) => {
    await tx.employees.insert({ id: engineer.id, name: engineer.name });
    await tx.engineers.insert({ id: engineer.id, skill: engineer.skill });
  });
}
 
async function loadEngineer(db: Db, id: number): Promise<Engineer | null> {
  const row = await db.queryOne(
    `SELECT e.id, e.name, g.skill
       FROM employees e
       JOIN engineers g ON g.id = e.id
      WHERE e.id = ?`,
    [id],
  );
  return row ? { id: row.id, name: row.name, skill: row.skill } : null;
}

这里的 Db 和 SQL 占位符只是说明持久化契约的伪接口,不绑定某个 ORM。可验收的行为是:父类插入失败时没有子类行;子类插入失败时整个事务回滚;加载只在两行都存在时返回完整的 Engineer。若需要允许“暂时没有具体类型”的员工,就必须显式定义状态,而不能靠一条缺失的子类行猜测类型。

查询类型越宽,JOIN 代价越明显

读取单一子类时,employees JOIN engineers 很直观。读取所有员工的则需要决定数据库如何表达类型:可以对每个子类做 LEFT JOIN,再由 CASE 判断非空列;也可以分别查询每个子类并 UNION ALL 成统一结果。两者都必须记录缺失子类行的处理方式,不能把空结果默认为某个类型。

多态查询不是类表继承的错误,而是它的账单:公共字段只存一份,代价是更长的读取计划和更复杂的结果组装。若系统几乎只按具体类型读取,这个账单通常可接受;若每个请求都要扫描十几种子类,选择前应把 JOIN 数量、索引命中和映射器代码量写进评审记录。

目录单元到教学证据

12.8 类表继承

本章只覆盖一个官方目录单元:12.8 类表继承。单元键为 poeaa24-pattern-19-class-table-inheritance,模式族为 mapping。本章的证据链不是“表能建起来”,而是能从同一个 ID 追踪父子行,证明插入和删除的事务顺序,并在多态查询变宽时指出拒绝信号。

评审一份实现时,固定使用员工样本:Employee(42, "Lin")Engineer(42, "compiler")Manager(77, 1200000)。记录四个观察项:共享身份是否唯一,父子行是否一起提交,查询是否能恢复具体类型,新增公共字段是否只需要改父类表。任何一项没有可复现的 SQL、代码或截图证据,都不能只用“ORM 已处理”带过。

专属代码案例:员工继承的映射边界

把一次员工保存切成“对象 → 父类行 → 子类行 → JOIN 恢复”四个观察点。领域代码拥有 Engineer.skill 的业务校验,映射器把对象翻译成两张表的行,数据库用外键保护共享身份;三者各自的失败都要能被定位。

一个实用的评审卡包含五项:模式键为 poeaa24-pattern-19-class-table-inheritance;当前裁决是“公共字段稳定、具体类型常被单独读取、共享主键可在事务内维护”;观测项包括 JOIN 深度、多态查询比例、公共字段迁移次数、父子写入失败率和索引命中;拒绝条件是“每个请求都必须扫描大量子类,或跨服务写入使父子行无法保持同一事务边界”。

下面的图把这张卡拆成三步。动手试前先预测:第 2 步把 skillemployees 移到 engineers 后,哪一个 ID 必须保持不变;第 3 步加入“列出所有员工”后,查询复杂度会在哪个位置增加。切换步骤时观察变化,再用练习题写出你的选择理由。

三步交互实验:从建表到多态查询

分步1 / 3

步骤 1:把继承层次落成共享身份

先固定 Employee(42)Engineer(42)。图中父类表和子类表各有自己的字段,但两处 ID 必须相同;如果子类另造一个 ID,数据库就无法证明它们是同一个对象。

Class Table Inheritance:共享主键重建对象共享主键:公共字段与专属字段各归其表1. 建模2. 恢复3. 多态读取对象继承树Employeeid = 42 · nameEngineerid = 42 · skillManagerid = 77 · budget一个对象身份 · 多张表关系表employeesid (PK) = 42name = Linengineersid (PK/FK) = 42skill = compilermanagersid (PK/FK) = 77budget = 1.2M映射评审焦点✓ 公共字段只存一份✓ 子类身份可约束JOIN 是读取账单事务是写入边界先测量,再选型类表继承的核心:字段分层,身份不分裂每个类一张表 · 子类 id = 父类 id · JOIN 重建对象
类表继承把公共字段放入父类表,把差异字段放入子类表;共享主键维护对象身份,JOIN 与多态读取则构成需要测量的成本。

选择与拒绝矩阵

评审问题选择类表继承的证据应拒绝或比较其他策略的信号
身份父子表可在同一事务中共享稳定主键跨服务写入使父行和子行无法一起提交
字段公共字段需要唯一来源,子类差异字段有清晰边界公共字段频繁变化,迁移要同时改很多外部表
读取具体类型查询多,JOIN 深度可测量且有索引每个请求都必须扫描大量子类,且多态响应是主路径
完整性子表 FK 约束和删除顺序可以自动验收应用靠“缺少子类行就当作某类型”猜测对象类型
替代已将 JOIN 成本与单表、具体表方案做过同一数据集对比只因 ORM 的继承 API 方便就跳过 SQL 计划和事务评审

和单表继承相比,类表继承减少了无关类型的空列,但读取需要 JOIN;和具体表继承相比,它避免复制公共字段,但多态读取不再是简单的单表查询。选择的依据应是实际读写比例和演化证据,不是“规范上更面向对象”。

常见误区

本章小结

类表继承的关键不是“每个类都有一张表”,而是父类行与子类行用同一个主键表示同一对象。共享主键和参照完整性守住身份,事务顺序守住写入与删除,映射器通过 JOIN 把多行恢复成一个对象;多态查询则把额外读取成本明确暴露出来。

因此,公共字段需要唯一来源、具体类型读取占主导且父子写入能保持同一事务时,类表继承是有证据支撑的选择。若多态扫描成为热路径,或父子行跨越无法协调的边界,应把测量结果带回单表继承、具体表继承或读写分离方案,而不是继续把 JOIN 藏在框架默认值后面。

本章练习

练习

问题 1: employees.id = 42,要保存 Engineer { id: 42, name: "Lin", skill: "compiler" }。请写出两张表的插入顺序,并说明为什么不能先写 engineers

问题 2: 下面的查询只返回员工公共字段,怎样把它改成只加载工程师,并保证没有子类行时不伪造一个 Engineer

SELECT e.id, e.name, g.skill
FROM employees e
JOIN engineers g ON g.id = e.id
WHERE e.id = 42;

问题 3: 团队发现“列出全部员工”占 70% 的读取量,而 EngineerManager 已经有 12 张子类表。你会继续扩展类表继承吗?请写出至少两项需要测量的证据。

名词解释

名词解释

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

类表继承

继承层次中的每个类各有一张关系表;子类表用共享主键连接父类表,读取时再 JOIN 成对象。

共享主键

父类表和子类表共同使用的同一个主键值;在子类表中通常同时承担外键作用。

映射器

把对象字段和关系表行互相转换的代码边界,负责加载、写回和错误传播,不替代数据库约束。

参照完整性

外键值必须能在被引用表中找到对应主键的数据库规则,用来阻止孤立的子类行。

多态查询

以父类接口读取多个具体子类实例,并在结果中保留它们具体类型的查询场景。

前后导航

参考资料

资料与写作方式声明

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

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

讨论

评论区加载中…