mcpField
A
active-record-callbacks

active-record-callbacks

github.com/thinkoodle开发7188 次安装8 周前更新
代码仓库

关于 active-record-callbacks

帮助 Rails 开发者理解和使用 Active Record 回调的技能,涵盖生命周期、回调选择与常见陷阱。重点区分模型自身逻辑与应放在服务对象中的业务流程,并说明事务提交后的副作用处理。

主要功能

  1. 01判断逻辑是否适合回调
  2. 02梳理创建、更新和销毁顺序
  3. 03选择合适的回调钩子
  4. 04安全处理事务提交后的副作用
  5. 05控制回调链及跳过回调

适用场景

  1. 01添加或修改模型回调
  2. 02排查回调顺序和行为问题
  3. 03将事务外副作用安排在提交后
  4. 04决定逻辑应放入回调还是服务对象

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/thinkoodle/rails-skills --skill active-record-callbacks

安装后在 Claude Code 中输入/active-record-callbacks确认已加载。

附带文件reference.md

详细介绍

Active Record 回调

挂接到 Active Record 对象的生命周期中——但仅在确实适合使用时才这样做。

何时使用此技能

  • 添加或修改模型回调(before_save、after_create、after_commit 等)
  • 判断逻辑应该放在回调中,还是服务对象中
  • 调试意外的回调行为或执行顺序问题
  • 实现事务安全的副作用(after_commit 模式)
  • 中止回调链或抑制回调

黄金法则

回调只用于模型固有的关注点。 如果这段逻辑不适合作为列默认值或数据库约束,它可能就不应该放在回调中。

✅ 适合使用回调的场景
  • 设置默认值 — before_validation :set_default_role
  • 规范化数据 — before_save :strip_whitespace、before_save :downcase_email
  • 派生值 — before_save :calculate_total_from_line_items
  • 生成令牌或 slug — before_create :generate_uuid
  • 维护计数器缓存 — Rails 内置功能不够用时
  • 更新时间戳 — 简单传播 updated_at
  • 清理自有资源 — after_destroy_commit :delete_file_from_storage
❌ 不要使用回调的场景
  • 发送电子邮件 → 服务对象,或至少使用 after_commit + 作业
  • 调用外部 API → 服务对象
  • 创建其他记录 → 服务对象
  • 复杂业务逻辑 → 服务对象
  • 依赖上下文的逻辑 → 服务对象(回调不知道保存操作为何发生)
  • 加入作业队列 → 使用 after_commit,绝不要使用 after_save(数据可能尚未提交)
  • 授权检查 → 控制器/策略层
  • 记录业务事件 → 服务对象或 after_commit
为什么这很重要

回调是 Rails 代码变得混乱的头号根源。 它们会:

  • 在每次保存时运行,即使你并不希望它们运行
  • 造成隐式耦合(保存 User 竟然会发送电子邮件?!)
  • 让调试变得极其困难(回调链很难追踪)
  • 在批量操作中失效(update_all 会完全跳过它们)
  • 导致测试脆弱(每个保存记录的测试都会触发整条回调链)

拿不准时,就用服务对象。 如果逻辑确实是模型固有的,之后总可以将其提取到回调中。要从回调中移出逻辑则困难得多。

生命周期顺序

创建记录
before_validation
after_validation
before_save
around_save
  before_create
  around_create
    [INSERT]
  around_create (after yield)
  after_create
around_save (after yield)
after_save
after_commit / after_rollback
更新记录
before_validation
after_validation
before_save
around_save
  before_update
  around_update
    [UPDATE]
  around_update (after yield)
  after_update
around_save (after yield)
after_save
after_commit / after_rollback
销毁记录
before_destroy
around_destroy
  [DELETE]
around_destroy (after yield)
after_destroy
after_commit / after_rollback

重要提示: before_save/after_save 会同时包裹创建和更新操作。它们总是在更具体的 before_create/after_create 或 before_update/after_update 之前/之后运行。

说明

步骤 1:问问自己“这应该是回调吗?”

应用固有性测试:无论记录在何处、出于何种原因保存,这段逻辑都需要运行吗?

  • 规范化电子邮件地址?是 — 始终转换为小写 → 回调
  • 发送欢迎邮件?否 — 仅在注册时发送,而不是管理员编辑时 → 服务对象
  • 设置 UUID?是 — 始终需要一个 → 回调
  • 通知 Slack?否 — 取决于上下文 → 服务对象

如果答案涉及“仅在……时”或“除非……时”,那它很可能不应该是回调。条件回调(if:/unless:)是一种代码异味,表明这段逻辑不属于这里。

步骤 2:选择合适的回调
需求回调原因
在验证前设置默认值before_validation验证时可以使用这些值
规范化/派生数据before_save创建和更新时都会运行
仅在首次创建时设置值before_create更新时不会运行
在数据库写入后(事务内)作出响应after_save / after_create / after_update数据已保存,但事务仍可能回滚
在数据库提交后(安全地)作出响应after_commit / after_create_commit事务已提交,可安全地执行作业、调用外部服务
删除时清理after_destroy_commit记录已被彻底删除
阻止保存/销毁before_save / before_destroy + throw :abort中止回调链

关键规则: 如果回调会与数据库之外的任何内容交互(API、作业队列、文件系统、邮件发送器),请使用 after_commit,而不是 after_save。after_save 中的数据仍可能回滚。

步骤 3:注册

优先使用私有方法和宏式注册:

class User < ApplicationRecord
  before_validation :normalize_email
  before_create :generate_api_token

  private

  def normalize_email
    self.email = email&.strip&.downcase
  end

  def generate_api_token
    self.api_token = SecureRandom.hex(32)
  end
end

仅在确实只有一行代码时使用代码块:

class User < ApplicationRecord
  before_save { self.email = email&.downcase }
end

切勿使用 lambda/proc 风格——这种写法更难阅读和测试:

# Don't do this
before_save ->(user) { user.email = user.email&.downcase }
步骤 4:条件回调(谨慎使用)

如果需要条件回调,优先使用带符号的 :if / :unless:

class Order < ApplicationRecord
  before_save :normalize_card_number, if: :paid_with_card?
  before_save :apply_discount, unless: :promotional?
end

多个条件(必须全部通过):

before_save :archive_data, if: [:completed?, :older_than_90_days?]

代码异味警告: 如果条件不止一个,或 proc 条件很复杂,这些逻辑几乎肯定应该放在服务对象中。

步骤 5:中止回调链

使用 throw :abort 阻止操作:

class Account < ApplicationRecord
  before_destroy :ensure_no_active_subscriptions

  private

  def ensure_no_active_subscriptions
    if subscriptions.active.any?
      errors.add(:base, "Cannot delete account with active subscriptions")
      throw :abort
    end
  end
end

throw :abort 的行为:

  • save → 返回 false
  • save! → 抛出 ActiveRecord::RecordNotSaved
  • destroy → 返回 false
  • destroy! → 抛出 ActiveRecord::RecordNotDestroyed

切勿在回调中抛出异常来中止执行。请使用 throw :abort。

步骤 6:事务回调(after_commit)

Rails 中最重要的回调区别:

回调运行时机事务状态是否适合外部调用?
after_saveINSERT/UPDATE 后仍在事务中❌ 不适合——可能回滚
after_commitCOMMIT 后已提交✅ 适合
after_rollbackROLLBACK 后已回滚N/A

便捷别名(优先使用):

class User < ApplicationRecord
  after_create_commit :send_welcome_email_job
  after_update_commit :sync_to_crm_job
  after_destroy_commit :cleanup_storage
  after_save_commit :broadcast_changes  # create OR update
end

注意事项——after_create_commit 和 after_update_commit 使用相同的方法名时:

# BUG: Only after_update_commit will fire — they alias to after_commit internally
after_create_commit :log_change
after_update_commit :log_change  # This overwrites the create one!

# FIX: Use after_save_commit for both
after_save_commit :log_change

注意事项——每个事务中,after_commit 对每条记录只运行一次:

User.transaction do
  user.update!(name: "First")
  user.update!(name: "Second")
end
# after_commit fires ONCE, not twice
步骤 7:跳过回调

以下方法会完全绕过回调:

  • update_column / update_columns — 单条记录,不运行回调/验证
  • update_all — 批量更新,不运行回调
  • delete / delete_all / delete_by — 不运行回调(而 destroy 会触发回调)
  • insert / insert_all / upsert / upsert_all — 批量插入,不运行回调
  • increment! / decrement! — 更新计数器,不运行回调
  • touch_all — 批量更新时间戳,不运行回调

仅在确定不需要回调时有意使用这些方法(例如,更新计数器、批量数据迁移)。

步骤 8:around_* 回调

使用 yield 包裹操作:

class User < ApplicationRecord
  around_save :benchmark_save

  private

  def benchmark_save
    start = Time.current
    yield  # The actual save happens here
    duration = Time.current - start
    Rails.logger.info("User#save took #{duration}s")
  end
end

**除非确实需要包裹操作(例如,基准测试、自定义事务处理),否则避免使用 around_* 回调。**它们会增加复杂度,也更难推理。

步骤 9:回调对象(用于复用)

当同一回调逻辑适用于多个模型时,将其提取到一个类中:

class NormalizeBlankToNil
  def self.before_save(record)
    record.attributes.each do |key, value|
      record[key] = nil if value.is_a?(String) && value.blank?
    end
  end
end

class User < ApplicationRecord
  before_save NormalizeBlankToNil
end

class Post < ApplicationRecord
  before_save NormalizeBlankToNil
end

类方法(如上所示)无需实例化。只有在回调对象需要自身状态时,才使用实例方法。

步骤 10:before_destroy 与 dependent: 的顺序
class User < ApplicationRecord
  # ⚠️ WRONG ORDER — children deleted before callback runs
  has_many :posts, dependent: :destroy
  before_destroy :check_can_delete

  # ✅ CORRECT — use prepend: true so callback runs first
  has_many :posts, dependent: :destroy
  before_destroy :check_can_delete, prepend: true

  # ✅ ALSO CORRECT — declare callback before association
  before_destroy :check_can_delete
  has_many :posts, dependent: :destroy
end

反模式

1. 上帝回调
# ❌ TERRIBLE — does way too much
after_create :setup_everything

def setup_everything
  send_welcome_email
  create_default_workspace
  sync_to_stripe
  notify_admin_slack
  enqueue_onboarding_drip
end

→ 改用 UserRegistrationService。

2. 条件逻辑一团乱麻
# ❌ Code smell — context-dependent logic
after_save :maybe_notify, if: -> { 
  saved_change_to_status? && status == "published" && !importing? && !admin_edit? 
}

→ 移到 PublishPostService 中,由该服务显式调用通知逻辑。

3. 由回调驱动的架构
# ❌ "Saving a record" triggers a Rube Goldberg machine
after_save :update_cache
after_save :recalculate_totals
after_save :sync_search_index
after_save :notify_subscribers
after_save :update_analytics

→ 其中大多数逻辑应放在服务对象或显式触发的异步任务中。

4. 使用 after_save 执行外部调用
# ❌ DANGEROUS — transaction may rollback after email is sent
after_save :send_notification_email

# ✅ SAFE — only fires after commit
after_commit :send_notification_email, on: :create

禁用回调

使用 ActiveRecord::Suppressor,临时阻止特定模型的保存操作:

Notification.suppress do
  User.create!(name: "Test") # Won't create notifications even if after_create does
end

谨慎使用——这是用于测试和填充数据的工具,不应作为生产逻辑。

参考

有关详细模式、示例、边界情况以及完整的回调生命周期,请参阅此技能目录中的 reference.md。

评论

支持 Markdown