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

RailsのFacadeパターンで実現する、パフォーマンスと保守性の高いアプリケーション設計

今回は「Facade(ファサード)」と呼ばれるソフトウェアデザインパターンについて掘り下げます。最初に採用した頃は少し違和感がありましたが、Railsアプリで使い込むほどにその有用性を実感するようになりました。さらに重要なのは、このパターンのおかげでコードをより徹底的にテストできるようになり、コントローラーをすっきりさせ、ビュー内のロジックを減らし、アプリケーション全体のコード構造について明確に考えられるようになったという点です。

Facadeはソフトウェア開発パターンの一つなのでフレームワークに依存しませんが、本記事の例はRuby on Rails向けに記述しています。とはいえ、どのフレームワークを使っていても、ぜひ読んで試してみてください。このパターンに慣れれば、コードベースのさまざまな箇所で活用のチャンスが見えてくるはずです。

それでは早速、始めましょう!

MVCパターンの課題

MVC(Model-View-Controller)パターンは1970年代に遡るソフトウェア開発パターンです。プログラミング上の関心事を3つの主要なグループに分離し、それぞれが独自の方法で連携するという、実戦で鍛えられたインターフェース設計のソリューションです。

2000年代前半には、MVCパターンを基盤とした大規模なWebフレームワークが数多く登場しました。JavaのSpring、PythonのDjango、RubyのRuby on Railsは、いずれもこの三位一体の相互連携要素を核として生まれています。MVCを採用しないソフトウェアから生まれるスパゲッティコードと比べれば、MVCパターンはソフトウェア開発とインターネットの進化における大きな成果であり、転換点でした。

本質的に、Model-View-Controllerパターンでは次のような流れが実現されます。ユーザーがView上でアクションを実行すると、ViewがControllerへリクエストを送り、Controllerは必要に応じてModelの作成・読み取り・更新・削除を行います。Modelでの処理結果がControllerに返され、Controllerはユーザーに見える形の変化をViewに描画します。

このプログラミングパターンには多くのメリットがあります。例を挙げると:

  • 関心の分離により、コードの保守性が向上する
  • テスト容易性が高まる(Model・View・Controllerをそれぞれ独立してテストできる)
  • SOLID原則の単一責任の原則(SRP)——「クラスが変更される理由は1つだけであるべき」——を強制することで、良いコーディング習慣を促進する

当時としては驚異的な達成だったMVCパターンですが、開発者たちはやがて、このパターンにも一定の制約があることに気づきました。そこでHMVC(階層型モデル・ビュー・コントローラー)、MVA(モデル・ビュー・アダプタ)、MVP(モデル・ビュー・プレゼンター)、MVVM(モデル・ビュー・ビューモデル)といったバリエーションが登場し、いずれもMVCの限界への対処を目指してきました。

MVCパターンが抱える問題の一つ——そして本記事のテーマ——は次の通りです。「複雑なビューロジックの処理は誰の責務なのか?」という問いです。Viewは単にデータを表示することだけに関心を持つべきであり、ControllerはModelから受け取ったメッセージを中継するだけ、Modelはビューロジックに関与すべきではありません。

このよくある難題を支援するために、すべてのRailsアプリケーションにはhelpersディレクトリが初期状態で用意されています。helpersディレクトリには、複雑なViewロジックを支援するメソッドを持つモジュールを配置できます。

Railsアプリケーションにおけるヘルパーの例を見てみましょう。

app/helpers/application_helper.rb

module ApplicationHelper
  def display_ad_type(advertisement)
    type = advertisement.ad_type
    case type
    when 'foo'
      content_tag(:span, class: "foo ad-#{type}") { type }
    when 'bar'
      content_tag(:p, 'bar advertisement')
    else
      content_tag(:span, class: "badge ads-badge badge-pill ad-#{type}") { type }
    end
  end
end

シンプルな例ですが、テンプレート自体の複雑さを抑えるために、こうした条件分岐のロジックをテンプレートから切り出したくなることがわかるでしょう。

ヘルパーも便利ですが、複雑なViewロジックを扱う手法として、ここ数年で広く受け入れられているもう一つのパターンがあります。それがFacadeパターンです。

Facadeパターン入門

Ruby on Railsアプリケーションでは、Facadeは通常app/facadesディレクトリに配置します。

helpersと似ていますが、facadesはモジュール内のメソッド群ではありません。Facadeとは、コントローラー内でインスタンス化されるPORO(Plain Old Ruby Object:普通のRubyオブジェクト)であり、凝ったView用ビジネスロジックを担当するものです。これにより、次のようなメリットが得られます。

  1. UsersHelperArticlesHelperBooksHelperのようにコントローラーごとに1つのモジュールを共有するのではなく、各コントローラーアクションごとに専用のFacadeを持てます:Users::IndexFacadeArticles::ShowFacadeBooks::EditFacadeなど。
  2. モジュール以上に、Facadeはネストできるため、単一責任の原則を守った良いコーディング習慣を促進します。何百階層もの深いネストを作りたいわけではないでしょうが、保守性とテストカバレッジの向上のために1〜2層のネストを持たせるのは有効です。

以下は架空の例です。

module Books
  class IndexFacade
    attr_reader :books, :params, :user
 
    def initialize(user:, params:)
      @params = params
      @user   = user
      @books  = user.books
    end
 
    def filtered_books
      @filtered_books ||= begin
        scope = if query.present?
                  books.where('name ILIKE ?', "%#{query}%")
                elsif isbn.present?
                  books.where(isbn: isbn)
                else
                  books
                end
 
        scope.order(created_at: :desc).page(params[:page])
      end
    end
 
    def recommended
      # ここでネストされたファサードを使用しています。
      # ビューの「おすすめ書籍」部分は単一の責務を持つため、
      # カプセル化とテスト容易性を高めるために切り出すのが最適です。
      @recommended ||= Books::RecommendedFacade.new(
        books: books,
        user: user
      )
    end
 
    private
 
    def query
      @query ||= params[:query]
    end
 
    def isbn
      @isbn ||= params[:isbn]
    end
  end
end

Facadeパターンを使うべきでないケース

ここで、Facadeが「何ではないのか」についても考えてみましょう。

  • 例えばlibディレクトリにあるような、Viewに表示する必要のないコードを扱うクラスにFacadeを配置すべきではありません。FacadeのライフサイクルはControllerアクションで生成され、対応するViewで使用されるべきものです。

  • FacadeはCRUD操作などのビジネスロジックの実行を目的としたものではありません(それにはServiceやInteractorなど、別のパターンが適しています——これはまた別の機会に)。つまり、Facadeはデータの作成・更新・削除に関与すべきではありません。その目的は、ViewやControllerから複雑なプレゼンテーションロジックを切り出し、それらの情報へアクセスするための単一のインターフェースを提供することにあります。

  • 最後に重要な点として、Facadeは銀の弾丸ではありません。MVCパターンを回避する手段ではなく、MVCと協調して動くものです。Modelに変更が発生しても、それは即座にViewへ反映されるわけではありません。MVCの場合と同様に、FacadeがViewに変更を表示するには、Controllerアクションの再描画が必要になります。

コントローラーのメリット

Facadeの主な明白なメリットの一つは、コントローラーのロジックを劇的に削減できることです。

コントローラーのコードは、次のような状態から:

class BooksController < ApplicationController
  def index
    @books  = if params[:query].present?
                current_user.books.where('name ILIKE ?', "%#{params[:query]}%")
              elsif params[:isbn].present?
                current_user.books.where(isbn: params[:isbn])
              else
                current_user.books
              end
 
    @books.order(created_at: :desc).page(params[:page])
    @recommended = @books.where(some_complex_query: true)
  end
end

次のように変わります。

class BooksController < ApplicationController
  def index
    @index_facade = Books::IndexFacade.new(user: current_user, params: params)
  end
end

ビューのメリット

Viewにとって、Facadeを使う主なメリットは2つあります。

  1. 条件分岐、インラインクエリ、その他のロジックをテンプレート自体からきれいに切り出せるため、コードの可読性が大幅に向上します。例えばフォームでは次のように使えます。
<%= f.label :location %>
<%= f.select :location, options_for_select(User::LOCATION_TYPES.map { |type| [type.underscore.humanize, type] }.sort.prepend(['All', 'all'])), multiple: (current_user.active_ips.size > 1 && current_user.settings.use_multiple_locations?) %>

これが次のようにシンプルになります。

<%= f.label :location %>
<%= f.select :location, options_for_select(@form_facade.user_locations), multiple: @form_facade.multiple_locations? %>
  1. 複数回呼び出される変数をキャッシュできます。これによりアプリのパフォーマンスが大幅に向上し、厄介なN+1クエリ問題の解消にも役立ちます。
// ビューのある場所で、クエリが実行される。
<% current_user.books.where(isbn: params[:isbn]).each do |book| %>
  // 何か処理をする
<% end %>
 
// ビューの別の場所で、同じクエリが再度実行される。
<% current_user.books.where(isbn: params[:isbn]).each do |book| %>
  // 何か処理をする
<% end %>

これが次のようになります。

// ビューのある場所で、クエリが実行される。
<% @index_facade.filtered_books.each do |book| %>
  // 何か処理をする
<% end %>
 
// ビューの別の場所。
// インスタンス変数のキャッシュにより、2回目のクエリは実行されない。
<% @index_facade.filtered_books.each do |book| %>
  // 何か処理をする
<% end %>

テストのメリット

Facadeの大きなメリットは、コントローラーテスト全体を書かずに済み、さらにはデータ表示が期待どおりであることを確認するためだけにフローを通してページに到達する統合テストを書く必要もなく、個々のビジネスロジックを単体でテストできる点です。

単一のPOROをテストすることになるため、テストスイートの高速維持にもつながります。

説明のため、Minitestで書いたシンプルなテストの例を示します。

require 'test_helper'
 
module Books
  class IndexFacadeTest < ActiveSupport::TestCase
    attr_reader :user, :params
 
    setup do
      @user = User.create(first_name: 'Bob', last_name: 'Dylan')
      @params = {}
    end
 
    test "#filtered_books returns all user's books when params are empty"
      index_facade = Books::IndexFacade.new(user: user, params: params)
 
      expectation = user.books.order(created_at: :desc).page(params[:page])
 
      # コントローラーテストや統合テストを丸ごと書かなくても、
      # 空のパラメータでファサードを使った場合に
      # 正しい結果がユーザーに返されることを検証できる。
      assert_equal expectation, index_facade.filtered_books
    end
 
    test "#filtered_books returns books matching a query"
      @params = { query: 'Lord of the Rings' }
      index_facade = Books::IndexFacade.new(user: user, params: params)
 
      expectation = user
        .books
        .where('name ILIKE ?', "%#{params[:query]}%")
        .order(created_at: :desc)
        .page(params[:page])
 
      assert_equal expectation, index_facade.filtered_books
    end
  end
end

Facadeのユニットテストはテストスイートのパフォーマンスを大幅に改善します。大規模な企業であれば、こうした問題に真剣に取り組まない限り、いずれ遅いテストスイートに悩まされることになるでしょう。

ファサードはいくつ作る?ネストはどこまで?

あるViewが部分的なデータを出力するパーシャルを描画するケースに遭遇することがあります。その場合、親のFacadeを使うことも、ネストされたFacadeを使うことも可能です。どちらを選ぶかは、ロジックの量、個別にテストしたいかどうか、機能を切り出す意味があるかどうかに大きく依存します。

Facadeをいくつ使うべきか、互いにどれだけネストすべきかについての黄金律はありません。それは開発者の裁量に委ねられます。私自身は、コントローラーアクションごとに1つのFacadeを用意し、コードを追いやすくするためにネストは1段階までに抑えるのが好みです。

開発中に自分自身に問いかけるべき一般的な質問をいくつか挙げます。

  • そのFacadeは、Viewに表示しようとしているロジックを適切にカプセル化しているか?
  • Facade内のそのメソッドは、この文脈において意味をなしているか?
  • コードは今、追いやすくなったか、それとも逆に難しくなったか?

迷ったときは、常にコードをできる限り追いやすいものにすることを心がけましょう。

まとめ

まとめると、Facadeはコントローラーとビューをスリムに保ちながら、コードの保守性・パフォーマンス・テスト容易性を向上させる素晴らしいパターンです。

しかし、どんなプログラミングパラダイムにも銀の弾丸は存在しません。近年登場した多数のパターン(HMVC、MVVMなど)でさえ、ソフトウェア開発の複雑さに対する万能の解決策ではありません。

熱力学第二法則——閉じた系のエントロピーは常に増加する——と同様に、あらゆるソフトウェアプロジェクトでも複雑さは時間とともに増大し、進化していきます。長期的な目標は、できる限り読みやすく、テストしやすく、保守しやすく、追いやすいコードを書くことです。Facadeはまさにそれを実現してくれるのです。

P.S. Ruby Magicの記事を公開と同時にお読みになりたい方は、Ruby Magicニュースレターを購読して、記事を見逃さないようにしましょう!

  1. 価格とパフォーマンスで選ぶ!おすすめChromeboxミニPC徹底比較

    Googleの軽量OS「Chrome OS」は、デスクトップ向けながら大掛かりなPCを必要としません。モニターの背面に取り付けられる小型PC「Chromebox(クロームボックス)」は、低価格でありながら非常に優れた買い物になります。 ただし、デスクトップPCが小型化するほど処理能力も下がりがちです。とはいえ、この影響が大きいのはWindowsの場合。Chrome OSは軽量かつ高速なので、基本的なスペックのハードウェアでも快適に動作します。 では、どのモデルを選べばよいのでしょうか。ここでは特におすすめのChromeboxをご紹介します。 ベストバランスのChromebox:Asus Chr

  2. Parallels Desktop 17 for Macが登場――パフォーマンス大幅向上とWindows 11対応で進化

    人気の仮想化ソフトウェア「Parallels Desktop」の最新版「Parallels Desktop 17 for Mac」が本日リリースされました。Intel製チップを搭載したMacはもちろん、Apple独自設計のM1チップを搭載した新型Macにも対応しています。ユニバーサルバイナリアプリケーションとしてx86アーキテクチャとARMアーキテクチャの両方に最適化されており、内蔵のインストールアシスタントからは、間もなく登場するWindows 11やmacOS Montereyのプレビュー版へ簡単にアクセスできるようになりました。 これまでParallels Desktop 16.5を使え