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。评审时记录五个问题:对象代表哪一行,行为是否只依赖该对象,脏状态何时产生,事务由谁开启,并发冲突如何返回。若答案只能引用某个框架的默认配置,就说明模式语义还没有被说清楚。

专属设计案例:订单聚合持久化

案例对象 OrderidcustomerIdamountstatusversion 五个关键字段。它可以执行与订单自身紧密相关的 applyCoupon()cancel()save();支付、库存与配送由外层用例协调,不通过订单对象的 getter 偷偷触发。

设计记录把一次保存拆成五个观察点:领域调用提出意图,订单对象检查自身状态,数据边界绑定身份与版本,持久化入口执行条件更新,应用流程处理成功或冲突。观测项包括对象身份、脏状态、事务边界、版本条件和测试替身;拒绝条件是“订单对象开始拥有跨聚合写入顺序或无法回滚的外部副作用”。

模式结构图

Active Record:对象 = 行 + 业务逻辑数据行id=42 · version=7amount=100映射Order · 对象边界id: 42 · version: 7amount: 100+ save(expectedVersion)+ delete()+ applyCoupon(code)+ cancel()字段 + 局部行为 + 明确写回写回结果success / error由调用者处理活动记录 = 行数据入口 + 与该行紧密相关的领域行为专属观察轴:身份 → 脏状态 → 版本条件 → 写回结果
字段 + 局部行为 + 明确写回;对象既是数据容器又是行为载体,适合边界清楚的简单领域。

图中的 Order 同时显示字段、局部行为和保存入口;这不是“所有业务都应放进实体”的许可,而是一个边界提醒:只有与这一行数据强相关、能在对象内解释的行为才适合靠近它。

用三个阶段检查活动记录的生命周期

分步1 / 3

先加载并绑定这一行

先预测:查询结果带回订单 ID 与版本后,为什么还要记录对象身份?把数据库行映射到一个 Order 实例,核对同一工作单元是否复用了相同 ID 的实例,并确认读取动作没有触发写回。

Active Record:对象 = 行 + 业务逻辑数据行id=42 · version=7amount=100映射Order · 1 · 加载身份id: 42 · version: 7amount: 100+ save(expectedVersion)+ delete()+ applyCoupon(code)+ cancel()数据库行 → 一个 Order 实例写回结果success / error由调用者处理加载阶段:身份先绑定,读取不产生写回专属观察轴:身份 → 脏状态 → 版本条件 → 写回结果
数据库行 → 一个 Order 实例;对象既是数据容器又是行为载体,适合边界清楚的简单领域。

常见误区

选择与拒绝矩阵

评审问题可以选择活动记录的证据应拒绝或准备迁移的信号
责任字段、局部行为和单行写回的所有者清楚对象编排支付、库存和配送等跨聚合流程
身份工作单元中的实例复用范围可说明同一 ID 产生多个无协调副本
写回脏状态、保存时机和影响列可观察getter、setter 或析构动作隐藏数据库写入
并发版本条件和冲突结果进入接口最后保存者无条件覆盖旧版本
复杂度领域规则主要围绕这一行数据关联、批量查询和事务补偿不断扩张

本章小结

  • 活动记录把一行数据与局部领域行为放在同一对象中。
  • 对象身份、脏状态和版本条件必须是可观察的生命周期信息。
  • save() 负责写回,不应偷偷承担跨聚合的业务编排。
  • 事务边界需要与失败、重试和冲突处理一起被明确表达。

本章练习

练习

问题 1: 订单金额修改后还要执行库存校验。为什么不应让属性 setter 立即写入数据库?

问题 2: 请把下面的伪代码改成“版本冲突可见”的保存条件,并说明更新影响 0 行时的返回值:

function save(order) {
  return db.update("orders", order.id, order.fields);
}

问题 3: 一个下单流程要同时写订单、扣减库存并调用支付。应不应该把三个动作都加到 Order.save() 中?请给出替代设计。

前后导航

出处声明

名词解释

名词解释

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

活动记录

让一个对象代表一行数据,并承担与这行数据紧密相关的读写和局部行为。

对象身份

规定同一个数据标识在一个内存范围内应对应哪些对象实例。

脏状态

对象已经改变但尚未把变化提交到数据源的状态或变更记录。

事务边界

规定一组读写应当一起提交或一起回滚的范围。

乐观并发

保存时带上旧版本条件,发现他人修改后报告冲突而不是静默覆盖。

讨论

评论区加载中…