Railsアプリケーションの例外ハンドリングとエラーページの表示方法については色々な方法があると思いますが、そのなかでもよく使われる方法についてまとめてみました。
前提
- Rails 4.2.6
Rails 標準の仕組みで静的ページを表示する
Rails は標準では Rack Middleware のActionDispatch::ShowExceptions
の仕組みで例外を捕捉して、エラーページを表示しています。その際、捕捉した例外に対してどんなエラーページを表示するのかをActionDispatch::PublicExceptions
が処理しています。
例えばActionController::RoutingError
が発生した場合、対応する404のHTMLファイルpublic/404.html
が表示される、という感じです。
なお、rails generate
したときに用意されるHTMLファイルは404.html
, 422.html
, 500.html
です。
この捕捉する例外とステータスコードとの割り当てはActionDispatch::ExceptionWrapper.rescue_responses
のHashで定義されます。ここに定義されていない例外に関しては500として扱われます(Hashのデフォルト指定Hash.new(:internal_server_error)
より)
ここで使用されている Symbol はRack::Utils::SYMBOL_TO_STATUS_CODE
にあるので適宜参照してください。
また、エラーコードに対応するテンプレートがない場合は404として扱われ、画面には何も表示されません。
開発環境での動作確認はconfig.consider_all_request_local = false
にしてお目当ての例外を発生させればOKです。
ところで、新たに例外を捕捉してエラーページを表示したいときはどうすればよいでしょうか?
例えばBanken::NotAuthorizedError
を403 Forbidden
としてエラーページを表示したいとします。
これに任意の定義を追加するには、config.action_dispatch.rescue_responses
に追加したい例外とステータスコードを割り当て、public/403.html
を用意しておけば良いです。
# config/application.rb
Rails.application.configure do
config.action_dispatch.rescue_responses = {
"Banken::NotAuthorizedError" => :forbidden
}
end
なお、format=json
のリクエストに関してはきちんとJSON形式で返るようになっていますが、内容は自由に設定できないようです。
この方法は一番面倒がなくて不具合を仕込む可能性も少なく、安全だと思います。
デメリットとしては静的なファイルである必要があるため、既存のテンプレートやcssを使えないですし、状況に応じた出し分けが難しいです。
ただ、エラーページはシンプルに保てるならそれが一番良いとも思います。普段表示されないページなのでレイアウト崩れや不具合に気付く機会が少ないですし、最低限正常に表示されないとユーザーに状態を伝えるという目的を果たせないです。
それでも、もっと色々なことをやりたいという要求はあるでしょう。
平常時と同じヘッダーを404ページにも出して、ユーザーが迷ってしまうことを減らし離脱を抑えたいとか、ログイン状態に関連する不具合で500になっているとき、ログイン中のユーザーにはログアウトへの動線を用意して一次解決を促したいとか、そういう時は動的なエラーページの制御が必要になります。
Controllerでのrescue_from
による例外捕捉
動的なエラーページを表示する方法としては、おそらくApplicationController
で下記のようにするのが最もポピュラーで手軽な方法だと思います。
class ApplicationController < ActionController::Base
unless Rails.env.development?
rescue_from StandardError, with: :render_500
rescue_from ActiveRecord::RecordNotFound, with: :render_404
...
end
def render_404
logger.error "404だよ: #{exception.message}" if exception
render template: 'errors/error_404', status: 404, layout: 'application'
end
def render_500
logger.error "500だよ: #{exception.message}" if exception
render template: 'errors/error_500', status: 500, layout: 'application'
end
end
ただ、この方法には多少問題があります。
- Rack Middleware で一部の例外を拾えなくなり、それらがやっていたことを自前でやらないといけない。NewRelic, Airbrake などの Rack Middleware に差し込むタイプのgemも(標準で)使えなくなる
-
ApplicationController
の呼び出しより後の例外しか捕捉できない。例えばRoutingで発生した例外は対象外となる -
ShowExceptions
での例外捕捉とApplicationController
による例外捕捉が混在し、いつどのエラーページが出るか把握しづらい
経験上、2のようにApplicationController
外の例外までも捕捉しなければならない状況はActionController::RoutingError
位で、他はあまり遭遇したことがありません。RoutingError
もRouterの記述で回避できることが多いです。それが良いかどうかは議論の余地があると思いますが、以上のようなことが気になるときは、他の手段を取りたいです。
exceptions_app
による例外捕捉
Rack Middleware のActionDispatch::ShowExceptions
は、config.exceptions_app
に指定したオブジェクトに対して#call
を呼び出して例外を捕捉する仕組みです。
デフォルトではこれにActionDispatch::PublicExceptions
が設定されていて、これが一番最初に紹介した方法になります。
ここでconfig.exceptions_app
に任意のオブジェクトを設定することで、動作をカスタマイズすることができます。(このオブジェクトは#call
に応答できる必要があります)
Controllerオブジェクトを利用する場合
ActionController::Base
の資産を利用して、exceptions_app
で動的renderしようという作戦です。
# app/controllers/errors_controller.rb
class ErrorsController < ActionController::Base
rescue_from StandardError, with: :internal_server_error
rescue_from ActiveRecord::RecordNotFound, with: :not_found
def show
raise env['action_dispatch.exception']
end
def not_found
render 'not_found', status: 404, layout: 'error'
end
def internal_server_error
render 'internal_server_error', status: 500, layout: 'error'
end
end
# config/initializers/exceptions_app.rb
Rails.application.configure do
config.exceptions_app = ErrorsController.action(:show)
end
env['action_dispatch.exception']
は発生した例外で、ShowExceptions
が捕捉し、exceptions_app
が呼ばれる際にセットされます。
これを再度raise
してrescue_from
で拾い、対応するテンプレートをrenderしています。(一例であって、このような方法でなくても対応するテンプレートがrenderできれば良いですね)
ちなみにexceptions_app
内で発生した例外が捕捉されなかった場合はActionDispatch::ShowExceptions.FAILSAFE_RESPONSE
が返ることになります(500)。
envには下記の値がセットされているので必要に応じて利用できます。
env["action_dispatch.exception"] = wrapper.exception # 捕捉した例外
env["action_dispatch.original_path"] = env["PATH_INFO"] # もともとのリクエストのパス
env["PATH_INFO"] = "/#{status}" # 例外に対応するステータスコードのパス(`/404`など)
また、上記ErrorsController
はActionController::Base
を継承しています。
ApplicationController
を継承していない意図は、エラー表示という目的を達成するための最小限の機能を持たせたいからですが、実際は一部のApplicaitonController
の機能が必要になってくることもあります。そのときは適宜モジュール化してincludeする方向で対処するのが良いと思います。
さらには例外発生元で取得したオブジェクトを引き継ぎたいということもあるかもしれません。その場合はenv経由で参照を受け渡す形になるのかなと思います。
ところで上記のようにすると、例外とエラーページの対応を自前で行わなければいけないです。そういうのが面倒だったり、見落としがありそうで怖かったりする場合は、gemを利用するのが一番早いと思います。
gemを使う場合、Rambulanceが小さくかつ必要な機能を網羅していて、とても良さそうです。設定方法はREADME、もしくは他に詳しく説明している記事を参照していただければと思います。
便利機能として、開発環境での動作確認は/rambulance/ERROR_STAUTS_NAME
にアクセスすることでも行えます。Router経由になるので少し動作は異なりますが、表示の確認で重宝します。
Routerを利用する場合
exceptions_app
を利用したもうひとつの方法として、下記のようにすることができます。
# config/initializers/exceptions_app.rb
Rails.application.configure do
config.exceptions_app = self.routes
end
# config/routes.rb
Rails.application.routes.draw do
get '404', to: 'errors#not_found'
end
# app/controllers/errors_controller.rb
class ErrorsController < ActionController::Base
def not_found
render status: 404
end
end
ActionDispatch::Routing::RouteSet
は#call(env)
に応答し、env['PATH_INFO']
をもとにRoutesをたどります。そしてその定義に沿ってErrorsController
のアクションを呼び出すという流れです。
env['PATH_INFO']
には先に説明したとおり、/404
のような例外に対応するステータスコードのパスが設定されているので、これとマッチするRoutesを用意すれば良いです。
注意点は、ステータスコードにマッチするRoutesがない場合の動作です。RoutingはActionDispatch::Journey::Router#serve
により処理されるのですが、どのRoutesにもマッチしなかった場合、[404, {'X-Cascade' => 'pass'}, ['Not Found']]
が返ります。
'X-Cascade' => 'pass'
だと、ActionDispatch::ShowExceptions
ではそのレスポンスを破棄し、env['PATH_INFO']
の元になっているレスポンスコードで空ページを出します。例えば、/500
を出そうとしたのにRoutingでマッチしなかった場合、ステータスコード500の空ページが出る、ということです。
なお、通常のリクエストに対するRoutingでは、'X-Cascade' => 'pass'
だとActionDipatch::DebugExceptions
がRoutingError
をraiseするようです。今回はその外側の出来事なので上記のような動作になります。
メリットとしては既存のControllerとroutesの仕組みに相乗りできるので楽できるというところでしょうか。
しかし少し遠回りな分ハマりどころがありそうなので、この方法を選択する機会はControllerオブジェクトを利用する方法よりも少なそうです。
むすび
例外を捕捉してエラーページを表示する方法として、標準の静的ページを表示する方法と、Controllerでrescue_from
する方法、そしてexceptions_app
をカスタマイズする方法を紹介しました。
また、execptions_app
を利用したgemとしてRambulanceを紹介しました。
実はもうひとつExceptionHandlerという重厚なgemも見つけたのですが、こちらは試していません。もし試したことのある方がいましたら使用感等教えていただければ幸いです。
なお、exceptions_app
を利用する方法はActionDispatch::ShowExceptions
より外側の例外は捕捉することはできないので、それが問題になる場合は別の方法を検討しなければならないです。たぶん新たな Rack Middleware を必要な位置に差し込む感じになると思います。
今回色々調べてみましたが、常にこれが正解という方法はないなーと感じたのでした。
ただ、本当に動的に表示する必要があるのかどうかはしっかりと考えたほうが良さそうです。そのうえでどの方法を使うのが理にかなうのかを検討するのが良いと思います。
なのでサービスの性質やアプリケーションの要件に応じて、必要十分な方法で対応できるようになっていることが大事なのだと思います。