12.1 标识字段
在对象中保存数据库标识字段,维持内存对象与关系行之间可追踪的身份对应。
学习目标
- 能解释标识字段如何把内存对象与数据库关系行对应起来,并区分身份相等和值相等
- 能修改一个新订单的持久化代码,为未写回对象安排临时身份,并在提交后保持引用稳定
- 能回答:业务名称会变化时,为什么不应该直接把自然键当作对象的长期身份
为什么 12.1 标识字段值得单独学习
想象一个订单编辑页:页面先读到一份订单,用户改了状态,保存动作又从数据库读了一次同一订单。两份资料的金额可能碰巧一样,但程序仍必须知道它们是不是同一个对象,以及哪一份修改应该被写回。
如果系统只把一组字段当成普通数据,就会在三个时刻失去方向:读出一行时不知道它对应哪个对象;新对象还没有数据库编号时无法安全追踪;提交成功后如果换掉整个对象,其他引用仍然指向旧的那份状态。本章的核心是把这条身份生命周期画出来,并把它写成可检查的代码边界。
先建立直觉:一张行李牌
先猜一猜:机场给两件外观相同的行李挂上同一个空白标签,工作人员能在传送带上稳定地把它们送回各自的主人吗?外观相同只说明“内容看起来一样”,不能说明“它就是那一件”。一张不会随内容变化的行李牌,才让每一次交接都能回到同一件行李。
对象也需要这样的牌子。金额、状态和客户都可能改变,但身份坐标应该能让加载、关联、更新和写回回到同一个对象;没有它,代码只能不断比较全部字段,或者把两份对象误当成同一份。
核心模型:身份不是值
标识字段把两边接上
<Term term="对象身份">对象身份</Term>回答“内存中的这一份对象究竟是哪一份”。它和字段值是否相等是两件事:两个订单都可能是 199 元,但它们仍然可以代表两行不同的订单。
<Term term="标识字段">标识字段</Term>是对象中专门保存身份坐标的字段。映射器加载 orders.id = 42 时,把 42 放进对象的 id;之后更新、关联和写回都可以沿着这条坐标找到原来的关系行,而不必把所有业务字段拼成比较条件。
关系表中的 <Term term="主键">主键</Term>约束每一行的唯一性。标识字段通常保存这个值,但二者的职责仍不同:主键是数据库的约束,标识字段是对象模型中可被程序携带和传递的身份入口。两边对同一坐标的约定,才是可追踪的映射。
新对象要先有可追踪身份
新订单在插入数据库前还没有正式主键。此时使用 <Term term="临时身份">临时身份</Term>,例如 temp-7,可以让内存中的两个新对象先彼此区分;它不是最终会落库的编号,也不能被当作外部 API 的永久 ID。
写回时不要新建一个“已经有 id 的替代对象”再把旧对象丢掉。让映射层把数据库生成的编号写回原对象,所有持有原引用的服务、集合和界面才能继续观察同一份状态。这样,持久化动作只改变身份字段,不改变对象的归属。
选择可长期使用的身份策略
当邮箱、订单号或用户名会被业务修改时,它们属于 <Term term="自然键">自然键</Term>:能描述业务,但不一定适合做长期对象坐标。数据库生成的 <Term term="代理键">代理键</Term>只承担识别职责,通常更稳定;代价是系统需要额外保存业务唯一性约束,不能因为有代理键就放弃校验重复订单号。
因此,身份字段的评审问题不是“哪个字段最像订单”,而是“哪个坐标能在加载、修改和写回之间保持稳定”。业务字段负责表达业务,身份字段负责让对象回到正确的关系行。
三步交互实验:从加载到写回
动手试:先看当前步的对象 ID 和关系行 ID,再用下方的步点或进度条切换。观察变化:对象的业务字段不变,但身份从已持久化、待持久化到写回完成;注意第三步没有替换对象引用。
步骤 1:用主键找到同一行
先预测:如果加载 orders.id = 42,对象的哪一个字段应该保存 42?图中两侧的身份坐标相同,更新状态时只需沿标识字段回到这一行,不需要比较金额、客户和状态的全部组合。
代码对照:把身份边界写出来
下面的 TypeScript 只表达身份生命周期,不依赖某个 ORM。关键点是 id 可以暂时为空或带有本地前缀,但 insert 返回的主键要写回原对象:
type Order = { id: number | `temp-${number}` | null; amount: number; status: string };
const draft: Order = { id: "temp-7", amount: 199, status: "pending" };
const sameReference = draft;
const row = await orders.insert({ amount: draft.amount, status: draft.status });
draft.id = row.id; // 写回同一对象,而不是 new Order(...)
console.assert(sameReference === draft);这段代码没有规定主键一定自增,也没有把临时值写进关系表;它只规定了一个可测试的契约:提交后原引用仍然指向原对象,而对象的身份字段已经能定位持久化行。
常见误区
选择与拒绝矩阵
| 评审问题 | 选择标识字段的证据 | 应拒绝或改用其他方案的信号 |
|---|---|---|
| 身份 | 一个字段能稳定定位一行 | 只能靠全部业务字段拼接判断 |
| 新对象 | 临时值可区分对象且不会对外发布 | 空值让多个新对象相撞 |
| 写回 | 数据库主键写回原引用 | 保存后替换对象导致引用分叉 |
| 业务变化 | 自然键变化不影响对象坐标 | 把会改名的业务字段当长期主键 |
如果团队把身份字段误用成全局缓存、关联加载器或事务协调器,应回到相应模式重新划分职责。标识字段只负责“这是谁”,不负责“何时加载”“如何提交”或“业务是否允许修改”。
本章小结
- 标识字段把对象身份坐标带进内存,让对象能回到正确的关系行。
- 数据库主键与对象身份职责不同,但可以通过同一坐标建立对应。
- 新对象先使用临时身份,写回成功后把正式主键放回原对象。
- 业务会变化的自然键不适合作为长期身份,代理键需要配合业务唯一性约束。
- 评审时要同时检查加载、临时身份、写回引用和业务变化四个边界。
本章练习
练习
问题 1: 两个订单的 amount、customer 和 status 全部相同,但数据库 ID 分别是 42 和 43。为什么不能把它们合并成一个对象?
问题 2: 改 Demo 代码:让两个新订单在插入前都拥有可区分的临时身份,并让数据库返回的 43 写回原对象。验收时应检查什么?
问题 3: 订单号可以被客服修改,但邮箱在业务上要求唯一。应该把哪一个直接当作长期对象身份?为什么?
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 对象身份
- 回答“内存中的这一份对象是哪一份”的稳定坐标,不等同于字段值完全相同。
- 标识字段
- 对象中专门保存身份坐标的字段,通常与数据库主键对应。
- 主键
- 数据库用来唯一定位一行的约束值;它属于关系表一侧。
- 临时身份
- 对象还没有正式数据库编号时,用来区分内存对象的本地值。
- 自然键
- 来自业务含义的字段组合,例如订单号或邮箱;它可能变化,所以未必适合做长期身份。
- 代理键
- 不承担业务含义、只负责稳定识别一行的数据库生成编号。
参考资料
- Martin Fowler 作者图书页:核对全书主题与章节边界。
- Martin Fowler 模式目录:核对 Identity Field 的公开模式摘要。
- Pearson 出版社页面:交叉核对英文版书目信息。