🐶

Webサービスでエラーハンドリング設計した話

に公開

0. 前提

現在開発中の Web サービスにて、エラーハンドリングを整理したので、その際にどんなことをしたのかをまとめます。

この記事で書いていること

  • Web サービスのレイヤーごとの責務とエラーハンドリングの統一をした時に考えたこと
  • エラーコードの設計と実装をした時に考えたこと

書いていないこと

  • 実装詳細

前提となる技術条件

  • Node.js / Fastify による Web サービス
  • OpenTelemetry / Pino は導入済み
  • 本番はレイヤーごとに分散した環境で動作

1. 抱えていた問題とゴール

開発中の Web サービスでは以下のような問題がありました。

  • 問題点

    • エラーの投げ方がバラバラ(文字列、素の Error、独自クラス乱立)
    • レイヤー跨ぎでのエラーハンドリングの方法が曖昧
    • どこでログを出すべきか不明で、重複ログやログ漏れが発生
    • ログの出し方がバラバラで、ログの見づらさが発生

そのため、以下のゴールを設定し、エラーハンドリングの標準化を目指しました。

  • ゴール
    • 利用者に対して、適切なエラーメッセージを表示 / 処理することができる
    • 実装者が迷わず、エラーを実装することができる
    • エラーログがわかりやすく、どこで何のエラーが発生したかがわかりやすい
    • エラーハンドリングの方法が標準化されている

2. 前提とスコープ

まずはスコープを整理し、実施するタスクを大まかにまとめました。

前提

  • 開発はモノリス、本番はレイヤー分離
  • 本番では、OpenTelemetry, CloudLogging を利用したレイヤー間のトレースを実装ずみ

スコープ

  • やること
    • ログ出力の統一
    • エラー定義の標準化
    • エラーハンドリングの標準化
  • やらないこと
    • OpenTelemetry 側の詳細設定

3. 設計方針

方針 1:エラーは「分類」と「次アクション」を持つべき

  • なぜカスタムエラーが必要か?
    • catch 時にカスタムエラーごとに独自処理ができること
  • エラーに含まれる情報の統一
    • 「統一された型」に寄せ、持たせる情報を統一する
  • 実装しやすさ
    • 実装者が、「どのエラーを使うべきか」迷わないように MECE, 均一な粒度で定義する

方針 2:境界で変換し、境界でログする

  • アプリケーション全体でエラー情報がどのように流れるかを整理
    • 今回は、レイヤー分離されているため、同一レイヤー内では エラーを BubbleUp し、レイヤー境界でキャッチする
    • レイヤー境界でキャッチしたエラーは、ログ出力した後、上位レイヤーに渡すデータを JSON に変換し、上位レイヤーへ渡す
    • 上位レイヤーでは、エラーを適切にパースし、必要に応じてエラーを再 throw する

4. レイヤーごとの責務の決定

ここでは特別なことはしていませんが、各レイヤーでのエラー情報の処理を基本的には BubbleUp とし、レイヤー間の処理は、BaseClass 内などに隠蔽して実装することで、実装者が意識せずともエラー情報が適切に処理されるようにしました。

  • 各レイヤー:事実をそのまま throw(BubbleUp)
  • レイヤー境界(下位):BubbleUp したエラーを catch、必要ならログ、HTTP 用にシリアライズして上位レイヤーへ渡す
  • レイヤー境界(上位):エラーを適切にパースし、必要に応じてエラーを再 throw する
  • フロントとの境界:エラーを ユーザーメッセージ, HTTP ステータスコードに変換してフロントへ返す

5. 共通エラー型の設計

5.1 共通エラー型に持たせるプロパティ

  • code(識別子:ログや監視でのエラーの判別に利用)
  • message(開発者向け)
  • publicMessage(ユーザー向け)
  • metadata(調査用のキー・値)
  • cause(元の例外を保持、OTel/log 用)

5.2 エラーコード設計

エラーを分類し、それぞれのエラーに対して適切な次アクションを取ることができるようにすることが重要です。利用するカスタムエラーのコードについては、MECE かつ、できるだけ均一な粒度になるように定義しました。

以下は、エラーを分類し、それぞれのエラーに対して適切な次アクションを取ることができるようになる例です。

try {
  const file = IO.OpenFile("some_text_file.txt"); // FileNotFound, PermissionDenied
  for (line in file) {
    const n = 10 / Number.Parse(line); // FormatException, DivideByZero
  }
} catch (FileNotFoundException) {
  // ファイル名を聞き直して再トライできる
} catch (PermissionDeniedException) {
  // パーミッション設定を見直してね、とユーザーに教える
} catch (FormatException) {
  // ファイルは開けたけど内容がおかしいよ、とユーザーに教える
} catch (DivideByZeroException) {
  // システムをクラッシュから保護する
} catch {
  // それ以外
}

5.3 素のエラーの制限

一通りのエラーを定義したら、素のエラーは原則使用しないようにしました。linter で検知できるようにすることで、エラーの誤使用を防止することができます。

6. 今回の開発で学んだこと

6.1 エラーコードはわかりやすく、かつ、少なくする

エラーコードは、開発者が一目で見て何のエラーかわかることが重要です。実装する際にも、ログを見る際にも、何を意味しているかができるだけわかりやすくなるようにする必要があります。

6.2 境界層の意識

サービスレイヤー間が HTTP や gRPC などのプロトコルで繋がっている場合、どのように情報を伝達すべきか、考える必要があります。基本的には「通信には内部情報を載せない」、「必要最低限の情報を渡す」ことが重要だと思います。そのため、詳細情報は境界で記録する、受け取り側では情報を再構築する、といった処理が必要になりました。

6.3 フロントに渡す情報 / 渡さない情報を明確にする

大事なのは、フロント側の次アクションが明確になることです。そのため今回はユーザー向けメッセージとエラーコードのみを渡すようにしました。
将来的には、retrycontact supportなどの次アクションを渡す必要が出てくるかもしれませんが、現時点では必要がなかったため、できる限りシンプルにしています。

Discussion