关于 active-record-callbacks
帮助 Rails 开发者理解和使用 Active Record 回调的技能,涵盖生命周期、回调选择与常见陷阱。重点区分模型自身逻辑与应放在服务对象中的业务流程,并说明事务提交后的副作用处理。
主要功能
- 01判断逻辑是否适合回调
- 02梳理创建、更新和销毁顺序
- 03选择合适的回调钩子
- 04安全处理事务提交后的副作用
- 05控制回调链及跳过回调
适用场景
- 01添加或修改模型回调
- 02排查回调顺序和行为问题
- 03将事务外副作用安排在提交后
- 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→ 返回falsesave!→ 抛出ActiveRecord::RecordNotSaveddestroy→ 返回falsedestroy!→ 抛出ActiveRecord::RecordNotDestroyed
切勿在回调中抛出异常来中止执行。请使用 throw :abort。
步骤 6:事务回调(after_commit)
Rails 中最重要的回调区别:
| 回调 | 运行时机 | 事务状态 | 是否适合外部调用? |
|---|---|---|---|
after_save | INSERT/UPDATE 后 | 仍在事务中 | ❌ 不适合——可能回滚 |
after_commit | COMMIT 后 | 已提交 | ✅ 适合 |
after_rollback | ROLLBACK 后 | 已回滚 | 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。