Railsアプリケーションにおけるエラー処理(例外設計)の考え方

  • 999
    いいね
  • 5
    コメント
この記事は最終更新日から1年以上が経過しています。

はじめに

Railsアプリケーションを本格的に作り込んでいくと、「エラー」とは無縁ではいられません。
しょうもないバグでエラーが発生することもありますし、ほとんど不可抗力ともいえるような大規模なネットワーク障害でエラーが発生することもあります。

エラーの種類がなんであれ、エラーが起きた場合は「原因を素早く特定し、速やかに復旧させること」と「あるエラーが引き金になって、さらに大きなエラーに引き起こさないようにすること」が重要です。

エラー処理を適切に実装していれば、原因の特定や復旧もすばやくできますし、さらに大きなエラーを引き起こす可能性も少ないです。
また、ソースコードも比較的シンプルに保てます。

逆にエラー処理が不適切だと原因の特定に時間がかかったり、異常なデータがどんどん増えてさらに大きなエラーを引き起こしたりします。
ソースコードにも無駄に複雑な処理フローや条件分岐がたくさん出てきて、保守性の悪いコードになってしまうことも多いです。

堅牢で壊れにくい、そして壊れても直しやすいアプリケーションを目指して、適切なエラー処理の考え方を学びましょう。

そこで、この記事ではRailsアプリケーションにおけるエラー処理の考え方をまとめてみました。

備考: この記事で説明することと、しないこと

この記事では「発生したエラーをどう処理すべきか」という設計寄りの話を書きます。
言い換えると、

「エラーをどう扱うと自分の味方になってくれるのか」
「どう扱うと泥沼にハマるのか」

そういった基礎的な考え方を説明します。

Rubyの例外機構をゼロから説明したり、Rails特有のエラーハンドラの使い方を説明したりするものではありません。
また、クライアントサイドではなく、サーバーサイドの話がメインです。

一応Railsを対象にしていますが、考え方自体は他の言語やフレームワークでも応用が利くはずです。

正常系と異常系(エラー)を分類する

正常系と異常系を理解する例として、簡単なユーザー登録処理を考えてみる。
何も問題がなければ以下のように処理が進む。

  1. メールアドレスとパスワードを入力する
  2. 登録ボタンをクリックする
  3. データベースにユーザーのデータが登録される
  4. マイページ画面が開く

上のような処理フローは「正常系」の処理フローである。

しかし、現実の運用では以下のような問題が発生する可能性もある。

  • メールアドレスのフォーマットがおかしい
    • hoge@@gmail..comになっている
  • データベースがダウンしている
  • マイページ画面の実装にバグがあり、nilに対してeachを呼びだしてしまった。
    • NoMethodError: undefined method `each' for nil:NilClassが発生した

上のようなケースは異常系である。
つまり「エラー」が起きたということになる。

エラーをさらに分類する

先ほどのエラーはふたつのグループにわけられる。

業務エラー

該当するエラーは以下の通り。

  • メールフォーマットがおかしい

「業務エラー」は業務設計の中で想定されている範囲内で処理が分岐し、正常終了できなかった場合のエラーをさす。

入力値異常の他にも、以下のようなケースは業務エラーとして扱って良い。

  • ユーザーが権限のないページにアクセスしようとした場合
  • すでに登録済みのユーザーIDでアカウント登録しようとした場合

システムエラー

該当するエラーは以下の通り。

  • データベースがダウンしている
  • マイページ画面の実装にバグがある

「システムエラー」は業務設計の想定範囲外の異常事態が発生し、処理を正常に遂行できなくなった場合のエラーをさす。

他にも以下のようなケースをシステムエラーとして扱って良い。

  • データ異常やデータの不整合
    • 注文データになぜか注文日が保存されていない
    • 社員は必ず企業に属しているはずだが、ある社員のデータにはなぜか関連する企業が存在しない
  • API連携の失敗
    • 決済サービスに対して課金処理を実行しようとしたが、決済サービスがダウンしていた

なお、このエラーの分類方法は下記のサイトを参考にした。

.NETの例外処理 Part.1 - とあるコンサルタントのつぶやき - Site Home - MSDN Blogs

業務エラーとシステムエラーの違い

業務エラーは簡単にいうと、ユーザーに対して「お前が悪い、ちゃんとやり直せ」と言えるエラーである。

システムエラーはその逆で、ユーザーがどうにもできないエラーである。
システムエラーはシステムの運用者が責任を持って復旧させる必要がある。

画面設計の違い

業務エラーの場合

業務エラーは「お前が悪い、ちゃんとやり直せ」と言えるエラーなので、エラーの原因とユーザーが次に行うべき操作を画面に表示する。

以下はRailsでバリデーションエラーが発生した場合の表示例。

Screen Shot 2015-09-27 at 19.49.23.png

システムエラーの場合

一方、システムエラーはユーザー側でどうにもできないエラーなので、「ごめんなさい、こっちでなんとかします」というメッセージ(いわゆる「500エラー画面」)を表示する。

以下はRailsの典型的な500エラー画面である。
ただし、実務で開発するwebサービスであれば、もっとユーザーフレンドリーに作り込む方が望ましい。

Screen Shot 2015-09-27 at 19.51.52.png

内部処理の違い

業務エラーの場合

業務エラーの場合は原則として以下のように実装する。

  • 保存メソッドの戻り値で成功と失敗を表現する。
    • メソッド内で例外をraiseして、呼び出し側でrescueする、というのはNG。
    • メソッドの呼び出し側は戻り値を必ずチェックする。チェックし忘れると処理の失敗が成功として扱われてしまう。
  • 業務エラーの有無はモデルのバリデーションで検証し、エラー内容はモデルのerrorsに格納する。

典型的な例はscaffoldで作成されるcreateやupdateのコードである。

events_controller.rb
def create
  @event = Event.new(event_params)
  # saveメソッドの戻り値をチェック
  if @event.save
    # 戻り値がtrueなので成功
    redirect_to @event, notice: 'Event was successfully created.'
  else
    # 戻り値がfalseなので失敗
    render :new
  end
end
_form.html.erb
<ul>
  <% # errorsに入っているエラー内容を出力する %>
  <% @event.errors.full_messages.each do |message| %>
    <li><%= message %></li>
  <% end %>
</ul>

システムエラーの場合

システムエラーの場合は原則として以下のように実装する。

  • 何もしない。Railsの共通処理に任せる。
    • ログへのエラー出力と、500エラー画面の表示はRailsが自動的にやってくれる。
    • 明確な理由がない限り、自分でrescueしない。
  • ErrbitやBugsnagのようなエラー通知サービスを導入している場合も、システムエラーは通常自動的に処理されるはずなので、個別に通知メソッドを呼んだりしない。
  • データを保存するとき、「普通に考えると業務エラーは起こりえない場合」や「万一エラーが起きたら処理を中止したい場合」はsaveではなく、save!メソッドを使う。(create!やupdate!でも可)
user.rb
# YAMLファイルのデータを保存するメソッド
def self.save_from_yaml!(yaml_path)
  # YAMLファイルは開発者が事前に用意したファイルなので、
  # 業務エラーが発生するようなデータは含まれないという前提
  yaml = YAML.load(yaml_path)
  user = self.new
  user.name = yaml['name']
  user.email = yaml['email']

  # 万一保存に失敗したら処理を中止したいので、save!を使う
  user.save!

  # エラーが起きてもrescueしない。システムエラーは共通処理に任せる。
end
  • データを取得するとき、「必ずそのデータが存在するはず」という場合はfindやfind_by!を使う。
product.rb
# 指定された製品に新しい価格を設定する
def self.update_price!(product_code, new_price)
  # 指定された製品コードに対応する製品が見つからなければ
  # エラーを起こして処理を中止する
  product = self.find_by!(code: product_code)

  product.price = new_price
  product.save!
end

ここまでがエラー処理の基本的な考え方である。
次からは応用的な考え方や、よくある間違いについて書いていく。

エラー処理に関する応用パターンやテストの実施方法

Webアプリケーションを開発しているとよく使うエラー処理の応用パターンやテストの実施方法をまとめてみた。

途中でエラーが発生しても続行したいケース

たとえばメールの一斉送信を行ったりする場合は、途中でエラーになるユーザーがいてもそこで処理を中止せず、残りのユーザーにメールを送信したい。
こういう場合はエラーをrescueして、処理を続行する。

user.rb
# 全ユーザーに対して「本日の更新情報」を送信する
def self.send_daily_summary_to_all_users
  User.all.each do |user|
    begin
      UserMail.daily_summary(user).deliver
    rescue => e
      # 何らかのエラーが発生した場合はログの書き込みと、
      # エラー通知サービスへの通知を行う
      logger.error e
      logger.error e.backtrace.join("\n")
      Bugsnag.notify e

      # エラーが起きても次のユーザーの処理へ進む
    end
  end
end

もちろん、運用者は発生したエラーを検知し、エラーが起きたユーザーに対しては適切な復旧作業を実施する必要がある。

参考:エラーをrescueする場合の共通処理をメソッド化する

先ほどのコードで登場したrescue内のコードは、以下のように共通処理としてメソッド化しておくと再利用しやすくなる。

class ErrorUtility
  def self.log_and_notify(e)
    Rails.logger.error e.message
    Rails.logger.error e.backtrace.join("\n")
    Bugsnag.notify e
  end
end
user.rb
def self.send_daily_summary_to_all_users
  User.all.each do |user|
    begin
      UserMail.daily_summary(user).deliver
    rescue => e
      ErrorUtility.log_and_notify e
    end
  end
end

参考: Strategies for Rails Logging and Error Handling - Rails on Maui

なお、このあとのサンプルコードでも必要に応じてこのErrorUtilityクラスを使っていく。

APIから返ってきたエラーを自分でシステムエラーに変換する

たとえば、外部APIを呼びだしたときに、API側から「システムエラー」を表すレスポンスが返ってくる可能性がある。
その場合は、自分でエラーをraiseしてシステムエラーに変換する。

payment.rb
# 外部APIを利用して課金を実行する
def execute_charge!
  response = PaymentApi.charge(self)
  error_code = response['ErrCode']

  # エラーコードが"0"以外であれば何らかのエラーが発生している
  if error_code != '0'    
    # 課金に失敗したのでシステムエラーを発生させる
    # エラーには必ず調査やデバッグに役立つ情報を含める
    raise "Charge failed. ErrCode: #{error_code} / ErrMessage: #{response['ErrMessage']}"
  end

  response['TranID']
end

なお、ここでは異常終了はすべてシステムエラーと見なしたが、APIの仕様によってはここでも「業務エラー」と「システムエラー」を区別しなければならない場合もある。

エラー処理も必ずテストする

エラー処理も必ずテストすること。
「たぶん動くはず」でそのままコードをリリースしてしまうと、最悪の場合「エラー処理のバグによるエラー」が発生してしまい、元のエラー内容が失われてしまう場合がある。(二重障害)
こうなるとエラーの原因を調査するのが非常に難しくなる。

以下は二重障害が発生するコード例である。

user.rb
def self.send_daily_summary_to_all_users
  User.all.each do |user|
    begin
      UserMail.daily_summary(user).deliver
    rescue => e
      # "notify"をtypoしているために新たなエラーが発生し、
      # 元のエラー内容が失われる!!
      ErrorUtility.log_and_notfy e
    end
  end
end

よって、エラー処理も必ずテストする必要があるのだが、そもそも「わざとエラーを発生させること自体が難しい」というケースがよくある。
そのような場合は自動化テスト + モックを使う。

以下はRSpecを使ったテスト実行例である。

user_spec.rb
describe User do
  describe '::send_daily_summary_to_all_users' do
    before do
      User.create!(name: 'Alice', email: 'alice@example.com')
    end
    it 'エラー処理が正しく動作すること' do
      # daily_summaryを呼び出したときにわざとエラーを発生させる
      allow(UserMail).to receive(:daily_summary).and_raise('For test')

      # エラーの共通処理が呼び出されることを検証する
      expect(ErrorUtility).to receive(:log_and_notify).once

      User.send_daily_summary_to_all_users
    end
  end
end

なお、RSpecでモックを利用する方法は以下の記事を参考にする。

使えるRSpec入門・その3「ゼロからわかるモック(mock)を使ったテストの書き方」 - Qiita

エラー処理に関する「べからず集」

エラー処理を実装する際に、「これは絶対にやってはいけない」という「べからず集」(アンチパターン)をまとめてみた。

成功/失敗を表す戻り値を無視しないこと

saveメソッドのように、成功/失敗を戻り値で表すメソッドの戻り値を無視してはいけない。
以下のような処理はNG。

employee.rb
# NG!!
def self.increase_salary_for_all(amount)
  Employee.all.each do |employee|
    employee.salary += amount
    employee.save # 失敗してもスルーされてしまう
  end
end

「業務エラー」が想定される場合は戻り値を受け取り、成功、失敗に応じた処理を書く。
「通常、業務エラーは起こりえない。もし業務エラーが発生すれば、それは何らかの異常事態」という場合はsave!メソッド(もしくはcreate!やupdate!)を使う。

employee.rb
def self.increase_salary_for_all!(amount)
  Employee.all.each do |employee|
    employee.salary += amount
    employee.save! # 万一失敗したらエラーを発生させる
  end
end

なお、上記のような一括更新処理はtransactionブロックを使って実行する方が望ましい。詳しくは後述する。

Exceptionクラスをrescueしないこと

JavaやC#など、他の言語からやってきた人は「例外 = Exception」と考えてしまい、下のようなコードを書いてしまうことがある。

user.rb
# NG!!
def self.send_daily_summary_to_all_users
  User.all.each do |user|
    begin
      UserMail.daily_summary(user).deliver
    rescue Exception => e
      ErrorUtility.log_and_notify e
    end
  end
end

しかし、RubyのExceptionクラスはすべての例外の祖先となるクラスであり、NoMemoryError等の致命的なエラー(Rubyの実行そのものが危うくなるエラー)まで含んでいる。
よって、うかつに捕捉してはならない。

ArgumentErrorやNoMethodError等の復旧可能な実行時エラーはStandardErrorのサブクラスになっているので、通常はこれを捕捉すること。

user.rb
def self.send_daily_summary_to_all_users
  User.all.each do |user|
    begin
      UserMail.daily_summary(user).deliver
    rescue StandardError => e
      ErrorUtility.log_and_notify e
    end
  end
end

また、すべてのStandardErrorとそのサブクラスを捕捉したい場合はrescue節のStandardErrorを省略できる。
つまり上のコードは以下のコードと等価である。

user.rb
def self.send_daily_summary_to_all_users
  User.all.each do |user|
    begin
      UserMail.daily_summary(user).deliver
    rescue => e
      ErrorUtility.log_and_notify e
    end
  end
end

参考:Rubyにおける例外クラスの継承関係

Rubyにおける例外クラスの継承関係は以下の図を参考にするとわかりやすい。

exception.jpg
(出典: Ruby Exceptions

エラーを握りつぶさないこと

特別な理由がない限り、エラー情報を破棄してはいけない。
最悪の場合、エラーの原因を特定できなくなる。
以下のような実装はNG。

# NG!!
def self.send_daily_summary_to_all_users
  User.all.each do |user|
    begin
      UserMail.daily_summary(user).deliver
    rescue
      # 何もしない
    end
  end
end

こんなふうにエラーオブジェクトを全く活用しないのもNG。

# NG!!
def self.send_daily_summary_to_all_users
  User.all.each do |user|
    begin
      UserMail.daily_summary(user).deliver
    rescue => e
      # エラーオブジェクト(e)の存在を完全に無視している
      logger.error "メールの送信に失敗しました。"
    end
  end
end

エラーをrescueしたときは原因の調査に役立つ情報(少なくともメッセージとバックトレース)をログに残したり、エラー通知サービスに通知したりすること。

user.rb
def self.send_daily_summary_to_all_users
  User.all.each do |user|
    begin
      UserMail.daily_summary(user).deliver
    rescue => e
      logger.error e.message
      logger.error e.backtrace.join("\n")
      Bugsnag.notify e

      # または共通処理を呼び出す
      # ErrorUtility.log_and_notify e
    end
  end
end

エラーを使って処理フローを制御しないこと

可読性やパフォーマンスの観点から、可能な限りエラーを使わないで処理する方法を検討すること。
以下のように、エラーの有無でファイルの存在確認をするのはNG。

# NG!!
def validate_csv_path
  CSV.read(csv_path)
rescue Errno::ENOENT
  self.errors.add(:csv_path, 'ファイルが見つかりません')
end

上の例であれば、エラーを使わずにファイルの存在確認をする方法があるので、それを採用する。

def validate_csv_path
  unless File.exists?(csv_path)
    self.errors.add(:csv_path, 'ファイルが見つかりません')
  end
end

見境なくエラーをrescueしないこと

例外クラスを指定しないと、本来システムエラーとなるべきエラーまで捕捉される恐れがある。
たとえば、以下のようなコードはNG。

# NG!!
def search_tweets(keyword)
  twitter_client.search(keyword)
rescue
  # レートリミットエラー以外のエラー(たとえば認証エラー)が
  # 起きた場合も同じように捕捉されてしまう
  ['レートリミットの上限に達しました。']
end

次のように想定されるエラーだけを捕捉すること。

def search_tweets(keyword)
  twitter_client.search(keyword)
rescue Twitter::Error::TooManyRequests
  # レートリミットエラーだけが捕捉される
  ['レートリミットの上限に達しました。']
end

エラー処理とトランザクションの関係

最後にエラー処理とデータベーストランザクションの関係についてまとめる。

データベーストランザクションとは

データベーストランザクションとは、大雑把に言うと複数レコードに対する更新を一括で保存する(コミット)、もしくは一括でキャンセルする(ロールバック)、データベースの重要な機能のことである。

トランザクションを適切に扱えば、致命的なデータの不整合を防ぐことができる。

saveメソッドは自動的にトランザクション内で処理される

Railsの場合、saveメソッドは自動的にトランザクション内で処理される。
(saveだけでなく、create、update、destroyも同様)

たとえば、以下はActiveRecordのコールバック機能を使って「誰かにフォローされたら、その情報をタイムラインに追加する」という架空のコードである。

relationship.rb
class Relationship < ActiveRecord::Base
  belongs_to :follower, class_name: "User"
  belongs_to :followed, class_name: "User"

  after_create :create_micropost
  def create_micropost
    followed.microposts.create!(content: "#{follower.name}があなたをフォローしました。")
  end
end

Relationshipの保存が正常に完了すれば、RelationshipとMicropostのレコードがそれぞれ1件ずつ追加される。
フローチャートっぽく表現すると以下のようになる。

relationship.save
 ↓
トランザクション開始
 ↓
Relationship のデータをデータベースに保存
 ↓
after_create で create_micropost が呼ばれる
 ↓
Micropost のデータをデータベースに保存
 ↓
コミット(データの更新が確定する)
 ↓
正常終了する

しかし、Relationshipの保存が完了したあと、何らかの原因でcreate_micropostメソッドでエラーが発生すると、トランザクションがロールバックされる。
このとき、Micropostが作成されないだけでなく、Relationshipの作成もキャンセルされる。(つまりどちらのレコードも増えない)

フローチャートっぽく書くと以下のようになる。

relationship.save
 ↓
トランザクション開始
 ↓
Relationship のデータをデータベースに保存
 ↓
after_create で create_micropost が呼ばれる
 ↓
なんらかのエラーが発生!
 ↓
ロールバック(Relationship の更新がキャンセルされる)
 ↓
異常終了する(システムエラー)

saveメソッドを複数回呼び出す場合はtransactionブロックの使用を検討する

コールバックを使わずにsaveメソッドを複数回呼び出す場合は、transactionブロックの使用を検討する。

ここではサンプルコードとして、CSVファイルの内容を読み取ってUserレコードを複数作成する処理を考えてみる。

以下はtransactionブロックを使わずに実装したコード例である。

user.rb
def self.create_users_from_csv!(csv_path)
  CSV.foreach('users.csv') do |row|
    user = User.new
    user.name = row['name']
    user.email = row['email']
    user.save! # ここで毎回トランザクションが開始&コミットされる
  end
end

このまま実行した場合、処理の途中でエラーが起きると、ユーザー情報が途中までしか登録されない。
また、中途半端に登録が進んだ状態になるので、再度実行すると「メールアドレスの重複エラー」等の業務エラーが発生する可能性がある。

transactionブロックを活用すると、トランザクションの範囲を明示的に指定できるため、「全件登録成功」または「全件登録キャンセル」のどちらかに処理結果を限定できる。

以下はtransactionブロックを使って実装したコード例である。

user.rb
def self.create_users_from_csv!(csv_path)
  # トランザクション開始
  self.transaction do
    CSV.foreach('users.csv') do |row|
      user = User.new
      user.name = row['name']
      user.email = row['email']
      user.save! # ここではまだコミットされない
    end
  end
  # transactionブロックを正常に抜けるとコミットされる
end

上のコードの場合、transactionブロック内の処理がエラーなしに完了すればトランザクションがコミットされ、「全件登録成功」する。
一方、ブロック内でエラーが発生した場合はトランザクションがロールバックされ、途中まで成功していた登録処理も「全件キャンセル」される。

saveメソッドを複数回呼び出す場合、「全件成功 or 失敗」にするか、「中途半端な更新も良しとする」かは要件によって変わるため、一概に「これが正解」といえる実装はない。
しかし、transactionブロックを使わなかったために、 予期せず 中途半端にデータが更新されてしまった、という事態は避けなければならない。

一括更新処理に限らず、複雑なデータ更新で複数のレコードを同時に更新する場合はエラーの発生を考慮し、transactionブロックの使用を検討すべきである。

まとめ

というわけでこの記事ではRailsにおけるエラー処理の考え方をあれこれ書いてみました。
頭の中には「だいたいこんな感じ」という対応パターンがあるのですが、いざ文章としてアウトプットしたり、サンプルコードを書こうとしたりするとなかなかまとまりませんでした。
もしわかりづらいところがあればコメント欄で質問してください。

また、ここに書いたエラー処理が唯一の正解ということはありません。
アプリケーションの要件によって、適切なエラー処理は変わってきます。
また業務エラーなのかシステムエラーなのか、明確に区別しにくいエラーもよくあります。
「このエラー処理は適切なんだろうか」と疑問に感じた場合は、一人で結論を出さずに開発チーム内で相談するようにしましょう。

さらに、コードレビューを定期的に行い、不適切なエラー処理がないか確認するのが望ましいです。
コードレビューを繰り返すと、開発チームにおける「エラー処理のベストプラクティス」も固まってくるはずです。

さて、この記事はこれで完成形ではなく、今後も適宜内容をアップデートしていく予定です。
新しい内容を追加したときは「通知」を出しますので、気になる方はこの記事をストックしてやってください。

今回書いた内容がみなさんのお役に立てば幸いです。

あわせて読みたい

本文にもちらっと出てきましたが、この記事で書いた内容は実は.NETをやっていたころに学んだエラー処理の考え方がベースになっています。
言語やフレームワークが変わっても、エラー処理の考え方は意外と大きく変わらないものです。

以下に挙げたリンクがその「.NET時代に学んだエラー処理」の記事です。
僕が書いた今回の記事よりも、詳しく、そしてわかりやすくまとまっています。
Railsプログラマの方も一度読んでみると勉強になるかもしれません。