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

Railsのパターンとアンチパターン2: モデル編とマイグレーション(翻訳)

概要

原著者の許諾を得て翻訳・公開いたします。

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

Railsのパターンとアンチパターン2: モデル編とマイグレーション(翻訳)

Railsのパターンとアンチパターン」シリーズ第2弾へようこそ。第1回では、ソフトウェア開発における一般的なパターンやアンチパターンの概要を扱うとともに、Rails界隈でよく知られているパターンやアンチパターンもいくつかご紹介しました。今回は、Railsのモデルにおけるアンチパターンやパターンをいくつか見ていくことにしましょう。

本記事は、普段からモデルで苦労している方のお役に立つでしょう。今回は、モデルをすっきりスリムにダイエットする方法と、モデルのマイグレーションを書くときに避けるべき注意点についてです。

ファットな重量過積載モデル

Railsアプリケーションを開発するとき、フルスタックのRails WebサイトにするかAPIサーバーにするかにかかわらず、多くの開発者がロジックをモデル内に配置する傾向があります。前回記事では、何でもかんでも引き受けてしまっていたSongクラスを例に用いました。モデルの責務が過剰になると、単一責任の原則(SIP)に違反してしまいます。

まずはSongクラスをもう一度見返してみましょう。

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

このようなモデルの問題は、歌ものの曲(song: 以下「曲」)に関連するさまざまなロジックがゴミ捨て場のように集積されていることです。メソッドは急に増えるというよりも、長い時間をかけてゆっくりジワジワと増えていきます。

モデル内にこのようなコードが積み重なってきたら、もっと小さいモジュールに切り出しましょう。ただし、それだけでコードがきれいになるのではなく、単にコードを別の場所に移動したということでしかありません(散らかっているものをタンスや押し入れに押し込めて見た目を取り繕うようなものです)。しかしそれでも、コードをモジュールに切り出せば少なくともコードは整頓されますし、読みづらい太り過ぎのモデルになることも避けられます。

中には、最後の手段としてRailsのconcernを用いるとロジックを複数のモデル間で再利用できることに目をつける開発者もいます。以前私が記事にも書いたように、Railsのconcernを好む開発者もいますが、好まない開発者もまたいるのです。いずれにしろ、Railsのconcernはモジュールと本質的に同じようなものです。コードをモジュールに切り出してincludeできるようにすることは「単にコードを移動する以上のものではない」ことを、ぜひ肝に銘じておくべきです。

別の方法は、必要に応じて普通に小さなクラスを作成することです。たとえば、先のSongクラスの変換コードは以下のように別のクラスに切り出せます。

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クラスにメソッドを切り出せました。これなら、SongConverterクラス独自のテストを書くことも、今後ここに新しい変換メソッドを追加することも問題なくできます。ある曲をMP3に変換したければ、以下のように書くだけで済みます。

@song.converter.to_mp3

私は、このように専用の小さなクラスを作る方が、モジュールやconcernを使うよりもコードが若干明確になると感じます。おそらく私が「継承」よりも「コンポジション」を好んでいることもその理由でしょう。私はこの方が直感的にも理解しやすく、読みやすさも向上すると考えています。皆さんがコードを書くときには、「クラス方式」と「モジュールまたは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に変更しよう」「曲をフェッチする方法をもう少し変えよう」という決定が下されたらどうしますか?3箇所あるフェッチ操作を同じように修正しなければならなくなります。そもそも現在の書き方はDRYではありませんよね。同じ書き方がアプリケーション全体で繰り返されています。これを見てがっかりすることはありません。こんなときのためのソリューションがあるのです。

Railsの「スコープ(scope)」を使えばコードをDRYにできます。スコープを使えば、よく使われるクエリを定義しておいて、さまざまな関連付けやオブジェクトで手軽に呼び出せるようになります。そしてスコープにする方がコードも読みやすくなり、変更も楽にできます。しかしスコープで最も重要なメリットは、Active Recordのjoinswhereなどのメソッドをチェインできるようになることです。先のコードにスコープを導入するとどうなるかを見てみましょう。

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

一丁上がりです。これで、同じようなコードの繰り返しをモデルから消し去ることができました。しかし、相手が太りに太ったファットモデルやGodオブジェクトともなると、この手が常に通用するとも限りません。1個のモデルにメソッドを次々に追加したり、モデルにさまざまな責務を乗っけるのはよくありません。

私からのアドバイスは、「スコープは最小限にとどめる」ことと「共通のクエリだけを切り出す」ことです。先のwhere(published: true)のようなクエリは、ほぼあらゆる場所で使われるのでスコープで使うのに完璧です。それ以外のSQL関連コードについては、これより紹介するRepositoryパターンも使えます。それでは詳しく見てみましょう。

Repositoryパターン

ここでこれから述べる内容は、ドメイン駆動設計の書籍で定義されているRepositoryパターンと1対1対応するものではありません。Railsで私たちが用いるRepositoryパターンを支えるアイデアは、ビジネスロジックからデータベースロジックを切り離すことです。Active Recordに代わって生SQLを呼び出す本格的なRepositoryクラスを作ることも一応できますが、どうしても必要でない限りおすすめいたしません。

ここでは、SongRepositoryというクラスを1つ作成して、データベースロジックをそこに集約します。

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

上のコードで行っているのは、クエリのロジックを切り出してテスト可能なクラスに配置することです。これにより、モデルはスコープやロジックを気にかける必要がなくなります。コントローラもモデルも薄くなり、万事丸く収まるとは思いませんか?ところで、切り出したコードではActive Recordがまだまだ仕事をしていますね。私たちのシナリオではfindを使いますが、これは以下のSQLを生成します。

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

原則論からすると、こういったものはすべてSongRepositoryの内部で定義するのが正しいということになりますが、それについては既に申し上げたように基本的におすすめしません。私たちはそれらをSongRepository内で定義する必要なしに、完全に制御したいと考えています。Active Recordを使わない場合のユースケースとしては、Active Recordでは簡単にサポートできない複雑なSQLが必要な場合などが考えられます。

生SQLとActive Recordとくれば、もうひとつお話ししておかなければならないトピックがあります。マイグレーションの話、そしてマイグレーションを正しく行う方法についてです。それでは見てみましょう。

マイグレーションのコードも大事

「マイグレーションのコードは、アプリケーション本体のコードのように隅々まできれいに書くものではない」というような議論をちょくちょく目にします。しかし私はそうした主張に納得が行きません。「マイグレーションはどうせ1回実行してそれっきりなんだから、少々コードが臭くても構わないじゃないか」という言い訳に走りがちです。プロジェクトの開発者がせいぜい数人どまりで、すべてのコードやデータが常に同期されているのであれば、それでもよいのかもしれません。

しかし現実はそううまくいかないものです。アプリケーションが大勢の開発者によって開発され、各開発者がアプリケーションの他のパーツについてよくわかっていないということもざらではありません。そんなプロジェクトに怪しい使い捨てコードを押し込めたら、データベースのステートが損なわれたりマイグレーションがおかしなことになったりして、数時間後に別の誰かが悲鳴を上げるかもしれないのです。これをアンチパターンと呼んでいいのかどうかは私には何とも言えませんが、そういうことが起こる可能性があることを十分承知しておく必要はあります。

他の開発者にとってもっと有用なマイグレーションを書くにはどうすればよいでしょうか?プロジェクトメンバーの誰もがマイグレーションを実行しやすくなる方法をいくつかご紹介します。

1. downメソッドも必ず書くこと

マイグレーションのロールバックがいつ何時行われるかは、事前に予測しようがありません。自分の書いたマイグレーションが実はロールバックできないものであれば、必ず以下のようにActiveRecord::IrreversibleMigrationraiseしましょう。

def down
  raise ActiveRecord::IrreversibleMigration
end

2. マイグレーションの中からActive Recordを呼び出すことはできる限り避けること

これは、マイグレーションが実行される時点で何らかの外部要素(データベースのステートは除きます)に依存することは最小限にとどめましょうということです。当てにしているActive Recordのバリデーションが将来消されてしまえば、マイグレーションを実行した瞬間にその日を棒に振ることになります。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モデルをどうしても使わざるを得ないのであれば、「マイグレーションの中でSongモデルを定義する」という手法がおすすめです。それなら、app/models/ディレクトリ以下に置かれた実際のActive Recordモデルが将来どうなっても、マイグレーションが影響を受ける可能性を避けられます。

しかしそれだけで十分なのでしょうか?次のポイントをご紹介します。

3. 「スキーマのマイグレーション」と「データのマイグレーション」は分けること

RailsガイドのActive Record マイグレーションの冒頭にはマイグレーションについて以下の概要が書かれています。

マイグレーション (migration) はActive Recordの機能の1つであり、データベーススキーマを長期にわたって安定して発展・増築し続けることができるようにするための仕組みです。マイグレーション機能のおかげで、Rubyで作成されたマイグレーション用のDSL (ドメイン固有言語) を用いて、テーブルの変更を簡単に記述できます。スキーマを変更するためにSQLを直に書いて実行する必要がありません。
Railsガイドより

マイグレーションガイドの概要では、データベースの構造をマイグレーションで編集することについては触れていますが、データベースのテーブルにある実際のデータをマイグレーションで編集する方法については言及していません。つまり、先ほどの2.のように曲データをマイグレーションで定期的に更新する運用は、実は正しい方法とは言い切れないのです。

プロジェクトのデータに定期的に手を加える必要があるなら、data_migrate gemを検討してみましょう。

ilyakatz/data-migrate - GitHub

この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のうちビューやコントローラで起こりがちな問題やその解決方法をご紹介する予定です。

それでは次回お会いしましょう!

お知らせ

Rubyのマジックに関する記事をお読みになりたい方は、お見逃しにならぬよう、ぜひ私どものRuby Magicニュースレターの購読をお願いします。

関連記事

Railsのパターンとアンチパターン1: 概要編(翻訳)

Railsのパターンとアンチパターン3: ビュー編(翻訳)

Railsのパターンとアンチパターン4: コントローラ編(翻訳)

Railsのパターンとアンチパターン5: 一般的な問題と、その教訓(翻訳)

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


CONTACT

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