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

こんにちは、hachi8833です。今回は、自分が知りたかった、ActiveRecordモデルのリファクタリングに関する記事を翻訳いたしました。1年前の記事なのでRails 3が前提ですが、Rails 4でも基本的には変わらないと思います。リンクは可能なものについては日本語のものに置き換えています。

なお、ここでご紹介したオブジェクトは、app以下にそれぞれ以下のようにフォルダを追加してそこに配置します。

注記: 以下は使われそうなフォルダを列挙しただけであり、現実にはこの一部だけとなります。

refactor

  1. Value Object
  2. Service Object
  3. Form Object
  4. Query Object
  5. View Object
  6. Policy Object
  7. Decorator

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

(元記事: 7 Patterns to Refactor Fat ActiveRecord Models)
Posted by @brynary on Oct 17th, 2012 (Code Climate Blog)

Railsアプリケーションの品質を高めるためにチーム内でCode Climateを使用していれば、モデルの肥大化を自然と避けるようになるでしょう。モデルが肥大化(ファットモデル)すると、大規模アプリケーションのメンテナンスが困難になります。ファットモデルは、コントローラがドメインロジックで散らかってしまうよりは1段階だけましであるとはいえ、たいていの場合Single Responsibility Principle (SRP:単一責任の原則)の適用に失敗した状態であると言えます。

SRPの適用は、元々難しいものではありません。ActiveRecordクラスは永続性と関連付けを扱うものであり、それ以外のものではありません。しかしクラスはじわじわ成長していきます。永続性について本質的に責任を持つオブジェクトは、やがて事実上ビジネスロジックも持つようになるのです。1年2年が経過すると、User クラスには500行ものコードがはびこり、パブリックなインターフェイスには数百ものメソッドが追加されることでしょう。それに続くのはコールバック地獄です。

アプリケーションに何か本質的に複雑な要素を追加したら、ちょうどケーキのタネをケーキ型に流し込むのと同じように、それらを小規模かつカプセル化されたオブジェクト群(あるいはより上位のモジュール)に整然と配置することが目標になります。ファットモデルは、さしずめタネをケーキ型に流し込むときに見つかるダマ(混ざらなかった粉の固まり)のようなものでしょう。これらのダマを砕いて、ロジックが等分に広がって配置されるようにしなければなりません。これを繰り返し、それらが最終的に、シンプルで、きちんと定義されたインターフェイスを持つ一連のオブジェクトとなって、それらが見事に協調動作するようにしましょう。

そうは言っても、きっとこう思う人もいることでしょう。

“でもRailsでちゃんとOOPするのってめちゃくちゃ大変ぢゃなくね?!”

私も以前は同じことを思ってました。でも若干の調査と実践の結果、RailsというフレームワークはOOPを妨げてなどいないという結論に達しました。スケールできないでいるのはRailsのフレームワークではなく、従来のRailsの慣習・流儀の方です。より具体的に言えば、Active Recordパターンできちんと扱える範囲を超えるような複雑な要素を扱うための定番の手法がまだないのです。幸いにも、オブジェクト指向における一般的な原則とベストプラクティスというものがあるので、Railsに欠けている部分にこれらを適用することができます。

[その前に]ファットモデルからミックスインで展開しないこと

肥大化したActiveRecordクラスから単に一連のメソッドを切り出して “concerns” やモジュールに移動するのはよくありません。移動したところで、後でまた1つのモデルの中でミックスインされてしまうのですから。いつだったか、こんなことを言っていた人がいました。

“app/concerns ディレクトリを使っているようなアプリケーションって、だいたい後から頭痛の種になる(=concerning)んだよね”

私もそう思います。ミックスインよりも、継承で構成する方がよいと思います継承よりコンポジションの方がよいと思います。このようなミックスインは、部屋に散らかっているガラクタを引き出しに押し込めてピシャリと閉めたのと変わりません。一見片付いているように見えても、引き出しの中はぐちゃぐちゃ、どこに何があるのかを調べるだけでも大変です。ドメインモデルを明らかにするために必要な分解と再構成を実装するのも並大抵ではありません。
これはもうリファクタリングするしかないでしょう。

1. Value Object(link

Value Objectは、異なるオブジェクト同士であっても値が等しければ等しいと見なされる、シンプルなオブジェクトです。Value Objectは変更不可能であるのが普通です。Rubyの標準ライブラリにはDateURIPathname などのValue がありますが、Railsアプリケーションでもドメイン固有のValue Objectを定義できますし、そうすべきです。ActiveRecordからValue Objectへの展開は、すぐにもメリットの得られるリファクタリングです。

Railsでは、ロジックが関連付けられている属性が1つ以上ある場合にはValue Objectが有用です。単なるテキストフィールドやカウンタ以上の要素は、何でもValue Objectの候補になりえます。

ちょうど著者が仕事をしている某テキストメッセージングアプリケーションには、PhoneNumber というValue Objectがあります。そして某eコマースアプリケーションではMoneyクラスを必要としています。私たちのCode Climateには RatingというValue Objectがあり、受け取ったクラスやモジュールのランキングをAからFまでの段階で表します。ここではRuby のStringクラスのインスタンスを使用することもできます(実際使用していました)が、このRatingを使用すると以下のように振る舞いとデータを一体化することができます。

class Rating
  include Comparable

  def self.from_cost(cost)
    if cost <= 2
      new("A")
    elsif cost <= 4
      new("B")
    elsif cost <= 8
      new("C")
    elsif cost <= 16
      new("D")
    else
      new("F")
    end
  end

  def initialize(letter)
    @letter = letter
  end

  def better_than?(other)
    self > other
  end

  def <=>(other)
    other.to_s <=> to_s
  end

  def hash
    @letter.hash
  end

  def eql?(other)
    to_s == other.to_s
  end

  def to_s
    @letter.to_s
  end
end

次にすべてのConstantSnapshotRatingのインスタンスをパブリックなインターフェイスに公開します。

class ConstantSnapshot < ActiveRecord::Base
  # …

  def rating
    @rating ||= Rating.from_cost(cost)
  end
end

これによりConstantSnapshotがスリムになるだけでなく、他にも多くの利点があります。

  • #worse_than?メソッドと#better_than?メソッドは、レートを比較する場合には<>などのRubyの組み込み演算子よりも適切です。
  • #hash#eql?を定義しておけばRatingをハッシュキーとして使用できます。Code Climateではこれを使用して、定数をレートごとにEnumberable#group_byでグループ化しています。
  • #to_sメソッドを定義してあるので、Ratingを簡単に文字列やテンプレートに変換できます。
  • このクラス定義は、ファクトリーメソッドを導入する場合にも便利です。矯正コスト (=クラスの「臭い」を除去するのにかかる時間) に見合う、正しい Rating を得られます。

2. Service Object(link

アクションによってはService Objectを使用して操作をカプセル化することができるものがあります。著者の場合、以下の基準に1つでも合えばService Objectの導入を検討します。

  • アクションが複雑になる場合 (決算期の終わりに帳簿をクローズする、など)
  • アクションが複数のモデルにわたって動作する場合 (eコマースの購入でOrder, CreditCard, Customer を使用する、など)
  • アクションから外部サービスとやりとりする場合 (SNSに投稿する、など)
  • アクションが背後のモデルの中核をなすものではない場合 (一定期間ごとに古くなったデータを消去する、など)
  • アクションの実行方法が多岐にわたる場合 (認証をアクセストークンやパスワードで行なう、など)。これはGoF (Gang of Four) のStrategyパターンです。

例として、User#authenticate メソッドを取り出して UserAuthenticatorに配置しましょう。

class UserAuthenticator
  def initialize(user)
    @user = user
  end

  def authenticate(unencrypted_password)
    return false unless @user

    if BCrypt::Password.new(@user.password_digest) == unencrypted_password
      @user
    else
      false
    end
  end
end

このとき、SessionsController は以下のような感じになります。

class SessionsController < ApplicationController
  def create
    user = User.where(email: params[:email]).first

    if UserAuthenticator.new(user).authenticate(params[:password])
      self.current_user = user
      redirect_to dashboard_path
    else
      flash[:alert] = "Login failed."
      render "new"
    end
  end
end

3. Form Object(link

1つのフォーム送信で複数のActiveRecordモデルを更新する場合、Form Objectを使用して集約することができます。Form Objectを使えば、(個人的には使用を避けたい) accepts_nested_attributes_forよりもずっときれいなコードになります。CompanyUserを同時に作成するユーザー登録フォームを例にとってみましょう。

class Signup
  include Virtus

  extend ActiveModel::Naming
  include ActiveModel::Conversion
  include ActiveModel::Validations

  attr_reader :user
  attr_reader :company

  attribute :name, String
  attribute :company_name, String
  attribute :email, String

  validates :email, presence: true
  # … more validations …

  # Forms are never themselves persisted
  def persisted?
    false
  end

  def save
    if valid?
      persist!
      true
    else
      false
    end
  end

private

  def persist!
    @company = Company.create!(name: company_name)
    @user = @company.users.create!(name: name, email: email)
  end
end

これらのオブジェクトではVirtus gemを使用してActiveRecord的な属性機能を利用しています。Form ObjectはActiveRecordと同様に振る舞うので、コントローラは通常と変わらないものになります。

class SignupsController < ApplicationController
  def create
    @signup = Signup.new(params[:signup])

    if @signup.save
      redirect_to dashboard_path
    else
      render "new"
    end
  end
end

Form Objectは、上のようなシンプルな例ではうまくいきますが、永続性のロジックが含まれていてフォームが複雑になるのであれば、Service Objectも併用するのがよいでしょう。

Validationロジックはコンテキストに依存することが多いのですが、Form Objectを導入するメリットとして、ActiveRecord自身の中でvalidationを行なうという融通の効かない方法に代えて、validationロジックをコンテキストに応じた場所で定義できるというのがあります。

4. Query Object(link

スコープやクラスメソッドなどのActiveRecordサブクラスが乱雑に定義された、複雑なSQLクエリがある場合は、Query Objectに展開することを検討します。1つのQuery Objectは、ビジネスロジックに基づいた結果セットを1つだけ返す責任を持ちます。

たとえば、訪問されないままになっているお試しを検索するQuery Objectは以下のような感じになります。

class AbandonedTrialQuery
  def initialize(relation = Account.scoped)
    @relation = relation
  end

  def find_each(&block)
    @relation.
      where(plan: nil, invites_count: 0).
      find_each(&block)
  end
end

このオブジェクトを使ってバックグラウンドで以下のようにメールを送信します。

AbandonedTrialQuery.new.find_each do |account|
  account.send_offer_for_support
end

ActiveRecord::RelationインスタンスはRails 3によって第一級オブジェクトとして扱われるため、Query Objectでも多くの機能を使用できます。このおかげで、コンポジションを使用してクエリを結合できます。

old_accounts = Account.where("created_at < ?", 1.month.ago)
old_abandoned_trials = AbandonedTrialQuery.new(old_accounts)

この種のクラスのテストは個別には行ないません。オブジェクトのテストとデータベースのテストは同時に行うようにし、それによって正しい行が正しい順序で返されることと、結合(join)とeager loadingがすべて動作する(N + 1クエリ問題などを回避できている)ことを確認します。

5. View Object(link

表示にしか使わないようなロジックが必要な場合、それはモデルに置くべきではありません。「仮にアプリケーションのUIががらりと変わったら(たとえば音声駆動UIになったとしたら)、その時にもこれをモデルに置く必要があるだろうか」と自問自答してみましょう。モデルに置く必要のない表示ロジックであることがわかったら、ヘルパーに置くか、できればなるべくView Object(訳注: これはRailsのビューテンプレートとは別です)に置くようにしましょう。

たとえば、Code ClimateではRails on Code Climateなどでコードベースのスナップショットに基づいたクラスのレート付けを行う円グラフを使用していますが、これらは次のようにView Objectにカプセル化できます。

class DonutChart
  def initialize(snapshot)
    @snapshot = snapshot
  end

  def cache_key
    @snapshot.id.to_s
  end

  def data
    # @snapshotからデータを取り出してJSON構造に変換するコードを置く
  end
end

ところで、著者はビューとERBテンプレート (Haml/Slimでも同じですが) が一対一対応していることが多いのに気が付きました。そこで、Railsで使えるTwo Step Viewパターンを実装できないか調べ始めたのですが、今のところこれについては明快なソリューションを見つけられずにいます。

メモ

Railsコミュニティでよく使用されている「Presenter」という用語についてですが、この用語が他の用法と重複したり誤解を招いたりする可能性があるため、著者はこの用語を避けるようにしています。Presenterという語は、本記事で言うところのForm Objectを説明するためにJay Fieldsによって導入されました。また、運の悪いことにRailsでは「View」という用語もいわゆる「(ビューの)テンプレート」を指すものとして使用しています。曖昧さを避けるため、著者はView Objectを「Viewモデル」と書くことがあります。

6. Policy Object(link

複雑な読み出し操作はそのオブジェクト自身で行なうのがふさわしいことがあります。著者はこのような場合にPolicy Objectを検討します。Policy Objectを使うことで、本質的でないロジック (分析用にどのユーザーをアクティブとみなすか、など) を、中核となるドメインオブジェクトから切り離すことができます。以下は例です。

class ActiveUserPolicy
  def initialize(user)
    @user = user
  end

  def active?
    @user.email_confirmed? &&
    @user.last_login_at > 14.days.ago
  end
end

このPolicy Objectには1つのビジネスルールがカプセル化されています。このビジネスルールでは、emailが確認済みで、かつ2週間以内にログインしたことがあるユーザーをアクティブなユーザーとみなすようになっています。Policy Objectは、複数のビジネスルール (特定のデータへのアクセスを許可するAuthorizer など) をカプセル化することもできます。

Policy ObjectはService Objectと似ていますが、著者はService Objectは書き込み操作、Policy Objectは読み出し操作と使い分けています。これらはQuery Objectとも似ていますが、Policy Objectはメモリに読み込み済みのドメインモデルについて操作を行なうのに対し、Query Objectは特定の結果セットを返す「SQLの実行」に特化している点が異なります。

7. Decorator(link

Decoratorは既存の操作に関する機能を階層化することによって、コールバックとよく似た機能を果たします。Decoratorは、特定の環境でしか実行したくないコールバックロジックがある場合や、ある機能をモデルに含めるとモデルの担当責任が増え過ぎる(=モデルが肥大化する)場合に便利です。

あるブログ投稿にコメントが付くと誰かのFacebookウォールに自動的に投稿されるようにしたとします。この場合、このロジック自体をCommentクラスにハードコードしなければならないということにはなりません。コールバックに多くの責任を負わせすぎると、テストの実行が遅くなり、不安定になるという形で兆候が現れます。こうした副作用は、何の関連もないテストケースから取り除くべきでしょう。

Facebookへの投稿ロジックをDecoracorに展開する方法を以下に示します。

class FacebookCommentNotifier
  def initialize(comment)
    @comment = comment
  end

  def save
    @comment.save && post_to_wall
  end

private

  def post_to_wall
    Facebook.post(title: @comment.title, user: @comment.author)
  end
end

このDecoratorをコントローラで以下のように使用します。

class CommentsController < ApplicationController
  def create
    @comment = FacebookCommentNotifier.new(Comment.new(params[:comment]))

    if @comment.save
      redirect_to blog_path, notice: "Your comment was posted."
    else
      render "new"
    end
  end
end

DecoratorはService Objectとは異なります。Service Objectは既存のインターフェイスに対する責任を階層化しますが、これをDecorator化すると、FacebookCommentNotifier インスタンスをあたかも単なるCommentであるかのように取り扱います。

Rubyは、メタプログラミングを使用してDecoratorを簡単に作成するための仕組みを標準ライブラリに多数備えています。

最後に

複雑なモデル層をうまく取り扱うためのツールは、Railsアプリケーションにも多数存在します。これらのツールを使用するためにRailsを捨てる必要などありません。ActiveRecordsは素晴らしいライブラリですが、これだけに頼っていてはどんなパターンも失敗します。ActiveRecordsは、極力永続的な振る舞いにとどめておくようにしてください。本記事で紹介したテクニックを一部だけでも適用して、自分のアプリケーションのドメインモデルに固まっているロジックを分散させることができれば、アプリケーションのメンテナンスはずっと容易になるでしょう。

本記事で紹介したパターンの多くはシンプルです。これらのオブジェクトは、いずれも「昔ながらのシンプルなRubyオブジェクト(Plain Old Ruby Objects: PORO)」であって、ただその使い方が異なるだけです。これはOOPの一部であり、OOPの美しさをなすものです。問題を解決するのにフレームワークやライブラリだけに頼る必要はありません。手法に適切な名前を付けることも重要な技法です。

本記事で紹介した7つの技法はいかがでしたでしょうか。お気に入りの技法は見つかりましたでしょうか。それはどんな理由でしょうか。コメントをお待ちしています。

追伸: この記事を気に入っていただけましたら、元記事の下にあるフォームからCode Climateのニュースレターをぜひ購読してみてください。OOPやRailsアプリケーションのリファクタリングなど、今回の記事のようなトピックを扱っています。記事のボリュームは控えめにしています。

より詳細な情報

本記事をレビューしてくれた皆様に感謝します: Steven Bristol, Piotr Solnica, Don Morrison, Jason Roelofs, Giles Bowkett, Justin Ko, Ernie Miller, Steve Klabnik, Pat Maddox, Sergey Nartimov, Nick Gauthier

デザインも頼めるシステム開発会社をお探しならBPS株式会社までどうぞ 開発エンジニア積極採用中です! Ruby on Rails の開発なら実績豊富なBPS

この記事の著者

hachi8833

Twitter: @hachi8833、GitHub: @hachi8833 コボラー、ITコンサル、ローカライズ業界、Rails開発を経てTechRachoの編集・記事作成を担当。 これまでにRuby on Rails チュートリアル第2版の半分ほど、Railsガイドの初期翻訳ではほぼすべてを翻訳。その後も折に触れてそれぞれ一部を翻訳。 かと思うと、正規表現の粋を尽くした日本語エラーチェックサービス enno.jpを運営。 実は最近Go言語が好き。 仕事に関係ないすっとこブログ「あけてくれ」は2000年頃から多少の中断をはさんで継続、現在はnote.muに移転。

hachi8833の書いた記事

週刊Railsウォッチ

インフラ

ActiveSupport探訪シリーズ