Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

Ruby on Railsモデルのパターンとアンチパターン徹底解説

Ruby on Railsのパターン&アンチパターンシリーズ第2弾へようこそ。前回の記事では、パターンとアンチパターンの基本的な概念を解説し、Rails界隈でよく知られているものをいくつかご紹介しました。今回は、Railsのモデルにおける代表的なアンチパターンと、それに対する改善パターンを見ていきます。

モデルの設計に日々悩んでいる開発者の方にとって、この記事はきっと役立つはずです。まずはモデルを「ダイエット」させる手法を手短に紹介し、最後はマイグレーションを書く際に避けるべきポイントで締めくくります。それでは早速始めましょう。

Fat 肥大化したモデル

Railsアプリケーションを開発していると、本格的なWebサイトであれAPIであれ、ロジックの大半をモデルに詰め込みがちです。前回の記事では、多くの役割を担うSongクラスの例を取り上げました。モデルに大量の処理を押し込むと、単一責任の原則(SRP)が崩れてしまいます。

実際のコードを見てみましょう。

class Song < ApplicationRecord
  belongs_to :album
  belongs_to :artist
  belongs_to :publisher
 
  has_one :text
  has_many :downloads
 
  validates :artist_id, presence: true
  validates :publisher_id, presence: true
 
  after_update :alert_artist_followers
  after_update :alert_publisher
 
  def alert_artist_followers
    return if unreleased?
 
    artist.followers.each { |follower| follower.notify(self) }
  end
 
  def alert_publisher
    PublisherMailer.song_email(publisher, self).deliver_now
  end
 
  def includes_profanities?
    text.scan_for_profanities.any?
  end
 
  def user_downloaded?(user)
    user.library.has_song?(self)
  end
 
  def find_published_from_artist_with_albums
    ...
  end
 
  def find_published_with_albums
    ...
  end
 
  def to_wav
    ...
  end
 
  def to_mp3
    ...
  end
 
  def to_flac
    ...
  end
end

このようなモデルの問題は、楽曲に関するあらゆるロジックの「捨て場」になってしまうことです。メソッドは時間とともに少しずつ積み重なり、気づけば可読性の低い巨大なファイルになってしまいます。

対策としては、モデル内のコードを小さなモジュールに分割する方法があります。ただし、これは単にコードを別の場所へ移動しているだけである点には注意が必要です。とはいえ、コードを整理して配置することで、肥大化したモデルによる可読性の低下を防ぐことができます。

中にはRailsのconcernsを活用し、複数モデル間でロジックを再利用できるようにするアプローチもあります。以前この話題について書いたところ、賛否両論ありました。いずれにせよ、concernsの仕組みはモジュールと似ています。「どこからでもincludeできる場所にコードを移動しただけ」だという認識を持っておくことが大切です。

もうひとつの選択肢は、小さなクラスを作成し、必要なときに呼び出す方法です。例えば、楽曲フォーマット変換のコードを独立したクラスに抽出してみましょう。

class SongConverter
  attr_reader :song
 
  def initialize(song)
    @song = song
  end
 
  def to_wav
    ...
  end
 
  def to_mp3
    ...
  end
 
  def to_flac
    ...
  end
end
 
class Song
  ...
 
  def converter
    SongConverter.new(self)
  end
 
  ...
end

これで、楽曲を別フォーマットへ変換するという明確な目的を持ったSongConverterクラスができました。変換処理専用のテストを書くこともできますし、今後の変換ロジックの追加も容易になります。MP3への変換を行いたいときは、次のように呼び出すだけです。

@song.converter.to_mp3

筆者個人としては、モジュールやconcernを使うよりもこちらの方が分かりやすいと感じます。おそらく継承よりもコンポジションを好むためでしょう。より直感的で読みやすいと考えています。どちらの方式を採るか決める前に、両方のケースを検討してみてください。もちろん両方を組み合わせても構いません、誰も止めませんから。

SQLパスタ・パルメザン

現実の世界でおいしいパスタが嫌いな人はいませんよね。しかしコードの世界における「パスタ」、いわゆるスパゲッティコードとなると、ほとんど誰も歓迎しません。当然のことです。Railsのモデルでは、Active Recordの使い方を誤ると、あっという間にコードベース全体を絡め取るスパゲッティ状の長いクエリが生まれてしまいます。どうすればこれを防げるのでしょうか?

長大なクエリがスパゲッティ化するのを防ぐアイデアがいくつかあります。まず、データベース関連のコードがあちこちに散らばってしまう様子を見てみましょう。Songモデルに戻って、データを取得しようとしている場面を確認します。

class SongReportService
  def gather_songs_from_artist(artist_id)
    songs = Song.where(status: :published)
                .where(artist_id: artist_id)
                .order(:title)
 
    ...
  end
end
 
class SongController < ApplicationController
  def index
    @songs = Song.where(status: :published)
                 .order(:release_date)
 
    ...
  end
end
 
class SongRefreshJob < ApplicationJob
  def perform
    songs = Song.where(status: :published)
 
    ...
  end
end

上記の例では、Songモデルをクエリするユースケースが3つ登場します。楽曲データのレポート生成に使われるSongReporterServiceでは、特定のアーティストの公開済み楽曲を取得しています。次にSongControllerでは、公開済み楽曲をリリース日の順に並べて取得します。そして最後のSongRefreshJobでは、公開済み楽曲のみを取得して何らかの処理を行っています。

これ自体は問題ありません。しかし、突然ステータス名をreleasedに変更したり、楽曲取得の条件を変更したくなったらどうでしょうか?すべての出現箇所を個別に修正する必要があります。さらに、上記のコードはDRYではありません。アプリケーション全体で同じ内容が繰り返されています。心配はいりません。幸い、この問題には解決策があります。

Railsのscopeを使えば、このコードをDRYにできます。scopeを定義すると、よく使うクエリに名前を付けられ、関連やオブジェクトから呼び出せるようになります。これによりコードは読みやすくなり、変更も容易になります。そして何より重要なのは、scope同士やjoinswhereといった他のActive Recordメソッドとチェーンできることです。scopeを適用すると、コードは次のようになります。

class Song < ApplicationRecord
  ...
 
  scope :published, ->            { where(published: true) }
  scope :by_artist, ->(artist_id) { where(artist_id: artist_id) }
  scope :sorted_by_title,         { order(:title) }
  scope :sorted_by_release_date,  { order(:release_date) }
 
  ...
end
 
class SongReportService
  def gather_songs_from_artist(artist_id)
    songs = Song.published.by_artist(artist_id).sorted_by_title
 
    ...
  end
end
 
class SongController < ApplicationController
  def index
    @songs = Song.published.sorted_by_release_date
 
    ...
  end
end
 
class SongRefreshJob < ApplicationJob
  def perform
    songs = Song.published
 
    ...
  end
end

これで完了です。繰り返していたコードを削り、モデル側に集約することができました。ただし、これが常に最善とは限りません。特に、肥大化したモデル(Fat Model)やGod Objectに悩まされている場合は注意が必要です。モデルにメソッドと責務を追加し続けるのは、あまり良いアイデアではないかもしれません。

筆者のアドバイスとしては、scopeの利用は最小限にとどめ、本当に共通のクエリだけを抽出することです。今回のケースなら、至る所で使われているwhere(published: true)はscopeにする絶好の候補と言えます。その他のSQL関連のコードについては、「リポジトリパターン」と呼ばれる手法が使えます。詳しく見ていきましょう。

リポジトリパターン

ここで紹介するのは、Domain-Driven Designの書籍で定義されているリポジトリパターンそのものではありません。Railsにおけるリポジトリパターンの狙いは、データベースロジックをビジネスロジックから分離することにあります。Active Recordの代わりに生SQLを実行するリポジトリクラスを完全に作り込むことも可能ですが、本当に必要になるまでおすすめはしません。

私たちにできるのは、SongRepositoryを作成し、そこにデータベースロジックを集めることです。

class SongRepository
  class << self
    def find(id)
      Song.find(id)
    rescue ActiveRecord::RecordNotFound => e
      raise RecordNotFoundError, e
    end
 
    def destroy(id)
      find(id).destroy
    end
 
    def recently_published_by_artist(artist_id)
      Song.where(published: true)
          .where(artist_id: artist_id)
          .order(:release_date)
    end
  end
end
 
class SongReportService
  def gather_songs_from_artist(artist_id)
    songs = SongRepository.recently_published_by_artist(artist_id)
 
    ...
  end
end
 
class SongController < ApplicationController
  def destroy
    ...
 
    SongRepository.destroy(params[:id])
 
    ...
  end
end

ここで行ったのは、クエリロジックをテスト可能なクラスに隔離することです。また、モデルはscopeやロジックを抱える必要がなくなり、コントローラーとモデルは薄く保たれます。みんなハッピーですね。本当でしょうか?実はまだ、裏でActive Recordが重い処理を引き受けています。今回のシナリオではfindを使用しており、これは次のようなSQLを生成します。

SELECT "songs".* FROM "songs" WHERE "songs"."id" = $1 LIMIT $2  [["id", 1], ["LIMIT", 1]]

「正しい」やり方は、こうしたSQL定義をすべてSongRepository内に持つことです。前述の通り、筆者はそれをおすすめしません。必要がないですし、Active Recordのままの方が柔軟に制御できます。Active Recordから離れるべきユースケースは、Active Recordでは簡単にサポートされていない複雑なSQLテクニックが必要になった場合などです。

生SQLとActive Recordの話が出たところで、もうひとつ触れておきたいトピックがあります。それはマイグレーションと、その適切な書き方についてです。詳しく見ていきましょう。

マイグレーション ― 誰も気にしていない?

マイグレーションを書く際に、「そこにあるコードはアプリケーションの他の部分ほど品質を求めるべきではない」という議論を耳にすることがよくあります。筆者はこの主張に納得していません。マイグレーションは一度実行されたら忘れられるのだから、という言い訳を使って、臭いコードを平気で仕込む人が多いのです。数人だけで常に足並みを揃えて開発しているチームなら、それでも許されるかもしれません。

しかし現実はそう単純ではありません。アプリケーションは、各部分で何が起きているか把握していない多数の人々によって扱われることがあります。疑わしい使い捨てコードを仕込んでしまうと、破損したデータベース状態や奇妙なマイグレーションのせいで、誰かの開発環境を数時間にわたって壊してしまう可能性があります。これが厳密な意味でのアンチパターンかどうかはさておき、十分に意識しておくべきポイントです。

では、マイグレーションを他のメンバーにとって扱いやすいものにするにはどうすればよいのでしょうか?プロジェクト全員の負担を軽減するチェックリストを順番に見ていきます。

downメソッドを必ず用意する

いつロールバックが必要になるかは予測できません。もしマイグレーションが reversible(可逆)でないなら、必ず次のようにActiveRecord::IrreversibleMigration例外を発生させるようにしましょう。

def down
  raise ActiveRecord::IrreversibleMigration
end

マイグレーションでのActive Record使用は極力避ける

ここでの考え方は、マイグレーション実行時点のデータベース状態以外への外部依存を最小限に抑えることです。そうすれば、Active Recordのバリデーションに邪魔される(あるいは救われる)ことはありません。素のSQLだけが残ります。例えば、特定のアーティストの全楽曲を公開済みにするマイグレーションを書いてみましょう。

class UpdateArtistsSongsToPublished < ActiveRecord::Migration[6.0]
  def up
    execute <<-SQL
      UPDATE songs
      SET published = true
      WHERE artist_id = 46
    SQL
  end
 
  def down
    execute <<-SQL
      UPDATE songs
      SET published = false
      WHERE artist_id = 46
    SQL
  end
end

どうしてもSongモデルが必要な場合は、マイグレーション内部でモデルを定義するのが一案です。そうすれば、app/models内の実際のActive Recordモデルが将来変更されても、マイグレーションを破綻から守ることができます。しかしこれで万事OKなのでしょうか?次のポイントに進みましょう。

スキーママイグレーションとデータマイグレーションを分離する

Rails Guidesのマイグレーションの項目を読むと、次のように書かれています。

マイグレーションはActive Recordの機能のひとつで、データベーススキーマを時間とともに進化させることを可能にします。スキーマの変更を純粋なSQLで書く代わりに、マイグレーションではRuby DSLを使ってテーブルへの変更を記述できます。

このガイドの要約には、データベーステーブルの実際のデータを編集することには一切触れられておらず、構造のみが対象となっています。つまり、先ほどの例で通常のマイグレーションを使って楽曲データを更新したのは、完全に正しい做法ではなかったということです。

プロジェクトで同様の操作を定期的に行う必要があるなら、data_migrate gemの導入を検討してください。これはデータマイグレーションをスキーママイグレーションから分離するのに便利なツールです。先ほどの例は、このgemを使えば簡単に書き換えられます。データマイグレーションを生成するには、次のコマンドを実行します。

bin/rails generate data_migration update_artists_songs_to_published

そして、そこにマイグレーションロジックを追加します。

class UpdateArtistsSongsToPublished < ActiveRecord::Migration[6.0]
  def up
    execute <<-SQL
      UPDATE songs
      SET published = true
      WHERE artist_id = 46
    SQL
  end
 
  def down
    execute <<-SQL
      UPDATE songs
      SET published = false
      WHERE artist_id = 46
    SQL
  end
end

こうすることで、スキーママイグレーションはすべてdb/migrateディレクトリに、データを扱うマイグレーションはすべてdb/dataディレクトリに整理されます。

まとめ

Railsでモデルと付き合い、可読性を保ち続けるのは終わりのない戦いです。この記事を通じて、陥りやすい落とし穴と、よくある問題への解決策が見えたなら幸いです。ここで挙げたモデルのアンチパターンとパターンは網羅的なリストではありませんが、筆者が最近特に重要だと感じたものばかりです。

Railsのパターン&アンチパターンをもっと知りたい方は、シリーズの続編をお楽しみに。今後の記事では、Rails MVCのビューとコントローラー側でよくある問題とその解決策を取り上げます。

それではまた次回、ごきげんよう!

P.S. Ruby Magicの記事を公開と同時にお読みになりたい方は、Ruby Magicニュースレターをぜひ購読してください。最新記事を見逃すことはありません!

  1. 【Rails入門】scopeの使い方を徹底解説!基本構文から引数付きスコープ、クラスメソッドとの違いまで

    Railsにおける「スコープ(scope)」とは何か?そしてなぜ便利なのでしょうか? 答えはこうです。 スコープとは、scopeメソッドを使ってモデル内に定義するカスタムクエリのことです。よく使う検索条件に名前をつけて再利用できるようにする仕組みと言えます。 すべてのスコープは2つの引数を受け取ります。 名前:コード内でこのスコープを呼び出す際に使用します。 ラムダ(lambda):実際のクエリ処理を実装します。 具体的には次のように書きます。 class Fruit < ApplicationRecord scope :with_juice, -> { where(jui

  2. Ruby on Railsとは?初心者にもわかる仕組み・魅力・学び方を徹底解説

    Ruby on Railsとは? Ruby on Rails(略称:RoR)は、世界で最も人気のあるオープンソースのWebアプリケーションフレームワークです。プログラミング言語「Ruby」をベースに構築されており、シンプルなサイトから大規模で複雑なサービスまで、幅広いWebアプリケーションの開発を支援します。 そもそもフレームワークとは? フレームワークとは、ソフトウェア開発の際に土台となる構造を提供してくれるコードやツール、ユーティリティの集合体です。あらかじめ用意された構造に沿ってコードを書くことで、プログラムが整理され、保守性も高まります。正しく使いこなせるようになれば、開発作業は格段に