12.3 关联表映射
用包含两侧外键的关联表保存对象关系,适合多对多或关联本身没有独立对象的情况。
学习目标
- 能解释多对多对象关系为什么需要单独的关联表,并把对象集合映射到两列外键
- 能修改一段持久化代码,为关联行加入复合唯一约束、事务边界和幂等删除
- 能回答:关联行出现数量、顺序或状态后,什么时候应把它提升为独立的关联对象
为什么 12.3 关联表映射值得单独学习
想象一个订单编辑页:一张订单可以包含很多商品,同一种商品也会出现在很多订单里。页面上看到的是两个列表,保存时却要把每一次“加入”或“移除”记录下来;如果只保存订单和商品本身,两边的连接就会丢失。
本章解决的问题是:怎样把这份连接保存下来,又不把端点对象的生命周期混在一起。没有清晰的连接记录,重复点击可能产生重复商品,删除订单时也可能误删商品;查询结果看似正确,下一次更新却无法判断哪一条关系应该被改动。
先建立直觉:一张独立的关系清单
先猜一猜:同一件商品被两个订单购买时,应该在商品卡片上写两个订单编号,还是单独维护一张“谁和谁相连”的清单?清单让两端保持独立,增加或删除一条连接只改清单中的一行。
这张清单还有一个好处:它可以拒绝重复连接,并规定删除顺序。端点对象仍然是订单和商品,清单只表达“二者当前相连”这一事实;如果清单后来需要保存数量、成交价或履约状态,就要重新评估它是否已经成为一个有自己行为的对象。
核心模型:对象集合与关系行一一对应
多对多不是两列互相复制
<Term def="两类对象中的任意一个,都可以和另一类中的多个对象建立关系">多对多关系</Term>描述的是数量关系:一个订单有多个商品,一个商品也能被多个订单引用。对象模型通常写成 order.products 与 product.orders 两个集合,但关系数据库不能把一列直接装成另一个表的多个主键。
因此需要 <Term def="只保存两端标识及关系属性的独立表,用一行表示一条连接">关联表</Term>。每一行代表一个事实,例如 (order_id=42, product_id=7);读集合时查询这些行,写集合时新增或删除这些行,而不是复制端点记录。
两个外键保护端点身份
关联表中的 <Term def="指向另一张表唯一记录的列,用来阻止关系指向不存在的端点">外键</Term>分别指向 orders.id 和 products.id。它们表达的是“这条连接指向谁”,不是“谁拥有谁”。订单删除时,可以先删除关联行再删除订单;商品表不会因为某条连接消失而被级联删除。
关系行常用两端标识组成 <Term def="由两个或多个列共同组成唯一身份的键,在本例中是 order_id 与 product_id 的组合">复合主键</Term>。即使数据库不采用复合主键,也至少要有同样语义的唯一约束;否则两次相同的加入操作会生成两行,读取集合时就出现重复商品。
关系属性决定是否升级模型
如果连接只有“存在或不存在”两个状态,关联表可以保持很薄。若 order_product 还要保存数量、成交价、折扣快照或履约状态,这些字段就不再是端点的属性,而是连接本身的事实。此时可以把一行包装成 <Term def="拥有自己的属性、身份或行为,值得在对象模型中独立表示的关系记录">关联对象</Term>,例如 OrderLine,并由它校验数量和价格快照。
这不是“表多了就必须建类”的规则。判断标准是关系是否需要独立的校验、审计、事件或生命周期;若只有两个外键,薄关联表更直白,若关系属性会被业务单独读取和修改,提升为关联对象能避免把规则散落在订单和商品两边。
三个设计裁决:约束、边界与写回
约束先于 ORM 配置
先写数据库不变量,再写映射配置:order_id 和 product_id 都必须存在;两列组合必须唯一;删除策略要明确是先删关系行还是由数据库级联删关系行。ORM 的 connect()、disconnect() 或集合赋值只是这些语义的另一种调用方式,不能替代约束本身。
一个实用的评审卡可以只问三句:重复请求再次执行会不会多一行?端点删除会不会留下悬空外键?两个并发请求同时改变同一集合时,最后的集合是否仍符合业务不变量?答不出来时,先不要把问题归咎于查询 API。
聚合边界决定谁能改关系
<Term def="规定哪些对象和状态必须一起保持一致的业务边界">聚合边界</Term>决定了谁拥有增删关系的裁决权。例如订单可以决定“是否允许加入商品”,但商品对象不应绕过订单的折扣和库存规则直接写 order_product。数据库表是共享存储,不能自动替代领域上的所有权。
在订单聚合内,保存一次加入通常包含校验、写关联行和记录审计三件事,适合放在同一个事务里。若关系同时被多个聚合独立维护,应通过明确的命令或服务协调,而不是让两边各自读取集合后全量覆盖,后者很容易把并发更新抹掉。
增删关系要可重试
新增关系使用两端 ID 和唯一约束实现幂等:第一次插入成功,重复请求得到“已存在”或安全忽略。删除关系只删除 order_product 的目标行,不删除 orders 或 products 端点;批量替换集合则必须携带版本、锁或明确的差异集合,否则旧页面可能覆盖新页面的加入动作。
三步交互实验:从对象图到安全写回
动手试:按下方步点切换,观察同一份订单—商品关系如何从对象集合落到关系行,再变成可重试的写操作。每一步的图都会保留端点对象,只有当前裁决被高亮。
步骤 1:把两个集合之间的连接单独列出
先预测:如果订单 42 同时包含商品 7 和 9,关系表需要几行?答案是两行,每行只记录一对端点 ID;订单和商品本身仍然各自只有一条对象记录。
代码对照:差异写入比全量覆盖更安全
下面的 TypeScript 只表达持久化边界,不绑定某个 ORM。add 依靠唯一约束保证幂等,remove 用两端 ID 定位关系行,绝不把端点对象当作级联删除目标:
type Link = { orderId: number; productId: number };
async function addProduct(orderId: number, productId: number) {
await db.orderProduct.insert(
{ orderId, productId },
{ onConflict: "ignore" },
);
}
async function removeProduct(orderId: number, productId: number) {
await db.transaction(async (tx) => {
await tx.orderProduct.delete({ where: { orderId, productId } });
});
}代码中的 onConflict: "ignore" 只是意图示例,实际 API 可能叫 upsert 或 ON CONFLICT DO NOTHING。可迁移的契约是:唯一性由数据库守住,删除条件同时包含两端 ID,事务范围覆盖这一次关系变更。
如果关系行需要数量或价格快照,查询与写回就不应再把它当成无属性连接。下面的 SQL 展示最小表结构;quantity 出现后,可以把它映射成 OrderLine,由领域对象负责数量大于零等规则:
CREATE TABLE order_product (
order_id BIGINT NOT NULL REFERENCES orders(id),
product_id BIGINT NOT NULL REFERENCES products(id),
quantity INTEGER NOT NULL CHECK (quantity > 0),
PRIMARY KEY (order_id, product_id)
);这里的主键仍然保证同一订单和商品只有一行;若业务允许同一商品分成多条批次行,键就必须加入批次号,或者直接采用独立的 order_line_id。键的变化不是数据库细节,而是对“这条关系是否唯一”的业务声明。
常见误区
选择与拒绝矩阵
| 评审问题 | 可以选择关联表映射的证据 | 应拒绝或升级为其他映射的信号 |
|---|---|---|
| 关系形态 | 两端都可引用多个对方对象 | 实际是一对多,且从属端不能独立存在 |
| 行的含义 | 一行只表达“两个端点相连” | 行有独立状态、审计、价格或履约行为 |
| 唯一性 | 两端组合能定义一条唯一连接 | 同一端点对允许多条有序或分批记录 |
| 写回方式 | add/remove 差异可在事务内幂等执行 | 只能全量覆盖集合,无法检测过期快照 |
| 删除语义 | 删除关系不删除端点 | ORM 级联配置会把端点一起销毁 |
如果关系属性逐渐增多,不要因为已经有 order_product 表就继续把所有逻辑塞进订单模型。可以保留表名,通过 OrderLine 等关联对象收拢校验与行为;反过来,如果关系始终只有两个外键,也不要为了“面向对象完整”而凭空制造一个没有职责的类。
本章小结
- 多对多关系用关联表的一行表达一对端点,而不是复制端点对象。
- 外键保护端点存在性,复合唯一约束保护关系不重复。
- 聚合边界决定谁有权修改关系;数据库约束不能替代业务裁决。
- add/remove 差异、事务和版本检查让关系写回可重试、可审计。
- 关系有独立属性或生命周期时,应评估是否提升为关联对象。
本章练习
练习
问题 1: orders 与 products 是多对多关系。为什么不能在 orders 表里增加 product_ids 字符串列?请写出关联表至少需要的两列以及各自的指向。
问题 2: 改写上面的 addProduct:重复点击加入按钮不能产生第二条关系;移除商品 7 时不能删除商品记录。你会把哪些条件放进数据库和事务?
问题 3: order_product 新增 quantity、unit_price 和 fulfillment_status,还要记录价格快照。仍把它当成两个集合之间的薄连接是否合适?为什么?
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 多对多关系
两边都可以连接多个对方对象,例如一个订单有多个商品,而一个商品也出现在多个订单中。
- 关联表
专门记录“谁和谁相连”的表;通常一行就是一对端点的 ID。
- 外键
表里指向另一张表唯一记录的列,用来阻止关系连到不存在的对象。
- 复合主键
由多列一起组成的唯一身份;这里用订单 ID 加商品 ID 标识一条连接。
- 聚合边界
规定哪些对象要一起遵守业务规则,以及谁有权改变它们状态的边界。
- 关联对象
关系本身拥有属性或行为时,在对象模型中单独表示的那条关系记录。
前后导航
参考资料
- Martin Fowler 作者图书页:核对全书主题、教程与模式参考结构。
- Martin Fowler 模式目录:核对 Association Table Mapping 的作者公开模式摘要。
- Pearson 出版社页面:交叉核对英文版出版信息与目录范围。