鍍金池/ 教程/ Ruby/ 4.5 模型中的回調(diào)(Callback)
寫在后面
寫在前面
第六章 Rails 的配置及部署
第四章 Rails 中的模型
4.4 模型中的校驗(Validates)
1.3 用戶界面(UI)設(shè)計
6.5 生產(chǎn)環(huán)境部署
3.2 表單
4.3 模型中的關(guān)聯(lián)關(guān)系(Relations)
4.5 模型中的回調(diào)(Callback)
第五章 Rails 中的控制器
4.2 深入模型查詢
5.2 控制器中的方法
6.2 緩存
3.4 模板引擎的使用
6.4 I18n
第一章 Ruby on Rails 概述
6.6 常用 Gem
1.2 Rails 文件簡介
2.2 REST 架構(gòu)
2.3 深入路由(routes)
第三章 Rails 中的視圖
6.3 異步任務(wù)及郵件發(fā)送
第二章 Rails 中的資源
3.3 視圖中的 AJAX 交互

4.5 模型中的回調(diào)(Callback)

概要:

本課時將講解 ActiveRecord 中常用的回調(diào)方法。

知識點:

  1. ActiveModel 中的回調(diào)
  2. ActiveRecord 中的回調(diào)
  3. 編寫回調(diào)
  4. 觸發(fā)回調(diào)
  5. 使用回調(diào)計算庫存

正文

4.5.1 ActiveModel 中的回調(diào)

ActiveModel 提供了多個實用的功能,它可以讓一個普通的類,具備如屬性校驗,回調(diào),顯示字段 I18n 值等眾多功能。

比如,我們可以為 Person 類增加了一個回調(diào)方法:

class Person
  extend ActiveModel::Callbacks
  define_model_callbacks :create
end

所謂回調(diào),是指在某個方法前(before)、后(after)、前后(around),執(zhí)行某個方法。上面的例子里,Person 擁有了三個標(biāo)準(zhǔn)的回調(diào)方法:before_create、after_create、around_create。

我們還需要為這個回調(diào)方法增加邏輯代碼:

class Person
  extend ActiveModel::Callbacks
  define_model_callbacks :create

  # 定義 create 方法代碼
  def create
    run_callbacks :create do
      puts "I am in create method."
    end
  end

  # 開始定義回調(diào)
  before_create :action_before_create
  def action_before_create
    puts "I am in before action of create."
  end

  after_create :action_after_create
  def action_after_create
    puts "I am in after action of create."
  end

  around_create :action_around_create
  def action_around_create
    puts "I am in around action of create."
    yield
    puts "I am in around action of create."
  end
end

進(jìn)入到 Rails 的終端里,我們測試下這個類:

% rails c
> person = Person.new
> person.create
I am in before action of create.
I am in around action of create.
I am in create method.
I am in around action of create.
I am in after action of create.

在 ActionModel 中有許多的 Ruby 元編程知識,如果你感興趣,可以讀一讀《Ruby 元編程(第二版)》這本書。

ActiveRecord 中的 回調(diào) 將常用的 find,create,updatedestroy 等方法進(jìn)行包裝。

Rails 在 controller 也有回調(diào),我們下一章會介紹。

4.5.2 ActiveRecord 中的回調(diào)

我們在 Rails 中使用的 Model 回調(diào),是通過調(diào)用 ActiveRecord 中定義的 實例方法 來實現(xiàn)的,比如 before_validation 方法,實現(xiàn)了在 validate 方法前的回調(diào)。

所謂 回調(diào),就是在目標(biāo)方法上,再執(zhí)行其他的方法代碼。

ActiveRecord 提供了眾多回調(diào)方法,包含了一個 model 實例在數(shù)據(jù)庫操作中的各個時期。按照數(shù)據(jù)庫操作的不同,可以將它們劃分為五種情形的回調(diào)方法。

第一種,創(chuàng)建對象時的回調(diào)。

  • before_validation
  • after_validation
  • before_save
  • around_save
  • before_create
  • around_create
  • after_create
  • after_save
  • after_commit/after_rollback

第二種,更新對象時的回調(diào)。

  • before_validation
  • after_validation
  • before_save
  • around_save
  • before_update
  • around_update
  • after_update
  • after_save
  • after_commit/after_rollback

第三種,刪除對象時的回調(diào)。

  • before_destroy
  • around_destroy
  • after_destroy
  • after_commit/after_rollback

第四種,初始化和查找時的回調(diào)。

  • after_find
  • after_initialize

after_initialize 會在一個實例使用 new 創(chuàng)建,或從數(shù)據(jù)庫讀取時觸發(fā)。這樣避免直接覆寫實例的 initialize 方法。

當(dāng)從數(shù)據(jù)庫讀取數(shù)據(jù)時,會觸發(fā) after_find 回調(diào):

  • all
  • first
  • find
  • find_by
  • findby*
  • findby*!
  • find_by_sql
  • last

after_find 執(zhí)行優(yōu)先于 after_initialize。

第五種,touch 回調(diào)。

  • after_touch

執(zhí)行實例的 touch 方法觸發(fā)該回調(diào)。

回調(diào)執(zhí)行順序

我們觀察一下以上每個回調(diào)的執(zhí)行的順序,這里做一個簡單的例子:

class Product < ActiveRecord::Base
  before_validation do
    puts "before_validation"
  end

  after_validation do
    puts "after_validation"
  end

  before_save do
    puts "before_save"
  end

  around_save :test_around_save
  def test_around_save
    puts "begin around_save"
    yield
    puts "end around_save"
  end

  before_create do
    puts "before_create"
  end

  around_create :test_around_create
  def test_around_create
    puts "begin around_create"
    yield
    puts "end around_create"
  end

  after_create do
    puts "after_create"
  end

  after_save do
    puts "after_save"
  end

  after_commit do
    puts "after_commit"
  end

  after_rollback do
    puts "after_rollback"
  end
end

進(jìn)入終端試驗下:

product = Product.new(name: "TTT")
product.save
 (0.1ms)  begin transaction
before_validation
after_validation
before_save
begin around_save
before_create
begin around_create
  SQL (0.6ms)  INSERT INTO "products" ("name", "created_at", "updated_at") VALUES (?, ?, ?)  [["name", "TTT"], ["created_at", "2015-06-16 02:49:20.871384"], ["updated_at", "2015-06-16 02:49:20.871384"]]
end around_create
after_create
end around_save
after_save
 (0.7ms)  commit transaction
after_commit
=> true

可以看到,create 回調(diào)是最接近 sql 執(zhí)行的,并且 validation、save、create 回調(diào)被包含在一個 transaction 事務(wù)中,最后,是 after_commit 回調(diào)。

我們在設(shè)計邏輯的過程中,需要了解它執(zhí)行的順序。當(dāng)需要在回調(diào)中操作保存到數(shù)據(jù)庫后的實例,需要把代碼放到 在 after_commit 中。

4.5.3 編寫回調(diào)

上面列出的,是回調(diào)的方法名,我們還需要編寫具體的回調(diào)代碼。

4.5.3.1 符號和方法

class Topic < ActiveRecord::Base
  before_destroy :delete_parents [1]

  private [2]
    def delete_parents [3]
      self.class.delete_all "parent_id = #{id}"
    end
end

[1] 用符號定義回調(diào)執(zhí)行的方法名稱 [2] private 或 protected 方法均可作為回調(diào)執(zhí)行方法 [3] 執(zhí)行的方法名,和定義的符號一致

對于 round_ 回調(diào),我們需要在方法中使用 yield,上面的例子已經(jīng)看到:

around_create :test_around_create
def test_around_create
  puts "begin around_create"
  yield
  puts "end around_create"
end

4.5.3.2 代碼塊(Block)

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

回調(diào)執(zhí)行時,self 指的是它本身。在注冊的時候,我們可能不需要填寫 name,而要填寫 login,所以默認(rèn)把 name 改成 login 的首字母大寫形式。

上面例子也可以改寫成:

before_create { |record|
  record.name = record.login.capitalize if record.name.blank?
}

4.5.3.3 在特定方法上使用回調(diào)

在一些注冊和修改的邏輯中,注冊時默認(rèn)填寫的數(shù)據(jù),在修改時不做處理,所以回調(diào)方法只在 create 上生效,下面的例子就是這種情形:

before_validation(on: :create) do
  self.number = number.gsub(/[^0-9]/, "")
end

或者:

before_validation :normalize_name, on: :create

4.5.3.4 有條件的回調(diào)

和校驗一樣,回調(diào)也可以增加 if 或 unless 判斷:

before_save :normalize_card_number, if: :paid_with_card?

4.5.3.5 字符串形式的回調(diào)

class Topic < ActiveRecord::Base
  before_destroy 'self.class.delete_all "parent_id = #{id}"'
end

before_destroy 既可以接受符號定義的方法名,也可以接受字符串。這種方式要被廢棄掉了。

4.5.3.6 回調(diào)的繼承

一個類集成自另一個類,也會繼承它的回調(diào),比如:

class Topic < ActiveRecord::Base
  before_destroy :destroy_author
end

class Reply < Topic
  before_destroy :destroy_readers
end

在執(zhí)行 Reply#destroy 的時候,兩個回調(diào)都會被執(zhí)行,為了避免這種情況,可以覆寫 before_destroy

class Reply < Topic
  def before_destroy() destroy_readers end
end

但是,這是非常不好的解決方案!這個代碼只是一個例子,來自 這里

回調(diào)雖然可以解決問題,但是它功能太過強大,當(dāng)項目代碼變得復(fù)雜,回調(diào)的維護(hù)會造成很大的技術(shù)難度。建議使用回調(diào)解決小問題,過多的業(yè)務(wù)邏輯應(yīng)該單獨處理,或者使用單獨的回調(diào)類。

4.5.3.6 單獨的回調(diào)類

我們可以用一個類作為 回調(diào)類,使用它的的實例方法實現(xiàn)回調(diào)邏輯:

class BankAccount < ActiveRecord::Base
  before_save EncryptionWrapper.new
end

class EncryptionWrapper
  def before_save(record) [1]
    record.credit_card_number = encrypt(record.credit_card_number)
  end
end

[1] 該方法僅能接受一個參數(shù),為該 model 實例。

還可以使用 回調(diào)類 的類方法,來定義回調(diào)邏輯:

class PictureFileCallbacks
  def self.after_destroy(picture_file)
    ...
  end
end

在使用上:

class PictureFile < ActiveRecord::Base
  after_destroy PictureFileCallbacks
end

使用單獨的回調(diào)類,可以方便我們維護(hù)回調(diào)代碼,但是使用起來也需慎重考慮,不要增加后期的維護(hù)難度。

4.5.4 觸發(fā)回調(diào)

在我們前面講解中,更新一個記錄時,destroy 方法會觸發(fā)校驗和回調(diào),而 delete 方法不會。在這里詳細(xì)的列出,ActiveRecord 方法中,哪些會觸發(fā)回調(diào),哪些不會。

觸發(fā)回調(diào):

  • create
  • create!
  • decrement!
  • destroy
  • destroy!
  • destroy_all
  • increment!
  • save
  • save!
  • save(validate: false)
  • toggle!
  • update_attribute
  • update
  • update!
  • valid?

不觸發(fā)回調(diào):

  • decrement
  • decrement_counter
  • delete
  • delete_all
  • increment
  • increment_counter
  • toggle
  • touch
  • update_column
  • update_columns
  • update_all
  • update_counters

4.5.5 回調(diào)的失敗

所有的回調(diào),在動作執(zhí)行的過程中,是順序觸發(fā)的。在 before_xxx 回調(diào)中,如果返回 false, 這個回調(diào)過程會被終止,并且觸發(fā)數(shù)據(jù)庫事務(wù)的 rollback,以及 after_rollback 回調(diào)。

但是,對于 after_xxx 回調(diào),就只能用 raise 拋出異常的方式,來終止它。這里拋出的異常必須是 ActiveRecord::Rollback。我們修改下 after_create 回調(diào):

after_create do
  puts "after_create"
  raise ActiveRecord::Rollback
end

在終端里:

> Product.create
   (0.1ms)  begin transaction
before_validation
after_validation
before_save
begin around_save
before_create
begin around_create
  SQL (0.4ms)  INSERT INTO "products" ("created_at", "updated_at") VALUES (?, ?)  [["created_at", "2015-08-03 15:30:20.552783"], ["updated_at", "2015-08-03 15:30:20.552783"]]
end around_create
after_create
   (8.5ms)  rollback transaction
after_rollback
 => #<Product id: nil, name: nil, price: nil, description: nil, created_at: "2015-08-03 15:30:20", updated_at: "2015-08-03 15:30:20", top: nil, hot: nil>

ActiveRecord::Rollback 終止了數(shù)據(jù)庫事務(wù),返回了一個沒有保存到數(shù)據(jù)庫中的實例。如果我們不拋出這個異常,比如拋出一個標(biāo)準(zhǔn)的異常類:

after_create do
  puts "after_create"
  raise StandardError
end

雖然它也會終止事務(wù),沒有把保存數(shù)據(jù),但是它再次拋出這個異常,而不是返回我們想要的未保存實例。

...
after_rollback
StandardError: StandardError
    from /PATH/shop/app/models/product.rb:40:in `block in <class:Product>'
...

4.5.6 after_commit中的實例

當(dāng)我們在回調(diào)中使用當(dāng)前實例的時候,它并沒有保存到數(shù)據(jù)庫中,只有當(dāng)數(shù)據(jù)庫事務(wù) commit 之后,這個實例才會被保存,所以我們在 after_commit 回調(diào)中讀取它數(shù)據(jù)庫中的 id,并在這里設(shè)置它和其他實例的關(guān)聯(lián)。

4.5.7 回調(diào)計算庫存

使用回調(diào)可以適當(dāng)精簡邏輯代碼,比如我們購買一個商品類型時,在創(chuàng)建訂單后,應(yīng)減少該商品類型的庫存數(shù)量。該 減少數(shù)量 的動作雖然屬于整體邏輯,但是和訂單邏輯是分開的,而它的觸發(fā)點正好在訂單 create 動作完成后,所以我們把它放到 after_create 中。

首先我們給 variants 增加 on_hand 屬性,表示當(dāng)前持有的數(shù)量:

rails g migration add_on_hand_to_variants on_hand:integer

在 order.rb 中編寫回調(diào):

after_create do
  line_items.each do |line_item|
    line_item.variant.decrement!(:on_hand, line_item.quantity)
  end
end