12.2 外键映射
把对象间关联映射为表中的外键引用,协调对象引用方向与关系约束。
学习目标
- 能解释
orders.customer_id怎样承接Order.customer,并指出两边各自保存什么 - 能修改一个加载与保存函数,处理关联对象、空关联和删除策略,而不把整份对象塞进订单行
- 能回答:当关联可选、需要级联删除或出现循环加载时,为什么要重新划分拥有方
为什么 12.2 外键映射值得单独学习
想象订单编辑页显示“订单 42 属于 Alice”。页面里有两份信息:订单自己的金额与状态,以及 Alice 的姓名。用户只改了金额,系统保存时仍然要找回 Alice;如果把姓名复制进订单行,下一次 Alice 改名就会留下两份互相矛盾的资料。
这章解决的就是“一个对象指向另一个对象”如何落到两张表之间。没有一个稳定的连接点,读取会变成反复拼接数据,删除可能留下孤儿记录,保存也可能把关联对象误当成订单本身的一部分。
先暂时不要记名词:把订单看作一张卡片,把客户看作另一张卡片。订单卡片只需要写下“客户卡片的编号”,而不需要把客户的全部内容复印过来;编号失效时,系统应该在边界处拒绝这次操作。
先从两张清单建立直觉
仓库中每个箱子都有自己的标签,箱子上还可以写“货主标签号”。搬运箱子时,搬运单只记录这个号码,货主信息仍由另一张清单维护。这样,货主改地址不需要打开每个箱子重写;但货主标签被删除时,仓库必须先决定箱子该退回、改成无主,还是一起清理。
这就是本章的判断线:连接点要足够小,能够被约束和索引;拥有状态的一方要明确,才能知道谁负责写入、校验和处理失败。图示实验会把“读出编号”“保存编号”“处理空值与删除”拆开,让每一步都能回到这条判断线。
外键到底保存什么
一列编号,而不是一份嵌套对象
<Term term="外键">外键</Term>是子表中保存另一张表唯一编号的列。在订单例子里,orders.customer_id = 7 表示订单引用 customers.id = 7;它只保存坐标,不复制客户姓名、地址和偏好。对象侧仍然可以暴露 order.customer,但关系侧的真实连接点是一列数字。
这种拆分同时保留了两种好处:客户资料可以独立更新,订单表可以用索引快速筛选;代价是读取订单时可能要多一次查询,保存时还要把对象引用转换成编号。因此,映射器必须明确“什么时候解析引用,什么时候只保留编号”。
由数据库守住连接的有效性
<Term term="参照完整性">参照完整性</Term>是数据库对“子表编号必须能在父表找到”的约束。若 orders.customer_id 写入 99,而 customers.id = 99 不存在,写入就应该失败,而不是让应用稍后才发现一条无法展示的订单。
应用层仍需做更友好的错误翻译:先检查请求中的客户是否属于当前租户,再让数据库执行约束。两层职责不要混成一个隐式行为;应用层回答“这次业务操作是否允许”,数据库回答“这个编号在关系上是否存在”。
谁写入这一列
真正持有编号的那一侧称为 <Term term="拥有方">拥有方</Term>。对 Order.customer 来说,orders.customer_id 所在的订单行是拥有方:保存订单时由它把客户编号写入外键列。Customer.orders 可以是方便导航的反向集合,但它不应偷偷再维护一份不同的编号来源。
如果对象模型同时维护两个方向,更新 order.customer 后必须同步 customer.orders;否则内存图看起来正确,写回时却可能使用旧集合。更稳妥的做法是让一个方法完成两边更新,或明确反向集合只用于读取。
加载、保存与边界策略
加载:先读编号,再决定是否取对象
加载订单时,映射器先得到 customer_id。若调用方需要客户姓名,可以按这个编号取客户;若列表页只需要订单金额,则可以暂不加载客户。也就是说,外键列把“关联存在”与“关联对象的完整内容”分开,查询范围由用例决定。
保存:对象引用要收敛成一个编号
保存时只把 order.customer?.id 写进 orders.customer_id,不要把客户对象序列化进订单行。若客户是新对象,先保存客户并取得正式编号,再保存订单;如果两个对象必须同一事务完成,就由工作单元安排顺序并在失败时回滚。
可选关联用 <Term term="可空外键">可空外键</Term>表达:没有客户的订单把 customer_id 写成 NULL,而不是写入 0、空字符串或一个“未知客户”假编号。读取端也要接受 null,否则数据库允许的状态会在应用层变成异常。
删除与循环不是默认行为
删除父对象时,数据库或映射器需要一条明确的 <Term term="级联规则">级联规则</Term>:限制删除(RESTRICT)、把子行置空(SET NULL),或删除子行(CASCADE)。订单与客户通常不应该因为客户删除就自动抹掉历史订单;业务要求保留历史时,限制删除或迁移到归档客户更安全。
对象图里的 <Term term="反向关联">反向关联</Term>还会带来循环:订单指向客户,客户集合又指回订单。序列化、自动加载和级联遍历若没有停止条件,可能重复查询或无限递归。解决办法是设定聚合边界、只在一侧负责加载,或者在输出 DTO 时显式截断回指。
三步交互实验:让编号沿着边界走
先预测:如果只改对象里的 customer 引用,哪一步会把 7 写进 orders.customer_id?再用步点切换,观察对象图与关系表始终共享同一个编号;最后看空关联与删除策略为什么不能靠默认值猜测。
步骤 1:加载时把 FK 解析为对象引用
动手试:先找到表里的 orders.customer_id = 7,再沿着它查到 customers.id = 7。对象侧得到 Order.customer,但订单仍只拥有自己的字段;这一步的产物是可导航的引用,不是把客户整份复制进订单。
代码对照:让映射边界可测试
下面的 TypeScript 只表达映射契约,不依赖某个 ORM。fromRow 负责从编号恢复对象引用,toRow 负责把引用收敛成一个可空的外键值;真正的 SQL 约束仍应在数据库迁移中声明。
type CustomerRef = { id: number; name: string };
type Order = { id: number; customer: CustomerRef | null; totalCents: number };
type OrderRow = { id: number; customer_id: number | null; total_cents: number };
function fromRow(row: OrderRow, customer: CustomerRef | null): Order {
if (row.customer_id !== null && customer?.id !== row.customer_id) {
throw new Error("customer reference does not match foreign key");
}
return { id: row.id, customer, totalCents: row.total_cents };
}
function toRow(order: Order): OrderRow {
return {
id: order.id,
customer_id: order.customer?.id ?? null,
total_cents: order.totalCents,
};
}这个边界有三个可验收断言:客户编号不一致时拒绝构造对象;空引用只写 NULL;保存订单不改变客户行。若客户是新建对象,应先完成客户插入,再调用 toRow,而不是用临时编号绕过参照完整性。
常见误区
选择与拒绝矩阵
| 评审问题 | 选择外键映射的证据 | 应拒绝或改用其他方案的信号 |
|---|---|---|
| 连接点 | 子表有稳定、可索引的父表编号 | 只能靠姓名、地址等会变化的字段拼接 |
| 拥有方 | 只有一侧负责写入 FK,反向集合职责清楚 | 两边都能独立改关系,最后写入者胜出 |
| 可选关系 | NULL 的语义、查询和约束都有测试 | 用 0、空字符串或“未知”伪造关联 |
| 删除 | 历史保留要求与级联规则相互匹配 | 框架默认级联会删除不该删除的对象 |
| 规模 | 关联加载范围和索引由用例决定 | 一次导航隐式拉出整个对象图 |
如果关系本身有独立属性、需要排序或频繁增删,多对多场景应比较关联表映射;如果对象从属于父对象且不能独立存在,也应比较依赖映射。外键映射的适用条件不是“有引用就用”,而是“一个可控编号足以表达关系,并且边界责任有人维护”。
本章小结
- 外键列保存目标行的编号,不复制关联对象。
- 加载把编号解析为引用,保存把引用收敛回编号。
- 拥有方负责写 FK,反向关联不能偷偷成为第二个真相源。
- 空关联、删除级联和循环加载都必须显式定义。
- 评审时同时检查索引、约束、查询范围和失败传播。
本章练习
练习
问题 1: orders.customer_id = 7,但请求同时带来了 Customer { id: 8, name: "Alice" }。加载函数应该接受还是拒绝?请说明理由。
问题 2: 改上面的 toRow:让没有客户的订单写入 NULL,让有客户但编号缺失的对象直接失败。验收时至少写出两个断言。
问题 3: 客户历史必须保留,但业务允许删除不再活跃的客户。你会选择 RESTRICT、SET NULL 还是 CASCADE?还需要补哪一项测试?
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 外键
子表中指向另一张表唯一编号的列;它像一张小纸条,只记录“要找哪一行”。
- 参照完整性
数据库保证外键编号确实能找到目标行的规则,避免关系指向不存在的对象。
- 拥有方
负责把关联编号写入外键列的一侧;本章中是带有 customer_id 的订单行。
- 可空外键
允许没有关联时写入 NULL 的外键列,区别于用 0 或假编号伪造关系。
- 级联规则
父行变化时如何处理子行的明确策略,例如限制删除、置空或连带删除。
- 反向关联
从被引用对象回看引用它的对象集合,例如 Customer.orders;它不应自动成为第二个写入来源。
参考资料
- Martin Fowler 作者图书页:核对全书主题、模式族与章节范围。
- Martin Fowler 模式目录:核对 Foreign Key Mapping 的公开模式摘要。
- Pearson 出版社页面:交叉核对英文版出版信息与目录。