10.3 活动记录
让领域对象包装一行数据,同时承担数据库访问和与该数据紧密相关的领域逻辑。
学习目标
- 能解释活动记录怎样把一行数据、持久化入口和局部领域行为放进同一个对象,并说出它的责任边界
- 能设计订单对象的加载、修改、校验与保存流程,用脏状态、事务边界和版本条件定位失败
- 能回答:当一次用例跨越多个对象和多张表时,为什么应拒绝继续给活动记录添加隐式协作?
为什么 10.3 活动记录 值得单独学习
想象一张订单卡:它能显示自己的编号和金额,也能执行“申请折扣”或“取消订单”。如果这张卡还会在任何字段变化时悄悄把结果写回存储,调用者就很难知道一次点击到底改变了什么、什么时候失败、失败后能否撤回。
本章解决的就是这条边界:让一张订单卡足够方便地完成简单读写,同时把身份、写回时机和业务规则的责任摆到明面上。没有这条边界,系统会把数据库副作用藏进普通对象操作,最后只能靠日志猜测状态是在哪里分叉的。
本文以 2024 年中文版公开目录限定 10.3 活动记录 的范围,依据 Martin Fowler 的作者图书页和模式目录独立重写。这里的订单案例、判断练习与阶段图均为课程原创,不复现原书正文、插图或代码。
先建立直觉:一张订单卡要负责多少事
先看一个不带术语的判断:如果订单卡只保存自己的编号、金额和客户编号,它像一张可编辑的记录卡;如果它还能执行与订单紧密相关的折扣校验,它又像一个小型业务对象。两种能力放在一起很顺手,但“顺手”不等于可以接管整个订单流程。
先预测:订单跨越支付、库存和配送时,是否应该继续把这些调用都塞进订单对象?如果一个动作需要同时协调多个对象、多个写入和一个共同的失败出口,便利的单对象入口就可能变成隐藏的流程编排器。
概念模型:数据与行为共居,但边界仍然可见
在本章的语言里,↡把一行数据包装成对象,并让对象承担与这行数据紧密相关的读写和局部行为是“行数据 + 持久化操作 + 局部领域行为”的组合。Order 可以暴露 save()、delete() 和金额校验,但它不因此自动拥有支付服务、库存锁或整条订单工作流。
它适合简单领域:对象字段与表列大致对应,行为主要围绕这一个对象展开,读写失败也能在同一用例内解释。它的代价是对象同时知道内存状态和数据源,测试需要分辨“业务规则失败”与“写回失败”,调用者也要防止把一个方便入口扩展成全局依赖中心。
对象身份不是数据库主键的同义词
一个订单 ID 可以在数据库里唯一,却在内存中出现两个互不知情的 Order。↡规定同一数据标识在一个内存范围内应对应哪些对象实例及其生命周期解决的是后一个问题:在一次工作单元中,同一个 ID 是否只能对应一个实例;跨请求重新加载时,又是否允许形成新的快照。
身份范围越大,重复实例越少,但缓存失效、内存占用和并发冲突越难处理。活动记录不应假装身份问题已经由数据库主键自动解决;它必须明确加载入口、复用范围和实例失效时机。
修改、校验与写回应当分成三个动作
订单金额从 100 改成 80 时,内存对象先记录新值,业务校验再决定能否提交,最后才产生明确的写回。这个中间状态就是↡对象已经改变但尚未把变化提交到数据源的状态:它可以是布尔标记,也可以是变更字段集合,但必须能被测试观察到。
把 setter 直接连接到数据库,会让每一次局部编辑都产生副作用;把脏状态完全藏起来,又会让调用者无法判断 save() 是否真的有变化。一个清楚的接口应说明:谁触发保存、保存影响哪些列、更新失败返回什么,以及失败后对象还能否重试。
事务边界决定“一个动作”是否真的完整
↡规定一组读写作为一个整体提交或回滚的范围不是
save()
方法的别名。单个订单对象可以在自己的边界内写回,但支付扣款、库存预留和订单状态变化若必须同成同败,就需要更高层的协作者打开事务并协调多个对象。
这也是活动记录的拒绝信号:如果为了完成一个用例,订单对象必须知道其他聚合的保存顺序、重试策略和补偿动作,它已经不再只是数据行的行为载体。此时可以把对象保留为简单入口,把协作上移到服务层、工作单元或显式事务脚本。
并发版本要成为保存合同的一部分
两个页面都加载订单版本 7 时,先保存者把版本写成 8,后保存者不能只凭对象仍然存在就覆盖结果。↡保存时携带旧版本条件,发现他人已修改就报告冲突的并发策略把这件事写成可检查的合同:更新必须带上期望版本,影响行数为 0 时返回冲突,应用层再决定重载、合并还是放弃。
活动记录可以封装版本条件,但不能替业务决定冲突合并。把版本检查藏在 ORM 默认行为里,会让调用者看不见失败原因;把冲突当成功返回,则会让“最后写入者获胜”伪装成正常流程。
目录单元到教学证据
10.3 活动记录
在 10.3 活动记录 的学习边界内,学习者要能画出“加载 → 修改 → 校验 → 保存”的生命周期,并解释对象数据、局部行为和写回入口分别承担什么责任。核心证据不是对象上有几个方法,而是一次保存能指出身份、版本和失败结果如何被观察。
本页的单元键为 poeaa24-pattern-07-active-record。评审时记录五个问题:对象代表哪一行,行为是否只依赖该对象,脏状态何时产生,事务由谁开启,并发冲突如何返回。若答案只能引用某个框架的默认配置,就说明模式语义还没有被说清楚。
专属设计案例:订单聚合持久化
案例对象 Order 有 id、customerId、amount、status 和 version 五个关键字段。它可以执行与订单自身紧密相关的 applyCoupon()、cancel() 和 save();支付、库存与配送由外层用例协调,不通过订单对象的 getter 偷偷触发。
设计记录把一次保存拆成五个观察点:领域调用提出意图,订单对象检查自身状态,数据边界绑定身份与版本,持久化入口执行条件更新,应用流程处理成功或冲突。观测项包括对象身份、脏状态、事务边界、版本条件和测试替身;拒绝条件是“订单对象开始拥有跨聚合写入顺序或无法回滚的外部副作用”。
模式结构图
图中的 Order 同时显示字段、局部行为和保存入口;这不是“所有业务都应放进实体”的许可,而是一个边界提醒:只有与这一行数据强相关、能在对象内解释的行为才适合靠近它。
用三个阶段检查活动记录的生命周期
先加载并绑定这一行
先预测:查询结果带回订单 ID 与版本后,为什么还要记录对象身份?把数据库行映射到一个 Order 实例,核对同一工作单元是否复用了相同 ID 的实例,并确认读取动作没有触发写回。
常见误区
选择与拒绝矩阵
| 评审问题 | 可以选择活动记录的证据 | 应拒绝或准备迁移的信号 |
|---|---|---|
| 责任 | 字段、局部行为和单行写回的所有者清楚 | 对象编排支付、库存和配送等跨聚合流程 |
| 身份 | 工作单元中的实例复用范围可说明 | 同一 ID 产生多个无协调副本 |
| 写回 | 脏状态、保存时机和影响列可观察 | getter、setter 或析构动作隐藏数据库写入 |
| 并发 | 版本条件和冲突结果进入接口 | 最后保存者无条件覆盖旧版本 |
| 复杂度 | 领域规则主要围绕这一行数据 | 关联、批量查询和事务补偿不断扩张 |
本章小结
- 活动记录把一行数据与局部领域行为放在同一对象中。
- 对象身份、脏状态和版本条件必须是可观察的生命周期信息。
save()负责写回,不应偷偷承担跨聚合的业务编排。- 事务边界需要与失败、重试和冲突处理一起被明确表达。
本章练习
练习
问题 1: 订单金额修改后还要执行库存校验。为什么不应让属性 setter 立即写入数据库?
问题 2: 请把下面的伪代码改成“版本冲突可见”的保存条件,并说明更新影响 0 行时的返回值:
function save(order) {
return db.update("orders", order.id, order.fields);
}问题 3: 一个下单流程要同时写订单、扣减库存并调用支付。应不应该把三个动作都加到 Order.save() 中?请给出替代设计。
前后导航
出处声明
名词解释
名词解释
本章出现的专业名词,用大白话再讲一遍。
- 活动记录
让一个对象代表一行数据,并承担与这行数据紧密相关的读写和局部行为。
- 对象身份
规定同一个数据标识在一个内存范围内应对应哪些对象实例。
- 脏状态
对象已经改变但尚未把变化提交到数据源的状态或变更记录。
- 事务边界
规定一组读写应当一起提交或一起回滚的范围。
- 乐观并发
保存时带上旧版本条件,发现他人修改后报告冲突而不是静默覆盖。