Tech Racho エンジニアの「?」を「!」に。
  • Ruby / Rails関連

Rails: Active Recordモデルのスレッド安全性問題をインスタンス変数で解決する(翻訳)

概要

元サイトの許諾を得て翻訳・公開いたします。

日本語タイトルは内容に即したものにしました。

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)」といった具合です。ファットモデルというアンチパターンから脱却するために、多くの手法が議論されています(23)。

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テーブルから推測されたいくつかの属性を持っています。idname属性だけの、特に面白みのないモデルです。

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_callbackPostインスタンスではなく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標準機能だけで問題を解決できることもあるのです。

本記事ではそうした例を紹介し、ActiveRecordskip_callbackメソッドで起きるマルチスレッド問題について検証しました。ActiveRecordモデルでskip_callbackメソッドの代わりに通常のインスタンス変数を使えば、この問題を解決できます。


本記事で紹介した手法についてお気づきの点や感想などありましたら、ぜひ元記事末尾のコメント欄までお寄せください。

本記事が気に入った方やWeb技術やWeb開発に興味がおありの方は、元記事のサイドバーにある私たちのメーリングリストにぜひ登録してください。今後公開される記事をメールで受け取れるようになります。

参考資料

  1. 🔗 Rails APIドキュメント ActiveRecord::Base
  2. 🔗 ファットモデルを分割する方法: 肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)
  3. 🔗 Airbrakeの"ファットモデル"アンチパターン記事: Top Tips for Refactoring Fat Models in Rails
  4. 🔗 ArkencyのRailsにおける神オブジェクト記事: OOP Refactoring: from a god class to smaller objects | Arkency Blog
  5. 🔗 本記事のソースコードが置かれているGitHubリポジトリ: domhnall/active-record-instance-variables
  6. 🔗 attr_accessor利用法の解説記事: How to Use attr_accessor, attr_writer & attr_reader - RubyGuides

関連記事

肥大化したActiveRecordモデルをリファクタリングする7つの方法(翻訳)


CONTACT

TechRachoでは、パートナーシップをご検討いただける方からの
ご連絡をお待ちしております。ぜひお気軽にご意見・ご相談ください。