🔥

Railsプロジェクトにおけるコントローラ

2022/12/28に公開

はじめに

株式会社ヴァージニアのエンジニアリング本部の津留です。以前投稿させて頂きましたRailsプロジェクトにおけるパラメータ処理の記事ではコントローラ層で受け取るパラメータの処理について書かせて頂きました。続いて今回はヴァージニアで実際に使用・作成しているコントローラクラスについて触れていこうと思います。
記事の流れとしてはコントローラクラスを作成する上でのアンチパターンについて述べ、実際にヴァージニアではどのようにコントローラクラスを作成しているのか触れていきたいと思います。

アンチパターン

1, ファットコントローラ

ファットコントローラとは

ファットコントローラとはコントローラ層にさまざまな処理を任せてしまうこと(複数の責務を持っている状態)でコントローラ層の役割が肥大してしまっている状態を指します。
コントローラクラスはクライアントからのリクエストを受け付ける最前線の箇所なので何も考えず機能の実現だけを考えて実装すれば、ほぼコントローラクラスで完結させられます。
例えばフロントエンドから受け取ったパラメータに対してバリデーションを行ったり、その後のビジネスロジックの処理までコントローラクラスに任せてしまうことが挙げられます。

問題点

ファットコントローラの問題点は、改修する際にどこに処理を書いたのか分かりにくく、可読性が落ちてしまうところです。また同じ処理を至る所に書いているケースが多く、改修漏れが発生しやすくなります。さらに、処理中の一部のコードを修正すると他の部分に影響が出てしまいやすくなる点が挙げられます。
また本来、共通化すべきエラーハンドリングも各アクションごとに例外処理を実装するとDRYの流儀に反する上、コードが冗長化し可読性が悪くなります。ある例外処理を修正した時、同じパターンで引き起こされる他の箇所の例外処理も同様の改修処理を行わないといけない為、改修漏れも発生しやすくなるでしょう。

ファットコントローラを避けるために

こちらのサイトでも記載されている通り、基本、コントローラは主に認証と認可サービスクラスの呼び出しテンプレートレンダリングに集中させたほうが良さそうです。
(サービスクラスとはデータをフェッチしたり、要件仕様を実現するビジネスロジックを実行するクラスを指します)

他には、前回の記事でも触れましたが、バリデーションクラスを用意しバリデーションを一任したり、一旦はビジネスロジックをコントローラに書くことをやめてモデルクラスに移譲するなど、対策をした方が良さそうです。例外処理や認証、認可はモジュールごとに切り出して各コントローラでincludeを行うか、ベースとなる共通処理クラスを作成して各コントローラで継承させるなどすることで可読性が高まると思われます。

2, before_actionでインスタンス変数をセット

問題点

Railsで開発していく上でアンチパターンの2つ目にbefore_actionでインスタンス変数をセットすることが挙げられます。
その問題点は大きく分けて下記の2つあります。

  • before_actionで定義したメソッドが他のメソッドに依存する場合、メソッドの実行順序に気を付けなければならない。
  • アクションの指定とbefore_actionフィルタの順序という2軸があるので、依存性がある場合に複雑なことをしようとすると煩雑になりがち。

それでは1つ目の問題点から見てみましょう。
まず、before_actionとはコントローラクラスの各アクションで実施される処理を共通化できる仕組みのことで、どのアクションでその処理を実行するかオプションで指定する事も可能です。
一見、DRYの実現も出来、デメリットも特になさそうですが何が良くないのか下記コードを見てください。

例えば、ある著者情報に基づく出版本の一覧を表示するindexアクションを実装します。before_actionでインスタンス変数をセットするset_booksメソッドを定義しています。下記のコードだと特に問題点があるようには見えません。

class BooksController < ApplicationController
  before_action :set_books, only: %i[index]

  def index
    # 著者情報に基づく出版本の一覧
    render json: { books: @books }
  end

  def set_books
    @books = Book.where(author_id: params[:author_id])
  end
end

その著者の詳細情報もindexアクションで一緒に返したいというケースを想定します。
before_actionset_authorメソッドを用いて著者(@author)もインスタンス化します。またset_booksで使用していたparams[:author_id]@author.idに修正しています。

class BooksController < ApplicationController
  before_action :set_books, :set_author, only: %i[index]

  def index
    # 著者情報に基づく出版本の一覧と著者情報
    render json: { books: @books, author: @author }
  end

  def set_books
    @books = Book.where(author_id: @author.id)
  end

  def set_author
    @author = Author.find(params[:author_id])
  end
end

一見問題なさそうですがこのまま実行するとエラーで落ちてしまいます。なぜかというとset_booksメソッドは内部で@authorを参照しており、authorが事前にインスタンス化されていることが必須となります。なのでset_authorメソッドが事前に実行されていないとエラーになります。
before_actionメソッドの並びを見てみるとset_authorメソッドより先にset_booksメソッドが実行されている為、エラーになってしまうのです。このようにbefore_actionを利用してインスタンス変数をセットする場合、セットを実行するメソッドの実行順序を気にしないといけないのが面倒になりますね。

上記の例ではbefore_actionメソッドの依存関係が実行順序を気にしないといけない問題を引き起こすものでしたが、
次は2つ目の問題点として挙げた「アクションの指定とbefore_actionフィルタの順序という2軸があるので、依存性がある場合に複雑なことをしようとすると煩雑になりがち」問題について触れます。上記のコードに加えてさらに下記の要件を加えてみましょう。

  • relation画面に関連本(@relation_books)とお薦め本(@recommend_books)を表示
  • 関連本(@relation_books)は@booksから導ける
  • お薦め本(@recommend_books)はピックアップ本(@pickup_books)があれば、それを除いた新着本
  • ピックアップ本(@pickup_books)は新着本(@new_books)から導き出す
  • お薦め本(@recommend_books)はrecommend画面でも表示

上記の要件を反映したコードが下記になります。


class BooksController < ApplicationController
  before_action :set_author, :set_books, only: %i[index relation recommend]
  before_action :set_new_books, only: %i[relation recommend]
  before_action :set_relation_books, :set_pickup_books, only: %i[relation]
  before_action :set_recommend_books, only: %i[relation recommend]

  def index
    render json: { books: @books, author: @author }
  end

  def relation
    render json: { relation_books: @relation_books, recommend_books: @recommend_books }
  end

  def recommend
    render json: { recommend_books: @recommend_books }
  end

  def set_books
    @books = Book.where(author_id: @author.id)
  end

  def set_author
    @author = Author.find(params[:author_id])
  end

  def set_new_books
    @new_books = Book.find_new_books(@books)
  end

  def set_relation_books
    @relation_books = Book.find_relation_books(@books)
  end

  def set_pickup_books
    @pickup_books = Book.find_pickup_books(@new_books)
  end

  def set_recommend_books
    @recommend_books = Book.find_recommend_books(@pickup_books)
  end
end

before_actionの並びに注目してください。
本来ならなるべく、recommendrelationアクションで実行するset_recommend_booksメソッドとset_new_booksメソッドをまとめて下記のように
定義したいですがset_recommend_booksは事前にset_pickup_booksが呼び出されていることが前提となっている為、まとめることができません。

  • 理想
  before_action :set_author, :set_books, only: %i[index show edit]
  before_action :set_new_books, :set_recommend_books, only: %i[relation recommend]
  before_action :set_relation_books, :set_pickup_books, only: %i[relation]
  • 実際
  before_action :set_author, :set_books, only: %i[index relation recommend]
  before_action :set_new_books, only: %i[relation recommend]
  before_action :set_relation_books, :set_pickup_books, only: %i[relation]
  before_action :set_recommend_books, only: %i[relation recommend]

個人的には理想の形でも処理が追いづらいと感じてしまいますが実際のコードを見てみると実行順序が煩雑化してしまい、可読性が落ちているように見えます。

ヴァージニアのコントローラクラスの構成

それではヴァージニアのプロジェクトで使用しているコントローラ層の構成について触れて行きたいと思います。
ヴァージニアでは共通処理を行うための継承元にするコントローラークラスを用意しています。(今回の記事ではそれをBaseControllerクラスと呼ぶことにします。)
共通処理を行うBaseControllerクラスではコントローラ層で発生した例外処理をキャッチするメソッド群と認証認可関連の処理が記述されています。大部分のコントローラクラスはBaseControllerクラスを継承しています。
継承することでBaseControllerクラスに記述されている処理を個々のコントローラクラスに記述しなくても処理を実現できるようになっています。

共通処理1, 例外処理

下記のコードを見て下さい。

class BaseController < ActionController::API
  # 省略
  rescue_from ActiveRecord::RecordNotFound, with: :render_error_response_400

  # 省略

  def render_error_response_400
    error = { status: 400, messages: ['not found'] }
    render json: error, status: error.status
  end
end

各コントローラクラスは上記のように例外処理を定義したBaseControllerクラスを継承しているので各コントローラクラスでraiseした例外をBaseControllerrescue_fromでキャッチできるか判断してくれます。
rescue_fromメソッドの第一引数のクラスと同じ例外が発生した場合、withオプションで指定しているメソッドでその後の処理を行なって行きます。上記の例で言うとrender_error_response_400メソッドでエラーレスポンスを返却する処理が実装されています。
つまりこの場合、リクエスト処理中の何処かでActiveRecord::RecordNotFoundエラーが発生した場合、render_error_response_400メソッドが常に呼び出されて決まったレスポンスを返してくれることになります。
大抵の例外処理は挙動中に発生するエラーをあらかじめ考慮する必要がなく、各処理でエラーハンドリングの実装をいちいち書かなくていいところが良いです。

共通処理2, 認証・認可

ヴァージニアでは認可をPunditgem, 認証をdevisegemを使用して実現しています。下記のコードを見てください。

class BaseController < ActionController::API
  # 省略
  include Pundit

  # 省略
  rescue_from Pundit::NotAuthorizedError, with: :render_forbidden

  before_action :authenticate_user!

  def render_forbidden
    error = { status: 403, messages: ['forbidden'] }
    render json: error, status: error.status
  end

  def current_user
    # 省略
    # devise gemで使用できる current_user メソッドをオーバーライドしています。
  end
end

Punditgemとはあるリソースに対してアクションを実施できるか否かを決定するポリシーと、それを判断するロジックを手軽に導入できるgemです。ヴァージニアではこのgemを使用してログインユーザーの属性ごとにリソース操作の実行可否を判別しています。
先ほども申し上げたようにヴァージニアで使用している大部分のコントローラクラスはBaseControllerクラスを継承しているため、このgemを使用して認可を行っています。
下記コードを見てください。


class CustomersController < BaseController
  def show
    customer = authorize(Customer.find(params[:customer_id]))
    render json: customer
  end
end

class CustomerPolicy < ApplicationPolicy
  def show?
    # ユーザー詳細は管理ユーザーのみ許可
    user.admin?
  end
end

細かいPunditgemの説明は今回は省きますが、BaseControllerを継承したコントローラクラスのアクションでauthorizeメソッドを呼び出し操作対象リソースのインスタンスオブジェクトを渡します。
今回の例で言うとauthorizeメソッドにCustomerのインスタンスオブジェクトを渡しているのでCustomerPolicyが適用され、showアクションが実行されようとしているのでCustomerPolicyに定義したshowメソッドが呼び出されます。
CustomerPolicyに定義したshowメソッドにはログインしたユーザーの属性が管理ユーザーの場合、実行できるようにしてあります。例外処理とも似ていますがこちらも各アクションで認可の判別を実行する処理を書かなくてよく、authorizeメソッドを呼び出せば良いので楽ですね。
また認可のロジックも統一できるメリットもあります。
もし認可できないユーザーだった場合、Pundit::NotAuthorizedErrorエラーが発生しBaseControllerクラスでキャッチ後、定義してあるrender_forbiddenメソッドが呼び出され、403エラー権限無し)のエラーレスポンスを返却してくれます。

認証を任されているdevisegemはRailsアプリケーションで手軽に認証を実現するための有名なgemです。
BaseControllerクラスではdevisegemで用意されているヘルパーメソッドのauthenticate_user!メソッドとcurrent_userメソッドを利用しています。
before_actionとしてauthenticate_user!メソッドを実行することでBaseControllerを継承したコントローラクラスでアクションが実行される前に、ユーザーが認証されているか判別します。
認証されていない場合、ログインページへ遷移するようにしています。
current_userメソッドも同様にBaseControllerを継承したコントローラクラスで、ログインしているユーザーデータを取得できるようにしています。

アンチパターンの観点から見るヴァージニアのコントローラクラス

それでは最後に、今回の記事で取り上げたコントローラクラスを作っていく上でのアンチパターンの観点から、ヴァージニアで使用しているコントローラクラスはどういったものかについて見て行きたいと思います。

ファットコントローラ

初めに申し立てた通り、先ずファットコントローラとはさまざまな責務をコントローラクラスに担わせており、可読性や保守性に問題がある状態のことでした。さらにファットコントローラを避けるためにコントローラクラスでは主に認証と認可、データをフェッチする役割を持つサービスクラスの呼び出しテンプレートレンダリングに集中させるべきだと言われています。

下記を見てください。ヴァージニアのあるコントローラクラスを参考に作成したものです。(BaseControllerは上に定義したクラスと同一であるとします。)

class CustomersController < BaseController
  # 省略
  def update
    update_params = CustomerUpdateParameter.new(params)
    update_params.validate!

    customer = Customer.find(update_params.customer_id)
    authorize(customer)

    result = CustomerUpdateService.new(update_params, customer).call
    render: result
  end

  def destroy
    destroy_params = CustomerDestoryParameter.new(params)
    destroy_params.validate!

    customer = Customer.find(destroy_params.customer_id)
    authorize(customer)

    result = CustomerDestoryService.new(destroy_params, customer).call
    render: result
  end
end

ヴァージニアではテンプレートレンダリングはそもそも行っていないので今回は省きますが、認証と認可、データをフェッチする役割を持つサービスクラスの呼び出しにのみ集中しているコントローラクラスが作成できていると思われます。
認証と認可についてはBaseControllerに定義しているので継承元の全てのコントローラクラスでbefore_actionで定義したauthenticate_user!メソッドを呼び出され、認証しているか判別出来ています。認可についてはCustomersControllerクラスのupdateアクションとdestroyアクションの中でやっています、authorizeメソッドで実現しています。

さらにアクションメソッドの内部に注目してみますと、受け取ったパラメータを格納し期待する値が渡されているか判別する責務をCustomerUpdateParameterCustomerDestoryParameterに担ってもらい、ロジック等はCustomerUpdateServiceCustomerDestoryServiceといったクラスに責務を担ってもらっています。コントローラクラスを軸に考えるとパラメータを受け取ってサービスクラスに渡し、レスポンスを返却する責務に集中できていると言えますね。

before_actionアクションの使用方法

ヴァージニアではbefore_actionを使用していますがアンチパターンのようにインスタンス変数をセットしている箇所はほとんどありません。あってもごく少数の箇所でしか使用していないので可読性も損なっていない程度です。先ほどファットコントローラの例で記載させていただきましたコードのCustomersControllerクラスを再び見ていただくと、updatedestroyでは更新対象となるcustomerインスタンスをbefore_actionでセットすることなく、各アクションで生成しています。各アクションで似たような処理をするのでDRYの原則に反しているように思えますが、アンチパターンのように可読性が悪くなり、保守もしづらくなる事を考慮すると、コード量も大した量ではないので総合的にみるとコスパが良いように思えます。ちなみにヴァージニアでbefore_actionを使用している主な箇所はBaseControllerに記述されている認証関連と認可関連など横断的関心事と呼ばれるものです。

今回のまとめ

今回はRailsプロジェクトで使用されるコントローラ層と実際にヴァージニアで運用しているコントローラクラスの構成について触れてきました。
コントローラクラスにはファットコントローラbefore_actionでインスタンス変数をセットするのアンチパターンが存在し、コントローラクラスでは主に認証と認可、データをフェッチする役割を持つサービスクラスの呼び出しテンプレートレンダリング等の責務に集中させるべきであること、before_actionの使い方に注意することが大切だとわかりました。

BaseControllerクラスに横断的な関心ごと(例外処理や認証認可処理)をまとめ、パラメータのチェックやロジックを別クラスに任せているヴァージニアのコントローラクラスはアンチパターンを避け、比較的保守しやすいコントローラクラスになっているのではないかと個人的に感じました。ロジックの改修案件が入った際も認証の有無や大抵のエラーハンドリングも気にしなくて良いのでロジックに集中できるというメリットがあります。

使用すべき時とそうでない時をしっかり見極めて使う必要はありますがbefore_actionについてはシステム全体で使用している箇所があまり見受けられないので複雑なbefore_actionに苦手意識のある私個人にとってはありがたい作りです。前章のbefore_actionアクションの使用方法でも触れましたが、なんでもDRYの作りに則るのではなく、総合的なコストを加味して同じ処理を書いても良いのだなと感じました。

引用

VIRGINIA Tech Blog

Discussion