190
163

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Railsアプリの例外ハンドリングとエラーページの表示についてまとめてみた

Posted at

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::NotAuthorizedError403 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

ただ、この方法には多少問題があります。

  1. Rack Middleware で一部の例外を拾えなくなり、それらがやっていたことを自前でやらないといけない。NewRelic, Airbrake などの Rack Middleware に差し込むタイプのgemも(標準で)使えなくなる
  2. ApplicationControllerの呼び出しより後の例外しか捕捉できない。例えばRoutingで発生した例外は対象外となる
  3. 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`など)

また、上記ErrorsControllerActionController::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::DebugExceptionsRoutingErrorをraiseするようです。今回はその外側の出来事なので上記のような動作になります。

メリットとしては既存のControllerとroutesの仕組みに相乗りできるので楽できるというところでしょうか。
しかし少し遠回りな分ハマりどころがありそうなので、この方法を選択する機会はControllerオブジェクトを利用する方法よりも少なそうです。

むすび

例外を捕捉してエラーページを表示する方法として、標準の静的ページを表示する方法と、Controllerでrescue_fromする方法、そしてexceptions_appをカスタマイズする方法を紹介しました。

また、execptions_appを利用したgemとしてRambulanceを紹介しました。
実はもうひとつExceptionHandlerという重厚なgemも見つけたのですが、こちらは試していません。もし試したことのある方がいましたら使用感等教えていただければ幸いです。

なお、exceptions_appを利用する方法はActionDispatch::ShowExceptionsより外側の例外は捕捉することはできないので、それが問題になる場合は別の方法を検討しなければならないです。たぶん新たな Rack Middleware を必要な位置に差し込む感じになると思います。

今回色々調べてみましたが、常にこれが正解という方法はないなーと感じたのでした。
ただ、本当に動的に表示する必要があるのかどうかはしっかりと考えたほうが良さそうです。そのうえでどの方法を使うのが理にかなうのかを検討するのが良いと思います。
なのでサービスの性質やアプリケーションの要件に応じて、必要十分な方法で対応できるようになっていることが大事なのだと思います。

参考

190
163
4

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
190
163

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?