李玮 · 更新于 2018-11-28 11:00:42

4.5 模型中的回调(Callback)

概要:

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

知识点:

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

正文

4.5.1 ActiveModel 中的回调

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 元编程(第二版)》这本书。

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 方法触发该回调。

回调执行顺序

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

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

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

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

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