Ruby
 Computer >> コンピューター >  >> プログラミング >> Ruby

Rubyの例外処理(rescue)入門:正しい例外ハンドリングの書き方

AppSignalではRubyアプリケーション向けのエラートラッキングサービスを提供しています。そのために、アプリケーションが発生させるすべての例外をキャッチし、発生したタイミングで開発者へ通知する仕組みを持っています。

例外処理を正しく実装するのは意外と難しいものです。この記事では、Rubyにおける例外処理の仕組み、不適切なハンドリングが引き起こす問題、そして例外を適切にrescueする方法について詳しく解説します。

rescueによる例外の捕捉

Rubyで例外をrescueすると、何か問題が起きた瞬間にアプリケーションがクラッシュするのを防げます。begin .. rescueブロックを使えば、エラー発生時にアプリケーションが取るべき代替パスを指定できます。

begin
  File.read "config.yml"
rescue
  puts "設定ファイルが見つかりません。デフォルト値を使用します。"
end

また、どの例外を捕捉するかを指定することも可能です。例外クラスを指定した場合、そのサブクラスもすべて捕捉されます。

begin
  File.read "config.yml"
rescue SystemCallError => e
  puts e.class # => Errno::ENOENT
  puts e.class.superclass # => SystemCallError
  puts e.class.superclass.superclass # => StandardError
end

上記の例では、親クラスであるSystemCallErrorをrescueすることで、Errno::ENOENTという例外が捕捉されているのがわかります。

例外階層の高すぎる位置でrescueしない

例外階層の高すぎる位置でrescueするのは避けるべきです。上位のクラスを指定すると、そのサブクラスの例外までまとめて捕捉してしまい、rescueブロックの捕捉範囲が広すぎて汎用的になりすぎます。

次の例は、プログラムに渡された引数をもとに設定ファイルを読み込むコードです。

# $ ruby example.rb config.yml
def config_file
  ARGV.firs # ここにタイポがあります。正しくは `ARGV.first`
end
 
begin
  File.read config_file
rescue
  puts "設定ファイルを読み込めませんでした"
end

エラーメッセージには「設定ファイルを読み込めなかった」と表示されますが、本当の原因はコード内のタイポです。

begin
  File.read config_file
rescue => e
  puts e.inspect
end
#<NoMethodError: undefined method `firs' for []:Array>

begin .. rescueブロックがデフォルトで捕捉する例外クラスはStandardErrorです。特定のクラスを指定しない場合、RubyはStandardErrorとそのすべてのサブクラスをrescueします。NoMethodErrorもその一つです。

具体的な例外クラスをrescueすれば、無関係なエラーが誤って失敗状態を引き起こすのを防げます。さらに、エンドユーザーにとってより役立つ、具体的なカスタムエラーメッセージを出せるようにもなります。

config_file = "config.yml"
begin
  File.read config_file
rescue Errno::ENOENT => e
  puts "ファイルまたはディレクトリ #{config_file} が存在しません。"
rescue Errno::EACCES => e
  puts "#{config_file} を読み取れません。権限がありません。"
end

Exceptionクラスをrescueする危険性

それでも、階層の高い位置でrescueしたくなるかもしれません。「アプリケーションが発生しうるすべてのエラーを捕捉すればクラッシュしない(これでアップタイム100%だ!)」と思うかもしれませんが、これは多くの問題を引き起こします。

ExceptionクラスはRubyにおける例外の基底クラスです。他のすべての例外はこのクラスのサブクラスであり、Exceptionをrescueするとすべてのエラーが捕捉されてしまいます。

ほとんどのアプリケーションでは捕捉すべきでない例外として、SignalExceptionとSystemExitの2つがあります。

SignalExceptionは、外部からアプリケーションの停止が指示されたときに発生します。OSがシャットダウンしようとしている場合や、システム管理者がアプリケーションを停止させようとしている場合などが該当します。

SystemExitは、Rubyアプリケーションからexitが呼び出されたときに発生します。この例外が発生するということは、開発者がアプリケーションを停止させたいということです。

もしExceptionをrescueしていると、アプリケーションがbegin ... rescue ... endブロックを実行中にこれらの例外が発生しても、プロセスを終了できなくなります。

通常の状況でExceptionをrescueするのは避けるべきです。Exceptionをrescueすると、SignalExceptionやSystemExitが機能しなくなるだけでなく、LoadError、SyntaxError、NoMemoryErrorなども捕捉されてしまいます。より具体的な例外をrescueする方が賢明です。

テストで失敗が隠れてしまう問題

Exceptionをrescueすると、rescue Exception => eのように書いた場合、アプリケーション以外の部分まで壊れる可能性があります。実際、テストスイートが一部のエラーを隠してしまうことがあります。

minitestやRSpecでは、アサーションが失敗すると例外が発生し、それによってテストが失敗したことを通知します。このとき、これらのフレームワークは独自のカスタム例外(Exceptionのサブクラス)を発生させます。

テスト内やアプリケーションコード内でExceptionをrescueしていると、アサーションの失敗が握りつぶされてしまう可能性があります。

# RSpecの例
def foo(bar)
  bar.baz
rescue Exception => e
  puts "本来ならこのテストは失敗するはず"
  # Failure/Error: bar.baz
  #   <Double (anonymous)> received unexpected message :baz with (no args)
end
 
describe "#foo" do
  it "unexpected message例外を隠してしまう" do
    bar = double(to_s: "")
    foo(bar)
  end
end

例外の発生を期待するテスト

そもそも例外を発生させることを意図したコードもあります。テストスイートでは、単純に例外を握りつぶして、例外が発生してもテストが失敗しないようにできます。

def foo
  raise RuntimeError, "something went wrong"
end
 
foo rescue RuntimeError

しかし、この書き方では例外が実際に発生したかどうかを検証できません。例外が発生しなかった場合でも、テストは動作がまだ正しいかどうかを判断できません。

例外が発生することをアサートし、さらにどの例外が発生したかを検証することが可能です。

# expecting_exceptions_spec.rb
# RSpecの例
def foo
  raise NotImplementedError, "foo method not implemented"
end
 
describe "#foo" do
  it "raises a RuntimeError" do
    expect { foo }.to raise_error(RuntimeError)
  end
end
1) #foo raises a RuntimeError
   Failure/Error: expect { foo }.to raise_error(RuntimeError)

     expected RuntimeError, got #<NotImplementedError: foo method not implemented> with backtrace:
       # ./expecting_exceptions_spec.rb:4:in `foo'
       # ./expecting_exceptions_spec.rb:9:in `block (3 levels) in <top (required)>'
       # ./expecting_exceptions_spec.rb:9:in `block (2 levels) in <top (required)>'
       # ./expecting_exceptions_spec.rb:9:in `block (2 levels) in <top (required)>'

例外の再raise

Exceptionクラスレベルで例外を捕捉すべきなのは、非常に正当な理由がある場合だけです。たとえば、コードブロックを抜ける前に一時ファイルの削除など、どうしても必要な後始末を行う場合などが挙げられます。

どうしてもExceptionをrescueしなければならない場合のおすすめの方法は、エラー処理が完了した後に例外を再raiseすることです。こうすることで、その後のプロセスの運命をRubyの例外処理機構に委ねられます。

File.open("/tmp/my_app.status", "w") { |f| "running" }
 
begin
  foo
rescue Exception => e
  Appsignal.add_error e
  File.open("/tmp/my_app.status", "w") { |f| "stopped" }
  raise e
end

何をrescueすべきかわからないときは?

先述のとおり、どのエラーを捕捉するかはできる限り具体的に指定するのが良いでしょう。

ある操作がどんな例外を発生させうるのかわからない場合は、まずStandardErrorをrescueしてみると良い出発点になります。さまざまなシナリオでコードを実行し、どんな例外が発生するか観察してみましょう。

begin
  File.open('/tmp/appsignal.log', 'a') { |f| f.write "Starting AppSignal" }
rescue => e
  puts e.inspect
end
#<Errno::EACCES: Permission denied @ rb_sysopen - /tmp/appsignal.log>

新しい例外に遭遇するたびに、その例外または関連する親クラスに対する個別のrescueケースを追加していきます。広範囲の例外をまとめて捕捉するよりも、具体的に指定する方が安全です。

begin
  file = '/tmp/appsignal.log'
  File.open(file, 'a') { |f| f.write("AppSignal started!") }
rescue Errno::ENOENT => e
  puts "ファイルまたはディレクトリ #{file} が存在しません。"
rescue Errno::EACCES => e
  puts "#{file} に書き込めません。権限がありません。"
end
 
# 親クラスのエラーを使う方法
begin
  file = '/tmp/appsignal.log'
  File.open(file, 'a')
rescue SystemCallError => e
  puts "ファイル #{file} への書き込み中にエラーが発生しました。"
  puts e
end

以上が、Rubyにおける例外処理の入門ガイドでした。さらに詳しく知りたいことや具体的な質問があれば、@AppSignalまでお気軽にお問い合わせください。アプリ内でどこで、どれくらいの頻度で例外が発生しているのかを把握したい方は、ぜひAppSignalをお試しください。

  1. Rubyでカスタム例外を作成する方法【初心者向けガイド】

    Rubyでは、独自の例外クラスを簡単に作成できます。この記事では、カスタム例外を作るための基本的な手順を、コード例とともにわかりやすく解説します。 1. 新しいクラスを作成する Rubyにおいて、例外も他のオブジェクトと同じくクラスとして定義されています。新しい種類の例外を作りたい場合は、StandardError、あるいはそのサブクラスを継承したクラスを定義するだけでOKです。 class MyError < StandardError end raise MyError 慣例として、新しく作る例外クラスの名前は「Error」で終わるように命名します。また、カスタム例外をモジュ

  2. Rubyで例外にコンテキストデータを追加する3つの方法

    標準のバックトレースやエラーメッセージだけでは、エラーの原因を特定するのに十分な情報が得られないことがあります。そんなときに役立つのが、例外への追加データの付与です。幸い、Rubyではこれをとても簡単に実現できます。 エラーメッセージをカスタマイズする 例外にコンテキスト情報を加える最もシンプルな方法は、メッセージそのものに情報を組み込んでしまうことです。次の例では、発生した例外を捕捉し、新しいメッセージを付けて再送出しています。 begin raise foo rescue => e raise e.class, bar end # RuntimeError: bar