Rails では様々な機能を使って、共通化や抽象化、シンプルなコードへの書き換えなどを行えるようになっています。今回はモデルに焦点を絞り、それらをまとめていきたいと思います。また、整理を始める前に気をつけるべきことをまとめておこうと思います。
Rails5 以降をメインとしていますが、概ね Rails4 でも同じであることが多いのではないかと思います。
おもに自分向けの情報整理です。
- コールバック
- バリデーション
- 値オブジェクト
- ActiveSupport::Concern と Mix-in
- ActiveModel
- ApplicationRecord
- Decorator
- Interactor
- STI(単一テーブル継承)
- まとめ
コールバック
コールバックは以下のような、特定のメソッド呼び出し時の前後などに自動的に呼び出される処理を宣言的に記述する方法です。
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
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 によって導入することができます。
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 としてみた場合、他にも様々な方法が考えられると思います。例としては、以下のサイトにまとまっているようなオブジェクトを導入する方法です。
また、CQRS(コマンドクエリ責務分離)などにより、複雑性を排除することも検討できるかと思います。
なんにせよ、適切な粒度で責務を分離し、適切な命名をしたオブジェクトやモジュールを導入することが求められるでしょう。