# 4.5 模型中的回調(Callback)
## 概要:
本課時將講解 ActiveRecord 中常用的回調方法。
## 知識點:
1. ActiveModel 中的回調
1. ActiveRecord 中的回調
1. 編寫回調
1. 觸發回調
1. 使用回調計算庫存
## 正文
### 4.5.1 ActiveModel 中的回調
[ActiveModel](https://github.com/rails/rails/tree/master/activemodel) 提供了多個實用的功能,它可以讓一個普通的類,具備如屬性校驗,回調,顯示字段 I18n 值等眾多功能。
比如,我們可以為 Person 類增加了一個回調方法:
~~~
class Person
extend ActiveModel::Callbacks
define_model_callbacks :create
end
~~~
所謂回調,是指在某個方法前(before)、后(after)、前后(around),執行某個方法。上面的例子里,Person 擁有了三個標準的回調方法:before_create、after_create、around_create。
我們還需要為這個回調方法增加邏輯代碼:
~~~
class Person
extend ActiveModel::Callbacks
define_model_callbacks :create
# 定義 create 方法代碼
def create
run_callbacks :create do
puts "I am in create method."
end
end
# 開始定義回調
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
~~~
進入到 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 元編程(第二版)](https://pragprog.com/book/ppmetr2/metaprogramming-ruby-2)》這本書。
ActiveRecord 中的 [回調](http://api.rubyonrails.org/classes/ActiveRecord/Callbacks.html) 將常用的 `find`,`create`,`update`,`destroy` 等方法進行包裝。
Rails 在 controller 也有回調,我們下一章會介紹。
### 4.5.2 ActiveRecord 中的回調
我們在 Rails 中使用的 Model 回調,是通過調用 ActiveRecord 中定義的 `實例方法` 來實現的,比如 `before_validation` 方法,實現了在 `validate` 方法前的回調。
所謂 `回調`,就是在目標方法上,再執行其他的方法代碼。
ActiveRecord 提供了眾多回調方法,包含了一個 model 實例在數據庫操作中的各個時期。按照數據庫操作的不同,可以將它們劃分為五種情形的回調方法。
#### 第一種,創建對象時的回調。
- before_validation
- after_validation
- before_save
- around_save
- before_create
- around_create
- after_create
- after_save
- after_commit/after_rollback
#### 第二種,更新對象時的回調。
- before_validation
- after_validation
- before_save
- around_save
- before_update
- around_update
- after_update
- after_save
- after_commit/after_rollback
#### 第三種,刪除對象時的回調。
- before_destroy
- around_destroy
- after_destroy
- after_commit/after_rollback
#### 第四種,初始化和查找時的回調。
- after_find
- after_initialize
after_initialize 會在一個實例使用 new 創建,或從數據庫讀取時觸發。這樣避免直接覆寫實例的 initialize 方法。
當從數據庫讀取數據時,會觸發 after_find 回調:
- all
- first
- find
- find_by
- find*by**
- find*by**!
- find_by_sql
- last
after_find 執行優先于 after_initialize。
#### 第五種,touch 回調。
- after_touch
執行實例的 `touch` 方法觸發該回調。
#### 回調執行順序
我們觀察一下以上每個回調的執行的順序,這里做一個簡單的例子:
~~~
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
~~~
進入終端試驗下:
~~~
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 回調是最接近 sql 執行的,并且 validation、save、create 回調被包含在一個 transaction 事務中,最后,是 after_commit 回調。
我們在設計邏輯的過程中,需要了解它執行的順序。當需要在回調中操作保存到數據庫后的實例,需要把代碼放到 在 `after_commit` 中。
### 4.5.3 編寫回調
上面列出的,是回調的方法名,我們還需要編寫具體的回調代碼。
#### 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] 用符號定義回調執行的方法名稱[2] private 或 protected 方法均可作為回調執行方法[3] 執行的方法名,和定義的符號一致
對于 `round_` 回調,我們需要在方法中使用 `yield`,上面的例子已經看到:
~~~
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
~~~
回調執行時,self 指的是它本身。在注冊的時候,我們可能不需要填寫 name,而要填寫 login,所以默認把 name 改成 login 的首字母大寫形式。
上面例子也可以改寫成:
~~~
before_create { |record|
record.name = record.login.capitalize if record.name.blank?
}
~~~
#### 4.5.3.3 在特定方法上使用回調
在一些注冊和修改的邏輯中,注冊時默認填寫的數據,在修改時不做處理,所以回調方法只在 create 上生效,下面的例子就是這種情形:
~~~
before_validation(on: :create) do
self.number = number.gsub(/[^0-9]/, "")
end
~~~
或者:
~~~
before_validation :normalize_name, on: :create
~~~
#### 4.5.3.4 有條件的回調
和校驗一樣,回調也可以增加 if 或 unless 判斷:
~~~
before_save :normalize_card_number, if: :paid_with_card?
~~~
#### 4.5.3.5 字符串形式的回調
~~~
class Topic < ActiveRecord::Base
before_destroy 'self.class.delete_all "parent_id = #{id}"'
end
~~~
`before_destroy` 既可以接受符號定義的方法名,也可以接受字符串。這種方式要被廢棄掉了。
#### 4.5.3.6 回調的繼承
一個類集成自另一個類,也會繼承它的回調,比如:
~~~
class Topic < ActiveRecord::Base
before_destroy :destroy_author
end
class Reply < Topic
before_destroy :destroy_readers
end
~~~
在執行 `Reply#destroy` 的時候,兩個回調都會被執行,為了避免這種情況,可以覆寫 `before_destroy`:
~~~
class Reply < Topic
def before_destroy() destroy_readers end
end
~~~
但是,這是非常不好的解決方案!這個代碼只是一個例子,來自 [這里](http://api.rubyonrails.org/classes/ActiveRecord/Callbacks.html)。
回調雖然可以解決問題,但是它功能太過強大,當項目代碼變得復雜,回調的維護會造成很大的技術難度。建議使用回調解決小問題,過多的業務邏輯應該單獨處理,或者使用單獨的回調類。
#### 4.5.3.6 單獨的回調類
我們可以用一個類作為 `回調類`,使用它的的實例方法實現回調邏輯:
~~~
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] 該方法僅能接受一個參數,為該 model 實例。
還可以使用 `回調類` 的類方法,來定義回調邏輯:
~~~
class PictureFileCallbacks
def self.after_destroy(picture_file)
...
end
end
~~~
在使用上:
~~~
class PictureFile < ActiveRecord::Base
after_destroy PictureFileCallbacks
end
~~~
使用單獨的回調類,可以方便我們維護回調代碼,但是使用起來也需慎重考慮,不要增加后期的維護難度。
### 4.5.4 觸發回調
在我們前面講解中,更新一個記錄時,destroy 方法會觸發校驗和回調,而 delete 方法不會。在這里詳細的列出,ActiveRecord 方法中,哪些會觸發回調,哪些不會。
觸發回調:
- create
- create!
- decrement!
- destroy
- destroy!
- destroy_all
- increment!
- save
- save!
- save(validate: false)
- toggle!
- update_attribute
- update
- update!
- valid?
不觸發回調:
- decrement
- decrement_counter
- delete
- delete_all
- increment
- increment_counter
- toggle
- touch
- update_column
- update_columns
- update_all
- update_counters
### 4.5.5 回調的失敗
所有的回調,在動作執行的過程中,是順序觸發的。在 `before_xxx` 回調中,如果返回 `false`, 這個回調過程會被終止,并且觸發數據庫事務的 `rollback`,以及 `after_rollback` 回調。
但是,對于 `after_xxx` 回調,就只能用 `raise` 拋出異常的方式,來終止它。這里拋出的異常必須是 `ActiveRecord::Rollback`。我們修改下 `after_create` 回調:
~~~
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` 終止了數據庫事務,返回了一個沒有保存到數據庫中的實例。如果我們不拋出這個異常,比如拋出一個標準的異常類:
~~~
after_create do
puts "after_create"
raise StandardError
end
~~~
雖然它也會終止事務,沒有把保存數據,但是它再次拋出這個異常,而不是返回我們想要的未保存實例。
~~~
...
after_rollback
StandardError: StandardError
from /PATH/shop/app/models/product.rb:40:in `block in <class:Product>'
...
~~~
### 4.5.6 `after_commit`中的實例
當我們在回調中使用當前實例的時候,它并沒有保存到數據庫中,只有當數據庫事務 `commit` 之后,這個實例才會被保存,所以我們在 `after_commit` 回調中讀取它數據庫中的 id,并在這里設置它和其他實例的關聯。
### 4.5.7 回調計算庫存
使用回調可以適當精簡邏輯代碼,比如我們購買一個商品類型時,在創建訂單后,應減少該商品類型的庫存數量。該 `減少數量` 的動作雖然屬于整體邏輯,但是和訂單邏輯是分開的,而它的觸發點正好在訂單 `create` 動作完成后,所以我們把它放到 `after_create` 中。
首先我們給 variants 增加 on_hand 屬性,表示當前持有的數量:
~~~
rails g migration add_on_hand_to_variants on_hand:integer
~~~
在 order.rb 中編寫回調:
~~~
after_create do
line_items.each do |line_item|
line_item.variant.decrement!(:on_hand, line_item.quantity)
end
end
~~~
- 寫在前面
- 第一章 Ruby on Rails 概述
- Ruby on Rails 開發環境介紹
- Rails 文件簡介
- 用戶界面(UI)設計
- 第二章 Rails 中的資源
- 應用 scaffold 命令創建資源
- REST 架構
- 深入路由(routes)
- 第三章 Rails 中的視圖
- 布局和輔助方法
- 表單
- 視圖中的 AJAX 交互
- 模板引擎的使用
- 第四章 Rails 中的模型
- 模型的基礎操作
- 深入模型查詢
- 模型中的關聯關系
- 模型中的校驗
- 模型中的回調
- 第五章 Rails 中的控制器
- 控制器中的方法
- 控制器中的邏輯
- 第六章 Rails 的配置及部署
- Assets 管理
- 緩存及緩存服務
- 異步任務及郵件發送
- I18n
- 生產環境部署
- 常用 Gem
- 寫在后面