v7.1.3.2
更多資訊請至 rubyonrails.org: 更多 Ruby on Rails

Active Record 回呼

本指南教導您如何連結到 Active Record 物件的生命週期。

閱讀本指南後,您將知道

1 物件生命週期

在 Rails 應用程式的正常運作期間,物件可能會建立、更新和銷毀。Active Record 提供了這個 物件生命週期 的掛鉤,以便您可以控制您的應用程式及其資料。

回呼讓您可以在物件狀態變更之前或之後觸發邏輯。

class Baby < ApplicationRecord
  after_create -> { puts "Congratulations!" }
end
irb> @baby = Baby.create
Congratulations!

正如您所見,有許多生命週期事件,您可以選擇在這些事件之前、之後,甚至是在它們周圍進行掛鉤。

2 回呼概觀

回呼是在物件生命週期的特定時刻被呼叫的方法。透過回呼,可以撰寫在每次 Active Record 物件從資料庫建立、儲存、更新、刪除、驗證或載入時執行的程式碼。

2.1 回呼註冊

為了使用可用的回呼,您需要註冊它們。您可以將回呼實作為一般方法,並使用巨集樣式的類別方法將它們註冊為回呼

class User < ApplicationRecord
  validates :login, :email, presence: true

  before_validation :ensure_login_has_a_value

  private
    def ensure_login_has_a_value
      if login.blank?
        self.login = email unless email.blank?
      end
    end
end

巨集樣式的類別方法也可以接收一個區塊。如果區塊內的程式碼短到可以放在單一行中,請考慮使用這種樣式

class User < ApplicationRecord
  validates :login, :email, presence: true

  before_create do
    self.name = login.capitalize if name.blank?
  end
end

或者,您可以將一個程序傳遞給要觸發的回呼。

class User < ApplicationRecord
  before_create ->(user) { user.name = user.login.capitalize if user.name.blank? }
end

最後,您可以定義您自己的自訂回呼物件,我們將在 下方的後續內容中更詳細地說明。

class User < ApplicationRecord
  before_create MaybeAddName
end

class MaybeAddName
  def self.before_create(record)
    if record.name.blank?
      record.name = record.login.capitalize
    end
  end
end

回呼也可以註冊為僅在特定生命週期事件中觸發,這允許完全控制您的回呼觸發的時間和背景。

class User < ApplicationRecord
  before_validation :normalize_name, on: :create

  # :on takes an array as well
  after_validation :set_location, on: [ :create, :update ]

  private
    def normalize_name
      self.name = name.downcase.titleize
    end

    def set_location
      self.location = LocationService.query(self)
    end
end

將回呼方法宣告為私有方法被認為是良好的做法。如果保留為公開方法,它們可以從模型外部呼叫,並違反物件封裝的原則。

避免在回呼中呼叫會對物件產生副作用的 updatesave 或其他方法。例如,不要在回呼中呼叫 update(attribute: "value")。這可能會改變模型的狀態,並可能在提交期間造成意外的副作用。相反地,你可以在 before_create / before_update 或更早的回呼中安全地直接指定值(例如,self.attribute = "value")。

3 個可用的回呼

以下是所有可用的 Active Record 回呼清單,列出的順序與它們在各個操作期間被呼叫的順序相同

3.1 建立物件

3.2 更新物件

after_save 會在建立和更新時執行,但總是 更具體的回呼 after_createafter_update 之後 執行,不論巨集呼叫執行的順序為何。

3.3 銷毀物件

before_destroy 回呼應該置於 dependent: :destroy 關聯之前(或使用 prepend: true 選項),以確保它們在記錄被 dependent: :destroy 刪除之前執行。

after_commit 所提供的保證與 after_saveafter_updateafter_destroy 大不相同。例如,如果在 after_save 中發生例外狀況,交易將會回滾,資料也不會持續存在。而發生在 after_commit 中的任何事都可以保證交易已完成,且資料已持續存在資料庫中。有關 交易回呼 的更多資訊如下。

3.4 after_initializeafter_find

每當建立 Active Record 物件時,after_initialize 回呼就會被呼叫,無論是直接使用 new 或從資料庫載入記錄時。這有助於避免直接覆寫 Active Record 的 initialize 方法。

從資料庫載入記錄時,after_find 回呼就會被呼叫。如果兩個回呼都已定義,after_find 會在 after_initialize 之前被呼叫。

after_initializeafter_find 回呼沒有 before_* 對應項。

它們可以像其他 Active Record 回呼一樣註冊。

class User < ApplicationRecord
  after_initialize do |user|
    puts "You have initialized an object!"
  end

  after_find do |user|
    puts "You have found an object!"
  end
end
irb> User.new
You have initialized an object!
=> #<User id: nil>

irb> User.first
You have found an object!
You have initialized an object!
=> #<User id: 1>

3.5 after_touch

每當 Active Record 物件被觸及時,after_touch 回呼就會被呼叫。

class User < ApplicationRecord
  after_touch do |user|
    puts "You have touched an object"
  end
end
irb> u = User.create(name: 'Kuldeep')
=> #<User id: 1, name: "Kuldeep", created_at: "2013-11-25 12:17:49", updated_at: "2013-11-25 12:17:49">

irb> u.touch
You have touched an object
=> true

它可以與 belongs_to 搭配使用

class Book < ApplicationRecord
  belongs_to :library, touch: true
  after_touch do
    puts 'A Book was touched'
  end
end

class Library < ApplicationRecord
  has_many :books
  after_touch :log_when_books_or_library_touched

  private
    def log_when_books_or_library_touched
      puts 'Book/Library was touched'
    end
end
irb> @book = Book.last
=> #<Book id: 1, library_id: 1, created_at: "2013-11-25 17:04:22", updated_at: "2013-11-25 17:05:05">

irb> @book.touch # triggers @book.library.touch
A Book was touched
Book/Library was touched
=> true

4 執行回呼

下列方法會觸發回呼

  • create
  • create!
  • destroy
  • destroy!
  • destroy_all
  • destroy_by
  • save
  • save!
  • save(validate: false)
  • save!(validate: false)
  • 切換!
  • 觸摸
  • 更新_屬性
  • 更新
  • 更新!
  • 有效?

此外,after_find 回呼會由下列尋找器方法觸發

  • 全部
  • 第一個
  • 尋找
  • 依據尋找
  • 依據_*尋找
  • 依據_*!尋找
  • 依據_sql尋找
  • 最後一個

每次類別的新物件初始化時,after_initialize 回呼都會觸發。

find_by_*find_by_*! 方法是動態尋找器,會自動為每個屬性產生。在 動態尋找器區段 了解更多關於它們的資訊

5 略過回呼

就像驗證一樣,也可以使用下列方法略過回呼

  • 遞減!
  • 遞減計數器
  • 刪除
  • 全部刪除
  • 依據刪除
  • 遞增!
  • 遞增計數器
  • 插入
  • 插入!
  • 全部插入
  • 全部插入!
  • 全部觸摸
  • 更新欄位
  • 更新欄位
  • 全部更新
  • 更新計數器
  • 更新或插入
  • 全部更新或插入

不過,這些方法應謹慎使用,因為重要的商業規則和應用程式邏輯可能會保留在回呼中。在不了解潛在影響的情況下繞過它們可能會導致無效的資料。

6 停止執行

當您開始為模型註冊新的回呼時,它們會排隊執行。此佇列將包含您所有模型的驗證、註冊的回呼和要執行的資料庫操作。

整個回呼鏈會包裝在交易中。如果任何回呼引發例外,執行鏈會停止,並發出 ROLLBACK。若要故意停止鏈,請使用

throw :abort

任何不是 ActiveRecord::RollbackActiveRecord::RecordInvalid 的例外,都會在呼叫回鏈停止後由 Rails 重新引發。此外,可能會中斷不預期方法(例如通常嘗試傳回 truefalsesaveupdate)引發例外的程式碼。

如果在 after_destroybefore_destroyaround_destroy 呼叫回中引發 ActiveRecord::RecordNotDestroyed,則不會重新引發,而 destroy 方法將傳回 false

7 關聯呼叫回

呼叫回透過模型關聯運作,甚至可以由它們定義。假設一個範例,一個使用者有許多文章。如果使用者被刪除,使用者的文章應該被刪除。讓我們透過與 Article 模型的關聯,新增一個 after_destroy 呼叫回至 User 模型

class User < ApplicationRecord
  has_many :articles, dependent: :destroy
end

class Article < ApplicationRecord
  after_destroy :log_destroy_action

  def log_destroy_action
    puts 'Article destroyed'
  end
end
irb> user = User.first
=> #<User id: 1>
irb> user.articles.create!
=> #<Article id: 1, user_id: 1>
irb> user.destroy
Article destroyed
=> #<User id: 1>

8 關聯呼叫回

關聯呼叫回類似於一般呼叫回,但它們是由集合生命週期中的事件觸發的。有四個可用的關聯呼叫回

  • before_add
  • after_add
  • before_remove
  • after_remove

您可以透過新增選項至關聯宣告來定義關聯呼叫回。例如

class Author < ApplicationRecord
  has_many :books, before_add: :check_credit_limit

  def check_credit_limit(book)
    # ...
  end
end

Rails 將正在新增或移除的物件傳遞給呼叫回。

您可以透過將呼叫回作為陣列傳遞,將它們堆疊在單一事件上

class Author < ApplicationRecord
  has_many :books,
    before_add: [:check_credit_limit, :calculate_shipping_charges]

  def check_credit_limit(book)
    # ...
  end

  def calculate_shipping_charges(book)
    # ...
  end
end

如果 before_add 呼叫回擲回 :abort,則物件不會被新增至集合。類似地,如果 before_remove 呼叫回擲回 :abort,則物件不會從集合中移除

# book won't be added if the limit has been reached
def check_credit_limit(book)
  throw(:abort) if limit_reached?
end

這些呼叫回僅在透過關聯集合新增或移除關聯物件時才會被呼叫

# Triggers `before_add` callback
author.books << book
author.books = [book, book2]

# Does not trigger the `before_add` callback
book.update(author_id: 1)

9 條件呼叫回

與驗證相同,我們也可以讓呼叫回呼函數的方法有條件地滿足給定的謂詞。我們可以使用 :if:unless 選項來做到這一點,它們可以接受符號、ProcArray

當您想要指定在哪些條件下應該呼叫回呼時,可以使用 :if 選項。如果您想要指定在哪些條件下不應該呼叫回呼,則可以使用 :unless 選項。

9.1 使用 :if:unlessSymbol

您可以將 :if:unless 選項與對應於謂詞方法名稱的符號關聯起來,該謂詞方法將在回呼之前立即被呼叫。

在使用 :if 選項時,如果謂詞方法傳回 false,則不會執行回呼;在使用 :unless 選項時,如果謂詞方法傳回 true,則不會執行回呼。這是最常見的選項。

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

使用這種註冊表單,也可以註冊幾個不同的謂詞,這些謂詞應該被呼叫來檢查是否應該執行回呼。我們將在下方介紹這一點

9.2 使用 :if:unlessProc

可以將 :if:unlessProc 物件關聯起來。此選項最適合用於撰寫簡短的驗證方法,通常是一行式

class Order < ApplicationRecord
  before_save :normalize_card_number,
    if: Proc.new { |order| order.paid_with_card? }
end

由於 proc 是在物件的背景下評估的,因此也可以寫成

class Order < ApplicationRecord
  before_save :normalize_card_number, if: Proc.new { paid_with_card? }
end

9.3 多個回呼條件

:if:unless 選項也接受 proc 或方法名稱的陣列作為符號

class Comment < ApplicationRecord
  before_save :filter_content,
    if: [:subject_to_parental_control?, :untrusted_author?]
end

您可以在條件清單中輕鬆地包含 proc

class Comment < ApplicationRecord
  before_save :filter_content,
    if: [:subject_to_parental_control?, Proc.new { untrusted_author? }]
end

9.4 同時使用 :if:unless

回呼函式可以在同一個宣告中混合 :if:unless

class Comment < ApplicationRecord
  before_save :filter_content,
    if: Proc.new { forum.parental_control? },
    unless: Proc.new { author.trusted? }
end

回呼函式只會在所有 :if 條件都成立且沒有任何 :unless 條件評估為 true 時執行。

10 回呼函式類別

有時你寫的回呼函式方法對其他模型來說夠有幫助,可以重複使用。Active Record 讓你可以建立封裝回呼函式方法的類別,以便重複使用。

以下是我們建立一個類別的範例,其中包含一個 after_destroy 回呼函式,用來處理檔案系統中已捨棄檔案的清除。這種行為可能不只我們 PictureFile 模型獨有,我們可能想要分享它,因此將它封裝到一個獨立的類別中會是個好主意。這會讓測試這種行為和變更它變得容易許多。

class FileDestroyerCallback
  def after_destroy(file)
    if File.exist?(file.filepath)
      File.delete(file.filepath)
    end
  end
end

當在類別內宣告,如同上述,回呼函式方法會收到模型物件作為一個參數。這會在任何使用該類別的模型上執行,如下所示

class PictureFile < ApplicationRecord
  after_destroy FileDestroyerCallback.new
end

請注意我們需要實例化一個新的 FileDestroyerCallback 物件,因為我們將我們的回呼函式宣告為一個實例方法。如果回呼函式使用實例化物件的狀態,這會特別有用。然而,通常將回呼函式宣告為類別方法會比較有意義

class FileDestroyerCallback
  def self.after_destroy(file)
    if File.exist?(file.filepath)
      File.delete(file.filepath)
    end
  end
end

當回呼函式方法以這種方式宣告時,將不需要在我們的模型中實例化一個新的 FileDestroyerCallback 物件。

class PictureFile < ApplicationRecord
  after_destroy FileDestroyerCallback
end

你可以在回呼函式類別中宣告任意數量的回呼函式。

11 交易回呼函式

11.1 after_commitafter_rollback

資料庫交易完成時會觸發兩個附加的回呼:after_commitafter_rollback。這些回呼與 after_save 回呼非常類似,只是在資料庫變更提交或回滾後才會執行。當 Active Record 模型需要與不屬於資料庫交易的外部系統互動時,它們最為有用。

例如,考慮先前的範例,其中 PictureFile 模型需要在對應的記錄被刪除後刪除檔案。如果在呼叫 after_destroy 回呼後有任何錯誤導致交易回滾,檔案將會被刪除,而模型將會處於不一致的狀態。例如,假設以下程式碼中的 picture_file_2 無效,而 save! 方法引發錯誤。

PictureFile.transaction do
  picture_file_1.destroy
  picture_file_2.save!
end

透過使用 after_commit 回呼,我們可以考量這種情況。

class PictureFile < ApplicationRecord
  after_commit :delete_picture_file_from_disk, on: :destroy

  def delete_picture_file_from_disk
    if File.exist?(filepath)
      File.delete(filepath)
    end
  end
end

:on 選項指定何時觸發回呼。如果您未提供 :on 選項,回呼將會對每個動作觸發。

當交易完成時,將會針對在該交易中建立、更新或刪除的所有模型呼叫 after_commitafter_rollback 回呼。不過,如果在其中一個回呼中引發例外狀況,例外狀況將會浮現,而任何剩餘的 after_commitafter_rollback 方法將不會執行。因此,如果您的回呼程式碼可能會引發例外狀況,您需要在回呼中救援並處理它,以允許其他回呼執行。

after_commitafter_rollback 回呼中執行的程式碼本身並未包含在交易中。

在單一交易的背景下,如果你與多個載入的物件互動,這些物件代表資料庫中的同一個記錄,在 after_commitafter_rollback 回呼中有一個關鍵行為需要注意。這些回呼只會觸發在交易中經歷變更的特定記錄的第一個物件。其他載入的物件,儘管代表同一個資料庫記錄,它們各自的 after_commitafter_rollback 回呼不會被觸發。這種細微的行為在以下情況中特別有影響力:你預期與同一個資料庫記錄相關聯的每個物件都能獨立執行回呼。它會影響回呼順序的流程和可預測性,導致交易後應用程式邏輯出現潛在的不一致性。

11.2 after_commit 的別名

由於只在建立、更新或刪除時使用 after_commit 回呼很常見,因此這些操作有一些別名

class PictureFile < ApplicationRecord
  after_destroy_commit :delete_picture_file_from_disk

  def delete_picture_file_from_disk
    if File.exist?(filepath)
      File.delete(filepath)
    end
  end
end

after_create_commitafter_update_commit 與同一個方法名稱一起使用,只會讓最後定義的回呼生效,因為它們在內部都別名為 after_commit,它會覆寫先前定義的、具有相同方法名稱的回呼。

class User < ApplicationRecord
  after_create_commit :log_user_saved_to_db
  after_update_commit :log_user_saved_to_db

  private
    def log_user_saved_to_db
      puts 'User was saved to database'
    end
end
irb> @user = User.create # prints nothing

irb> @user.save # updating @user
User was saved to database

11.3 after_save_commit

還有一個 after_save_commit,它是同時使用 after_commit 回呼建立和更新的別名

class User < ApplicationRecord
  after_save_commit :log_user_saved_to_db

  private
    def log_user_saved_to_db
      puts 'User was saved to database'
    end
end
irb> @user = User.create # creating a User
User was saved to database

irb> @user.save # updating @user
User was saved to database

11.4 交易回呼排序

預設情況下,回呼會按照它們被定義的順序執行。然而,在定義多個交易 after_ 回呼(after_commitafter_rollback 等)時,順序可能會與它們被定義時的順序相反。

class User < ActiveRecord::Base
  after_commit { puts("this actually gets called second") }
  after_commit { puts("this actually gets called first") }
end

這也適用於所有 after_*_commit 變體,例如 after_destroy_commit

此順序可透過設定檔設定

config.active_record.run_after_transaction_callbacks_in_order_defined = false

設定為 true(Rails 7.1 的預設值)時,會按定義順序執行 callback。設定為 false 時,順序會反轉,就像上面的範例一樣。

回饋

我們鼓勵您協助提升本指南的品質。

如果您發現任何錯字或事實錯誤,請協助我們修正。您可以閱讀我們的 文件貢獻 部分,了解如何開始。

您也可能會發現不完整或過時的內容。請務必為 main 新增任何遺漏的文件。請先查看 Edge Guides,確認問題是否已在 main 分支中修正。查看 Ruby on Rails Guides Guidelines,了解風格和慣例。

如果您發現需要修正的地方,但無法自行修改,請 開啟問題

最後,我們歡迎您在 官方 Ruby on Rails 論壇 討論任何與 Ruby on Rails 文件相關的主題。