4 章 エラー
システムで発生するエラーを回復可能なものと不能なものに分類して、それぞれがどういうものなのかを説明した章。 実装する際に、回復可能なエラー (回復することが期待されているエラーも含む) をどう扱うかコードベースで話してる。
まず回復可能性について。
- 回復可能なエラー
- ネットワークエラー
- 深刻ではないタスクエラー
- 回復不能なエラー
- コードと一緒にあるべきリソースがない
- 他のコードを誤用している
回復可能なエラー
の説明はわかりやすかった。
例えばネットワークエラーとか、クラウド上でアプリケーションを構築していると実装時に考慮する事が多い。一時的なネットワーク障害とかトラフィックの増大とかは、ちょっと時間をおいて再試行すると成功するってパターンがある。
負荷の話をするとアムダールの法則とかエクスポネンシャルバックオフとか思いつく。こういうの実装にいれてたりする。エクスポネンシャルバックオフってほどでもないけど、Polly (.NET の library) を使って簡単な jitter はさんでたりもする。ネットワークを介する事が多いアプリケーションを開発してるからすっと腹落ちした。
.NET 系で開発してると HttpClientFactory を使った実装とかマイクロソフトアーキテクチャセンターとかよく参考にしてる。Azure Function には再試行用の Attribute があるね。Service Bus Trigger を使った時は batch のの挙動がどうとか言ってたけどまだあまり使いこなせてないや。
深刻でないタスクエラーって何かなぁ。例えばだけど、運用目的でユーザの行動ログを残したいときに、
- ユーザの要求に応えるコアロジック
- ユーザの行動を記録するロジック
の 2 つを同時にしてたとする。この場合に、2 が原因で 1 の処理に影響を及ぼしたくないって事かなと思う。もちろんそのログの重要性にもよるんだけど。監査ログとかだと必ず残るように作らなきゃいけないのかな?(監査ログの要件によるのかな?)
この本では システムの外部が原因のエラーの多くは、正しく回復しようとすべきである
と紹介している。
アプリケーションのコアロジックに行きつく前に、よりユーザに近い距離でエラーを捕捉したいって事なんだと思う。捕捉する場所ミスってデータ不整合とか起こしたくないし、素早くエラーを返したほうがユーザ体験もよくなるだろうし。スタックトレース深すぎて追うのしんどかったりするし。 (よりユーザに近い位置ってより、レイヤードアーキテクチャのより外側で捕捉したいってイメージを持ってる)
もちろん下位レイヤーじゃなきゃ捕捉できないこともあると思う (DB 更新時に Conflict 起きたとか)。
回復不能なエラー
は筆者の主張していることと一致しているかはわからんが、あるクラスのあるメソッドを実行する前に満たしておかなければいけない事前状況が満たされていない場合、と理解した。例えばだけど、SqlConnection を使っているときに、Open
を呼び出す前に BeginTransaction
を呼び出してしまうとか。
using var connection1 = new SqlConnection(connectionString); using var transaction = connection1.BeginTransaction(); // InvalidOperationException が発生 connection1.Open();
この場合って実行時にエラーを捕捉しても、そもそものコードがミスってるから回復出来ない。無理に回復しようとしてエラーを握りつぶしてしまうとバグにも気づけない。こういうのは回復不能だし、回復しようとしちゃだめだよなぁって思う。こういうエラーを運用で拾った時に何をすれば良いのかわかるようなエラーメッセージにしなきゃ打よなぁって思う。↑の例の場合、Invalid operation. The connection is closed.
ってのが表示されて、Open しないと使えないのねってのが一目瞭然。
あと、エラーを握り潰しちゃだめだよって文脈ていうと、Exception の再スローする時にスタックトレースを握りつぶさないように気を付けないといけない。
try { Test.Run(); } catch (Exception ex) { throw new ArgumentNullException(ex.Message); // bad case throw ex; // bad case throw; // good case throw new ArgumentNullException("無効な引数です。", ex); } class Test { public static void Run() { throw new InvalidOperationException("無効な処理です。"); } }
ExceptionDispatchInfo を使っといたほうがいいのかなぁ~。
エラーから回復可能かどうかは、呼び出し元だけが知っている
これもわかりやすい。あるメソッドを設計する時に、それがどんな場面でどんな呼び出されるかって想像出来ないし、そこにバキバキに依存というか結合しちゃってるメソッドって良くないと思う。例えばだけど、あるバリデーションロジックを持つメソッドを設計している時に、そのメソッドが
- ユーザから入力された値を検証するのか
- システムで動的に作られた値を検証するのか
とかっていちいち意識しないと思う。目的は インプットした値を検証する事
で、それ以上でも以下でもないかなぁ。んで、検証に失敗した場合 (つまりエラーが発生した場合) に、回復可能かどうかは確かに呼び出し元にしかわからない。ユーザからの入力の検証ために使っている場合はそのエラーをユーザに通知するかもしれない。システムで動的に作った値を検証するために使っている場合は、バグを伝えるために捕捉せずに投げっぱなしにするかもしれない。
単純な値検証だったら Try パターンが好きだな。bool 値でしか結果を伝えられないから、例えばエラーの原因ごとにユーザに表示されるエラーメッセージを変えたいみたいな場合には不向きそう。あ、 Try パターンの結果を見ない、とか実装漏れが起きたりすると嫌だなぁ。検証をメソッドに切り出す代わりに、ValueObject にしてしまうってパターンも考えられるね。
早い失敗 (Fail fase) では、できる限り、問題が起きた場所の近くでのエラーを通知することを担保します
なるほど~。そもそも委譲が多すぎると制御追いづらいから、そうならないようにしたいってのがあるけど。2~3 回を超えて委譲されてると読むのしんどいな~って感じる。
委譲が多すぎて結局どこで発生した例外やねん、ってなっちゃうし。2~3 くらいの委譲先で発生しうる例外を把握して呼び出し元の実装を書くのきついからなぁ。どういうメソッドはどんな例外を発生しうるかってドキュメントがあればまだまし。ドキュメントを読めばわかるんだが、読まなきゃわかんないってのを最善の状態だとは思わないけどね。ここまで書いて Azure.Data.Tables
の TableClient.GetEntityAsync
を呼び出したときに、Entity が存在しないとRequestFailedException が投げられることを思い出した。そして GetEntityIfExistsAsync というメソッドがあったんだと気づいた。
(そういえば RPC って呼び出し先の例外を捕捉出来たっけ
この言葉を初めて知った。C# には検査例外のような仕組みがないからな~。
明示的にするためのいくつかのパターンがある
- エラーを表すために null を返す
- Result 型の戻り値
- エラーを示す戻り値 (bool 値など)
null を返すのはちょっとなぁ。null に意味を持たせたくない。何を意味しているのかドキュメントやコメントに残すのが大変だし、そのルールが守られるとも思えない。メンテナンスも漏れそう。何かの意味を持たせて呼び出し側にその判断を押し付けるのは危険だろう。その意味が変わったときに影響範囲の調査が大変だろうし、最悪バグる。
Result 型は C# にないけど、自前ではよく作る。Original Exception みたいなのを持たせて呼び出し側で再スローしたいときはスタックトレース消さないように気を付けないとね。
エラーを返す戻り値は、メソッドのシグネチャで bool 値が何を意味しているのか表現できていることが重要と思う。Try パターンとか Is Prefix を付けたりとか。そういう慣習に則って入れたら戻り値を処理することを忘れるとかも防ぎやすくなるんだろうな。
まぁでもやっぱ「型」とか言語機能とか慣習で表現できることはうまく活用していきたいね。
もぐらたたきのような例外
呼び出し側で発生しうる例外をすべて捕捉して、それぞれ個別に何かしらの処理を書いてるのは保守性低くていやだなって思う。もちろんチームのスタイルによる部分もあるんだけどね。