LoginSignup
36

More than 5 years have passed since last update.

Railsアプリの例外構造パターン

Posted at

はじめに

Railsアプリケーションにおけるエラー処理(例外設計)の考え方 - Qiita ← こちらで@jnchitoさんに例外設計について言いたいことはほとんど言われてしまった感が強いのですが、もうちょっとだけこうしたら使いやすいだろうな〜という例外構造パターンについて紹介します。

パターンにはめることで以下のメリットがあるかと思います。

  • 実装者の意図が表現しやすい
  • レビューがしやすい
  • 致命的なエラーを検知しやすい
  • 無秩序に例外クラスが氾濫しにくい

Fatal-Major-Minorエラーパターン

冒頭のリンク記事ではエラーを「システムエラー」と「業務エラー」の2種類に分類していました。ここでは更に細分化して3種類に分類します。

  • システムエラー
    • FatalError
      • システム稼働を継続できない致命的なエラー
  • 業務エラー
    • MajorError
      • リクエスト処理を継続できない重大なエラー
    • MinorError
      • リクエスト処理を継続できる軽微なエラー

例外構造パターン.png

Fatalエラー

業務処理として想定していない事象が発生した場合にFatalエラーを利用します。仮に、本番環境でこのエラーが発生した場合には実行環境やプログラムに何かしらの障害や不具合があると考えられるためいち早く検知して対処する必要があります。(検知の仕方は後述)

Fatalエラーがもし発生した場合は、HTTPレスポンスとしては「500 Internal Server Error」や「503 Service Unavailable」など500番台のステータスコードを返すようにします。

module FatalError
  class Base < StandardError
    def http_status
      :internal_server_error
    end
  end
end

例: インターフェースメソッド

基底クラスでインターフェースメソッドを用意しておくことで、このクラスはどんな機能があるのか何をする役割を持っているのかを宣言することがよくあります。そんなとき、Rubyにはいわゆるインターフェースメソッドと呼ばれるものがありません。そこで代わりに、コールされたら必ず例外を発生させるメソッドを定義することで表現できます。

このときあげる例外にFatalエラーを用います。意味合いとしては「このクラスを継承して具象クラスを作るときには絶対にこのメソッドを実装しなさいよ! さもないと落としちゃうんだからね!!」みたいな、後の実装者へのメッセージとなります。

module FatalError
  class MustBeOrverriden < Base
  end
end

class Device < ActiveRecord::Base
  def device_name
    # インターフェースメソッド。Deviceクラスを継承した具象クラスでは必ずオーバーライドすること
    raise FatalError::MustBeOrverriden
  end
end

class Iphone < Device
  def device_name
    self.device_attributes.name
  end
end

class Android < Device
  # device_nameメソッドの定義漏れ
end

Device.each do |dev|
  puts dev.device_name # FatalError::MustBeOrverriden発生!
end

例: 未知の値

プログラムとして予め想定された値とは違うものが指定された場合にあげる例外にFatalエラーを用います。意味合いとしては「ちょっとそんな値入らないわよ。顔洗って出直して来なさい!」みたいな。

elsif句を使った場合とか、case文では必ずelse句をつけなさいという先人の教え(MISRA-C 15.7とか)がありますが、そのようなときにこの例外を仕込んでおくとよいかと思います。

module FatalError
  class InvalidArgument < Base
  end
end

class Device
  VALID_STATUSES = {
    :deleted   => 0,
    :activated => 1,
  }

  def status=(new_status)
    if new_status.is_a?(String)
      super VALID_STATUSES[new_status.underscore.to_sym]
    elsif new_status.is_a?(Symbol)
      super VALID_STATUSES[new_status]
    else
      # 想定外の値が指定されたので例外にする
      raise FatalError::InvalidArgument
    end
  end
end

Majorエラー

ひとつのリクエストを処理している中でユーザーから(プログラムとしては想定した範囲内で)異常な値が指定されており、そのリクエストを処理できない場合にMajorエラーを利用します。

Majorエラーが発生した場合は、HTTPレスポンスとしては「400 Bad Request」や「401 Unauthorized」などの400番台のステータスコードを返すようにします。

module MajorError
  class Base < StandardError
    def http_status
      :bad_request
    end
  end
end

例: 認証エラー

リクエストを処理できないケースには例えば認証エラーが挙げられます。リクエストされても認証NGであれば処理するわけにはいきませんので、そこでリクエスト処理を中止してエラーのレスポンスを返すような場合にMajorエラーを用います。意味合いとしては「勝手にアクセスして来ないでよ! 情報教えてあげたりなんかしないんだから!!」みたいな。

module MajorError
  class Unauthorized < Base
    def http_status
      :unauthorized
    end
  end
end

class ApplicationController < ActionController::Base
  rescue_from MajorError::Base do |err|
    response.status = err.http_status
    ....
  end
end

class DevicesController < ApplicationController
  before_filter :authenticated?

  private

  def authenticated?
    if current_user.auth_token != request.env['HTTP_AUTHORIZATION']
      raise MajorError::Unauthorized
    end
  end
end

Minorエラー

モデルのロジック内などの限られた範囲内でロジックを簡素にする目的で意図して発生させる例外にMinorエラーを用います。この種の例外を発生させた場合は必ず近くでキャッチするようにコーディングルールを決めておくと、raiseしたけどrescueしていないような場合にコードレビュー時に発見しやすくなります。意味合いとしては「ばかぁ。こ、これはわたしのための例外なんだから、あなたのために作ったわけじゃないんだからね!」みたいな。

うん。デレた。

なお、例外処理は一般的に言って処理コストが高いです。単純な条件分岐で代用可能な場合はカッコつけて例外を使わずに地道にif文を書きましょう。

module MinorError
  class Base < StandardError
    def http_status
      :internal_server_error
    end
  end
end

仮にキャッチし忘れてコントローラーのアクションの外まで流出した場合はプログラムエラーですので、Fatalエラーと同様にHTTPレスポンスとしては「500 Internal Server Error」を返すようにします。

例: xxx

ここまで書いておいてなんですが、例が思いつかん (-"-;

Fatalエラーの検知

Fatalエラーは原則として発生してはいけないエラーとして定義しました。なので、本番環境で発生した場合はいち早く知りたい。開発環境であったとしても早めに知ることができれば工程の後戻りが少なくてすみます。

以下ではいくつかの検知方法の例を挙げます。システムや開発工程に合わせて手段を選ぶとよいと思います。

500番台ステータスチェックしてアラート送信

Fatalエラーが発生した場合は、HTTPステータスとして500番台を返すようにしてありますので、apacheやnginxのログやAWSのELBを利用していればCloudWatchで確認することが可能です。なのでRailsアプリには特に仕組みをいれずとも、ログを逐次チェックして500番台のステータスコードがあればアラートをあげるようにします。

参考: CloudWatch Logsを使って500系のレスポンスを検知する方法 | 遍歴プログラマ日記

例外キャッチ時にアラート送信

Rails内でエラー検知&アラート送信をしてしまうなら、コントローラーで例外キャッチしたときの処理の一部として実施してしまうとよいでしょう。

class ApplicationController < ActionController::Base
  rescue_from MinorError::Base, with: :render_internal_server_error
  rescue_from FatalError::Base, with: :render_internal_server_error

  def render_internal_server_error(err)
    response.status = err.http_status
    render xxx

    # ここでアラートメール送信
    # 本番ならシステム管理者宛て
    # 開発環境ならプロジェクトリーダー宛てなど
  end
end

ただ、リクエストの度にメール送信されるのはちょっとやり過ぎな気もしますし、プログラム異常が発生しているのにきちんとアラート送信までできるかどうかちょっと不安が残ります...

ログから抽出してアラート送信

例外キャッチした際にログにキーワードを出力しておきます。キーワードは単純に「*****」とかでもOKです。そして、ログファイルをローテートする直前にログ全体をgrep掛けてキーワードを抽出して、ヒットした場合はアラート送信させます。

即時性を求めていない場合は推奨の方法です。
grepを使いますので、先のキーワードに限らずnilClass参照してNoMethodError例外が発生した場合とかも検知可能ですし、コントローラーでキャッチできないDelayedJobによるバックグラウンド実行時のエラーでも検知可能です。

ログローテート直前に実行するスクリプトは下記のようなもので実現できるかと思います。

APP_NAMES="panel api bg"
STAGE="DEV"
SEND_TO="dev-leader@example.com"
REGEXP="*****|undefined method"


for app_name in $APP_NAMES; do

  egrep -n -B 1 -A 10 "$REGEXP" /path/to/rails/root/$app_name/current/log/*.log > /tmp/fatal_errors_in_$app_name.txt
  if [ $? == 0 ]; then
    cat << _BODY_ > /tmp/fatal_erorrs_mail_body.txt
$STAGE のログに重要なプログラムエラーを検知しました。
  検知対象正規表現: $REGEXP

$(cat /tmp/fatal_errors_in_$app_name.txt)
_BODY_
    cat /tmp/fatal_erorrs_mail_body.txt | mail -s "[Warning] Fatal errors detected in $STAGE $app_name" $SEND_TO

  fi
done

おわりに

@jnchitoさんの記事に乗っかっただけのような気がハンパないですが、例外構造のパターンについて紹介しました。

冒頭にメリットとして記述しましたが、主に実装者の意図を込めるため、あるいはレビューがしやすくなるためのパターンかと思います。実装者の意図に反して例外があっちゃこっちゃしてしまわないように整理しましょう!

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
36