4.5 模型中的回调(Callback)

概要:

本课时将讲解 ActiveRecord 中常用的回调方法。

知识点:

  1. ActiveModel 中的回调
  2. ActiveRecord 中的回调
  3. 编写回调
  4. 触发回调
  5. 使用回调计算库存

正文

4.5.1 ActiveModel 中的回调

ActiveModel 提供了多个实用的功能,它可以让一个普通的类,具备如属性校验,回调,显示字段 I18n 值等众多功能。

比如,我们可以为 Person 类增加了一个回调方法:

  1. class Person
  2. extend ActiveModel::Callbacks
  3. define_model_callbacks :create
  4. end

所谓回调,是指在某个方法前(before)、后(after)、前后(around),执行某个方法。上面的例子里,Person 拥有了三个标准的回调方法:before_create、after_create、around_create。

我们还需要为这个回调方法增加逻辑代码:

  1. class Person
  2. extend ActiveModel::Callbacks
  3. define_model_callbacks :create
  4. # 定义 create 方法代码
  5. def create
  6. run_callbacks :create do
  7. puts "I am in create method."
  8. end
  9. end
  10. # 开始定义回调
  11. before_create :action_before_create
  12. def action_before_create
  13. puts "I am in before action of create."
  14. end
  15. after_create :action_after_create
  16. def action_after_create
  17. puts "I am in after action of create."
  18. end
  19. around_create :action_around_create
  20. def action_around_create
  21. puts "I am in around action of create."
  22. yield
  23. puts "I am in around action of create."
  24. end
  25. end

进入到 Rails 的终端里,我们测试下这个类:

  1. % rails c
  2. > person = Person.new
  3. > person.create
  4. I am in before action of create.
  5. I am in around action of create.
  6. I am in create method.
  7. I am in around action of create.
  8. I am in after action of create.

在 ActionModel 中有许多的 Ruby 元编程知识,如果你感兴趣,可以读一读《Ruby 元编程(第二版)》这本书。

ActiveRecord 中的 回调 将常用的 findcreateupdatedestroy 等方法进行包装。

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
  • findby*
  • findby*!
  • find_by_sql
  • last

after_find 执行优先于 after_initialize。

第五种,touch 回调。

  • after_touch

执行实例的 touch 方法触发该回调。

回调执行顺序

我们观察一下以上每个回调的执行的顺序,这里做一个简单的例子:

  1. class Product < ActiveRecord::Base
  2. before_validation do
  3. puts "before_validation"
  4. end
  5. after_validation do
  6. puts "after_validation"
  7. end
  8. before_save do
  9. puts "before_save"
  10. end
  11. around_save :test_around_save
  12. def test_around_save
  13. puts "begin around_save"
  14. yield
  15. puts "end around_save"
  16. end
  17. before_create do
  18. puts "before_create"
  19. end
  20. around_create :test_around_create
  21. def test_around_create
  22. puts "begin around_create"
  23. yield
  24. puts "end around_create"
  25. end
  26. after_create do
  27. puts "after_create"
  28. end
  29. after_save do
  30. puts "after_save"
  31. end
  32. after_commit do
  33. puts "after_commit"
  34. end
  35. after_rollback do
  36. puts "after_rollback"
  37. end
  38. end

进入终端试验下:

  1. product = Product.new(name: "TTT")
  2. product.save
  3. (0.1ms) begin transaction
  4. before_validation
  5. after_validation
  6. before_save
  7. begin around_save
  8. before_create
  9. begin around_create
  10. 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"]]
  11. end around_create
  12. after_create
  13. end around_save
  14. after_save
  15. (0.7ms) commit transaction
  16. after_commit
  17. => true

可以看到,create 回调是最接近 sql 执行的,并且 validation、save、create 回调被包含在一个 transaction 事务中,最后,是 after_commit 回调。

我们在设计逻辑的过程中,需要了解它执行的顺序。当需要在回调中操作保存到数据库后的实例,需要把代码放到 在 after_commit 中。

4.5.3 编写回调

上面列出的,是回调的方法名,我们还需要编写具体的回调代码。

4.5.3.1 符号和方法

  1. class Topic < ActiveRecord::Base
  2. before_destroy :delete_parents [1]
  3. private [2]
  4. def delete_parents [3]
  5. self.class.delete_all "parent_id = #{id}"
  6. end
  7. end

[1] 用符号定义回调执行的方法名称
[2] private 或 protected 方法均可作为回调执行方法
[3] 执行的方法名,和定义的符号一致

对于 round_ 回调,我们需要在方法中使用 yield,上面的例子已经看到:

  1. around_create :test_around_create
  2. def test_around_create
  3. puts "begin around_create"
  4. yield
  5. puts "end around_create"
  6. end

4.5.3.2 代码块(Block)

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

回调执行时,self 指的是它本身。在注册的时候,我们可能不需要填写 name,而要填写 login,所以默认把 name 改成 login 的首字母大写形式。

上面例子也可以改写成:

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

4.5.3.3 在特定方法上使用回调

在一些注册和修改的逻辑中,注册时默认填写的数据,在修改时不做处理,所以回调方法只在 create 上生效,下面的例子就是这种情形:

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

或者:

  1. before_validation :normalize_name, on: :create

4.5.3.4 有条件的回调

和校验一样,回调也可以增加 if 或 unless 判断:

  1. before_save :normalize_card_number, if: :paid_with_card?

4.5.3.5 字符串形式的回调

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

before_destroy 既可以接受符号定义的方法名,也可以接受字符串。这种方式要被废弃掉了。

4.5.3.6 回调的继承

一个类集成自另一个类,也会继承它的回调,比如:

  1. class Topic < ActiveRecord::Base
  2. before_destroy :destroy_author
  3. end
  4. class Reply < Topic
  5. before_destroy :destroy_readers
  6. end

在执行 Reply#destroy 的时候,两个回调都会被执行,为了避免这种情况,可以覆写 before_destroy

  1. class Reply < Topic
  2. def before_destroy() destroy_readers end
  3. end

但是,这是非常不好的解决方案!这个代码只是一个例子,来自 这里

回调虽然可以解决问题,但是它功能太过强大,当项目代码变得复杂,回调的维护会造成很大的技术难度。建议使用回调解决小问题,过多的业务逻辑应该单独处理,或者使用单独的回调类。

4.5.3.6 单独的回调类

我们可以用一个类作为 回调类,使用它的的实例方法实现回调逻辑:

  1. class BankAccount < ActiveRecord::Base
  2. before_save EncryptionWrapper.new
  3. end
  4. class EncryptionWrapper
  5. def before_save(record) [1]
  6. record.credit_card_number = encrypt(record.credit_card_number)
  7. end
  8. end

[1] 该方法仅能接受一个参数,为该 model 实例。

还可以使用 回调类 的类方法,来定义回调逻辑:

  1. class PictureFileCallbacks
  2. def self.after_destroy(picture_file)
  3. ...
  4. end
  5. end

在使用上:

  1. class PictureFile < ActiveRecord::Base
  2. after_destroy PictureFileCallbacks
  3. 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 回调:

  1. after_create do
  2. puts "after_create"
  3. raise ActiveRecord::Rollback
  4. end

在终端里:

  1. > Product.create
  2. (0.1ms) begin transaction
  3. before_validation
  4. after_validation
  5. before_save
  6. begin around_save
  7. before_create
  8. begin around_create
  9. 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"]]
  10. end around_create
  11. after_create
  12. (8.5ms) rollback transaction
  13. after_rollback
  14. => #<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 终止了数据库事务,返回了一个没有保存到数据库中的实例。如果我们不抛出这个异常,比如抛出一个标准的异常类:

  1. after_create do
  2. puts "after_create"
  3. raise StandardError
  4. end

虽然它也会终止事务,没有把保存数据,但是它再次抛出这个异常,而不是返回我们想要的未保存实例。

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

4.5.6 after_commit中的实例

当我们在回调中使用当前实例的时候,它并没有保存到数据库中,只有当数据库事务 commit 之后,这个实例才会被保存,所以我们在 after_commit 回调中读取它数据库中的 id,并在这里设置它和其他实例的关联。

4.5.7 回调计算库存

使用回调可以适当精简逻辑代码,比如我们购买一个商品类型时,在创建订单后,应减少该商品类型的库存数量。该 减少数量 的动作虽然属于整体逻辑,但是和订单逻辑是分开的,而它的触发点正好在订单 create 动作完成后,所以我们把它放到 after_create 中。

首先我们给 variants 增加 on_hand 属性,表示当前持有的数量:

  1. rails g migration add_on_hand_to_variants on_hand:integer

在 order.rb 中编写回调:

  1. after_create do
  2. line_items.each do |line_item|
  3. line_item.variant.decrement!(:on_hand, line_item.quantity)
  4. end
  5. end