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

Ruby on Railsコントローラーのパターンとアンチパターン徹底解説

Ruby on Railsのパターンとアンチパターンシリーズ、第4回へようこそ。これまでの記事では、パターンとアンチパターンの概要、そしてRailsのモデルとビューに関する内容を取り上げてきました。

今回はMVC(Model-View-Controller)デザインパターンの最後の要素である「コントローラー」に焦点を当て、Railsコントローラーに関連するパターンとアンチパターンを詳しく見ていきましょう。

最前線に立つコントローラー

Ruby on RailsはWebフレームワークであるため、HTTPリクエストはその生命線とも言える存在です。さまざまなクライアントがリクエストを通じてRailsバックエンドにアクセスしますが、その窓口となるのがコントローラーです。コントローラーはリクエストを受け取り、処理する最前線に位置しており、Ruby on Railsフレームワークの中核を担う重要な部分です。

もちろん、コントローラーより前に動作するコード(ミドルウェアやルーティングなど)も存在しますが、私たち開発者が直接制御できるのは主にコントローラーのコードです。config/routes.rbでルートを定義すれば、設定したルートへのリクエストに対して、対応するコントローラーが残りの処理を担当します。

この説明だけ聞くと、すべてがシンプルに思えるかもしれません。しかし実際には、多くの責任がコントローラーの肩にのしかかります。認証・認可の処理、必要なデータの取得方法、ビジネスロジックをどこでどのように実行すべきかなど、考慮すべき点は多岐にわたります。

こうした懸念事項や責任がすべてコントローラー内に詰め込まれると、アンチパターンが生まれやすくなります。中でも最も「有名」なのが、「ファット(肥大化した)コントローラー」と呼ばれるアンチパターンです。

ファット(肥大化した)コントローラー

コントローラーにロジックを詰め込みすぎると、単一責任の原則(SRP:Single Responsibility Principle)に違反することになります。つまり、コントローラーがやりすぎている状態です。この結果、大量のコードと責任がコントローラーに蓄積されていきます。「ファット」という言葉は、コントローラーファイルに含まれる膨大なコード量と、そこで処理されるロジックの多さを指しています。

コントローラーが何をすべきかについては様々な意見がありますが、一般的な責任範囲として以下の3点が挙げられます。

  • 認証と認可 — リクエストの背後にいるエンティティ(多くの場合ユーザー)が本人であるか、またリソースへのアクセスやアクションの実行が許可されているかを確認します。認証情報はセッションやCookieに保存されることが多いですが、コントローラーはその認証データが依然として有効であるかを検証する必要があります。
  • データ取得 — リクエストパラメータに基づいて適切なデータを見つけるためのロジックを呼び出します。理想的には、すべての処理を行う1つのメソッドを呼び出すだけで済む形が望ましく、重い処理はコントローラー自身ではなく、さらに先に委譲すべきです。
  • テンプレートのレンダリング — 最後に、適切な形式(HTML、JSONなど)で結果をレンダリングしてレスポンスを返すか、別のパスやURLへリダイレクトします。

これらの原則に従うことで、コントローラーアクション内での過剰な処理を防ぎ、アプリケーションの他の領域へ作業を委譲できます。責任を分割し、それぞれ個別にテストすることで、堅牢なアプリケーション開発につながります。

それでは、コントローラーの負担を軽減するために使える具体的なパターンを見ていきましょう。

Query Object(クエリオブジェクト)

コントローラーアクションで起こりがちな問題の一つが、データクエリのやりすぎです。以前のRailsモデルに関する記事でも同様の問題を扱いましたが、今回は「Query Object」というパターンを活用します。Query Objectとは、複雑なクエリを1つのオブジェクトに分離するテクニックです。

多くの場合、Query ObjectはActiveRecordのリレーションで初期化される、プレーンなRubyオブジェクト(PORO)として実装されます。典型的な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

コントローラーでは以下のように使用します。

class SongsController < ApplicationController
  def index
    @songs = AllSongsQuery.new.call(all_songs_params)
  end
 
  private
 
  def all_songs_params
    params.slice(:artist_id)
  end
end

もう少し洗練されたアプローチもあります。

# 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をオプション扱いにできるため、より堅牢な設計になります。また、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と呼び出すだけで完了です。artist_idを含むparamsを渡せますし、何らかの理由で初期スコープを変更したい場合は、スコープ自体を渡すこともできます。どうしてもクエリクラスに対して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を試してみたくなりましたか?

Service(サービス)の活用

Query Objectによってデータの収集・取得を委譲する方法を扱いました。では、データ取得からレンダリングまでの間に積み上がったロジックはどう扱えばよいのでしょうか?その答えの一つが「Service(サービス)」です。サービスとは、一般的に単一の(ビジネス)アクションを実行するPORO(Plain Old Ruby Object)とみなされています。

例として、2つのサービスを考えてみましょう。1つは領収書を作成するサービス、もう1つは領収書をユーザーに送信するサービスです。

# 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つのサービスがすべての作業を行い、コントローラーはそれらを呼び出すだけになりました。それぞれ個別にテストできますが、ここで一つの問題が浮上します。サービス間に明確なつながりがないのです。確かに理論上、各サービスは単一のビジネスアクションを実行しています。しかし、ステークホルダーの視点から抽象化レベルを考えると、「領収書を作成する」というアクションにはメール送信まで含まれています。どちらの抽象化レベルが「正しい」のでしょうか?

この思考実験をもう少し複雑にしてみましょう。領収書の合計金額を、作成時にどこかから計算または取得する必要があるという要件が追加されたらどうでしょう?合計金額の計算用に別のサービスを書くべきでしょうか?答えは、単一責任の原則(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

SRPに従うことで、各サービスをより大きな抽象化、例えばReceiptCreationプロセスへと組み合わせられるようになります。この「プロセスクラス」を作れば、プロセスの完了に必要なすべてのアクションをグループ化できます。最初は抽象化しすぎに聞こえるかもしれませんが、これらのアクションをあちこちで呼び出しているなら、大きなメリットになる可能性があります。この考え方に興味があれば、TrailblazerのOperationもチェックしてみてください。

まとめると、新しく追加したCalculateReceiptTotalServiceは数値計算を担当し、CreateReceiptServiceは領収書のデータベースへの書き込みを、SendReceiptServiceはユーザーへの領収書メール送信を担当します。小さく焦点を絞ったクラス群にしておけば、他のユースケースでの組み合わせも容易になり、保守性とテストのしやすいコードベースにつながります。

サービスの背景にある考え方

Rubyの世界では、サービスクラスを使うアプローチは「アクション」「オペレーション」などとも呼ばれます。これらはすべて「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サーバーへのリクエストがタイムアウトするまで待たされることになります。相手側のサーバーが遅ければ、ページの表示も遅くなります。サードパーティサービスの呼び出しを別のアクションに切り出しましょう。

# 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 %>

gemがページ読み込み時にAJAXリクエストでbook_rating_pathを呼び出し、評価の取得が完了するとページ上に表示されます。大きなメリットは、評価を別途読み込むことで、ユーザーが書籍ページをより速く閲覧できるようになる点です。

あるいは、Basecampが提供するTurbo Framesを使うこともできます。考え方は同じで、マークアップに<turbo-frame>要素を次のように記述します。

<turbo-frame id="rating_1" src="/books/1/rating"> </turbo-frame>

どの選択肢を選んでも、目的は共通しています。重い、あるいは不安定な処理をメインのコントローラーアクションから切り離し、ページをできるだけ早くユーザーに表示することです。

まとめ

コントローラーを薄く保ち、単なる「呼び出し役」として捉えたいとお考えなら、本記事がその実現のためのヒントになったはずです。ここで紹介したパターンとアンチパターンはもちろん網羅的なリストではありません。より良い方法やお好みのアプローチがあれば、ぜひSNSなどで意見交換しましょう。

このシリーズはまだ続きます。今後は、シリーズ全体で扱ったRailsのよくある問題と教訓を総括する記事を少なくとも1本公開予定です。

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

P.S. Ruby Magicの最新記事をいち早く読みたい方は、Ruby Magicニュースレターをご購読ください。記事を見逃すことはありません!

  1. SMバスコントローラーとは?役割とドライバーの更新方法を徹底解説

    SMバスコントローラー(System Management Bus Controller、SMBus/SMBとも表記されます)は、軽量なデータ通信を目的として設計された、シンプルな2線式のバス規格です。このコントローラーは、パソコン本体と電源ユニット(PSU)や冷却ファンなど、システムの中核を担うハードウェアとの間で、効率的かつシームレスな通信を実現する重要な役割を果たしています。具体的には、電源ユニットのオン/オフ切り替えや冷却ファンの回転数制御といったコマンドの処理・実行を可能にするのがSMバスコントローラーです。しかしその機能は、OSとハードウェア間の通信にとどまりません。多くのノートパ

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

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