💎一站式轻松地调用各大LLM模型接口,支持GPT4、智谱、星火、月之暗面及文生图 广告
# 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 ~~~