模型中的回调
最后更新于:2022-04-01 22:45:00
# 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
=> #
~~~
`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 '
...
~~~
### 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
~~~
';