Rails: Active Recordモデルのスレッド安全性問題をインスタンス変数で解決する(翻訳)
原文概要
RailsのActive Recordがあまりにも強力なので、開発者はともすると、Active Recordモデルが内部では普通のRubyオブジェクトにすぎないことを忘れてしまうときがあります。普通のRubyオブジェクトであるがゆえに、標準的なインスタンス変数で振る舞いを適応させることも可能です。本記事では、この手法を解説するとともに、実際に動かせるサンプルコードも提供します。
はじめに
多くのチュートリアルや技術記事では、Railsフレームワークを紹介するときにフレームワークの振る舞いやActiveRecord::Base
クラスのAPIを熱心に解説しています(もっともな話です)。そしてActiveRecord::Base
のサブクラスがビジネスドメインにおけるモデルを表現できること、そしてモデルをデータベースに永続化する自動ORMを提供していることも解説します。
ActiveRecord::Base
は豊富なAPIを提供し(1)、データモデリングに必要となるさまざまな振る舞いをカバーしています。
なお、Active RecordパターンのRailsにおける実装では、このAPIの深みが問題視されることもあります。曰く「この巨大なAPIは単一責任の原則に違反している」「開発者が"ファットモデル"(アプリケーションのあらゆるロジックが不適切にActive Recordモデルに集中する)というアンチパターンに陥りやすい(2)」「いわゆる"神オブジェクト"に結晶化されてしまいがち(4)」といった具合です。ファットモデルというアンチパターンから脱却するために、多くの手法が議論されています(2、3)。
Active Recordモデルの機能を考察するうえで、Active Recordモデルが単なるRubyオブジェクトに振る舞いを追加したものに過ぎないという点は非常に見過ごされがちです。つまり、Ruby標準のオブジェクト機能を使えばモデルの振る舞いをカスタマイズできるということです。本記事では、オブジェクトのインスタンス変数を用いて成功した例を見ていくことにします。
本記事に登場するコードの完全版はGitHubで参照できます(5)。
ActiveRecord
ドメインモデル
ここでは、User
クラスとPost
クラスがある平凡なブログアプリケーションを考えてみましょう。これらのクラスは以下のように定義されています。
# 属性: :idと:nameのみ
class User < ActiveRecord::Base
end
# 属性: :id、:user_id (FK)、:title、:body
class Post < ActiveRecord::Base
belongs_to :user
validates :user, :body, :title, presence: true
after_create :enrich_body
def copy
Post.skip_callback(:create, :after, :enrich_body)
Post.new(is_copy: true) do |post|
sleep 1 # マルチスレッドで興味深い振る舞いを観察するためにsleepを追加する
post.user = self.user
post.body = self.body
post.title = self.title
post.save!
end
ensure
Post.set_callback(:create, :after, :enrich_body)
end
private
def enrich_body
self.body += "\n著者: #{user.name}"
end
end
User
モデルは、標準的に継承されるActiveRecord::Base
の振る舞いに依存しており、users
テーブルから推測されたいくつかの属性を持っています。id
とname
属性だけの、特に面白みのないモデルです。
Post
モデルはもう少し興味深いものになっていて、Post
の著者を表現するUsers
のインスタンスと関連付けられています。必須の属性が存在しているかどうかをチェックするバリデーションがいくつか備わっていて、:after_create
フックでpostのbody
属性に著者名を追加するシンプルな更新も実行します。
ActiveRecord
標準の振る舞いに加えて、Post
のインスタンスをcopy
するカスタムメソッドも実装してあります。
単に元のpostの属性を用いてPost
インスタンスを新たに作成すると、この新しいモデルを保存するときに問題が発生します(:after_create
フックがもう一度実行されてbody
に著者名が2つ追加されてしまう)。
そこで、この振る舞いを回避するためにcopy
メソッドでActiveRecord::Base
APIのskip_callback
メソッドを呼び出すことにしました。
すなわち、このコードは、PumaやSidekiqなどのマルチスレッド環境で動かさない限り問題なく動作します。この問題は、以下のようなモデルの単体テストを書くことで再現できます。
require "test_helper"
class PostTest < ActiveSupport::TestCase
setup do
@user = User.create!(name: "Domhnall")
@attrs = {
user: @user,
title: "ARインスタンス変数",
body: "こんなの誰が思いつくだろう"
}
@post = Post.new(@attrs)
end
...
test "copyは新しいPostオブジェクトを返すこと" do
assert @post.copy.is_a?(Post)
assert_not_equal @post.copy.id, @post.id
end
test "copyはpostのbodyに元と同じものを設定すること" do
assert_equal @post.copy.body, @post.body
end
test "copyはスレッドセーフであること" do
n=2
(0...n).map do |i|
Thread.new do
puts "Thread #{i}"
post = Post.new({
user: @user,
title: "ARインスタンス変数 #{i}",
body: "こんなの誰が思いつくだろう #{i}"
})
copy = post.copy
puts copy.body
assert_equal post.body, copy.body
end
end.each(&:join)
end
end
冒頭の2つのテストは見ればおわかりかと思いますが、末尾のテストは少し説明が必要でしょう。
このテストはn
個の独立したスレッドをセットアップし、各スレッド内でPost
を新たにビルドして、インスタンス化されたPost
にコピーを試みます。最後は、そのpostのbody
が元のpostと一致すべきというアサーションです。
このテストを実行すると、以下のように失敗します。
copy
操作をマルチスレッド環境で実行しようとすると、単体テストが失敗します。
失敗の理由は、以下のエラー出力に示されています。
After create callback :enrich_body has not been defined (ArgumentError)
実は、:skip_callback
がスレッドセーフでないことが失敗の原因だったのです。おそらく、この:skip_callback
をPost
インスタンスではなくPost
クラスで呼び出したらraiseすべきです。
n>1
の値をいろいろ変えても同じエラーが発生し、n=1
(シングルスレッドを表す)の場合は問題なくパスすることから、この問題がスレッド安全性に関係していることが確認できます。
このスレッド安全性の問題をどう解決すればよいでしょうか?
通常のインスタンス変数で解決する
私たちの解決方法は「インスタンス変数を使う」といういたってシンプルなものです。このインスタンス変数は、copy
操作中にオブジェクトの新しいインスタンスに設定可能で、これでafter_create
フックを実行すべきかどうかを制御します。
最初に解決済みのコード全体を示し、次に各部分を見ていくことにします。
# 属性: :id、:user_id (FK)、:title、:body
class Post < ActiveRecord::Base
attr_accessor :is_copy
belongs_to :user
validates :user, :body, :title, presence: true
after_create :enrich_body, unless: :is_copy
def copy
Post.new(is_copy: true) do |post|
sleep 1
post.user = self.user
post.body = self.body
post.title = self.title
post.save!
end
end
private
def enrich_body
self.body += "\nAuthored by #{user.name}"
end
end
ここではattr_accessor
メソッド(6を参照)でis_copy
インスタンス変数を定義し、Post
の各インスタンスで参照できるようにしています。これはRuby標準のインスタンス変数であり、ActiveRecord
モデルの典型的な属性とは異なります。
属性と対照的に、このis_copy
インスタンス変数は、メモリ上に存在するオブジェクトのステートを保持するためのものであり、データベースには永続化されません。しかし今必要なのは、まさしくこれなのです。
is_copy
インスタンス変数はブーリアン値を保持し、それを元にafter_create
フックを実行すべきかどうかのフラグとして利用します。
after_create :enrich_body, unless: :is_copy
これで、特定のコンテキストではafter_create
を実行してはならないことを、copy
操作で設定できるブーリアンフラグで示せるようになりました。後はcopy
操作中にis_copy
フラグを設定すれば完了です。
def copy
Post.new(is_copy: true) do |post|
sleep 1
post.user = self.user
post.body = self.body
post.title = self.title
post.save!
end
end
さらに、sleep
呼び出しを追加するだけで、マルチスレッドの問題を明確にテストできるようになりました。新しい実装の単体テストは問題なくパスします。
copy
操作でskip_callback
の代わりにインスタンス変数を使ったことで、
単体テストがすべてパスするようになりました。
まとめ
RailsのActiveRecord
モジュールは、ドメインモデリングに必要な機能を多数提供していますが、Active Recordモデルの内部を見てみれば通常のRubyオブジェクトにすぎません。場合によっては、このRuby標準機能だけで問題を解決できることもあるのです。
本記事ではそうした例を紹介し、ActiveRecord
のskip_callback
メソッドで起きるマルチスレッド問題について検証しました。ActiveRecord
モデルでskip_callback
メソッドの代わりに通常のインスタンス変数を使えば、この問題を解決できます。
本記事で紹介した手法についてお気づきの点や感想などありましたら、ぜひ元記事末尾のコメント欄までお寄せください。
本記事が気に入った方やWeb技術やWeb開発に興味がおありの方は、元記事のサイドバーにある私たちのメーリングリストにぜひ登録してください。今後公開される記事をメールで受け取れるようになります。
参考資料
- 🔗 Rails APIドキュメント
ActiveRecord::Base
- 🔗 ファットモデルを分割する方法: 肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)
- 🔗 Airbrakeの"ファットモデル"アンチパターン記事: Top Tips for Refactoring Fat Models in Rails
- 🔗 ArkencyのRailsにおける神オブジェクト記事: OOP Refactoring: from a god class to smaller objects | Arkency Blog
- 🔗 本記事のソースコードが置かれているGitHubリポジトリ: domhnall/active-record-instance-variables
- 🔗
attr_accessor
利用法の解説記事: How to Use attr_accessor, attr_writer & attr_reader - RubyGuides
概要
元サイトの許諾を得て翻訳・公開いたします。
日本語タイトルは内容に即したものにしました。