Railsのパターンとアンチパターン4: コントローラ編(翻訳)
Ruby on Railsのパターンとアンチパターンシリーズの第4回目にようこそ。
前回までの記事では「一般的なパターンとアンチパターン」「Railsのモデル関連のパターンとアンチパターン」「Railsビューのアンチパターン」をそれぞれ取り上げました。今回は、MVC(Model-View-Controller)の最後であるRailsコントローラ関連のパターンとアンチパターンを見ていきましょう。
最前線としてのコントローラ
Ruby on RailsはWebフレームワークなので、HTTPリクエストが重要な部分になります。あらゆる種類のクライアントがHTTPリクエストを介してRailsバックエンドにアクセスしますが、このときにコントローラが本領を発揮します。コントローラはリクエストの受信と処理の最前線に配置されているので、Ruby on Railsフレームワークの基礎となる部分です。もちろんコントローラに到達する前にもコードがありますが、コントローラのコードはほとんどの開発者が制御可能です。
config/routes.rbファイルでルーティングを定義してけば、設定したルーティングでサーバーを叩いたときに、そのルーティングに対応するコントローラが後を引き受けます。この説明だけだと、コントローラがまるっきり単純なものに思えるかもしれませんが、コントローラには多くの負担がしわ寄せされがちです。認証(authentication)や認可(authorization)の問題、次に必要なデータをどのようにフェッチするかという問題や、ビジネスロジックをどこで実行するかという問題もあります。
こうしたもろもろの考慮や責務がコントローラに押し寄せると、いくつかのアンチパターンにつながる可能性があります。中でも「ファット(肥大化した)コントローラ」は有名なアンチパターンです。
ファットコントローラ
コントローラに置くロジックを増やしすぎたときの問題は、単一責任の原則(SRP: Single Responsibility Principle)に違反することです。つまり、処理をコントローラ内で引き受けすぎてしまっているということです。この状態になるとコード量が増えて責務を抱え込みすぎてしまいがちです。ここでいう「ファット」は、コントローラファイルのコード量も、コントローラがサポートするロジックの量も増えるということを指します。ファットコントローラは多くの場合アンチパターンと見なされます。
コントローラがどんな処理を引き受けるべきかについては多くの意見があり、ひとつではありません。コントローラが引き受けるべき責務としてよく挙げられるのは以下です。
- 認証と認可: リクエストの背後にあるエンティティ(多くの場合ユーザー)が、そのエンティティが示す人物と一致することを確認し、リソースへのアクセスやアクション実行が許可されているかどうかをチェックします。認証情報は多くの場合セッションまたはcookieに保存されますが、コントローラは認証データが有効かどうかを引き続きチェックする必要があります。
- データの取得: リクエストに含まれるパラメータ(params)を元に適切なデータを検索するロジックを呼び出す必要があります。理想的な世界では、1個のメソッド呼び出しですべての処理を行うべきです。コントローラ内では重たい作業を行うべきではなく、外部に委譲すべきです。
- テンプレートのレンダリング: 最後に、結果を適切なフォーマット(HTMLやJSONなど)でレンダリングして正しいレスポンスを返す必要があります。それ以外の場合は何らかのパスまたはURLにリダイレクトする必要があります。
以上を守っていれば、コントローラのアクションやコントローラ内で多くの処理を行う必要はなくなります。コードをコントローラレベルでシンプルにしておけば、アプリケーションの他の部分に処理を委譲できます。責務を委譲し、個別の責務をひとつひとつテストすることで、開発するアプリが堅牢になります。
上記の原則に従うのはもちろんですが、いくつかの具体的な例についてぜひとも知りたいでしょう。そこで、コントローラの責務を軽減できるパターンにはどんなものがあるかを見ていくことにしましょう。
Query Objectパターン
コントローラのアクションでよく起きるのが、データのクエリが多すぎるという問題です。本シリーズの『Railsモデルのパターンとアンチパターン』では、これとよく似たモデル内のクエリロジックが多すぎるという問題を扱いました。しかし今回利用するのはQuery Objectパターンです。Query Objectは、複雑なクエリを切り離して単一のオブジェクトにする手法です。
Query Objectはほとんどの場合、Active Recordリレーションで初期化されるPORO(Plain Old Ruby Object)の形を取ります。典型的なQuery Objectは以下のような感じになるでしょう。
# app/queries/all_songs_query.rb
class AllSongsQuery
def initialize(songs = Song.all)
@songs = songs
end
def call(params, songs = Song.all)
songs.where(published: true)
.where(artist_id: params[:artist_id])
.order(:title)
end
end
このQuery Objectは、コントローラ内で以下のように使います。
class SongsController < ApplicationController
def index
@songs = AllSongsQuery.new.call(all_songs_params)
end
private
def all_songs_params
params.slice(:artist_id)
end
end
Query Objectを以下のように別の形で使ってみるのもありです。
# app/queries/all_songs_query.rb
class AllSongsQuery
attr_reader :songs
def initialize(songs = Song.all)
@songs = songs
end
def call(params = {})
scope = published(songs)
scope = by_artist_id(scope, params[:artist_id])
scope = order_by_title(scope)
end
private
def published(scope)
scope.where(published: true)
end
def by_artist_id(scope, artist_id)
artist_id ? scope.where(artist_id: artist_id) : scope
end
def order_by_title(scope)
scope.order(:title)
end
end
後者のアプローチでは、params
引数を(必須ではなく)オプションにしたことでQuery Objectがより強固になります。AllSongsQuery.new.call
を呼び出せるようになった点にもご注目ください。この方法が好みでなければ、クラスメソッドにする手もあります。クエリのクラスをクラスメソッドで書くと「オブジェクト」ではなくなりますが、これは個人の好みの問題です。ここでは説明のため、AllSongsQuery
の呼び出しをよりシンプルにする方法を見てみましょう。
# app/queries/all_songs_query.rb
class AllSongsQuery
class << self
def call(params = {}, songs = Song.all)
scope = published(songs)
scope = by_artist_id(scope, params[:artist_id])
scope = order_by_title(scope)
end
private
def published(scope)
scope.where(published: true)
end
def by_artist_id(scope, artist_id)
artist_id ? scope.where(artist_id: artist_id) : scope
end
def order_by_title(scope)
scope.order(:title)
end
end
end
これで、AllSongsQuery.call
を呼び出すだけで済むようになりました。params
引数にはartist_id
を渡せます。初期スコープを何らかの理由で変更したければ初期スコープを渡すこともできます。クエリのクラスでnew
をどうしても呼びたくないのであれば、以下の小技をお試しください。
# app/queries/application_query.rb
class ApplicationQuery
def self.call(*params)
new(*params).call
end
end
以下のように、ApplicationQuery
の作成時に別のクエリクラスを継承することも可能です。
# app/queries/all_songs_query.rb
class AllSongsQuery < ApplicationQuery
...
end
これまでどおりAllSongsQuery.call
を利用でき、しかもコードがエレガントになりました。
Query Objectのよい点は、Query Objectを個別にテストしてQuery Objectが期待どおりに振る舞うことを確かめられることです。さらに、クエリクラスを拡張してテストするときにも、コントローラ内のロジックを気にする必要はありません。ひとつご注意いただきたいのは、リクエストパラメータについてはQuery Object以外の部分で処理すべきという点です。どうでしょう、皆さんもQuery Objectを試してみる気になりましたか?
Serviceパターン1
ここまでは、データの収集と取得をQuery Objectにお任せする方法について説明いたしました。「では、データ収集とレンダリング処理の間にびっしり詰まったロジックを整理するにはどうすればよいでしょうか?」よくぞ聞いてくださいました。それを解決する方法のひとつが、Service2と呼ばれるパターンです。Serviceは多くの場合、(ビジネス上の)単一操作を実行するPOROと見なされます。このアイデアをもう少し掘り下げてみましょう。
以下のようにServiceが2つあるとします。ひとつはレシートを作成するService、もうひとつはレシートをユーザーに送信するServiceです。
# app/services/create_receipt_service.rb
class CreateReceiptService
def self.call(total, user_id)
Receipt.create!(total: total, user_id: user_id)
end
end
# app/services/send_receipt_service.rb
class SendReceiptService
def self.call(receipt)
UserMailer.send_receipt(receipt).deliver_later
end
end
続いて、コントローラで以下のようにSendReceiptService
を呼び出します。
# app/controllers/receipts_controller.rb
class ReceiptsController < ApplicationController
def create
receipt = CreateReceiptService.call(total: receipt_params[:total],
user_id: receipt_params[:user_id])
SendReceiptService.call(receipt)
end
end
これで2つのServiceですべての作業を行えるようになったので、コントローラでは2つのServiceを呼び出すだけで済みます。Serviceは個別にテストできますが、問題はService同士の処理のつながりがわかりにくいことです。たしかに理論上はどのServiceも単独のビジネス処理を実行します。しかし、ステークホルダー目線では「レシートを作成する」操作には「レシートをメールで送信する」操作も関連しています。どちらのレベルの抽象化が「正しい™️」のでしょうか?
この思考実験をもう少し複雑にしてみましょう。レシートの合計金額を算出するか、さもなければレシート作成中にどこかから合計金額を取得しなければならないという要件が追加されたとします。この場合はどうすればよいでしょうか。合計金額の集計処理用にまた別のServiceを書くのでしょうか?おそらくその答えは、単一責任原則(SRP)に沿って互いを抽象化することかもしれません。
# app/services/create_receipt_service.rb
class CreateReceiptService
...
end
# app/services/send_receipt_service.rb
class SendReceiptService
...
end
# app/services/calculate_receipt_total_service.rb
class CalculateReceiptTotalService
...
end
# app/controllers/receipts_controller.rb
class ReceiptsController < ApplicationController
def create
total = CalculateReceiptTotalService.call(user_id: receipts_controller[:user_id])
receipt = CreateReceiptService.call(total: total,
user_id: receipt_params[:user_id])
SendReceiptService.call(receipt)
end
end
単一責任の原則に沿うことで、SeriviceをReceiptCreation
処理のようなより大きな抽象構造にまとめています。この「処理用クラス」を作成すれば、処理の完了に必要なすべてのアクションをグループ化できます。皆さんはこの方法についてどう思いますか?一見すると抽象化のやりすぎではないかと思えるかもしれませんが、このアクションが多くの場所で呼び出されるなら有用性を実証できることもあるでしょう。この方法がよいと思うのであれば、TrailblazerのOperationをチェックしてみてください。
以上をまとめます。新しいCalculateReceiptTotalService
はすべての計算処理を扱えます。CreateReceiptService
の責務は、レシートをデータベースに書き込むことです。SendReceiptService
はユーザーにレシートをメール配信します。このように特定の処理に集中した小さなクラスを使うことで、他のユースケースで処理を組み合わせやすくなり、その結果コードベースのメンテナンスやテストもやりやすくなります。
Serviceという名称について
Serviceクラスを用いるアプローチは、Rubyの世界では「action」や「operation」などさまざまな名前で呼ばれていますが、これらはすべてCommandパターンに集約されます。Commandパターンの根底にあるアイデアは、ビジネス上の操作やイベントのトリガーに必要なすべての情報を1個のオブジェクト(本記事のコード例ではクラス)にカプセル化するというものです。Commandの呼び出し側が知っておくべき情報は以下のとおりです。
- Command名
- CommandオブジェクトまたはCommandクラスで呼び出すメソッド名
- メソッドのパラメータに渡す値
つまり、ここで言うCommandの呼び出し側とはすなわちコントローラのことです。アプローチは非常に似ていますが、RubyではServiceと呼ばれているだけのことです。
作業を分割する
コントローラでサードパーティのサービスを呼び出し中にレンダリングがブロックされるのであれば、呼び出しとレンダリングを別のコントローラアクションに切り出すことを検討してもよいかもしれません。例としては、ある書籍の情報を表示し、次にこちらで制御できない外部サービス(Goodreadsなど)からレーティング情報のフェッチを試みる場合があります。
# app/controllers/books_controller.rb
class BooksController < ApplicationController
def show
@book = Book.find(params[:id])
@rating = GoodreadsRatingService.new(book).call
end
end
Goodreadsがダウンしたりすると、Goodreadsサービスへのリクエストがタイムアウトするまでユーザーが待たされてしまいますし、Goodreadsが遅くなればページの読み込みも遅くなります。このようなサードパーティサービスの呼び出しを以下のように別のアクションに切り出せます。
# app/controllers/books_controller.rb
class BooksController < ApplicationController
...
def show
@book = Book.find(params[:id])
end
def rating
@rating = GoodreadsRatingService.new(@book).call
render partial: 'book_rating'
end
...
end
次にrating
パスをビューで呼び出す必要がありますが、show
アクションがブロックされることもなくなります。また、book_ratingパーシャルも必要です。これらをもっと手軽に行うにはrender_async gemを利用します。書籍のレーティングをレンダリングする箇所を以下のように書くだけで済みます。
<%= render_async book_rating_path %>
レーティング情報をレンダリングするHTMLをbook_ratingパーシャルに切り出して以下のように書きます。
<%= content_for :render_async %>
レイアウトファイル内でページが読み込まれると、render_async gemがAJAXリクエストでbook_rating_path
を呼び出し、レーティング情報がフェッチされたらページ上に表示します。このgemの大きなメリットは、レーティング情報が個別に読み込まれるおかげで書籍ページの表示が速くなることです。
あるいは、もし使ってみたければBasecampのTurbo Framesのlazy loadingを利用してもよいでしょう。考え方はrender_asyncと同じですが、以下のようにマークアップ内で<turbo-frame>
要素を置くだけで使えます。
<turbo-frame id='rating_1' src='/books/1/rating'>
</turbo-frame>
どのオプションを選んでも、「重たい操作や不安定な操作をメインのコントローラアクションから切り離し、できるだけ早いうちにページを表示する」という考え方は共通です。
まとめ
コントローラを薄く保ち、コントローラを他のメソッドの単なる「呼び出し元」と見なしたいのであれば、本記事が多少なりともそのための洞察を皆さんにもたらすと私は信じています。もちろん、本記事で取り上げたパターンやアンチパターンは一部に過ぎず、網羅的ではありません。「自分はこうするのがよいと思う」「自分はこういうのが好き」といったご意見がありましたらTwitterまでお寄せください。
本シリーズの続きにぜひご期待ください。「Railsの一般的な問題」や「本シリーズで得られた教訓」について、少なくとももう1回は記事を掲載する予定です。
それでは次回またお会いしましょう!
お知らせ
Rubyのマジックに関する記事が公開されたらすぐ読みたい方は、元記事末尾のフォームにて「Ruby Magic」ニュースレターをご購読いただければ、新着記事を見逃さずに読めるようになります。
関連記事
- 原文「Ready to serve」は「すぐ食べられます」というイディオムでもあります。 ↩
- Service Objectパターンとも呼ばれます。本記事では「Serviceパターン」と表記します。 ↩
概要
原著者の許諾を得て翻訳・公開いたします。