効率的なデバッグ例外

他の言語: English Español 한국어 Português 中文

Javaとの調教で、最初に学ぶべき概念の1つは exceptions(例外)です。この用語は、プログラムの実行中に予期せぬシナリオを定義します。 たとえば、ネットワークの失敗や予期せぬファイルの終わりなどです。 また、Oracleのドキュメンテーションによると:

Exception クラスとその下位クラスは、適切なアプリケーションが捕捉したいと思う状態を示す形式の Throwable です。

プログラムがこれらの状況を効果的に処理する「プランb」を装備している場合、 それらのいずれかが発生してもスムーズに動作を続けます。 そうでなければ、プログラムは予期せぬクラッシュを起こすか、不正な状態になる可能性があります。

プログラムが例外のために失敗した場合、デバッグする必要があります。 プログラミング言語は、例外関連のエラーのデバッグを容易にするために、 スタックトレースという特殊なメッセージを提供しています。これは、失敗につながったコードパスを指しています。 この情報は非常に有用であり、時には十分であることがあります。 しかし、追加の詳細や技術が役立つ場合もあります。

この記事では、JSONパース中に発生する例外をデバッグする ケーススタディを通じて、スタックトレースの見方だけでなく、 デバッガーの使用の利点を発見することに焦点をあてていきます。

サンプルアプリケーション

このユースケースのサンプルアプリケーションは、 世界中の空港に関するデータが含まれた一連のJSONファイルを解析する小さなJavaプログラムです。 ファイルには、空港のIATAコード、国、緯度、経度などの詳細が含まれています。以下は、エントリの例です:

{
    "iso_country": "AR",
    "iata_code": "MDQ",
    "longitude_deg": "-57.5733",
    "latitude_deg": "-37.9342",
    "elevation_ft": "72",
    "name": "Ástor Piazzola International Airport",
    "municipality": "Mar del Plata",
    "iso_region": "AR-B"
}

プログラムは非常にシンプルです。一連のファイルを走査し、 それぞれを読み取ってパースし、空港オブジェクトを 入力制限に対してフィルタリングします。例えば、"country=AR"といった制限です。その後、一致する空港のリストを出力します:

public class Airports {

    static Path input = Paths.get("./data");
    static Gson gson = new Gson();
    static {
        System.setOut(new PrintStream(System.out, true, StandardCharsets.UTF_8));
    }

    public static void main(String[] args) throws IOException {

        List<Predicate<Airport>> filters = new ArrayList<>();

        for (String arg : args) {
            if (arg.startsWith("country=")) {
                filters.add(airport -> airport.isoCountry.equals(arg.substring("country=".length()))) ;
            } else if (arg.startsWith("region=")) {
                filters.add(airport -> airport.isoRegion.equals(arg.substring("region=".length()))) ;
            } else if (arg.startsWith("municipality=")) {
                filters.add(airport -> airport.municipality.equals(arg.substring("municipality=".length()))) ;
            }
        }

        try (Stream<Path> files = Files.list(input)) {
            Stream<Airport> airports = files.map(Airports::parse);
            for (Predicate<Airport> f : filters) {
                airports = airports.filter(f);
            }
            airports.forEach(System.out::println);
        }
    }

    static Airport parse(Path path) {
        try {
            JsonObject root = gson.fromJson(Files.readString(path), JsonObject.class);
            String name =           root.get("name").getAsString();
            String isoCountry =     root.get("isoCountry").getAsString();
            String iataCode =       root.get("iataCode").getAsString();
            String longitudeDeg =   root.get("longitudeDeg").getAsString();
            String latitudeDeg =    root.get("latitudeDeg").getAsString();
            String municipality =   root.get("municipality").getAsString();
            String isoRegion =      root.get("isoRegion").getAsString();
            Integer elevationFt =   Integer.parseInt(root.get("elevationFt").getAsString());
            return new Airport(name, isoCountry, iataCode, longitudeDeg, latitudeDeg, elevationFt, municipality, isoRegion);
        } catch (IOException e) {
            throw new RuntimeException(e);
        }
    }

    record Airport(String name, String isoCountry, String iataCode, String longitudeDeg, String latitudeDeg, Integer elevationFt, String municipality, String isoRegion) { }
}

問題

プログラムを実行すると、 NumberFormatException によってエラーが発生します。 以下が得られるスタックトレースです:

Exception in thread "main" java.lang.NumberFormatException: For input string: ""
	at java.base/java.lang.NumberFormatException.forInputString(NumberFormatException.java:67)
	at java.base/java.lang.Integer.parseInt(Integer.java:672)
	at java.base/java.lang.Integer.parseInt(Integer.java:778)
	at dev.flounder.Airports.parse(Airports.java:53)
	at java.base/java.util.stream.ReferencePipeline$3$1.accept(ReferencePipeline.java:197)
	at java.base/java.util.Iterator.forEachRemaining(Iterator.java:133)
	at java.base/java.util.Spliterators$IteratorSpliterator.forEachRemaining(Spliterators.java:1939)
	at java.base/java.util.stream.AbstractPipeline.copyInto(AbstractPipeline.java:509)
	at java.base/java.util.stream.AbstractPipeline.wrapAndCopyInto(AbstractPipeline.java:499)
	at java.base/java.util.stream.ForEachOps$ForEachOp.evaluateSequential(ForEachOps.java:151)
	at java.base/java.util.stream.ForEachOps$ForEachOp$OfRef.evaluateSequential(ForEachOps.java:174)
	at java.base/java.util.stream.AbstractPipeline.evaluate(AbstractPipeline.java:234)
	at java.base/java.util.stream.ReferencePipeline.forEach(ReferencePipeline.java:596)
	at dev.flounder.Airports.main(Airports.java:39)

これは、 Airports.java ファイルの53行目を指しています。 この行を見ると、いずれかの空港のelevationFtプロパティを数値に変換する際に問題があることがわかります。 例外のメッセージから、このプロパティが対応するファイルでは空白になっているため、ということがわかります。

スタックトレースだけでは十分ではない?

上記のスタックトレースはすでに多くの情報を提供していますが、それにはいくつかの限界があります。 それは失敗するコードの行を指し示しますが、それが原因となるデータファイルは示していますか? ファイル名は、それをより詳しく調査したり、データを修正したりする場合に便利です。 残念ながら、これらのランタイム詳細はアプリケーションがクラッシュすると失われます。

だからと言って、アプリケーションが終了する前にアプリケーションを一時停止する方法はあります。 これによって、スタックトレースやログに記録されていないコンテキストにアクセスすることができます。 さらに、アプリケーションが停止した状態では、プロトタイピング、テスト、 そして修正をロードすることができます。アプリケーションがまだ動いている間にです。

例外での一時停止

まず、例外ブレークポイントの設定を開始しましょう。 これは、ブレークポイントダイアログや、 スタックトレースでCreate breakpointをクリックすることで行うことができます:

'Create breakpoint'ボタンがコンソールのスタックトレースの近くに表示されます 'Create breakpoint'ボタンがコンソールのスタックトレースの近くに表示されます

このタイプのブレークポイントは、特定の行をターゲットにするのではなく、 例外がスローされる直前に、アプリケーションを一時停止します。 現在、私たちが関心を持っているのは NumberFormatException です。

ブレークポイントが機能し、プログラムが一時停止したことを示す強調表示された行 ブレークポイントが機能し、プログラムが一時停止したことを示す強調表示された行

したがって、例外がスローされる予定だったところでアプリケーションを一時停止しました。 エラーはすでに発生していますが、アプリケーションはまだクラッシュしていません。 我々はちょうど間に合っています、だからこれが何をもたらしてくれるか見てみましょう。

スレッド (Threads)タブで、 parse() フレームに移動します。

デバッガーの 'Threads' タブで 'parse()' フレームを選択する デバッガーの 'Threads' タブで 'parse()' フレームを選択する

これで、私たちが持っている情報がどれだけ多いかを見てみましょう: エラーが発生しているファイル名、 ファイルの完全な内容、すべての近接する変数、 そしてもちろん、エラーを引き起こしているプロパティが見えます。 問題は816.jsonというファイルにあることが明らかで、それはelevationFtプロパティが欠けているからです。

これで、問題を修正することができます。使用ケースによっては、データを修正するだけでいいかもしれないし、 プログラムがエラーを処理する方法を修正するかもしれません。 データの修正は直感的なので、デバッガーがエラーハンドリングにどのように役立つかを見てみましょう。

修正をプロトタイプ化する

expression の評価は、 既存のコードに対する修正など、変更をプロトタイピングするための素晴らしいツールです。 まさに我々が探していたものです。 parse() メソッド本体全体を選択し、 実行 (Run) | デバッグアクション (Debugging Actions) | 式の評価 (Evaluate Expression)に移動します。

Tip icon

体験の開始 (Evaluate)ダイアログを開く前にコードを選択しておくと、 選択したスニペットがコピーされるので、手動で入力する必要がありません。

私たちがメソッドのコードを評価すると、例外がスローされます。 正確には、プログラムのコードでは例外がスローされます:

評価ダイアログで同じ失敗したコードを評価すると、NumberFormatExceptionも発生します 評価ダイアログで同じ失敗したコードを評価すると、NumberFormatExceptionも発生します

コードを少し変更して、データが欠けているときに null を保存するようにしましょう。 また、エラーを標準エラー出力に出力するための print 文を追加します:

try {
    JsonObject root = gson.fromJson(Files.readString(path), JsonObject.class);
    String name =           root.get("name").getAsString();
    String isoCountry =     root.get("isoCountry").getAsString();
    String iataCode =       root.get("iataCode").getAsString();
    String longitudeDeg =   root.get("longitudeDeg").getAsString();
    String latitudeDeg =    root.get("latitudeDeg").getAsString();
    String municipality =   root.get("municipality").getAsString();
    String isoRegion =      root.get("isoRegion").getAsString();
    Integer elevationFt;
    try {
        elevationFt = Integer.parseInt(root.get("elevationFt").getAsString());
    } catch (NumberFormatException e) {
        elevationFt = null;
        System.err.println("Failed to parse elevation for file: " + path);
    }
    return new Airport(name, isoCountry, iataCode, longitudeDeg, latitudeDeg, elevationFt, municipality, isoRegion);
} catch (IOException e) {
    throw new RuntimeException(e);
}

体験の開始 (Evaluate)ダイアログで調整したコードを実行することで、 このコードが想定通りにエラーを処理することを確認することができます。 今度はアプリケーションをクラッシュさせるのではなく、対応するフィールドをnullに設定します。

修正されたコードに対して「評価」をクリックした後、結果には有効な戻り値が表示されます 修正されたコードに対して「評価」をクリックした後、結果には有効な戻り値が表示されます

メソッドが例外をスローせずに返ることをテストできるだけでなく、 エラーテキストがコンソールにどのように表示されるかも確認できます。

コンソールには 'Failed to parse elevation for file: ./data/816.json' と表示されます コンソールには 'Failed to parse elevation for file: ./data/816.json' と表示されます

フレームのリセット

さて、私たちはエラーを修正して、これからはエラーが発生しないようにしましたが、 エラー自体を元に戻すことができるでしょうか?実際には可能です! IntelliJ IDEAのデバッガーは、エラーの発生したフレームをスタックからポップオフして、メソッドを最初から実行することができます。

スレッド (Threads)タブでフレームのリセット (Reset Frame)をクリックします。

'Threads' タブで 'parse()' フレームの 'Reset frame' アイコンを指しています 'Threads' タブで 'parse()' フレームの 'Reset frame' アイコンを指しています
Info icon

フレームのリセット (Reset Frame)はフレームの内部状態だけを巻き戻します。 今回のケースでは、メソッドは純粋であり、ローカルのスコープの外側で何も変更していないため、これは問題ではありません。 しかし、グローバルなアプリケーションの状態に変更が加えられた場合、 これらは元に戻されません。 体験の開始 (Evaluate)やフレームのリセット (Reset Frame)のような機能を使用するときは、そのような効果を考慮に入れてください。

修正とホットリロード

エラーを捨てて、まるでそれが存在しなかったかのように pretending した後、ランタイムに修正を配送することもできます。現在の実行ポイントが変更したいメソッドの外側にあり、メソッドのシグネチャを変更していないため、変更したクラスの再ロード (Reload Changed Classes)オプションを使用することができます。この機能は以前の投稿ですでに取り上げています。

まず、修正したコードを体験の開始 (Evaluate)ダイアログからエディターにコピーします。 以前に評価したコードは、履歴(⌥↓ / Alt↓)を参照することで見つけることができます。 Airports クラスのコードを入れ替えた後、それを実行中のJVMにロードすることができます。 これには、実行 (Run) | デバッグアクション (Debugging Actions) | 変更したクラスの再ロード (Reload Changed Classes)を選択します。

クラスがリロードされたことを示すバルーンが表示されます クラスがリロードされたことを示すバルーンが表示されます

それがランニング中のアプリケーションに修正が適用されたことを確認するバルーンが表示されます。

Tip icon

IntelliJ IDEAの現在のEAPバージョン(2024.3 EAP1)を使用している場合は、 エディタの中に現れる新しいホットリロードボタンを試してみてください:

エディタの右上隅にポップアップが表示され、ファイルのリロードを促します エディタの右上隅にポップアップが表示され、ファイルのリロードを促します

未処理の例外フィルタ

現在、アプリケーションを再開すると、同じ例外で再び中断されます。どうしてこんなことが起こるのでしょうか?

我々はコードを修正して NumberFormatException を捕捉するようにしました。 この修正はアプリケーションのクラッシュを防ぎますが、例外のスローを防ぐわけではありません。 したがって、ブレークポイントは例外が発生するたびに発火します、たとえそれが最終的に捕捉されるとしても。

未処理の例外が発生した場合にのみアプリケーションを一時停止するようにIntelliJ IDEAに指示しましょう。 これには、ガターのブレークポイントを右クリックし、捕捉した例外 (Caught exception)のチェックボックスをクリアします:

'Exception breakpoint' ダイアログ内の 'Caught exception' チェックボックスが未選択です。このダイアログは、グーテル内のブレークポイントアイコンをクリックすると表示されます 'Exception breakpoint' ダイアログ内の 'Caught exception' チェックボックスが未選択です。このダイアログは、グーテル内のブレークポイントアイコンをクリックすると表示されます
Tip icon

例外ブレークポイントのアイコンは、アプリケーションが例外で中断されているときにのみガターに表示されます。 また、ブレークポイントは実行 (Run) | ブレークポイントの表示 (View Breakpoints)を通じて設定することもできます。

この設定では、未処理の NumberFormatException が発生した場合にのみ、アプリケーションが中断されます。

再開して楽しむ

これで、アプリケーションの再開が可能になります。

デバッガーツールバーの「プログラムを再開」ボタンを指しています デバッガーツールバーの「プログラムを再開」ボタンを指しています

それはただの罰金で、コンソールで欠けているデータについて確認できます。 エラーリストに816.jsonがあることに注意してください。これは、私たちは実際にこのファイルを処理したことを確認するためです。

コンソールには、アプリケーションの出力とエラーリストが表示されます コンソールには、アプリケーションの出力とエラーリストが表示されます

実際には、816.jsonには2つのエントリがあります。1つはexpression evaluationの実験から、もう1つはプログラム自体が parse() メソッドを修正して再実行した結果です。

まとめ

この投稿では、Javaの例外を効率的にデバッグする方法について学び、その方法は、 失敗の原因を指し示す追加のコンテキストを収集します。その後、デバッガーの機能を利用して、 エラーを元に戻し、デバッグセッションを修正のテスト用のサンドボックスに変え、 クラッシュの最中にランタイムにその修正を提供することを可能にします、 すべてアプリケーションを再起動せずにです。

これらのシンプルな技術は、特定の状況で非常に強力で、時間を大幅に節約することができます。 これからの投稿では、さらに多くのデバッグのヒントやコツを紹介していきますので、お楽しみに!

all posts ->