🔬

その例外、いつキャッチするの?

2023/11/03に公開
2

はじめに

最近、若手のコードレビューをしていて例外の使い方を教える機会があったので、ブログの方にもまとめたいと思います。今回はバッチ編。オンラインだとまた少し違う観点があると思います。また、言語はJavaを前提していますが考え方は例外機構をもつ言語ならあまり変わりません。

TL;DR

  • 例外は原則キャッチしない。バッチは速やかに殺せ
  • 個別箇所でログを出さずに必要な業務情報はExceptionを入れ子にして乗せる
  • 長いバッチのためにはスキップもやむなし

原則、例外はキャッチしない

JavaにはErrorとExceptionが存在し、OutOfMemoryErrorとかプログラム上ではどうしようもないものがエラー、ファイルが存在しない(FileNotFoundException)とかプログラム側でハンドリングするもの、と教科書では習うと思います。なのでException系はキャッチするものと、と思っている人もいますよね。例えばこんなサンプルコード。

try (var in = new FileInputStream(new File("no-file"))) {
    in.read(); // なんか必要なビジネスロジックを書く
}catch(FileNotFoundException ex){
    System.out.println("ファイルが見つかりませんでした");
    System.exit(-1);
}

実行結果は以下の通り。

ファイルが見つかりませんでした

学校の授業とかではこの書き方をすることは多いかもしれません。しかし実サービスにおいてバッチの開発/運用でこの書き方はお勧めできません。では、どう書くか? 私なら以下のように書きます。

try (var in = new FileInputStream(new File("no-file"))) {
    in.read(); // なんか必要なビジネスロジックを書く
}

そう、そもそもキャッチとか要りません。この方が断然運用しやすいのです。まず最初のコードの最大の問題はスタックトレースが出ないことです。修正版のコードの実行結果は以下の通りです。

Exception in thread "main" java.io.FileNotFoundException: no-file (No such file or directory)
	at java.base/java.io.FileInputStream.open0(Native Method)
	at java.base/java.io.FileInputStream.open(FileInputStream.java:213)
	at java.base/java.io.FileInputStream.<init>(FileInputStream.java:152)
	at dev.nklab.example.MyTest.main(MyTest.java:19)

Javaのスタックトレースはとても雄弁なので、ただ例外をキャッチしないだけで読み込みに失敗したファイル名何行目でエラーが発生したかも即座に確認できます。トラブルシュートの時にスタックトーレスがなく 「ファイルが見つかりません」 だけ見ると非常にがっかりした気持ちになりますね。開発者への呪詛の言葉を吐いてしまいそうになる程度には。

キャッチしてE002みたいなエラーコードだけ出してスタックトレースを出さないアプリも同罪です。ユーザ向けには内部詳細を出さずにそのようなエラーコードを出す設計は正しいですが、エンジニアがトラブルシュートのために見るログにはなるべく詳細情報を出すべきです。

「ErrorではなくExceptionに関してはハンドリングしろ」 と言われているのに何もしない のは気が引けますか? いえ、バッチは問題があったら速やかに殺す 。これが エラーハンドリングの基本形なので、何もしないのが最適解です。

業務情報を埋め込むために例外をキャッチ&スロー

原則、キャッチしないと先ほど言いました。これはスタックトレースを握りつぶされないためですね。ただ、握りつぶされるより万倍マシなのですが、デフォルトのスタックトレースだけでは業務情報が分かりません。例えばファイルを読み込んでいて何行目でエラーになったとかそういう情報ですね。これはトラブルシュートに非常に有効な情報です。これを理由に個別にエラーをログ出力しているケースを見かけます。例えばこんなコード。

例外が発生したそれぞれの箇所でキャッチして、どこまでデータを処理をしたのか、という情報とスタックトレースを標準出力しています。本来はLoggerとか使う場合も多いですがそこは割愛。

var names = List.of("Nanoha.Takamachi", "Vivio.Takamachi", "Fate");
var upperNames = new ArrayList<String>();
try {
    for (var name : names) {
        upperNames.add(name.split("\\.")[1].toUpperCase());
    }
} catch (Exception ex) {
    System.out.println("upperNames.size=" + upperNames.size());
    ex.printStackTrace();
    System.exit(1);
}
var lowerNames = new ArrayList<String>();
try {
    for (var name : names) {
        lowerNames.add(name.split("\\.")[1].toLowerCase());
    }
} catch (Exception ex) {
    System.out.println("lowerNames.size=" + lowerNames.size());
    ex.printStackTrace();
    System.exit(1);
}

この実行結果は以下のようになります。データを2つ目まで読んで3つ目で失敗している事が分かりますし、スタックトレースも出ているので、即座に原因が分かりますね? 一見問題ありません。

upperNames.size=2
java.lang.ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1
	at dev.nklab.example.MyTest.main(MyTest.java:24)

たしかに悪くはないのですが、この書き方だとそれぞれのところにログの出力が散らかって保守がしづらくなります。あと何度も書くので以下のような邪悪なコードを誤って書いてしまう可能性が高まります。

 catch (Exception ex) {
    System.out.println("upperNames.size=" + upperNames.size());
    ex.printStackTrace();
}

こういう時、Javaでは例外を入れ子にして伝播出来るので以下のようにメッセージを追加して上にスローします。Catchしている部分を以下のように変更してみましょう。

....
} catch (Exception ex) {
    throw new RuntimeException("upperNames.size=" + upperNames.size(), ex);
}

今回はRuntimeExceptionでラップしてエラーメッセージとして先ほど標準出力した値を詰め、上にスローしています。この場合の実行結果は以下です。

Exception in thread "main" java.lang.RuntimeException: upperNames.size=2
	at dev.nklab.example.MyTest.main(MyTest.java:27)
Caused by: java.lang.ArrayIndexOutOfBoundsException: Index 1 out of bounds for length 1
	at dev.nklab.example.MyTest.main(MyTest.java:24)

RuntimeExceptionに先ほど詰めた業務情報が載っていますね。スタックトレース元の例外情報が入ってるので、どこでエラーになったかを辿る事が出来ます。行数とか以外にもIDとかキーになるような情報を入れておくのがおすすめです。

今回のサンプルコードではロジック同士が近いのでまとめてキャッチしたら? という気持ちになりますが、メソッドやクラスが分かれていると必要な業務情報を取り出せる場所は限られてくるので、例外を入れ子にしてメッセージを埋め込み、上にそのままエスカレーションする必要性が増してきます。

また、今回はRuntimeExceptionにしましたが必要に応じて個別のより適切な既存例外か、SystemExceptionとかBusinessExceptionのような独自例外でくくるパターンもあるかと思います。

データをスキップするために例外をキャッチ

先ほど、バッチは問題があったら速やかに殺せ、と言いました。おかしいまま動いてデータの整合性が取れなくなると不味いですからね。しかしバッチの中には数十分から数時間動くジョブもしばしばあります。なので、例えばデータ読み込み不正なデータ行があるとかある程度内容が分かっていて、かつ後から個別修復(データパッチなど) を当ても業務影響が無いものに関してはその行をスキップするという事もよくやります。
この場合は、意図的に例外を握りつぶしたっぽい挙動にするわけですが、この書き方をスキップさせたい時以外にはしないように注意をしてください。

try (var br = Files.newBufferedReader(Path.of("pom.xml"), StandardCharsets.UTF_8)) {
    int procCount = 0;
    int skipCount = 0;
    for (var line = br.readLine(); line != null; line = br.readLine()) {
        try {
            parse(line);
            procCount++;
        } catch (CharacterCodingException ex) {
            skipCount++;
            System.out.println("WARN: Skip: line=" + line);
        }
    }
    System.out.println("End Job, proc=" + procCount + ", skip=" + skipCount);
}

この場合、重要なのはcatchするのはExceptionとか大本のクラスでは無く、可能な限り限定された例外を指定することです。いつもの問題と思ってスキップしてたら別の問題だったというケースもあり得るので、注意をしましょう。
あと、余談ですが処理した数とスキップした数をログに出すと良いです。

オマケ1: よく見るPONな例外ハンドリング

例外ハンドリングでついやってしまいがちなエラーハンドリングを紹介しましょう。

まずは一番多そうなcatchをしてスタックトレースに吐くだけのコード。例外握りつぶす系ですね。これ当たり前なのですがプログラムは異常終了しません。たんにログにエラーを吐くだけで処理自体は正常終了します。そのため手で実行してるとかならさておきジョブスケジューラ等でabend(異常終了)が検知できず、動いてはいけない後続処理が動くとかしばしばあります。先ほども紹介したけどこれは 「ダメ、ゼッタイ!」 なコードなので注意してください。

} catch (Exception ex) {
    ex.printStackTrace();
}
....

続いてよく見るのがException#getMessage()をログに出力しようとしているケース。

} catch (Exception ex) {
    System.out.println(ex.getMessage());
    System.exit(1);
}

多くの場合、スタックトレースとかがgetMessageでとれると勘違いしてそうなのですが、これはPG上で明示的に入れた値が入ってるだけなので標準ではNULLとかが返ってきます。この記事に書いてるようにキー情報とかが入ってることもあるので必ずしも間違いではないのですが、スタックトレースはそれはそれで出して欲しいのと、経験上出力結果を勘違いしている人が多いので注意しましょう。

オマケ2: スタックトレースとアラート

以前、ログは機械が読めるように書くべしという話をしたのですが、実はJavaのスタックトレースは複数行のため大変相性が悪いのです...
というわけで、それを何とかするための簡単なハックを紹介します。

} catch (Exception ex) {
    String msg;
    try (var sw = new StringWriter()) {
        try (var pw = new PrintWriter(sw)) {
            ex.printStackTrace(pw);
        }
        msg = sw.toString().replaceAll("\n", "<br>");
    }
    System.out.println(msg);
}

こんな感じでスタックトレースを出力するprintStackTraceですが、実はPrintWriterを引数にとる事で標準出力以外にする事が出来ます。なのでStringWriterベースのPrintWiterを使うと文字列として取得し、改行を適当な文字列(この場合は<br>)に変換しています。これで例外のスタックトレースが1行になるので、監視ツールフレンドリーになるというわけですね。

まあ、賢いロガーを使えばこの手の機能に対応をしているものもあると思うのですが、手軽に対応できるので覚えておくと便利です。

(追記)オマケ3: 検査例外のラップ

コメントで指摘があったので、Javaの検査例外と非検査例外に関しても少し。JavaではRuntimeExceptionを継承していないExceptionは検査例外なのでcatchかthrowをしないとコンパイルエラーになります。正直これ、コンパイラのWARNでビルド時の警告くらいならまだしも毎回出るのは普通にめんどう。というわけで、おすすめの対応を2つ。

まずは、シンプルに原則メソッドにはthrows Exceptionをつける。正直ダサいし、Lambda使う時とか弊害が出なくもないのだけど、特に小規模だとワークするので小さなアプリをパパッと作る時には悪くない。バッチは速やかに殺せポリシーだと、エラー毎に振る舞いが違わない、というか仮にそういうのがあったら発生箇所で処理したあとだろうしね。

次がRuntimeExceptionとかUncheckedIOExceptionでラッピングする方法。特にストリームAPIとは相性悪いのでどこで使われるか分からないライブラリ系は少なくともこの実装がおすすめ。

try {
	throw new IOException();
} catch (IOException ex) {
	throw new UncheckedIOException(ex);
}

もちろん想定しえる異常を明示する意味でJavaの標準例外を適切に使い分けて処理することも有用なのですが、速やかに殺せポリシーだと上に伝搬するところではラップしてしまうのは一つの手ですね。同じJVM系の後発のScalaやKotlinも検査例外の考え方は廃しているので、理念はともかく実務的には生産性と品質のトレードオフが割に合わない、と判断されてきた歴史かとも思います。

まとめ

とりあえず良くある例外ハンドリングを書きました。結構若手のレビューでは指摘しがちな箇所なので、同じようにハマる人もいるんじゃないかなー、と思います。
DBのロールバックとリランの話もしようかと思いましたが力尽きたのでこのくらいで。

それではHappy Hacking!

Ref

https://zenn.dev/koduki/articles/7eb90f3d0bed88
https://qiita.com/koduki/items/e90ee1fea5aa75071d95

Discussion

いのしーいのしー
 throw new RuntimeException("upperNames.size=" + upperNames.size(), ex);

Exceptionのclassを変えたくない場合は、addSuppressedで追加してます。

ex.addSuppressed(new RuntimeException("upperNames.size=" + upperNames.size())
kodukikoduki

なるほど。確かにその書き方の方がスマートな感じがしますね。ありがとうございます!