クリーンなコードと例外処理の技術
公開: 2022-03-11例外はプログラミング自体と同じくらい古いものです。 プログラミングがハードウェアで、または低レベルのプログラミング言語を介して行われていた時代には、プログラムのフローを変更し、ハードウェアの障害を回避するために例外が使用されていました。 今日、ウィキペディアは例外を次のように定義しています。
特別な処理を必要とする異常または例外的な条件–プログラム実行の通常のフローを変更することがよくあります…
そして、それらを処理するには、次のものが必要です。
特殊なプログラミング言語構造またはコンピューターハードウェアメカニズム。
したがって、例外には特別な処理が必要であり、未処理の例外は予期しない動作を引き起こす可能性があります。 結果はしばしば壮観です。 1996年、有名なアリアン5ロケットの打ち上げ失敗は、未処理のオーバーフロー例外が原因でした。 履歴の最悪のソフトウェアバグには、未処理または誤って処理された例外に起因する可能性のある他のバグがいくつか含まれています。
時間の経過とともに、これらのエラーやその他の無数のエラー(おそらく、それほど劇的ではありませんでしたが、関係者にとっては壊滅的でした)は、例外が悪いという印象を与えました。
しかし、例外は現代のプログラミングの基本的な要素です。 それらは私たちのソフトウェアをより良くするために存在します。 例外を恐れるのではなく、それらを受け入れ、それらから利益を得る方法を学ぶ必要があります。 この記事では、例外をエレガントに管理し、それらを使用して、より保守しやすいクリーンなコードを作成する方法について説明します。
例外処理:それは良いことです
オブジェクト指向プログラミング(OOP)の台頭により、例外サポートは現代のプログラミング言語の重要な要素になりました。 現在、堅牢な例外処理システムがほとんどの言語に組み込まれています。 たとえば、Rubyは次の典型的なパターンを提供します。
begin do_something_that_might_not_work! rescue SpecificError => e do_some_specific_error_clean_up retry if some_condition_met? ensure this_will_always_be_executed end
前のコードに問題はありません。 しかし、これらのパターンを使いすぎるとコードの臭いが発生し、必ずしも有益であるとは限りません。 同様に、それらを誤用すると、実際にはコードベースに多くの害を及ぼし、コードベースを脆弱にしたり、エラーの原因をわかりにくくしたりする可能性があります。
例外を取り巻く汚名は、プログラマーに途方に暮れることをしばしば感じさせます。 例外を回避できないのは現実ですが、例外は迅速かつ断固として対処する必要があるとよく言われます。 これから説明するように、これは必ずしも真実ではありません。 むしろ、例外を適切に処理して、他のコードと調和させる方法を学ぶ必要があります。
以下は、例外を受け入れ、それらを利用して、コードを保守可能、拡張可能、および読み取り可能に保つのに役立ついくつかの推奨プラクティスです。
- 保守性:現在の機能を壊したり、さらにバグを導入したり、時間の経過とともに複雑さが増したためにコードを完全に放棄したりすることを恐れずに、新しいバグを簡単に見つけて修正できます。
- 拡張性:既存の機能を壊すことなく、コードベースに簡単に追加して、新しい要件や変更された要件を実装できます。 拡張性は柔軟性を提供し、コードベースの高レベルの再利用を可能にします。
- 読みやすさ:コードを簡単に読み、掘り下げることに時間をかけずにその目的を見つけることができます。 これは、バグやテストされていないコードを効率的に発見するために重要です。
これらの要素は、私たちが清潔さや品質と呼ぶかもしれないものの主な要因であり、それ自体は直接的な尺度ではなく、この漫画で示されているように、前のポイントの複合効果です。
そうは言っても、これらのプラクティスを詳しく調べて、それぞれがこれら3つのメジャーにどのように影響するかを見てみましょう。
注: Rubyの例を示しますが、ここで示すすべての構成には、最も一般的なOOP言語で同等のものがあります。
常に独自のApplicationError
階層を作成します
ほとんどの言語には、他のOOPクラスと同様に、継承階層に編成されたさまざまな例外クラスが付属しています。 コードの可読性、保守性、および拡張性を維持するために、基本例外クラスを拡張するアプリケーション固有の例外の独自のサブツリーを作成することをお勧めします。 この階層を論理的に構築するためにある程度の時間を投資することは、非常に有益です。 例えば:
class ApplicationError < StandardError; end # Validation Errors class ValidationError < ApplicationError; end class RequiredFieldError < ValidationError; end class UniqueFieldError < ValidationError; end # HTTP 4XX Response Errors class ResponseError < ApplicationError; end class BadRequestError < ResponseError; end class UnauthorizedError < ResponseError; end # ...
アプリケーションに拡張可能で包括的な例外パッケージを用意すると、これらのアプリケーション固有の状況の処理がはるかに簡単になります。 たとえば、より自然な方法で処理する例外を決定できます。 これにより、コードの可読性が向上するだけでなく、アプリケーションとライブラリ(gems)の保守性も向上します。
読みやすさの観点から、読みやすくなっています。
rescue ValidationError => e
読むより:
rescue RequiredFieldError, UniqueFieldError, ... => e
たとえば、保守性の観点から、JSON APIを実装しており、クライアントが不正なリクエストを送信したときに使用される、いくつかのサブタイプを使用して独自のClientError
を定義しました。 これらのいずれかが発生した場合、アプリケーションは応答でエラーのJSON表現をレンダリングする必要があります。 考えられる各クライアントエラーをループしてそれぞれに同じハンドラーコードを実装するよりも、 ClientError
を処理する単一のブロックに修正またはロジックを追加する方が簡単です。 拡張性の観点から、後で別のタイプのクライアントエラーを実装する必要がある場合は、ここですでに適切に処理されていると確信できます。
さらに、これにより、コールスタックの早い段階で特定のクライアントエラーに対して追加の特別な処理を実装したり、途中で同じ例外オブジェクトを変更したりすることができます。
# app/controller/pseudo_controller.rb def authenticate_user! fail AuthenticationError if token_invalid? || token_expired? User.find_by(authentication_token: token) rescue AuthenticationError => e report_suspicious_activity if token_invalid? raise e end def show authenticate_user! show_private_stuff!(params[:id]) rescue ClientError => e render_error(e) end
ご覧のとおり、この特定の例外を発生させても、さまざまなレベルでの処理、変更、再発生、および親クラスハンドラーによる解決が妨げられることはありませんでした。
ここで注意すべき2つのこと:
- すべての言語が、例外ハンドラー内からの例外の発生をサポートしているわけではありません。
- ほとんどの言語では、ハンドラー内から新しい例外を発生させると、元の例外が永久に失われるため、同じ例外オブジェクトを(上記の例のように)再発生させて、元の原因を見失わないようにすることをお勧めします。エラー。 (意図的にこれを行っている場合を除きます)。
rescue Exception
ない
つまり、基本例外タイプのキャッチオールハンドラーを実装しようとしないでください。 すべての例外を大規模に救済またはキャッチすることは、それがグローバルにベースアプリケーションレベルであろうと、一度だけ使用される小さな埋め込みメソッドであろうと、どの言語でも決して良い考えではありません。 例外は実際に起こったことを難読化し、保守性と拡張性の両方を損なうため、 Exception
を救済したくありません。 構文エラーのように単純な場合、実際の問題が何であるかをデバッグするのに膨大な時間を浪費する可能性があります。
# main.rb def bad_example i_might_raise_exception! rescue Exception nah_i_will_always_be_here_for_you end # elsewhere.rb def i_might_raise_exception! retrun do_a_lot_of_work! end
前の例でエラーに気付いたかもしれません。 return
の入力ミス。 最新のエディターは、この特定のタイプの構文エラーに対してある程度の保護を提供しますが、この例は、 rescue Exception
がコードにどのように害を及ぼすかを示しています。 例外の実際のタイプ(この場合はNoMethodError
)が対処されることも、開発者に公開されることもありません。これにより、サークルでの実行に多くの時間を浪費する可能性があります。
必要以上の例外をrescue
しないでください
前のポイントは、このルールの特定のケースです。例外ハンドラーを過度に一般化しないように常に注意する必要があります。 理由は同じです。 必要以上に多くの例外を救済するときはいつでも、アプリケーションロジックの一部をアプリケーションの上位レベルから隠すことになり、開発者が例外を自分で処理する能力を抑制することは言うまでもありません。 これは、コードの拡張性と保守性に深刻な影響を及ぼします。
同じハンドラーで異なる例外サブタイプを処理しようとすると、責任が多すぎるファットコードブロックが導入されます。 たとえば、リモートAPIを使用するライブラリを構築している場合、 MethodNotAllowedError
(HTTP 405)の処理は、両方ともResponseError
であっても、通常はUnauthorizedError
(HTTP 401)の処理とは異なります。

これから説明するように、特定の例外をよりドライな方法で処理するのに適した、アプリケーションの別の部分が存在することがよくあります。
したがって、クラスまたはメソッドの単一責任を定義し、この責任要件を満たす最低限の例外を処理します。 たとえば、メソッドがリモートAPIから株式情報を取得する責任がある場合、その情報のみを取得することから発生する例外を処理し、他のエラーの処理をこれらの責任のために特別に設計された別のメソッドに任せる必要があります。
def get_info begin response = HTTP.get(STOCKS_URL + "#{@symbol}/info") fail AuthenticationError if response.code == 401 fail StockNotFoundError, @symbol if response.code == 404 return JSON.parse response.body rescue JSON::ParserError retry end end
ここでは、株式に関する情報のみを取得するために、このメソッドのコントラクトを定義しました。 不完全または不正な形式のJSON応答など、エンドポイント固有のエラーを処理します。 認証が失敗または期限切れになった場合、または在庫が存在しない場合は処理されません。 これらは他の誰かの責任であり、DRYの方法でこれらのエラーを処理するためのより良い場所があるはずのコールスタックに明示的に渡されます。
例外をすぐに処理したいという衝動に抵抗する
これは最後のポイントを補足するものです。 例外は、呼び出しスタックの任意のポイント、およびクラス階層の任意のポイントで処理できるため、どこで処理するかを正確に知ることは不思議なことです。 この難問を解決するために、多くの開発者は例外が発生したらすぐに処理することを選択しますが、これを熟考することに時間を費やすと、通常、特定の例外を処理するためのより適切な場所を見つけることになります。
Railsアプリケーション(特にJSONのみのAPIを公開するアプリケーション)で見られる一般的なパターンの1つは、次のコントローラーメソッドです。
# app/controllers/client_controller.rb def create @client = Client.new(params[:client]) if @client.save render json: @client else render json: @client.errors end end
(これは技術的には例外ハンドラーではありませんが、機能的には同じ目的を果たします。 @client.save
は例外が発生した場合にのみfalseを返すためです。)
ただし、この場合、すべてのコントローラーアクションで同じエラーハンドラーを繰り返すことはDRYの反対であり、保守性と拡張性を損ないます。 代わりに、例外伝播の特殊な性質を利用して、親コントローラークラスであるApplicationController
で一度だけ処理できます。
# app/controllers/client_controller.rb def create @client = Client.create!(params[:client]) render json: @client end
# app/controller/application_controller.rb rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable_entity def render_unprocessable_entity(e) render \ json: { errors: e.record.errors }, status: 422 end
このようにして、すべてのActiveRecord::RecordInvalid
エラーが、ベースのApplicationController
レベルで1か所で適切にDRYで処理されるようにすることができます。 これにより、特定のケースを下位レベルで処理したい場合、または単にそれらを適切に伝播させたい場合に、それらをいじる自由が得られます。
すべての例外を処理する必要があるわけではありません
宝石やライブラリを開発するとき、多くの開発者は機能をカプセル化しようとし、例外がライブラリから伝播することを許可しません。 ただし、特定のアプリケーションが実装されるまで、例外を処理する方法が明確でない場合があります。
理想的なソリューションの例としてActiveRecord
を取り上げましょう。 ライブラリは、開発者に完全性のための2つのアプローチを提供します。 save
メソッドは、例外を伝播せずに処理し、 save!
中にfalse
を返すだけです。 失敗すると例外が発生します。 これにより、開発者は特定のエラーケースを別の方法で処理するか、単に一般的な方法で障害を処理するかを選択できます。
しかし、そのような完全な実装を提供するための時間やリソースがない場合はどうでしょうか。 その場合、不確実性がある場合は、例外を公開して公開するのが最善です。
理由は次のとおりです。私たちはほぼ常に移動要件に取り組んでおり、例外が常に特定の方法で処理されるという決定を下すと、実際に実装に悪影響を及ぼし、拡張性と保守性を損ない、特に開発時に莫大な技術的負債を追加する可能性がありますライブラリ。
株価を取得する株式APIコンシューマーの前の例を見てください。 不完全で不正な形式の応答をその場で処理することを選択し、有効な応答が得られるまで同じ要求を再試行することを選択しました。 ただし、後で要件が変更される可能性があるため、リクエストを再試行するのではなく、保存された過去の在庫データにフォールバックする必要があります。
この時点で、依存プロジェクトはこの例外を処理しないため、ライブラリ自体を変更して、この例外の処理方法を更新する必要があります。 (どうして彼らはできたのでしょうか?以前は彼らにさらされたことはありませんでした。)また、私たちの図書館に依存しているプロジェクトの所有者に通知する必要があります。 このようなプロジェクトが多数ある場合、このエラーが特定の方法で処理されることを前提として構築されている可能性が高いため、これは悪夢になる可能性があります。
これで、依存関係の管理がどこに向かっているのかがわかります。 見通しは良くありません。 この状況は非常に頻繁に発生し、多くの場合、ライブラリの有用性、拡張性、および柔軟性を低下させます。
つまり、ここに結論があります。例外をどのように処理する必要があるかが不明な場合は、適切に伝播させてください。 内部で例外を処理するための明確な場所が存在する場合は多くありますが、例外を公開する方がよい場合も多くあります。 したがって、例外の処理を選択する前に、考え直してください。 経験則として、エンドユーザーと直接対話している場合にのみ例外の処理を主張することをお勧めします。
規則に従う
Ruby、さらにはRailsの実装は、 method_names
とmethod_names!
「強打」で。 Rubyでは、バングはメソッドがそれを呼び出したオブジェクトを変更することを示し、Railsでは、期待される動作の実行に失敗した場合にメソッドが例外を発生させることを意味します。 特にライブラリをオープンソース化する場合は、同じ規則を尊重するようにしてください。
新しいmethod!
Railsアプリケーションに大きな影響を与える場合は、これらの規則を考慮に入れる必要があります。 このメソッドが失敗したときに例外を発生させることを強制するものは何もありませんが、規則から逸脱することにより、プログラマーは、実際には例外を処理できないのに、自分で例外を処理する機会が与えられると誤解する可能性があります。
Jim Weirichに起因するもう1つのRuby規則は、メソッドの失敗を示すためにfail
を使用し、例外を再発生させる場合にのみraise
を使用することです。
余談ですが、失敗を示すために例外を使用するため、Rubyではほとんどの場合、
raise
キーワードではなくfail
キーワードを使用します。 Failとraiseは同義語であるため、failがメソッドが失敗したことをより明確に伝えることを除いて、違いはありません。 私がraiseを使用するのは、例外をキャッチして再発生させるときだけです。これは、ここでは失敗していないためですが、明示的かつ意図的に例外を発生させているためです。 これは私が従う文体の問題ですが、他の多くの人がそうしているとは思えません。
他の多くの言語コミュニティでは、例外の処理方法についてこのような規則を採用しています。これらの規則を無視すると、コードの可読性と保守性が損なわれます。
Logger.log(すべて)
もちろん、この方法は例外だけに適用されるわけではありませんが、常にログに記録する必要があるものが1つある場合、それは例外です。
ロギングは非常に重要です(Rubyが標準バージョンのロガーを出荷するのに十分重要です)。 これはアプリケーションの日記であり、アプリケーションがどのように成功したかを記録するよりもさらに重要なのは、アプリケーションがいつどのように失敗したかをログに記録することです。
ロギングライブラリまたはログベースのサービスとデザインパターンに不足はありません。 例外を追跡して、何が起こったかを確認し、何かが正しくないかどうかを調査できるようにすることが重要です。 適切なログメッセージにより、開発者は問題の原因を直接指摘でき、計り知れない時間を節約できます。
そのクリーンなコードの信頼性
例外は、すべてのプログラミング言語の基本的な部分です。 それらは特別で非常に強力であり、それらと戦うことに疲れ果てるのではなく、それらの力を利用してコードの品質を向上させる必要があります。
この記事では、例外ツリーを構造化するためのいくつかの優れたプラクティスと、それらを論理的に構造化することが読みやすさと品質にどのように役立つかについて詳しく説明しました。 1つの場所または複数のレベルで、例外を処理するためのさまざまなアプローチを検討しました。
「すべてを捕まえる」のは悪いことであり、それらを浮かせて泡立たせても問題ないことがわかりました。
例外をDRY方式で処理する場所を調べたところ、例外が最初に発生したときまたは発生した場所で処理する義務がないことがわかりました。
正確にそれらを処理することが良いアイデアである場合、それが悪いアイデアである場合、そして疑わしい場合にそれらを伝播させることがなぜ良いアイデアであるかについて話し合いました。
最後に、規則に従う、すべてをログに記録するなど、例外の有用性を最大化するのに役立つ他のポイントについて説明しました。
これらの基本的なガイドラインを使用すると、コード内のエラーケースをより快適に、自信を持って処理し、例外を本当に例外的なものにすることができます。
この記事の作成に大いに役立ったAvdiGrimmと彼の素晴らしい講演ExceptionalRubyに特に感謝します。