Edited at
JavaDay 21

段階的に理解する Java 例外処理


はじめに

例外処理の問題は Java コードレビューでの頻出指摘事項である。この記事で述べる通り、Java の例外処理において守るべき基本的なルールはそれほど複雑ではない。だが、たとえ職務経歴上は経験年数の長い Java プログラマであっても、適切な例外処理を実装できないケースは残念ながらよく観測される。さらに経験年数が短い Java プログラマにおいては言わずもがなである。

なぜ不適切な例外処理が広くはびこっているのか。そこには大きく分けて三つの要因が考えられる。まず、Java 言語仕様において例外機構 (特に検査例外) に歴史的事情による混乱があり、プログラマに過度の自由が与えられていることである。次に、アプリケーションを開発するだけでなく実際に運用してみない限り、不適切な例外処理の弊害に気づけないことである。最後に、適切な例外処理を学ぶためのコンパクトにまとまった資料が世に存在しないことである。入門者向け書籍では e.printStackTrace() を乱発するような場当たり的な例外処理が目立ち、適切な例外処理の方法は中級者以上向けの書籍やフレームワークのドキュメントに散在している。

そこで本記事では、入門者向け・初級者向け・中級者向けの 3 段階に分けて、例外処理の過度の自由の罠をいかに避けるか、また運用の視点を含めて適切な例外処理とは何かを、コンパクトにまとめてみたいと思う。なお、対象アプリケーションは主にサーバサイドを想定している (が、多くの項目はサーバサイドに限らず適用可能なはずだ)。


入門者向け

ここでは、現場に出る前に理解しておくべきことについて述べる。


例外機構の特性を理解する


異常系の表現

メソッド呼び出しの異常系を特殊な戻り値で表現するこのコードには大きな問題がある。まず、呼び出し元が createItem() の呼び出し元が異常系の処理を実装し忘れるかも知れない。また、戻り値が空であることがわかるだけで、実際に何が異常だったのかが呼び出し元ではわからない。

Item item = createItem();

if (item == null) {
// 異常系
...
} else {
// 正常系
...
}

例外機構を導入すると、コードは次のように書き換えられる。これにより、呼び出し元に異常系の処理を明示的に実装するよう誘導できる。また、catch 節で捕捉した例外のメッセージやスタックトレースにより、呼び出し元で異常の詳細を把握できる。

try {

Item item = createItem();
// 正常系
...
} catch (ItemCreationException e) {
// 異常系
...
}


多段の呼び出し階層への対応

例外機構を使えば、多段の呼び出し階層においてもシンプルに異常系に対応できる。例えば、一般的なレイヤードアーキテクチャを採用したアプリケーションでは、呼び出し階層がこのように多段になる傾向がある (入門者レベルでは、何やら複雑そうな雰囲気がわかれば細かい用語は気にしなくても良い)。


  • (Presentation 層) Web フレームワークの前後処理


    • (Presentation 層) Controller の 入り口メソッド


      • (Presentation 層) Controller の private メソッド


        • (Domain 層) Facade 的な Service のメソッド


          • (Domain 層) 実際のビジネスロジックを担う Service のメソッド


            • (Data Source 層) データアクセスロジックを担う Repository のメソッド











ここで問題になるのは、下位層での異常系をどう上位層に伝えるかである。例えば、最下位層のデータアクセスで異常が発生した場合に、最上位層の後処理で適切なエラーレスポンスを返す必要があるとする。特殊な戻り値でこの要件を満たすには、最下位層から最上位層まで異常な戻り値をバケツリレー式に返していかなければならない。

これに対して、特殊な戻り値の代わりに例外機構を使うことで、メソッドの入出力をシンプルに保ったまま任意の下位層の異常を任意の上位層で処理できる。下位層は何も考えずに throw new IllegalStateException("Something is wrong") のような例外を送出し、どこかの上位層がその例外を catch 節で捕捉するだけで良い。


検査例外と非検査例外の違いを理解する


例外クラスの階層

以下に基本的な例外クラスの継承ツリーを示す (なおパッケージは全て java.lang である)。Exception を継承した例外は検査例外であり、RuntimeException を継承した例外は非検査例外である。なお ThrowableError については、入門者レベルでの詳しい理解は不要である (むしろ触れないほうが良い)。


  • Throwable


    • Exception


      • (各種検査例外)

      • RuntimeException


        • (各種非検査例外)





    • Error




検査例外

検査例外は、呼び出し元に何らかの対処を強制する例外である。例えば、以下のようにファイルの内容を読み込むコードを書くと、コンパイルエラーが出力される。

List<String> lines = Files.readAllLines(Paths.get("items.csv"));

Error:(x, x) java: 例外java.io.IOExceptionは報告されません。スローするには、捕捉または宣言する必要があります

ここで、とりあえず入門者目線でコンパイルを通すために取れる手段は 2 つある。まず、try-catch で捕捉する方法である。なお、ここで捕捉した後の処理には罠が多い点に注意すること (適切な対処については後述する)。

try {

List<String> lines = Files.readAllLines(Paths.get("items.csv"));
// 正常系
...
} catch (IOException e) {
// 異常系
...
}

もしくは、メソッドの throws 節に明示して呼び出し元に委ねる方法である。ただし、この方法が使える場面は現実的には多くない。詳しくは中級者向けの項目で述べる。

public void processItems() throws IOException {

List<String> lines = Files.readAllLines(Paths.get("items.csv"));
// 正常系
...
}


非検査例外

非検査例外とは、検査例外のような呼び出し元での対処が強制されない例外である。例えば、Integer.parseInt(String) メソッドは非検査例外の NumberFormatException を送出する可能性がある (参考: Javadoc) が、コンパイルエラーは出力されない。代わりに実行時にエラーが発生する。

Integer value = Integer.parseInt("ABC"); // 特に何もしなくてもコンパイルは通る

java.lang.NumberFormatException: For input string: "ABC"


検査例外をやり過ごす


RuntimeException にラップして送出する

さて、コンパイルを通すために検査例外を catch したらどうすべきか、入門者レベルではこの RuntimeException にラップして送出する方法だけでも覚えておいてほしい。送出した非検査例外がどうなるかについては、入門者レベルでは詳しく理解する必要はない。とにかく余計なことはしないことだ。

try {

List<String> lines = Files.readAllLines(Paths.get("items.csv"));
// 正常系
...
} catch (IOException e) {
throw new RuntimeException(e); // 非検査例外にラップして送出する
}


より適切な非検査例外にラップして送出する

RuntimeException 一辺倒ではなく、適切な非検査例外にラップして送出できるとなお良い。以下によく使う非検査例外とその非常にざっくりした使いどころを挙げる。

よって実は、上述の RuntimeException にラップしているコードは、UncheckedIOException を使うよう書き直したほうがより適切である。

try {

List<String> lines = Files.readAllLines(Paths.get("items.csv"));
// 正常系
...
} catch (IOException e) {
throw new UncheckedIOException(e); // より適切な非検査例外にラップして送出する
}


初級者向け

ここでは、現場で誰かの指示のもとに開発・運用を行う上で理解しておくべきことについて述べる。


例外処理の禁じ手を知る

上述の通り、とりあえず非検査例外にラップして送出することだけを覚えておけば、多くの場合で最低限に無難なコードは書ける。だが、なぜか Java プログラマ各位は余計なことをしたり肝心なことを忘れたりして、最低限のラインから逸脱しがちである。以下によくある不適切な例外処理を挙げる。くれぐれも真似をしないでほしい。


(禁じ手) 握り潰す

禁じ手の筆頭は握り潰しである。握り潰した結果、本来実行される処理の正しい結果を期待していた後続の処理で問題が発生する。典型的な問題は NullPointerException である。一般的に Java において NullPointerException が悪者扱いされる場面が散見されるが、ここでは何のことはない、NullPointerException ではなくあなた自身のコードが悪いのである。

List<String> lines = null;

try {
lines = Files.readAllLines(Paths.get("items.csv"));
} catch (IOException e) {
// 握り潰す
}
lines.stream().forEach(...); // ここで lines が null のままになっているため NullPointerException が発生する

ただし、意志を持って握り潰すべきケースは少ないながら存在する。その場合はログなりコメントなりでその意志をわざとらしいくらいに明示しておくと良い。


(禁じ手) スタックトレースやログを申し訳程度に出力する

入門書でよく見るのが catch 節で e.printStackTrace() を呼び出して良しとするコードであるが、これは現実世界の Java アプリケーションにおいてはほとんどの場合許容されない。e.printStackTrace() でメッセージを出力しようが、処理を継続すればそれは例外を握り潰しているのとあまり変わらない。また、e.printStackTrace() の出力先は一般的には標準エラー出力であり、ログと違って日時やリクエスト ID のような原因究明に役立つメタ情報が欠落している。このようなコードを放置しておくと、運用時に解決の手がかりがつかめない謎のスタックトレースに悩まされることになる。

List<String> lines = null;

try {
lines = Files.readAllLines(Paths.get("items.csv"));
} catch (IOException e) {
e.printStackTrace(); // 申し訳程度にスタックトレースを出力する
}
lines.stream().forEach(...); // 結局ここで NullPointerException が発生する

上述の e.printStackTrace()log.warn("Could not read items: " + e, e) のようにログ出力に置き換えたところで、出力のメタ情報の問題は解決したとしても、処理を継続すれば例外を握り潰しているのとあまり変わらない点は同じである。


(禁じ手) 自前でロギングしてから再送出する

几帳面な初級者がやってしまいがちなのがこの禁じ手である。自前のログが個別に出力された後、後述する大域の例外ハンドラで再度同じ問題に起因するログが出力される。これらは運用時には別々の問題に関する二種類の例外ログのように見え、混乱を引き起こす。大人の世界では、几帳面なことは無条件に良いことではないのだ。

List<String> lines = null;

try {
lines = Files.readAllLines(Paths.get("items.csv"));
} catch (IOException e) {
log.warn("Could not read items: " + e, e); // 几帳面さが裏目に出る
throw new UncheckedIOException(e);
}
lines.stream().forEach(...);

メッセージを込めたければ、例外メッセージとして送出すれば良い。

List<String> lines = null;

try {
lines = Files.readAllLines(Paths.get("items.csv"));
} catch (IOException e) {
throw new UncheckedIOException("Could not read items", e); // 几帳面さをさりげなくアピールする
}
lines.stream().forEach(...);


(禁じ手) 別の例外を送出する

「非検査例外にラップして送出する」の「ラップ」を忘れて、単に別の非検査例外を送出してしまう誤りも散見される。この場合の悪影響は、運用時に例外の根本原因がわからなくなることだ。例えば、ファイルアクセス時に発生する例外の原因としては、ファイルが存在しないことや、ファイルへのアクセス権がないことなどさまざまな事象が考えられる。こうした根本原因を示す例外をラップし忘れて別の例外を送出した場合、運用時の障害解析は困難になる。

List<String> lines = null;

try {
lines = Files.readAllLines(Paths.get("items.csv"));
} catch (IOException e) {
throw new IllegalStateException("Could not read items"); // 元の例外にあったはずの情報が失われる
}
lines.stream().forEach(...);


(禁じ手) リソースのクローズ処理を忘れる

ファイルやデータベース接続などのリソースのオープン処理を実行した場合、その後で原則としてクローズ処理を行わなければならない (厳密にクローズ処理が必要か否かについては、オープン処理メソッドの Javadoc を参照して判断すること)。クローズ処理を忘れた場合のよくある悪影響としては、俗に言うメモリリークが挙げられる。ただし、多くの場合問題はリリース直後には発覚せず、その後の運用に伴って時限爆弾のように顕在化する。一旦発覚すると、犯人探しや定期再起動のような暫定運用で現場は疲弊することになる。

try {

BufferedReader in = Files.newBufferedReader(Paths.get("items.csv"));
in.lines().forEach(...);
// in がクローズされていない
} catch (IOException e) {
throw new UncheckedIOException(e);
}

代わりに try-with-resources 構文を使おう。

try (BufferedReader in = Files.newBufferedReader(Paths.get("items.csv"))) {

in.lines().forEach(...);
} catch (IOException e) {
throw new UncheckedIOException(e);
}

try-with-resources 構文導入以前は finally 節でクローズ処理が行われていたが、冗長で読みにくく、かつ finally 節でさらに例外が発生した場合の対応など考えるべきことが多く煩雑である。初級者の段階では避けたほうが無難だ。

BufferedReader in = null;

try {
in = Files.newBufferedReader(Paths.get("items.csv"));
in.lines().forEach(...);
} catch (IOException e) {
throw new UncheckedIOException(e);
} finally {
try {
in.close();
} catch (IOException e) {
throw new UncheckedIOException(e);
}
}

参考までに、try-with-resources は、対象クラスが java.io.Closeable インタフェースもしくは java.lang.AutoCloseable インタフェースを実装している場合に限って利用できる。詳しくは公式ドキュメントを参照すること。


(禁じ手) 複数の例外に対して同じ処理を繰り返す

この禁じ手に運用上の実害はほぼないが、やはりコードの可読性が低いのは良くない。

try {

Item item = createItem();
// 正常系
...
} catch (ItemCreationException e) {
throw new IllegalStateException(e);
} catch (InvalidProcessingException e) {
throw new IllegalStateException(e);
}

代わりに multi-catch を使おう。

try {

Item item = createItem();
// 正常系
...
} catch (ItemCreationException | InvalidProcessingException e) {
throw new IllegalStateException(e);
}


非検査例外を防御的に活用する

非検査例外は、「ここにはこんなパラメータしか来ないはず」「この処理は実行されないはず」といった条件を防御的に表現する用途にも活用できる。

よくある適用例として、メソッド冒頭のガード節での不正なパラメータに対する IllegalArgumentException の送出が挙げられる。

public void processItem(Item item) {

if (!item.isValid()) {
// パラメータが不正
throw new IllegalArgumentException("Invalid item: " + item);
}
// 正常系
...
}

上記コードは、GuavaPreconditions を使うと以下のように一行で書ける。大きな声では言えないが、「念のため」レベルの分岐で単体テストカバレッジが下がるのを嫌うのであればこちらの方法でも良いだろう。

public void processItem(Item item) {

Preconditions.checkArgument(item.isValid(), "Invalid item: %s", item);
// 正常系
...
}

同じように、パラメータの null チェックを行う場合も、自前の if 文ではなく、標準クラスライブラリの java.util.Objects.requireNonNull(T) を使うと良い。ちなみに、ここで送出される例外はあの悪名高い NullPointerException である。若干抵抗を感じさせるところではあるが、Guava の類似機能の Preconditions.checkNotNull(T) も同様に NullPointerException を送出している。長い物には巻かれておこう。

public void processItem(Item item) {

Objects.requireNonNull(item, "Item is null");
// 正常系
...
}

さらに、switch 文の通常は到達し得ない default 部分で例外を送出することもある。

Result result = processItem(item);

switch (result) {
case SUCCEEDED:
...
break;
case FAILED:
...
break;
...
default:
throw new IllegalStateException("This would not happen");
}


例外に大域で対応する

ここまでは主に例外を送出する側に焦点を置いて記述してきたが、次に送出された例外をどう処理すべきかについて述べる。


(ほぼ禁じ手) 呼び出し階層最上位で自前の try-catch 処理を実装する

最もシンプルでわかりやすい例外捕捉は、呼び出し階層最上位 (コマンドラインアプリケーションの場合の main メソッドや、Web アプリケーションの場合の Presentation 層 Controller) で最後の砦的な自前の try-catch 処理を書くことである。だが実際にはこの方式を使う機会は少ない。現実世界の Java アプリケーション開発では、main メソッドからフルスクラッチで実装するようなケースはかなりの少数派であり、また Presentation 層 Controller に関しては Web フレームワークにもっと良い代替手段がある。


Web フレームワークの例外処理機構を利用する

モダンな Web フレームワークであれば、発生した例外クラスに応じた例外ハンドラを実行する機構を提供している。こうした機構の利点は、複数 Controller の共通処理を Controller クラスの継承構造を使わずに柔軟に組み合わせられる点である。以下に代表的な Web フレームワークでの例外処理機構を示す。


DI コンテナの Interceptor 機構を利用する

より汎用的な仕組みとして、DI コンテナの Interceptor 機構がある。Interceptor 機構を使えば、DI コンテナの管理下にあるクラスの任意のメソッド呼び出しに対して前後処理を差し込める。以下に代表的な DI コンテナでの Interceptor 機構を示す。

例外に関して Interceptor 機構を利用して実現される典型的な処理はトランザクション制御 (特定のメソッド配下の例外発生時に自動的にロールバックする) である。以下に代表的な DI コンテナでのトランザクション制御機構を示す。


独自の例外を定義する

大域で例外をクラスに応じて処理できるようになると、独自の例外を作りたくなる。例えば特定のビジネスロジックや外部 API アクセスロジックに特化した個別のエラーレスポンスを返したい場合などである。独自の例外を作るには、既存の例外クラスを継承してコンストラクタをオーバーライドすれば良い。

public class ItemCreationException extends RuntimeException {

public ItemCreationException() {
}

public ItemCreationException(String message) {
super(message);
}

public ItemCreationException(Throwable cause) {
super(cause);
}

public ItemCreationException(String message, Throwable cause) {
super(message, cause);
}

public ItemCreationException(String message, Throwable cause, boolean enableSuppression,
boolean writableStackTrace) {
super(message, cause, enableSuppression, writableStackTrace);
}
}

一見するとコード量は多く見えるものの、実際は IDE によって生成されるため、作業は軽微である。例えば Eclipse の場合は、新規クラス生成ダイアログで、スーパークラス名を指定しコンストラクタの生成にチェックを入れるだけである。


中級者向け

ここでは、現場で自身の裁量のもとに開発・運用を行う上で理解しておくべきことについて述べる。


例外ログを適切なレベルで出力して運用する

大域の例外ハンドラで扱う例外については、適切なレベルでログを出力し、日々の運用を回さなければならない。以下にレベルの使い分けと運用に関する方針の一例を示す。これはあくまで一例であり、チームによって方針は異なる。


  • FATAL


    • システム全体に影響するような深刻な例外ログを出力する

    • 発生したら緊急対応が始まる



  • ERROR


    • 発生したら原則としてインシデントとして扱わなければならない例外ログを出力する

    • 発生したらその場で監視警告が飛ぶ

    • インシデント分析後、問題が軽微な場合はログレベルを WARN や INFO に落とす



  • WARN


    • 発生しても対応が必要とは限らない例外ログを出力する

    • 発生頻度が高まったら監視警告が飛ぶ

    • 例外型別の集計を日次や週次でレポートする

    • 定期的にレポートを確認し、問題が無視できる場合はレベルを INFO に落とす



  • INFO


    • 運用上発生しても問題ない例外ログを出力する



  • DEBUG


    • 通常は例外ログ出力には使用しない



  • TRACE


    • 通常は例外ログ出力には使用しない



とはいえ、リリース直後から厳密にルールを適用することは一般的には難しく、実際はフェーズを切って段階的に上記のような運用を整備していくことになる。


例外に個別対応する

現実世界の Java アプリケーションにおいては、大域の例外処理ではなくある機能に特化した例外処理を求められることがある。代表的には以下のようなケースが挙げられる。


別の戻り値を返す

下位層が例外を送出することが設計上適切とは言い難い場合に、例外を捕捉して別の戻り値を返すことがある。例えば以下のコードでは、データベースアクセス時にレコードが存在しないことを示す例外を捕捉して、代わりに Optional な戻り値を返している。

public Optional<Item> findByName(String name) {

try {
Item item = entityManager.createQuery("...", Item.class).setParameter(1, name).getSingleResult();
return Optional.of(item);
} catch (NoResultException e) {
return Optional.empty();
}
}

なお、ここで Optional ではなく null を返す設計を選択してはならない。詳しい理由は 「null 殺す」の Twitter 検索結果を見て察してほしい。


リトライする

外部 API 呼び出しのような処理で、偶発的な通信エラー等を示す例外を捕捉してリトライ処理を実行することがある。ただし、リトライ処理を真面目に実装しようとすると、タイムアウト時間設定、リトライ回数設定、バックオフアルゴリズム、リトライ同時多発時の対処、リトライ発生状況のモニタリング、…など考えるべきことが意外に多い。自前で実装するよりは、Microprofile RetrySpring Retry のようなライブラリを利用したほうが無難だろう。また将来的にはこうしたロジックはアプリケーションの外の Istio のような Service Mesh 機構に巻き取られて行くと予想される。


その他の代替処理を実行する

その他、要件に従って代替処理を実行することがある。処理内容はケースバイケースであるため詳細は省略する。


検査例外を適切に活用する

最後に、ここまで避けていた検査例外の使いどころについて述べる。

まず、低レベルのライブラリやフレームワークについては、例えば java.io パッケージがそうであるように、検査例外を適切に活用すべき場面がある。

また、ビジネスロジックについても、呼び出し元に異常系の処理を強制させたい場合は、検査例外の利用が選択肢になる。以下に検査例外を利用する方式と enum を返す方式の例を示す。検査例外を利用する方式は、メソッドシグネチャがシンプルであり、また例外の継承構造を活用すれば類似処理を共通化できる。一方、enum で返す方式は、呼び出し元が戻り値を網羅的に処理していることがわかりやすい。どちらを選ぶかはケースバイケースである。

public void sendNotification(Member member, Notification notification) throws InvalidMemberException {

// 検査例外を利用する方式
...
}

try {

sendNotification(member, notification);
// 正常系
...
} catch (InvalidMemberException e) {
// 代替処理
...
}

public NotificationResult sendNotification(Member member, Notification notification) {

// enum で返す方式
...
}

NotificationResult result = sendNotification(member, notification);

switch (result) {
case SUCCEEDED:
// 正常系
...
break;
case INVALID_MEMBER:
// 代替処理
...
break;
...
}


おわりに

以上、現実世界の Java アプリケーションの例外処理に関して理解しておくべきことを、入門者向け・初級者向け・中級者向けの 3 段階に分けて示した。この記事によって非生産的なコードレビューやつらいアプリケーション運用が少しでも減ることを願う。