はじめに
Railsアプリケーションにおけるエラー処理(例外設計)の考え方 - Qiita ← こちらで@jnchitoさんに例外設計について言いたいことはほとんど言われてしまった感が強いのですが、もうちょっとだけこうしたら使いやすいだろうな〜という例外構造パターンについて紹介します。
パターンにはめることで以下のメリットがあるかと思います。
- 実装者の意図が表現しやすい
- レビューがしやすい
- 致命的なエラーを検知しやすい
- 無秩序に例外クラスが氾濫しにくい
Fatal-Major-Minorエラーパターン
冒頭のリンク記事ではエラーを「システムエラー」と「業務エラー」の2種類に分類していました。ここでは更に細分化して3種類に分類します。
- システムエラー
- FatalError
- システム稼働を継続できない致命的なエラー
- FatalError
- 業務エラー
- MajorError
- リクエスト処理を継続できない重大なエラー
- MinorError
- リクエスト処理を継続できる軽微なエラー
- MajorError
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さんの記事に乗っかっただけのような気がハンパないですが、例外構造のパターンについて紹介しました。
冒頭にメリットとして記述しましたが、主に実装者の意図を込めるため、あるいはレビューがしやすくなるためのパターンかと思います。実装者の意図に反して例外があっちゃこっちゃしてしまわないように整理しましょう!