@ZouChao

热爱PHP,RUBY,专注于web开发. 撸起袖子加油干!

Callbacks are methods that get called at certain moments of an object’s life cycle. With callbacks it is possible to write code that will run whenever an Active Record object is created, saved, updated, deleted, validated, or loaded from the database.

以上是RailsGuidesActiveRecord的回调的解释,大致意思是:

回调是在对象生命周期的特定时刻执行的方法。回调方法可以在 Active Record 对象创建、保存、更新、删除、验证或从数据库中读出时执行。

我们有很多场景都会用到,但是某些情况你可能会发现其实他们也没有你想象中那么好用,比如说,你有一个问答网站,并希望所有的问题都能被搜索到,为了搜索的效率你又引入了ElasticSearch,es的数据需要索引,而为了构建索引的效率以及实时性,你又引入了sidekiq。听起来很复杂,其实这种比你想象中要常见的多。

而此时视乎是使用after_save的绝佳时机,因此,在你的模型中,你大概会这样写:

app/models/question.rb
class Question < ActiveRecord::Base
  after_save :index_for_search

  # ...

  private

  def index_for_search
    QuestionIndexerJob.perform_later(self)
  end
end
app/jobs/question_indexer_job.rb
class QuestionIndexerJob < ActiveJob::Base
  queue_as :default

  def perform(question)
    # ... index the question ...
  end
end

到这看起来似乎都是完美的。直到你查看你的sidekiq日志,看到这些错误显示:

2015-03-10T05:29:02.881Z 52530 TID-oupf889w4 WARN: Error while trying to deserialize arguments: Couldn't find Question with 'id'=3

当然sidekiq里面可以使用重试机制,让索引构建成功,但是发生这样的错误还是很怪异,对吧?

多线程和多进程的错

其实此处的sidekiq相当于形成了一个多进程来执行查询,是的sidekiq是单独的进程在运行。而after_save执行回调时,事物并没有提交。此时在另一个进程中运行的sidekiq自然也就查询不到数据。

是的,我们自然而然的应该想到ActiveRecord的另外一个关于事物的回调after_commit,修改模型:

app/models/question.rb
class Question < ActiveRecord::Base
  after_commit :index_for_search, on: [:create, :update]

  # ...
end

再观察sidekiq日志,发现问题确实解决了。完美

高兴太早,还有一个问题

当你是用了一堆after_commit来替换after_save之后,接下来我们运行一下测试:

test/models/question_test.rb
require 'test_helper'

class QuestionTest < ActiveSupport::TestCase
  test "A saved question is queued for indexing" do
    assert_enqueued_with(job: QuestionIndexerJob) do
      Question.create(title: "Is it legal to kill a zombie?")
    end
  end
end
 1) Failure:
QuestionTest#test_A_saved_question_is_queued_for_indexing [/Users/jweiss/Source/testapps/after_commit/test/models/question_test.rb:7]:
No enqueued job found with {:job=>QuestionIndexerJob}

哎哟,什么情况?不是只换了一个回调方法嘛,而且作用都差不多。发生了什么?

默认情况下,rails的测试是每个测试用例使用 ** 一个事物 ** 包裹,这确实对测试效率提升很大。只需一个指令即可撤销在该测试用例中执行的所有数据库操作。因此你的数据在保存的时候并没有事物提交,so你的after_commit也根本不会执行。

其实这个问题也有一个比较简单的方式来解决,那就是引入一个叫test_after_commit的gem包:

Gemfile
group :test do
  gem "test_after_commit"
end

这样有after_commit的回调就能在测试中再加一层事物, 得到我们想要的效果。但是也许你还是会觉得别扭,为毛我要为这事儿单独去加载一个gem?你是对的,这非常别扭。但是这事也不会持续太久了,因为rails5中已经修复了这个问题:https://github.com/rails/rails/pull/18458

 本文译自: A Couple of Callback Gotchas (and a Rails 5 Fix) | Justin Weiss's blog