0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

Ruby on Railsにおけるエラーハンドリング: 過剰なrescueを避けるには

0
Posted at

はじめに

最近、コードレビューの場で例外処理をどう扱うべきかについてやり取りすることが何度かありました。
レビューの場ではどうしても各論になりがちなため、一度立ち止まって、設計としての全体像を俯瞰してみたいと考えました。

特に、思わず防御的に rescue してしまうコードが生まれる背景と、そこから脱してより見通しの良いコードを書くための手立てに着目して整理します。

以下、Ruby on Railsアプリケーションを前提に話を進めます。

レビューで気になった rescue の扱い方

レビュー時に見られた rescue の扱い方には以下のようなものがありました。

(例)CSVファイルをアップロードしてユーザーを一括登録する処理

# app/models/csv_parser.rb
class CsvParser
  class InvalidHeadersError < StandardError; end
  
  def initialize(required_headers:)
	  @required_headers = required_headers
  end

  def parse!(file)
	  csv = CSV.read(file.path, headers: true)
    # ヘッダーチェックなどのバリデーション
    validate_headers!(csv.headers)
    # ...
  end
  
  # 設定された必須ヘッダーが全て含まれているかチェック
  def validate_headers!(headers)
    missing = @required_headers - (headers || [])
    if missing.any?
      raise InvalidHeadersError, "不足しているヘッダーがあります: #{missing.join(', ')}"
    end
  end
end

# app/models/user_importer.rb
class UserImporter
  def self.run!(file)
    # Parserを呼び出してデータを取り出す
    parser = CsvParser.new(required_headers: %i[email name])
    users = parser.parse!(file)
    
    # ... DBへの保存処理など ... 
  end
end

# app/controllers/admin/user_imports_controller.rb
class UserImportsController < ApplicationController
  def create
    # インポート処理を実行
    UserImporter.run!(params[:file])

    redirect_to users_path, notice: "インポートが完了しました"
  rescue CsvParser::InvalidHeadersError => e
    redirect_to new_user_import_path, alert: "CSVのヘッダーが正しくありません"
  rescue StandardError => e
    logger.error "Import failed: #{e.message}"
    redirect_to new_user_import_path, alert: "インポートに失敗しました。ファイルの内容を確認してください。"
  end
end

このコードは以下の問題を抱えています。

(問題点1) 例外を握りつぶしている

UserImportsController#create で行われている rescue StandardError は、ユーザーがアップロードしたファイルの不備に起因するエラーだけではなく、プログラムのバグやシステムの異常(例: DBへの接続失敗)によって発生する例外までも捕捉してしまいます。

その結果、

  • 開発者がバグやシステムの異常に気づくのが大幅に遅れる。

    • Fail Fast」は、異常を検知した時点で即座にエラーを発生させることで、バグの早期発見と迅速な修正につながるという原則です。このコードはすべての例外を握りつぶすことで、この原則に反しています。
  • ユーザーに不正確なメッセージを伝えてしまう。

    ユーザーに対して「ファイルを確認してください」という不正確なメッセージを伝えてしまうことになります。

基本的に rescue は特定の例外に絞って行うべきです。

(問題点2) 遠い場所の例外をrescueしている

rescue CsvParser::InvalidHeadersError に注目してください。この例外は、コントローラが直接呼び出している UserImporter ではなく、その内部で使われている CsvParser が発生させるものです。

つまり、コントローラはUserImporterが行う処理の詳細を知っていることになります。これは以下のような問題につながります。

  • UserImporter の内部実装が変わるたびに、呼び出し側のコントローラも修正が必要になる。
  • コントローラを実装するために、UserImporterの内部まで調査しなければならない。

このようにカプセル化が壊れた状態は、保守性を著しく低下させます。

ApplicationController において、さまざまな例外を rescue_from するような以下のコードも同様の問題を抱えています。

class ApplicationController < ActionController::Base
	rescue_from CsvParser::InvalidHeadersError, with: :handle_csv_error

(ただし、後ほど「例外を集約する」でお話しするようなケースもあります。)

また、制御フローを理解することが難しくなります。例外は発生した瞬間に通常の処理の流れを断ち切り、スタックを遡って自分を捕捉する rescue まで不連続にジャンプします。コードを一行ずつ追っていても、「どこで処理が中断され、どこへ着地するのか」がソースコード上に明示されないため、処理の全貌を把握するための認知負荷が跳ね上がってしまいます。

なぜ過剰に rescue したくなるのか

先ほど紹介したような危ういコードが書かれてしまう背景には、例外が持つ特有の性質と、それに対する開発者の心理があると考えています。

開発者心理1: アラートへの恐怖

例外を捕捉しわすれると500エラーでアラートがなったり、ユーザーに「開発者に通知されました」のような不穏なメッセージが表示されたりしてしまいます。開発者にはこれを避けたいという防御本能が働きますが、例外の「メソッドのインターフェース(契約)に明示されない」という性質ゆえに、どこで何が発生するかを完璧に予見するのは困難です。その結果、「何が起きても大丈夫なように」と、何でもかんでも拾い上げようとしてしまうのかもしれません。

開発者心理2: 例外処理を一箇所で楽に済ませたい誘惑

レイヤーごとに適切なエラーハンドリングを行うのは手間がかかりますし、コードのあちこちがbegin … rescue だらけになってしまいます。そのため、ApplicationController などで一括して rescue してしまうことで、例外処理を「一箇所にまとめて楽をしたい」という心理が働きます。これは例外の「スタックのトップまで強制的にエスカレーションされる(どこでも捕まえられる)」という性質を、悪い方向に活用してしまった例といえます。

では、例外をどのように扱えばよいでしょうか。

エラーを2つに分類する

本セクションは以下の書籍を参考にしています。

  • 『A Philosophy of software Design』10章「Define Errors Out Of Existence」
  • 『Good Code Bad Code』 4章「エラー」
💡

エラーの分類方法として以下も参考になります。

例外の扱い方を考えるにあたって、まずエラーを以下の(A)(B)に分類したいと思います。

(A) 発生したら進行中の処理を中断すべきエラー

以降、このエラーを「中断すべきエラー」と呼びます。

例えば以下のようなエラーです。

  • DBへの接続失敗
  • バグによるNoMethodError
  • 本来存在するはずのマスターデータが見つからない(データの不整合)
  • 外部APIの予期せぬレスポンス形式変更

これらは次のような特徴を持ちます。

  • 通常発生することが予期されない
  • エラー解決のためにその場でできることがほとんどなく、アプリケーションの実行を続けることができない

このようなエラーの発生で例外が投げられる場合、rescue せず処理を中断し、エラーを上位に報告します。例外はトップレベルまで通過し、そこで開発者やユーザーへの通知などの例外ハンドリングを行います。

さきほども述べましたが、この種の例外を rescue してしまうと、エラーの発覚が大幅に遅れてしまうため、避けるべきです。Fail Fastの原則に従い、中断すべきエラーが発生した際は、即座にかつ明示的に失敗させましょう。

例としてユーザーがログインした際にログイン回数をカウントする以下のコードを考えます。

def increment_login_count!
  @user.increment(:login_count)
  @user.save! 
end

この保存処理の失敗は、DB制約違反や予期せぬデータ不整合など、通常は起きないはずの異常事態です。ここで単なる save を使うと、失敗したことに気づかず処理が続いてしまいます。そのため save! を用いることで、失敗時には例外を発生させ、処理を即座に中断させるべきです。

(B) 発生したら対処して進行中の作業を完了させたいエラー

以降、このエラーを「対処したいエラー」と呼びます。

例えば以下のようなエラーです。

  • ユーザーの入力不備
  • 外部APIのレートリミット
  • 権限不足によるアクセス拒否

これらは次のような特徴を持ちます。

  • 発生が予期される

    例えばシステムの外部(ユーザーや外部サービス)とのやりとりにおいて、外部から送られてくるデータが不適切であったり、外部サービスが一時的に利用できない状況は予期されます。

  • 深刻ではない

    深刻でないエラーが発生した場合、処理を中断するよりも、その場で適切に対処する(ユーザーにメッセージを表示する、リトライするなど)ことで、ユーザーの操作を無駄にせず、より良い体験を提供できます(システムがより堅牢になります)。

このようなエラーで発生する例外については適切に捕捉し、処理を継続します。

では、発生した例外はどこで捕捉すべきでしょうか。

基本的に、発生した例外を捕捉して処理を継続するか、あるいは処理を諦めて中断するかを決定する責任は、メソッドの直接の呼び出し元にあります(後述する「例外の集約」のようなケースを除きます)。

  • 基本的にメソッド自身は発生した例外について回復すべきかどうかを判断できません(のちに述べる「例外のマスク」のような例外的なケースもあります)。

    なぜなら、同じメソッドでも、呼び出される文脈によって発生するエラーが回復すべきものかどうかが変わるからです。例えば、あるメソッドにDBから取得したデータを渡して処理がエラーになる場合、データ不整合のため中断すべきかもしれませんが、ユーザーの入力値を渡す場合は入力値の不備と判断して処理を続けるべきかもしれません。(『Good Code Bad Code』4.1.3「エラーから回復可能かどうかは、呼び出し元だけが知っている」が参考になります)。

  • 一方、直接の呼び出し元より上位のレイヤーで個別的なハンドリングを行ってしまうと、問題点2で述べたように呼び出し側が内部実装の詳細に依存してカプセル化が壊れてしまったり制御フローがわかりにくくなったりする可能性があります。

rescue を忘れて 500 エラーが発生した場合、アラートを恐れて最上位で一律に rescue して蓋をするのではなく、例外を含めたインターフェースを正しく扱えなかったという実装ミスと考え、呼び出し側がその責任を果たすようにエラー時の振る舞いを正しく設計すべきです。

ここまでのまとめ

ここまで述べたことをふまえて、冒頭で紹介した rescue の扱い方は以下のようにまとめられます。

エラーが例外で表現されているとします。

  • (A) 中断すべきエラー

    例外を rescue をしません。

  • (B) 対処したいエラー

    基本的には直接の呼び出し元がその例外を rescue します。ただし、より上位で rescue する(例外の集約)場合やメソッド自身が例外に対処する(例外のマスク)場合もある。

「対処したいエラー」については、例外の持つ性質によって、以下のような不適切な実装をしてしまう可能性があります。

  • 例外の発生がインターフェースに明示されないため、呼び出し元で rescue を忘れる。
  • 例外は一度投げられると、rescue しない限り処理を強制中断し、スタックを突き抜けて上位へとエスカレーションされる。そのため rescue 漏れによるアラートを恐れて、さまざまな箇所で過剰に rescue が行われたり、例外が握りつぶされたりする。

例外の扱いにくさを設計で軽減する

「対処したいエラー」における上記のような例外の扱いにくさを軽減するにはどうすればいいでしょうか?

基本的に「対処したいエラー」の通知手段に例外を用いない

一般にエラーに対処して処理を継続する場合、先ほどの例外の扱いにくさを回避するために、エラーの通知手段として戻り値を用いることができます。

💡

ここではエラーの通知手段全体について詳細には触れません。この点については以下が参考になります。

エラーが起きたことを示す値(null や 空の配列、エラーの配列など)を返す

# userがない場合はnilが返る
user = User.find_by(email: 'xxxx')
# エラー時
return unless user 

# 成功時
# ....

成功したかどうかを表す値と、それに付随する詳細な情報からなるデータ(サイドバンドデータ)

書籍『Exceptional Ruby』では以下の Result のようなデータを「サイドバンドデータ」と呼んでいるため、本記事でもそのように呼びたいと思います。

class UserImporter
	# 結果を表すサイドバンドデータ。
	# 結果のステータスを表すsuccessと結果の詳細を保持するdetailで構成される。
	Result = Data.define(:success, :detail)
	
	def self.run(file)
		# エラー時
	  return Result.new(success: false, detail: "ヘッダーが不足しています") if header_invalid?
	  
	  # インポート処理
	  # ...
	  
	  # 成功時
	  Result.new(success: true, detail: created_users)
	end
end

class UserImportsController < ApplicationController
  def create
		result = UserImporter.run(params[:file])
		if result.success
		  redirect_to users_path, notice: "#{result.detail.size}名を登録しました"
		else
		  redirect_to new_user_import_path, alert: result.detail
		end
	end
end

ActiveModel::Errors

class UserImportForm
  include ActiveModel::Model
  
  attr_accessor :file
  
  validates :file, presence: { message: 'ファイルを選択してください' }
  validate :file_size_valid
  
  def save
    return false unless valid?
    
    # インポート処理
    # ...
  end
  
  private
  
  def file_size_valid
    return if file.blank?
    
    max_size = 10.megabytes
    if file.size > max_size
      errors.add(:file, 'ファイルサイズは10MB以下にしてください')
    end
  end
end

class UserImportsController < ApplicationController
  def create
    form = UserImportForm.new(file: params[:file])
    
    if form.save
      redirect_to users_path, notice: 'インポートが完了しました'
    else
      redirect_to new_user_import_path, alert: form.errors.full_messages.join(', ')
    end
  end
end

Rubyでは例外も戻り値もメソッドのシグネチャには現れません。つまり、どちらもインターフェースの暗黙の部分に含まれています。しかし、戻り値でエラーを通知する方法は例外と比較して以下の良さがあり、扱いやすいです。

  • わずらわしい begin … rescue を記述しなくてよい。
  • 戻り値は通常の制御フローに従うため、処理が不連続にジャンプすることがない。コードを上から下へ読むだけで処理の流れを追えるため、認知負荷が下がる。

これらの結果として、過剰に rescue するという動機も生まれにくくなります。

サイドバンドデータ利用時の注意点

レビューでは、以下のようなコードに出くわすことがありました。

# あちこちにアドホックにサイドバンドデータが定義されるコード

class UserImporter
  Result = Data.define(:success, :detail)
  
  def self.run(file)
    Result.new(success: true, detail: users)
  end
end

class BookImporter
  Result = Data.define(:ok, :message)
  
  def self.run(file)
    Result.new(ok: true, message: "完了")
  end
end

class ProductImporter
  Response = Data.define(:success, :errors)
  
  def self.run(file)
    Response.new(success: true, errors: [])
  end
end

あちこちで異なる構造のサイドバンドデータが作られると、利用のたびに学習コストが発生します。乱用に注意してください。

「対処したいエラー」の通知に例外を用いる場合

それでも、対処したいエラーを例外で通知したいことがあります。

例外を用いたい場合

呼び出し元がメソッドの戻り値のチェックをわすれそうな場合

以下はユーザー登録をファイルインポートで行い、成功時に管理者にメールを送信する例です。

class UserImporter
  def self.run(file)
    return false unless valid_file?(file)
    # インポート処理...
    true
  end
end

class UserImportsController < ApplicationController
  def create
	  # 戻り値をチェックしていないため、失敗しても処理が継続される
    UserImporter.run(params[:file])

    # インポートが失敗していても、管理者に対してインポートが成功したことを知らせるメールが送信されてしまう。
    AdminMailer.notify_user_import_completed.deliver_later
    redirect_to users_path, notice: "インポートが完了しました"
  end
end

このようにエラーを戻り値で通知した場合に、呼び出し側が誤って戻り値をチェックしないことが懸念されたり、チェックされない場合の被害を最小化したい(フェイルセーフにしたい)場合には、呼び出し側にエラーハンドリングを強制することができる例外を利用するほうがよいかもしれません。

class UserImporter
  def self.run!(file)
    raise ArgumentError, "ファイルが存在しません" if file.blank?
    # インポート処理...
  end
end

class UserImportsController < ApplicationController
  def create
    UserImporter.run!(params[:file])

    AdminMailer.notify_user_import_completed.deliver_later
    redirect_to users_path, notice: "インポートが完了しました"
  rescue ArgumentError => e
    redirect_to new_user_import_path, alert: e.message
  end
end

さまざまな箇所で発生するエラーの対処を1箇所で行いたい場合(例外の集約)

例えば、find メソッドについて考えましょう。

class Api::UsersController < Api::BaseController
  def show
	  @user = User.find(params[:id])
	rescue ActiveRecord::RecordNotFound => e
		render json: { error: 'Record not found' }, status: 404
  end
end

class Api::BooksController < Api::BaseController
  def show
	  @book = Book.find(params[:id])
	rescue ActiveRecord::RecordNotFound => e
		render json: { error: 'Record not found' }, status: 404
  end
end

このコードでは find でレコードが見つからないエラー時のハンドリングは render json: { error: 'Record not found' }, status: 404 という共通のものです。このような場合、以下のようにApi::BaseController 1箇所でハンドリングすることができます。

class Api::BaseController < ApplicationController
	rescue_from ActiveRecord::RecordNotFound do
    render json: { error: 'Record not found' }, status: 404
  end
end

class Api::UsersController < Api::BaseController
  def show
	  @user = User.find(params[:id])
	end
end

class Api::BooksController < Api::BaseController
  def show
	  @book = Book.find(params[:id])
  end
end

ただし、冒頭でも述べたように、下位レイヤーの遠い場所で発生するエラーを補足して責務外のハンドリングを行うと、制御フローが追いにくくなったりカプセル化が破壊されたりするためやりすぎは禁物です。

例外を用いる場合の工夫

これらの場合に、例外の問題点を軽減するために次のような工夫ができます。

(1) 例外が発生しうることをメソッド名に表現する

Railsの慣習として、! 付きメソッド(bang method)は例外を発生させる可能性があることを示します。この慣習を自分で定義するメソッドにも適用することで、呼び出し側に例外の可能性を伝えられます。

なお、メソッドが発生させる例外をコメントやドキュメントに残すことでも例外の暗黙性を補うことはできます。ただし、コードの変更に追随して更新され続けることは難しく、陳腐化しやすいという問題があります。

(2) 例外を処理する箇所を減らす

書籍『A Philosophy of Software Design』の10章「Define Errors Out Of Existence」では、例外処理はシステムをより複雑にするため、例外処理を減らすべきであると述べられています。その方法として、ここでは「例外の変換」と「例外のマスク」の2つを紹介します。

  • 例外の変換

例えば、様々な例外が発生する可能性のあるライブラリのメソッドを利用するケースを考えましょう。例外の分類が私たちにとって細かすぎたり、あるいは関心のない例外が含まれたりしているかもしれません。これらの例外が私たちのコードベースの上位レイヤーまで届くとシステムの複雑さが増大してしまうでしょう。そこで、ライブラリの例外を私たちのコードにとって最適な例外に変換するレイヤーを設けます。

class RecordNotFoundError < StandardError; end

# 外部ライブラリの例外をラップして独自の例外に変換する例
def fetch_user_data(user_id)
  ThirdPartyApi.get_user(user_id)
rescue ThirdPartyApi::UserNotFoundError, ThirdPartyApi::UserDeactivatedError => e
  # 自アプリケーションの関心事に合わせた例外に変換する
  raise RecordNotFoundError, "ユーザーが見つかりません: #{user_id}"
end
  • 例外のマスク

また、書籍『A Philosophy of Software Design』では例外処理を減らす方法として「例外をマスクする」アプローチが紹介されています。

このアプローチは、例外的な条件が低いレイヤーのメソッド内で発生する場合に、そこで例外を処理してしまえば上位のレイヤーで処理する必要がなくなるというものです。つまり、例外処理の複雑さを局所化する方法といえます。

例えば、キャッシュの読み取りに失敗してもDBから取得できるため処理を継続できる場合、以下のようにキャッシュ層の内部で例外を処理してしまうことで、呼び出し元に例外を伝播させないようにできます。

def fetch_cached_user(user_id)
  cache.read("user:#{user_id}")
rescue Redis::BaseError => e
  # キャッシュが使えなくてもDBから取得できるため、呼び出し元に伝播させない
  Rails.logger.warn "Cache read failed: #{e.message}"
  nil
end

最後に

本記事では、過剰な rescue が生まれる背景と、それを避けるための設計上の考え方を整理しました。最後に、全体の補足を1つさせてください。

本記事ではエラーを分類することが重要だと述べました。一方で、エラーが回復すべきものかどうかは基本的にメソッド自身ではなく呼び出し元が知っているとも述べました。では、メソッドを実装する側はエラーの伝え方をどのように決定すればよいのでしょうか?

ここがまさに設計の難しさなのかなと思っています。そのメソッドの呼び出し元の文脈にどのような想定が可能か、コードベース全体の様子やドメインやシステムの知識も踏まえた判断が必要になります。
また、エラーがどのように通知されるかを呼び出し元にとって明らかにする努力も欠かせません。Rubyではシグネチャに例外を明示できませんが、発生しうる例外とその状況をテストで表現することで、コードが生きたドキュメントとして機能するようにしたいです。

例外を単なる言語機能としてではなく、設計すべきものと捉え、日々のコーディングの中で意識して設計に臨んでいきたいと思います。

参考文献

書籍

Web記事・資料

0
0
0

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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?