What is it, naokirin?

Rails のモデルを整理しよう

Rails では様々な機能を使って、共通化や抽象化、シンプルなコードへの書き換えなどを行えるようになっています。今回はモデルに焦点を絞り、それらをまとめていきたいと思います。また、整理を始める前に気をつけるべきことをまとめておこうと思います。

Rails5 以降をメインとしていますが、概ね Rails4 でも同じであることが多いのではないかと思います。

おもに自分向けの情報整理です。

コールバック

コールバックは以下のような、特定のメソッド呼び出し時の前後などに自動的に呼び出される処理を宣言的に記述する方法です。

class User < ApplicationRecord
  before_save { self.email = email.downcase }
end

整理する前に

コールバックはシンプルな機能に絞る

コールバックを複雑化することで、実行フローのどこで変更がかかったかわからなくなる可能性があります。自動的にデータに変更がかかることは便利ではありますが、特にチーム開発では大きな問題となります。

class User < ApplicationRecord
  before_save { self.full_name = "#{first_name} #{last_name}" }
end

このようなコールバックがあった場合、ミドルネームを考慮して対応する事になった場合でも、モデル側のコールバックに気が付かないと途中にミドルネームを対応する処理を加えても勝手にミドルネームなしの名前になってしまいます。

確実に変更として加えて良いようなものなどに絞っておくのが良いでしょう。

テストでも走ることを考慮しておく

またコールバックは production でも test でも同じように走ることになります。テスト時に走ってしまうと困るような重い処理や、外部APIアクセスなどは避けるべきでしょう。

コールバックをクラスに分離する

コールバックはブロック、シンボル、オブジェクトで記述できます。例としてはブロックによる記述をしましたが、名前付けをする意味でもシンボルかオブジェクトによる記述を心がけるべきでしょう。

さてシンボルではなく、クラスに分離してコールバックを実装する理由は何でしょうか。

  • 特定の機能に対して複数のコールバックが設定される場合に、1つのクラスにまとめて記載することができる(責務の分離)
  • テストをコールバックのみに集中して記述できる

一方で分離しすぎると不必要にコード量が増えて追いにくくなるため、単にコールバックを追い出すのではなく、特定の責務というまとまりを意識して分離すべきでしょう。

class User < ApplicationRecord
  before_save PasswordHasher.new("hashed_password")
  after_save PasswordHasher.new("hashed_password")
  after_initialize PasswordHasher.new("hashed_password")
end

class PasswordHasher
  def initialize(attribute)
    @attribute = attribute
  end

  def before_save(record)
    record.send("#{@attribute}=", encrypt(record.send("#{@attribute}"))
  end

  ...
end

ちなみにコールバックは適切に記述しておく必要があり、それぞれの順序や特性を理解しておく必要があります。

Active Record コールバック - Rails ガイド

バリデーション

バリデーションもコールバックと同じようにシンボルやクラスに分離することができます。

# シンボルを利用する場合
validate :must_have_name
...
def must_have_name
  errors.add(:base, 'must have name') if name.blank?
end
# EachValidator によるクラスの実装例(lib/autoload に追加する)
class EmailValidator < ActiveModel::EachValidator
  def validate_each(record, attribute, value)
    # エラー時は record.errors に追加する 
  end
end

# モデルクラス内の実装
validates :email, email: true

EachValidator、PresenceValidator、UniquenessValidator

EachValidatorやPresenceValidator、UniquenessValidatorなどを用いると、単一の属性に対するバリデーションを行うことができます。例で示したように "#{validation_name.capitalize}Validator" のような名前のクラスを実装したい各Validatorクラスを継承して実装することで対応できます。今回は lib/autoload 以下としましたが、影響が大きいため別にしたい場合は次の validates_with の利用を検討すると良いかと思います。

Validatorとvalidates_with

より複雑であったり、複数の属性値にまたがる検証をまとめたい場合は、Validatorクラスを継承して、 validates_with メソッドを利用するのが良いでしょう。

また全体に影響したくない場合は、 validates_with でクラスを指定すると、影響範囲を抑えながらバリデーションの処理を整理することができます。 実装例はRails ガイドを見るのが早いと思います。

Active Recordバリデーション - Rails ガイド

値オブジェクト

ドメイン駆動設計を知っている人であれば、値オブジェクトの利用を検討することがあるでしょう。 値オブジェクトとは、一意識別を必要としない状態管理をしないオブジェクト(不変なオブジェクト)のことを指します。

値オブジェクトとcomposed_of

値オブジェクトを切り出してみます。最初にUserクラスに郵便番号と住所があるとして、それをAddressという値オブジェクトに切り出してみます。

class User < ApplicationRecord
  def address
    @address ||= Address.new(postcode, location)
  end

  def address=(address)
    self.postcode = address.postcode
    self.location = address.location
    @address = address
  end
end

class Address
  attr_accessor :postcode, :location

  def initialize(postcode, location)
    @postcode = postcode
    @location = location
  end

  def ==(other)
    return false if other.is_a?(Address)
    postcode == other.postcode && location == other.location
  end
end

値オブジェクトの利点として、ビジネス的なまとまりをクラス化できるとともに、それに関連するビジネスロジックを適切に分離できる点にもあります。 ちなみに上記のようなシンプルな値オブジェクトであれば、 Struct を継承することも検討するとよいです。

class Address < Struct.new(:postcode, :location)
end

先程は自身でメソッドを定義しましたが、 composed_of を利用してみます。

class User < ApplicationRecord
  composed_of :address, mapping: [ %w(postcode postcode), %w(location location)]
end

このようにすることで、値オブジェクトを指定して、Userオブジェクトを Addressオブジェクトを利用して生成したりできるようになります。

User.new(..., Address.new('123-4567', '東京都○□区XYZ'))

composed_of はクラス名と属性名が不一致だったり、コンストラクタが :new でない場合は複雑化するため、その場合は使用せずに書いてしまったほうがわかりやすいかもしれません。

ActiveSupport::Concern と Mix-in

Mix-in は Rails に限らず Ruby では利用されますが、Rails では ActiveSupport::Concern を利用することで簡単に書くことができるようになります。

# コメント機能を追加するモジュール
module Commentable
  extend ActiveSupport::Concern

  included do
    has_one :comment
    scope :commented, -> { where.not(comment: nil) }
  end
end

class User < ApplicationRecord
  include Commentable
end

Mix-in による共通化は、一見便利ですが、is-a や has-a というよりは acts-as-a(a のように振る舞う)のような関係といえるものです。継承や移譲が適切な場面で Mix-inを使うべきではないでしょう。

また、モジュール化する単位をクラスの切り出しと同様にきちんと考える必要があります。これまでに書いたことを踏まえて概ねMix-inのためのモジュールには ...able という名前を考えてみると良いかもしれません(単なる思考停止というより思考の基準として利用すると良いと思います)。

Module#concerning

Rails 4.1 から追加された機能で、 Module#concerning があります。これは分離というよりも意味のある単位で名前付けできることが利点となります。

class User < ApplicationRecord
  concerning :Comment do
    included do
      has_one :comment
      scope :commented, -> { where.not(comment: nil) }
    end
  end
end

通化や分離が適切でないものの、振る舞いのまとまりに名前をつけて整理したい場合に有効です。

整理する前に

  • Mix-in が適切な場面か検討する
  • concerns に分離する場合、特定のドメインやモデルに依存しないものかどうか確認する

ActiveModel

ActiveModel は ActiveRecord のような RDBに直接対応していないモデルを定義したときに Rails のような機能を利用しつつ実装できるようになります。

このあたりの機能は前に記事にまとめているのでこちらを参考にしてください。

ActiveModel::Model で DBに依存しないモデルを作ろう - What is it, naokirin?

Form Object

ActiveModelを利用する例としてよく知られているものとして、Form Object というものがあります。

フォームによるActiveRecordを通した登録や更新などは、単一のActiveRecordクラスに対応するときは難しくはありませんが、複数に対応している場合などはモデルにあるべきロジックが他のレイヤーに出てしまうなどの懸念があります。これを避けるために、Form Object という形で集約し、複数にまたがるバリデーションなどを記載できます。

一方で、単に長大なフォームのデータを押し込めるオブジェクトとするべきではありません。これはオブジェクト指向としての一般的な問題です。これらの複雑性に対して(多数の異なるドメインへの入力をフォームが受け付ける場合や複雑な永続性など)はその処理を行うオブジェクトなどに分離し、適切な責務の分離をするべきでしょう。

  • Form Object はフォームがActiveRecordオブジェクトと一対一の関係でない場合にその架け橋として責務をもたせることができる
  • 複数のActiveRecordオブジェクトにまたがるバリデーションなどを処理することができる
# Form Object
class Finance
  ...

  def save
    # バリデーションなどを行った上で保存する
  end
end

class UsersController < ApplicationController
  def create
    @finance = Finance.new(params[:finance])
    if @finance.save
      redirect_to home_url
    else
      render :new
    end
  end
end

また、 ActiveModel::Conversion#to_model メソッドを実装することで、 form_with で FormObject を渡した場合でも適切なControllerへのパスが生成されるようになります。

def to_model
  # フォームのデータを元に生成する ActiveRecord オブジェクト
  @user = User.new(...)
end

def save
  to_model.save
end

ActiveModel::Conversion

Rails: Form Objectと`#to_model`を使ってバリデーションをモデルから分離する(翻訳)

ちなみに、このような Struct 的なオブジェクトを扱う際に、 dry-* gem シリーズを利用すると良い場合もあると思います。これらについては以下のページが参考になります。

Ruby: Dry-rb gemシリーズのラインナップと概要

整理する前に

  • 変更するもともとのモデルの実装を整理する(メソッド化など)
  • 変更する各コードに対するテストコードを記述しておく
  • 自動的なControllerへの呼び出しがうまくいかなくなることがあるので、そのようなパターンがないか注意する

ApplicationRecord

Rails 5 から ActiveRecord のモデルの継承元が ActiveRecord::Base から ApplicationRecord になりました。 これにより、自前で ActiveRecord::Base を継承したクラスを作った上で、モデルの継承元を自前で書き換えるなどしなくても、モデル全体を通して追加したい機能を追加することができます。また、外部のプラグイン等には影響しない形で追加できるのも利点です。

利用するタイミングは限られていますが、都度モデルに機能を追加していたりする場合にアプリケーション全体で追加して良いと判断したら、こちらを利用することを検討すると良いでしょう。

整理する前に

  • アプリケーション全体を通じて追加してもよいか検討する

Decorator

Decorator は デザインパターンの一種ですが、Rails ではより狭義として、「モデルからビューに出すときに加工するといったロジックを乗せるオブジェクト」として使われていることが多いです。

これはヘルパーメソッド、モデル、ビューのどこに書いても都合があまり良くないこの種のロジックを記述する場所を提供してくれます。一方で、モデルに依存していないようなものはビューヘルパーとして、また肥大化した Decorator は適切な粒度に分離していくことが大切です。

ちなみにこの Decorator は Draper などの gem を利用して実装することが多く、ちょっとした設定や呼び出しによって簡単にActiveRecordのモデルを呼び出すように利用できるので、それらを紹介している記事へのリンクを記載しておきます。

RailsでDecoratorを作る意味を調べた - gaaamiiのブログ

整理する前に

  • Decoratorで対応するモデル、コントローラ、ビューを適切に整理しておく
  • テストを実行できるようにしておき、Decoratorによって問題が発生していないことを検知できるようにする
  • Decorator ですべき処理かきちんと整理してから、Decorator を導入する

Interactor

Service Object というのがよく知られていますが、この Service Object は制約が殆どないため、個々の開発者がどのようなルールで命名し、どのような粒度で導入し、どのような意図でオブジェクトを実装したかを全員が理解できるように務める必要があります。これはチーム開発では難しいかもしれません。

そこでそのような必要な制約の一部やサポート機能を、Interactor という gem によって導入することができます。

GitHub - collectiveidea/interactor: Interactor provides a common interface for performing complex user interactions.

Interactor の利用方法については Interactor のREADMEを参考にするのが良いかと思います。

class CreateOrder
  include Interactor

  def call
    order = Order.create(order_params)

    if order.persisted?
      context.order = order
    else
      context.fail!
    end
  end

  def rollback
    context.order.destroy
  end
end

class PlaceOrder
  include Interactor::Organizer

  organize CreateOrder, ChargeCard, SendThankYou
end

複数の Interactor のオブジェクトをチェーンしたり、 context という変数で状態を共有したり、 rollback メソッドの実装により、チェーンしたInteractorが途中で失敗した場合もロールバックしてくれるなど、先にルール化された形で Service Object で対応したい機能を実装できます。

ただこのルールを、開発する全員が理解する必要が出てくるため、導入するメリットがあるような複雑なビジネスロジックが出てきたときに導入するのが良いでしょう。

整理する前に

STI(単一テーブル継承)

STI は異なるモデルクラス間で共通化ができる非常に強力な機能です。

class Book < ApplicationRecord; end

class PaperBook < Book; end
class EBook < Book; end

このような継承関係を作ると、これらのモデルクラスはすべて同じテーブルに保存され、そのクラス名が type カラムに保存されます。これは共通化する上で非常に便利に感じますが、一方で欠点も多くあります。

  • クラス名の変更をするために、すでに保存された type カラムの値を変更する必要がある
  • 1テーブルに複数のデータが詰め込まれるため、パフォーマンスの問題がある
  • 一度導入すると他の継承関係を利用できなくなる
  • 各クラスでの変更に対して、逐一保存済みのデータを修正していく必要があり、保守にかかるコストが高い

また、単純にテーブルとして見た場合に理解するのも難しい部分があるので、その点も注意が必要です。

これらを踏まえた上で、以下のようなパターンに当てはまるときに導入を検討するとよいかと思います。

  • ビジネスドメインの知識として、共通化されるべきである
  • 検索等の理由から、共通のテーブルにデータが含まれていることが望ましい
  • ほとんどのカラムが共通であり、今後もその共通性がほとんど変化しないと想定される

個人的にも利用経験がありますが、追加は良いのですが属性の削除や更新には弱いので、単なる設計上の決定ではなく、他のもの以上にビジネス的な知識をしっかりと確認した上での導入が良いかと思います。

まとめ

ここまでに書いたものは、Rails の機能によるものをメインとしていました。

Ruby としてみた場合、他にも様々な方法が考えられると思います。例としては、以下のサイトにまとまっているようなオブジェクトを導入する方法です。

techracho.bpsinc.jp

また、CQRS(コマンドクエリ責務分離)などにより、複雑性を排除することも検討できるかと思います。

なんにせよ、適切な粒度で責務を分離し、適切な命名をしたオブジェクトやモジュールを導入することが求められるでしょう。