はじめに
株式会社ヴァージニアのエンジニアリング本部の津留です。以前投稿させて頂きました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_action
でset_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
の並びに注目してください。
本来ならなるべく、recommend
とrelation
アクションで実行する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した例外をBaseController
のrescue_from
でキャッチできるか判断してくれます。
rescue_from
メソッドの第一引数のクラスと同じ例外が発生した場合、with
オプションで指定しているメソッドでその後の処理を行なって行きます。上記の例で言うとrender_error_response_400
メソッドでエラーレスポンスを返却する処理が実装されています。
つまりこの場合、リクエスト処理中の何処かでActiveRecord::RecordNotFound
エラーが発生した場合、render_error_response_400
メソッドが常に呼び出されて決まったレスポンスを返してくれることになります。
大抵の例外処理は挙動中に発生するエラーをあらかじめ考慮する必要がなく、各処理でエラーハンドリングの実装をいちいち書かなくていいところが良いです。
共通処理2, 認証・認可
ヴァージニアでは認可をPundit
gem, 認証をdevise
gemを使用して実現しています。下記のコードを見てください。
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
Pundit
gemとはあるリソースに対してアクションを実施できるか否かを決定するポリシーと、それを判断するロジックを手軽に導入できる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
細かいPundit
gemの説明は今回は省きますが、BaseController
を継承したコントローラクラスのアクションでauthorize
メソッドを呼び出し操作対象リソースのインスタンスオブジェクトを渡します。
今回の例で言うとauthorize
メソッドにCustomer
のインスタンスオブジェクトを渡しているのでCustomerPolicy
が適用され、show
アクションが実行されようとしているのでCustomerPolicy
に定義したshow
メソッドが呼び出されます。
CustomerPolicy
に定義したshow
メソッドにはログインしたユーザーの属性が管理ユーザーの場合、実行できるようにしてあります。例外処理とも似ていますがこちらも各アクションで認可の判別を実行する処理を書かなくてよく、authorize
メソッドを呼び出せば良いので楽ですね。
また認可のロジックも統一できるメリットもあります。
もし認可できないユーザーだった場合、Pundit::NotAuthorizedError
エラーが発生しBaseController
クラスでキャッチ後、定義してあるrender_forbidden
メソッドが呼び出され、403エラー
(権限無し
)のエラーレスポンスを返却してくれます。
認証を任されているdevise
gemはRailsアプリケーションで手軽に認証を実現するための有名なgemです。
BaseController
クラスではdevise
gemで用意されているヘルパーメソッドの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
メソッドで実現しています。
さらにアクションメソッドの内部に注目してみますと、受け取ったパラメータを格納し期待する値が渡されているか判別する責務をCustomerUpdateParameter
やCustomerDestoryParameter
に担ってもらい、ロジック等はCustomerUpdateService
やCustomerDestoryService
といったクラスに責務を担ってもらっています。コントローラクラスを軸に考えるとパラメータを受け取ってサービスクラスに渡し、レスポンスを返却する責務に集中できていると言えますね。
before_actionアクションの使用方法
ヴァージニアではbefore_action
を使用していますがアンチパターンのようにインスタンス変数をセットしている箇所はほとんどありません。あってもごく少数の箇所でしか使用していないので可読性も損なっていない程度です。先ほどファットコントローラの例で記載させていただきましたコードのCustomersController
クラスを再び見ていただくと、update
とdestroy
では更新対象となるcustomer
インスタンスをbefore_action
でセットすることなく、各アクションで生成しています。各アクションで似たような処理をするのでDRYの原則に反しているように思えますが、アンチパターンのように可読性が悪くなり、保守もしづらくなる事を考慮すると、コード量も大した量ではないので総合的にみるとコスパが良いように思えます。ちなみにヴァージニアでbefore_action
を使用している主な箇所はBaseController
に記述されている認証関連と認可関連など横断的関心事と呼ばれるものです。
今回のまとめ
今回はRailsプロジェクトで使用されるコントローラ層と実際にヴァージニアで運用しているコントローラクラスの構成について触れてきました。
コントローラクラスにはファットコントローラとbefore_actionでインスタンス変数をセットするのアンチパターンが存在し、コントローラクラスでは主に認証と認可、データをフェッチする役割を持つサービスクラスの呼び出し、テンプレートレンダリング等の責務に集中させるべきであること、before_action
の使い方に注意することが大切だとわかりました。
BaseController
クラスに横断的な関心ごと(例外処理や認証認可処理)をまとめ、パラメータのチェックやロジックを別クラスに任せているヴァージニアのコントローラクラスはアンチパターンを避け、比較的保守しやすいコントローラクラスになっているのではないかと個人的に感じました。ロジックの改修案件が入った際も認証の有無や大抵のエラーハンドリングも気にしなくて良いのでロジックに集中できるというメリットがあります。
使用すべき時とそうでない時をしっかり見極めて使う必要はありますがbefore_action
についてはシステム全体で使用している箇所があまり見受けられないので複雑なbefore_action
に苦手意識のある私個人にとってはありがたい作りです。前章のbefore_actionアクションの使用方法でも触れましたが、なんでもDRYの作りに則るのではなく、総合的なコストを加味して同じ処理を書いても良いのだなと感じました。
Discussion