1
0

More than 3 years have passed since last update.

例外処理だって構造化設計したい

Posted at

動機

色んな例外処理の記事を読んだけど、腑に落ちなかったので、視点を広げて考察した。
「品質向上の為にいかに例外をシステムで生かすか」の観点でまとめる。

1. 例外について

1.1. 特徴

例外についての特徴を洗い出す。

  • 原則、処理に必要な条件が満たされていない場合に発生する
  • 例外クラスは、継承を利用して特化や汎化でキャッチ対象を制御できる
  • 非検査例外であれば、無視できる
  • データを付与できる
  • 例外クラス名である程度の説明はつく(ex: NotFoundException)
  • 構造化できる(例外をネスト可能である)

1.2. 例外の限界

例外の本質は、呼び出し側・例外発生側という関係において、
呼び出し側にメッセージ通知するという機能でしかない。リモートプログラムなど連携先であったりして、全ての原因を追跡するには工夫が必要。

そのまま利用すると

  • 例外の発生側は、発生した事象を通知する事しか出来ない
  • 例外のキャッチ側は、メソッドが失敗した事しか分からない
  • スタックトレースで発生メソッドは特定できる

2. 原因調査を例外処理で実現する

開発者が最大限コードを読まなくても済む様に、実現方法を考える。
原因調査の観点は以下の通り。

  • バグか障害か(一般的には欠損する情報)
  • 切り分け(悪いのは誰か)
  • 発生箇所の特定
  • 再現の為の情報(一般的には欠損する情報)

以下のサンプル実装をもとに整理する。
説明を簡潔にする為、一部メソッドは省略。

// 銀行送金クラス
class BankTransfer implements BankProcess {
  // 送金情報
  Account from; // 送金元口座
  Account to;   // 送金先口座
  long amount;  // 送金金額

  // 振替処理
  @override
  void process() throws BankException {
    try {
      from.withdrawal(amount);      // 出金処理
      to.deposit(amount);           // 入金処理
      log("transfer completed.");   // ログ出力
    } catch (TransferException e) {
      // 送金に失敗した
      log("transfer failed.");      // ログ出力
      rollback();
      log("rollback completed.");
      throw e;
    }
  }
}
// 口座処理
abstract class Account {
  int no; // 口座番号
  // 入金処理
  void deposit(long amount) {
    try {
      // 例外が発生する想定
      deposit(amount);
    } catch (ConnectionTimeoutException e) {      //2.2.3 切り分け(悪いのは誰か)
      // 接続に失敗し口座管理システムが利用できない
      var error = new TransferException(e);       //2.2 論理例外で構造化する
      error.setNo(this.no);                       //2.1 付加情報の活用
      throw error;
    }
  }
  // 出金処理
  void withdrawal(long amount) {
    try {
      // 例外が発生する想定
      withdrawal();
    } catch (ConnectionTimeoutException e) {      //2.2.3 切り分け(悪いのは誰か)
      // 接続に失敗し口座管理システムが利用できない
      var error = new TransferException(e);       //2.2 論理例外で構造化する
      error.setNo(this.no);                       //2.1 付加情報の活用
      throw error;
    }
  }

  // 口座毎に銀行が異なる為、抽象化
  abstract void deposit(int no, long amount);
  abstract void withdrawal(int no, long amount);
}

2.1. 例外スロー時の付加情報を活用する

例外スロー時には、そのキャッチ文を実装しているメソッド内の情報が収集できる。
メソッドの先頭〜キャッチ発生ポイントまでの関連するデータを例外に設定。あくまでも関連したデータを収集する。

  • クラス・フィールド
  • メソッド・パラメータの値
  • IF/For等の制御文の評価値(何件目のデータか、どんな条件か)
  • スタックトレース(既定で付加される)

以下の観点を解決。

  • 発生箇所の特定
  • 再現の為の情報

2.2. 論理例外で構造化する

以下の観点で、原因調査の結果として必要な情報を提供する。

  • バグか障害か
  • 切り分け(悪いのは誰か)

以降、想像しやすい「銀行送金処理」を例題にし、論理例外を用いた実装を行ってみる。
例外処理の実装部分に着目。

2.2.1 例外クラス継承ツリー

例外クラス名は、「処理が失敗した」事を知らせますが、クラス名は主語として機能します。
以下の例外クラスは、継承ツリーを示します。

RuntimeException                  非検査例外
    └── BankException             銀行業務が失敗
        └── NameResolveException  名あて処理が失敗 (以下では無関係)
        └── TransferException     送金処理が失敗

2.2.2. バグか障害か

バグと判断する為には、例外キャッチのルール化で解決出来る。
逆を言えば、いかに想定した例外をピンポイントでキャッチ出来るかが重要。その為には、抽象的な例外(SQLExceptionなど)の場合、例外の付加情報で処理する例外インスタンスを絞り込まなければならない。

  • バグ:キャッチされない例外
  • 障害:論理例外(想定された事象を示唆)

2.2.3 切り分け(悪いのは誰か)

原因調査において、必要な事は切り分けだが、一般的に疑惑を持たれる登場人物は以下の通り。

  • ユーザー(操作が悪いのではないか)
  • 運用者(構成・操作が悪いのではないか)
  • 接続している他のコンポーネント・システム(DB、他のWebサービスなど)

この問題は、キャッチした物理的な例外(原因となる例外)によって判別が可能である。
以下に例を示す。

  • ユーザー・運用者操作との接点で発生した例外
    • (バリデーションで解決)
  • 構成の不備によって発生した例外
    • FileNotFoundException
      構成ファイルが存在しない場合
    • ParseException
      構成ファイルが壊れた
  • 接続している他のコンポーネント・システムの接点で発生した例外
    • ConnectionExecption
      DB接続が失敗した場合など。ただし付加情報やスタックトレースを見て判断
    • HttpRequestException
      他のWebサービスへの接続、リクエスト不備(この場合バグ)など
    • SQLException
      DBの状態によって発生。エラーコードなどが付与されている。

上位メソッドへの通知の仕方は、自由です。論理例外に付加情報として設定するも良し、
新しくOperatorExceptionクラス(操作ミスを示唆するクラス)を作成し、抽象例外クラスを増やすも良し。メッセージに記載するも良し。(接続に失敗した為、〇〇に失敗しました。)

2.2.4 調査結果

結果、スタックトレースが付加され、調査結果は完了となる。
上記サンプルの一例を説明してみる。

  • 障害と認定 (論理例外の為)
  • 外部サービスとの接続問題により(論理例外に設定される原因例外が他のサービスとの接点で起こる例外)
  • 送金元口座番号、送金先口座番号、金額(付加情報)
  • 実際に起こったメソッド(スタックトレース)

3. 例外をどうするか

発生した例外は、必ずどこかでキャッチして何らかの処理をしなければならない。
この設計思想の動機は「品質改善の為に例外をシステムで生かす」の為、以下の様なルールが必要だと考えた。

3.1. 全ての例外をキャッチする原則ルール

全ての例外をキャッチする「Exception」を使う理由は少ない。

  • システムを止めない為
  • 提供しているライブラリなどが強制する場合(イケてない場合)

以下に実装して良いケースを示す。

  • 例外をリスローしてシステムを止めてしまう可能性があるメソッド
    • main関数
    • 自作のバックエンド処理のエンドポイント
    • イベントループ
  • 後続の処理が存在して、処理を止めては行けないメソッド
    • ジョブ基盤の実行ループ
    • HTTPリクエスト制御

Webアプリケーションサーバなど、例外を適切に処理してくれる物もあるので、
その場合は、全てをキャッチする必要はない。
例外を WASがキャッチした際の挙動を検証する必要がある。

3.2. バグを開発者に報告する

3.1.で示した全ての例外をキャッチした場合、想定していない物理的な例外もキャッチする事になる。そこで、開発者へのバグレポートをまとまった形で、何かに記録する必要がある。
考えられる方法は以下の通り。

  • ログファイルに記録する
  • Web画面などに表示する
  • ログ収集ツールや基盤などに送信する
  • 標準エラーに出力する

論理例外(想定された例外)、物理例外(想定されていない例外)は、簡単に区別が可能。
例外クラスの汎化や特化はここで生きてくる。
以下、コード例で示す。

try {
  var job = new BankTransfer(from, to);
  job.process();
} catch (BankException e) {
  // 想定された例外の処理

} catch (RuntimeException e) {
  // 想定されていない例外の処理
}

4. まとめ

例外クラスを自作しない場合、発生した例外クラス名だけでは、バグか障害かを判別できない。
また、再現する為の情報を得る事も出来ない。
そこで、以下の事を実現する事で、原因の特定に有効な情報を早期に発見する事ができる。

  • 独自の例外クラスを作成し、構造化設計する
  • 原因例外を独自の例外クラスでラップし、例外を再現させる為のデータを設定する
  • バグ・障害を判定する為、想定された例外クラスのみを処理する
  • 他のコンポーネントとの切り分けの為の情報を設定する
  • 例外は、上記で取り上げた原則によってキャッチする
1
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
1
0