概要
原著者の許諾を得て翻訳・公開いたします。
- 英語記事: Ruby on Rails Model Patterns and Anti-patterns | AppSignal Blog
- 原文公開日: 2020/11/18
- 著者: Nikola Đuza: ハンガリーNovi Sad在住のエンジニア兼ライター、ブログや登壇で知識の普及に努めています。JavaScriptやRubyで面白いものを作るのが好きです。
- サイト: AppSignal blog: The latest on everything AppSignal
日本語タイトルは内容に即したものにしました。
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のjoins
やwhere
などのメソッドをチェインできるようになることです。先のコードにスコープを導入するとどうなるかを見てみましょう。
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::IrreversibleMigration
をraise
しましょう。
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を検討してみましょう。
この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ニュースレターの購読をお願いします。