Effizientes Debuggen von Exceptions

Andere Sprachen: English Español Français 日本語 한국어 Português 中文

Auf deiner Reise mit Java ist eines der ersten Konzepte, die du lernst, Exceptions. Dieser Begriff definiert ein unerwartetes Szenario während einer Programmausführung, wie ein Netzwerkausfall oder ein unerwartetes Dateiende. Oder, wie die Oracle-Dokumentation es ausdrückt:

Die Klasse Exception und ihre Unterklassen sind eine Form von Throwable, die Bedingungen anzeigt, die eine vernünftige Anwendung möglicherweise abfangen möchte.

Wenn dein Programm mit einem “Plan B” ausgestattet ist, um diese Situationen effektiv zu handhaben, wird es unabhängig davon, ob eine davon auftritt, reibungslos weiterlaufen. Andernfalls kann das Programm unerwartet abstürzen oder in einen falschen Zustand geraten.

Wenn ein Programm aufgrund einer Exception fehlschlägt, musst du es debuggen. Programmiersprachen erleichtern das Debuggen von Exception-bezogenen Fehlern, indem sie einen Stack-Trace bereitstellen – eine spezielle Nachricht, die auf den Code-Pfad zeigt, der zum Fehler geführt hat. Diese Information ist unglaublich nützlich und manchmal ausreichend; es gibt jedoch Fälle, in denen wir von zusätzlichen Details und Techniken profitieren können.

In diesem Artikel werden wir eine Fallstudie durchgehen, die sich auf das Debuggen einer Exception konzentriert, die beim JSON-Parsen auftritt. Dabei werden wir über das bloße Betrachten von Stack-Traces hinausgehen und die Vorteile der Verwendung des Debuggers entdecken.

Beispielanwendung

Die Beispielanwendung für diesen Anwendungsfall ist ein kleines Java-Programm, das eine Reihe von JSON-Dateien parst, die Daten über Flughäfen weltweit enthalten. Die Dateien enthalten Details wie den IATA-Code der Flughäfen, Land, Breitengrad und Längengrad. Hier ist ein Beispiel eines Eintrags:

{
    "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"
}

Das Programm ist ziemlich einfach. Es iteriert über eine Reihe von Dateien, liest und parst jede einzelne, filtert die Flughafen-Objekte gegen Eingabebeschränkungen wie "country=AR" und gibt dann die Liste der passenden Flughäfen aus:

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) { }
}

Das Problem

Wenn wir das Programm ausführen, schlägt es mit einer NumberFormatException fehl. Hier ist der Stack-Trace, den wir erhalten:

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)

Er zeigt auf Zeile 53 der Airports.java -Datei. Wenn wir uns diese Zeile ansehen, können wir erkennen, dass es ein Problem beim Konvertieren der elevationFt-Eigenschaft in eine Zahl für einen der Flughäfen gibt. Die Exception-Nachricht sagt uns, dass dies daran liegt, dass diese Eigenschaft in der entsprechenden Datei leer ist.

Reicht der Stack-Trace nicht aus?

Obwohl der obige Stack-Trace uns bereits viele Informationen gibt, ist er in mehrfacher Hinsicht begrenzt. Er zeigt auf die Code-Zeilen, die fehlschlagen, aber sagt er uns, welche Datendatei die Ursache ist? Der Name der Datei wäre nützlich, wenn wir sie genauer inspizieren oder die Daten korrigieren möchten. Leider gehen die Laufzeitdetails wie diese verloren, wenn die Anwendung abstürzt.

Es gibt jedoch eine Möglichkeit, die Anwendung anzuhalten, bevor sie beendet wurde, was dir den Zugriff auf Kontext ermöglicht, der nicht in Stack-Traces und Logs erfasst wird. Darüber hinaus kannst du mit einer angehaltenen Anwendung prototypisieren, testen und sogar den Fix laden, während die Anwendung noch läuft.

Bei einer Exception anhalten

Zuerst beginnen wir mit dem Setzen eines Exception-Breakpoints. Du kannst das entweder im Breakpoints-Dialog tun oder indem du direkt im Stack-Trace auf Create breakpoint klickst:

'Breakpoint erstellen'-Taste wird in der Nähe des Stack-Trace in der Konsole angezeigt. 'Breakpoint erstellen'-Taste wird in der Nähe des Stack-Trace in der Konsole angezeigt.

Anstatt auf eine bestimmte Zeile zu zielen, hält dieser Breakpoint-Typ die Anwendung an, kurz bevor eine Exception geworfen wird. Derzeit sind wir an NumberFormatException interessiert.

Eine hervorgehobene Zeile zeigt an, dass der Breakpoint funktioniert hat und das Programm angehalten wurde. Eine hervorgehobene Zeile zeigt an, dass der Breakpoint funktioniert hat und das Programm angehalten wurde.

Also haben wir die Anwendung angehalten, als sie dabei war, die Exception zu werfen. Der Fehler ist bereits aufgetreten, aber die Anwendung ist noch nicht abgesturzt. Wir sind gerade rechtzeitig, also schauen wir, was uns das bringt.

Im Tab Threads gehe zum parse() -Frame:

Auswählen des 'parse()'-Frames im 'Threads'-Tab des Debuggers Auswählen des 'parse()'-Frames im 'Threads'-Tab des Debuggers

Schau dir an, wie viel mehr Informationen wir jetzt haben: wir sehen den Dateinamen, wo der Fehler passiert, den vollständigen Inhalt der Datei, alle nahegelegenen Variablen und natürlich die Eigenschaft, die den Fehler verursacht. Es wird offensichtlich, dass das Problem in der Datei namens 816.json liegt, weil ihr die elevationFt-Eigenschaft fehlt

Wir können jetzt fortfahren, das Problem zu beheben. Je nach unserem Anwendungsfall möchten wir vielleicht einfach die Daten korrigieren oder die Art ändern, wie das Programm den Fehler behandelt. Das Korrigieren von Daten ist einfach, also schauen wir, wie der Debugger uns bei der Fehlerbehandlung helfen kann.

Den Fix prototypisieren

Ausdruck auswerten ist ein großartiges Werkzeug zum Prototypisieren von Änderungen, einschließlich Fixes für deinen bestehenden Code. Genau das, wonach wir suchen. Wähle den gesamten parse() -Methodenkörper aus und gehe zu Run | Debugging Actions | Evaluate Expression.

Tip icon

Wenn du Code auswählst, bevor du den Evaluate-Dialog öffnest, wird das ausgewählte Snippet kopiert, sodass du es nicht manuell eingeben musst.

Wenn wir den Code aus der Methode auswerten, wirft er eine Exception, genau wie er es im Programmcode getan hat:

Die Auswertung des gleichen fehlerhaften Codes im Dialogfeld "Auswerten" führt ebenfalls zu einer NumberFormatException. Die Auswertung des gleichen fehlerhaften Codes im Dialogfeld "Auswerten" führt ebenfalls zu einer NumberFormatException.

Lass uns den Code ein wenig ändern, indem wir ihn ein null speichern lassen, wenn er auf fehlende Daten trifft. Fügen wir auch eine Print-Anweisung hinzu, um den Fehler in den Standard-Fehlerstrom auszugeben:

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);
}

Durch Ausführen des angepassten Codes im Evaluate-Dialog können wir verifizieren, dass dieser Code den Fehler wirklich wie erwartet behandelt. Jetzt setzt er das entsprechende Feld auf null, anstatt die App abstürzen zu lassen:

Nach dem Klicken auf 'Bewerten' für den korrigierten Code zeigt das Ergebnis einen gültigen Rückgabewert an. Nach dem Klicken auf 'Bewerten' für den korrigierten Code zeigt das Ergebnis einen gültigen Rückgabewert an.

Wir können nicht nur testen, dass die Methode zurückkehrt, ohne die Exception zu werfen, sondern auch sehen, wie der Fehlertext in der Konsole aussehen wird:

Die Konsole sagt: 'Fehler beim Parsen der Höhe für die Datei: ./data/816.json'. Die Konsole sagt: 'Fehler beim Parsen der Höhe für die Datei: ./data/816.json'.

Frame zurücksetzen

Ok, wir haben den Code repariert, sodass der Fehler in Zukunft nicht mehr passiert, aber können wir den Fehler selbst rückgängig machen? Tatsächlich können wir das! Der Debugger von IntelliJ IDEA lässt uns den fehlerhaften Frame vom Stack entfernen und die Methode von vorne ausführen.

Klicke auf Reset Frame im Tab Threads:

Zeigen Sie auf das 'Reset frame'-Symbol im 'parse()'-Frame im 'Threads'-Tab. Zeigen Sie auf das 'Reset frame'-Symbol im 'parse()'-Frame im 'Threads'-Tab.
Info icon

Reset Frame setzt nur den internen Zustand eines Frames zurück. In unserem Fall ist die Methode pur, was bedeutet, dass sie nichts außerhalb ihres lokalen Geltungsbereichs ändert, also ist das kein Problem. Andernfalls, wenn Änderungen am globalen Zustand der Anwendung vorgenommen werden, werden diese nicht rückgängig gemacht. Behalte solche Effekte im Hinterkopf, wenn du Funktionen wie Evaluate und Reset Frame verwendest

Fix und Hot-Reload

Nachdem wir den Fehler verworfen und so getan haben, als wäre er nie passiert, können wir den Fix auch zur Laufzeit liefern. Da der Ausführungspunkt derzeit außerhalb der Methode liegt, die wir ändern möchten, und wir die Signatur der Methode nicht geändert haben, können wir die Option Reload Changed Classes verwenden, die ich bereits in einem früheren Beitrag behandelt habe.

Kopiere zunächst den korrigierten Code aus dem Evaluate-Dialog in den Editor. Du kannst den zuvor ausgewerteten Code finden, indem du den Verlauf durchsuchst (Opt+Pfeil runter / Alt+Pfeil runter). Nachdem du den Code in der Airports -Klasse ersetzt hast, kannst du ihn in die laufende JVM laden. Wähle dazu Run | Debugging Actions | Reload Changed Classes.

Ein Hinweis erscheint mit der Meldung, dass die Klassen neu geladen wurden. Ein Hinweis erscheint mit der Meldung, dass die Klassen neu geladen wurden.

Ein Balloon erscheint und bestätigt, dass der Fix seinen Weg in die laufende Anwendung gefunden hat.

Tip icon

Wenn du die aktuelle EAP-Version von IntelliJ IDEA (2024.3 EAP1) verwendest, probiere den neuen Hot-Reload-Button aus, der direkt im Editor erscheint:

Ein Popup erscheint in der oberen rechten Ecke des Editors und fordert dazu auf, die Dateien neu zu laden. Ein Popup erscheint in der oberen rechten Ecke des Editors und fordert dazu auf, die Dateien neu zu laden.

Filter für nicht abgefangene Exceptions

Wenn wir die Anwendung jetzt fortsetzen, wird sie erneut bei derselben Exception angehalten. Warum passiert das?

Wir haben den Code so modifiziert, dass er NumberFormatException abfängt. Dieser Fix verhindert Anwendungsabstürze, aber er verhindert nicht, dass die Exception geworfen wird. Also feuert der Breakpoint jedes Mal, wenn die Exception ausgelöst wird, obwohl sie letztendlich abgefangen wird.

Lass uns IntelliJ IDEA mitteilen, dass wir die Anwendung nur anhalten möchten, wenn eine nicht abgefangene Exception auftritt. Klicke dazu mit der rechten Maustaste auf den Breakpoint in der Gutter und deaktiviere das Kontrollkästchen Caught exception:

Das Kontrollkästchen 'Ausnahme abfangen' im Dialogfeld 'Ausnahme-Breakpoint', das sich öffnete, als Sie auf das Breakpoint-Symbol im Gutter klickten, wurde nicht ausgewählt. Das Kontrollkästchen 'Ausnahme abfangen' im Dialogfeld 'Ausnahme-Breakpoint', das sich öffnete, als Sie auf das Breakpoint-Symbol im Gutter klickten, wurde nicht ausgewählt.
Tip icon

Das Exception-Breakpoint-Symbol erscheint nur in der Gutter, wenn die Anwendung bei einer Exception angehalten ist. Alternativ kannst du Breakpoints über Run | View Breakpoints konfigurieren.

Mit dieser Einstellung wird die Anwendung ununterbrochen laufen, es sei denn, eine nicht behandelte NumberFormatException tritt auf.

Fortsetzen und genießen

Wir können die Anwendung jetzt fortsetzen:

Zeigen Sie auf die Schaltfläche "Programm fortsetzen" in der Symbolleiste des Debuggers. Zeigen Sie auf die Schaltfläche "Programm fortsetzen" in der Symbolleiste des Debuggers.

Sie lauft einwandfrei und benachrichtigt uns uber die fehlenden Daten in der Konsole. Beachte, wie 816.json in der Fehlerliste ist, was bestatigt, dass wir diese Datei tatsachlich verarbeitet haben und nicht einfach ubersprungen haben.

Die Konsole zeigt die Anwendungsausgabe zusammen mit der Liste der Fehler an. Die Konsole zeigt die Anwendungsausgabe zusammen mit der Liste der Fehler an.

Tatsachlich gibt es zwei Eintrage fur 816.json – einen von unserem Ausdrucksauswertungs-Experiment, und den anderen, der vom Programm selbst dort platziert wurde, nachdem wir die parse() -Methode repariert und erneut ausgefuhrt haben.

Zusammenfassung

In diesem Beitrag haben wir gelernt, wie man Java-Exceptions effizient debuggt, indem man zusatzlichen Kontext sammelt, der auf die Ursache des Fehlers zeigt. Dann konnten wir mit den Fahigkeiten des Debuggers den Fehler ruckgangig machen, die Debug-Sitzung in eine Sandbox zum Testen eines Fixes verwandeln und ihn zur Laufzeit liefern, mitten im Absturz, alles ohne die Anwendung neu zu starten.

Diese einfachen Techniken konnen sehr machtig sein und dir in bestimmten Situationen viel Zeit sparen. In den kommenden Beitragen werden wir weitere Debugging-Tipps und -Tricks besprechen, also bleib dran!

all posts ->