Best Practices für Exception Handling in Java
Praktische Regeln für Exception Handling in Java — schnell scheitern, richtigen Typ werfen, Exceptions nie verschlucken und sinnvoll protokollieren.
Die vorherigen Kapitel behandelten die Mechanismen — try, catch, finally, throw, throws, benutzerdefinierte Klassen. Dieses Kapitel befasst sich mit der Urteilsseite. Zwei Programme können dieselben Konstrukte verwenden, und das eine ist robust, während das andere fragil ist. Der Unterschied liegt in einer kleinen Menge von Gewohnheiten, die sich mit der Übung zu Reflexen entwickeln.
Bei ungültiger Eingabe sofort scheitern
Wenn eine Methode mit Argumenten aufgerufen wird, die sie nicht erfüllen kann, sofort werfen:
public void send(String to, String body) {
if (to == null || to.isBlank()) {
throw new IllegalArgumentException("to must be non-blank");
}
if (body == null) {
throw new NullPointerException("body");
}
// ...real work
}Versuchen Sie nicht, mit null "das Beste zu tun". Der Fehler liegt beim Aufrufer, und je näher die Exception daran landet, desto einfacher ist sie zu beheben. Objects.requireNonNull(body, "body") ist der standardmäßige Einzeiler für den Null-Fall.
Die entgegengesetzte Gewohnheit — fehlende Eingaben stillschweigend durch Standardwerte zu ersetzen — führt zu Fehlern, die fünf Ebenen entfernt auftauchen, ohne Hinweis darauf, wer was übergeben hat.
Den spezifischsten passenden Typ werfen
Exception ist selten das Richtige zum Werfen, und RuntimeException nur dann, wenn kein spezifischerer Typ passt. Die Standardbibliothek gibt Ihnen ein Vokabular — nutzen Sie es:
- Ungültiges Argument →
IllegalArgumentException - Null-Argument →
NullPointerException(Objects.requireNonNull) - Falscher Zustand →
IllegalStateException - Operation nicht unterstützt →
UnsupportedOperationException - Index außerhalb des Bereichs →
IndexOutOfBoundsException - Zahl außerhalb des Bereichs →
ArithmeticExceptionoderIllegalArgumentException
Für domänenspezifische Fehler schreiben Sie eine benutzerdefinierte Exception, anstatt eine eingebaute wiederzuverwenden.
Den spezifischsten Typ fangen, für den Sie einen Plan haben
Die symmetrische Regel. catch (Exception e) ist ein Code-Geruch. Es fängt Programmierfehler (NullPointerException, IllegalStateException) und behebbare Fehler (IOException) sowie unbekannte Bibliotheks-Exceptions alle in einem Eimer — und behandelt sie fast immer identisch, was fast immer falsch ist.
// Bad — what does this even handle?
try { complex(); }
catch (Exception e) { log("failed"); }
// Better — specific cases get specific responses
try { complex(); }
catch (IOException e) { retryLater(); }
catch (ParseException e) { recordCorruptInput(e); }Wenn Sie wirklich nicht wissen, was mit einer Klasse von Exceptions zu tun ist, lautet die Antwort: fangen Sie sie nicht. Lassen Sie sie zu einem Handler propagieren, der es weiß.
Exceptions niemals stillschweigend verschlucken
Das schlimmste Exception-Handling-Muster überhaupt:
try { doWork(); }
catch (Exception e) { } // never write thisWenn der unvermeidliche Produktionsfehler auftritt, gibt es keinen Stack-Trace, keine Meldung, keinen Log-Eintrag — der Fehler ist einfach verschwunden. Wenn Sie wirklich beabsichtigen, einen Fehler zu ignorieren (selten, aber möglich — z. B. beim Schließen einer Ressource auf einem Bereinigungspfad), sagen Sie das explizit:
try { connection.close(); }
catch (IOException ignored) {
// close-time failure on a cleanup path; original cause already propagating
}Der Variablenname ignored und der Kommentar machen die Absicht für den nächsten Leser sichtbar.
Sinnvoll protokollieren, einmal protokollieren
Zwei Fehler beim Protokollieren sind häufig:
- Protokollieren ohne ausreichend Kontext —
log.error("failed")sagt nichts aus. - Protokollieren und dann neu werfen — jede Schicht protokolliert dieselbe Exception, und derselbe Trace landet fünfmal im Log.
Wählen Sie eine Schicht, die den meisten Kontext kennt (normalerweise auf hoher Ebene: Request-Handler, Job-Runner) und protokollieren Sie dort mit der Eingabe, die den Fehler ausgelöst hat. Schichten darunter sollten sich darauf konzentrieren, die Exception zu übersetzen, nicht sie zu protokollieren.
try {
userService.activate(id);
} catch (UserNotFoundException e) {
log.warn("activation failed: no user with id={}", id, e); // include the exception object as the last arg
return Response.notFound();
}Das Übergeben der Exception als letztes Logger-Argument ist die SLF4J-Konvention — es stellt sicher, dass der vollständige Stack-Trace und jede Ursachenkette in der Ausgabe landen.
Die Ursache beim Einwickeln bewahren
Wenn Sie eine Exception auf eine höhere Schicht übersetzen, übergeben Sie immer das Original als Ursache:
// Good — cause is preserved
catch (IOException e) {
throw new ConfigLoadException("failed to load " + path, e);
}
// Bad — original IOException is lost
catch (IOException e) {
throw new ConfigLoadException("failed to load " + path);
}Die Caused by:-Kette im resultierenden Stack-Trace ist das, was dem Bereitschaftsingenieur ermöglicht, eine Domänen-Exception auf den Fehler auf Byte-Ebene zurückzuverfolgen. Verlieren Sie sie, und eine halbstündige Debugging-Sitzung wird zu einem halben Tag.
Exceptions nicht für die Steuerung des Kontrollflusses verwenden
Werfen ist teuer — das Erstellen eines Stack-Trace bei der Konstruktion ist der größte Kostenfaktor. Noch wichtiger: Es verschleiert die Absicht. Eine Schleife, die try/catch (NoSuchElementException) verwendet, um zu wissen, wann sie aufhören soll, verbirgt, was sie tut:
// Bad
try {
while (true) {
process(iter.next());
}
} catch (NoSuchElementException end) { }
// Good
while (iter.hasNext()) {
process(iter.next());
}Wenn "nicht gefunden" ein gewöhnliches Ergebnis ist, geben Sie Optional<T> oder einen boolean zurück. Bewahren Sie Exceptions für das wirklich Außergewöhnliche auf.
finally und try-with-resources für die Bereinigung verwenden
finally sollte Ressourcen freigeben. try-with-resources sollte der Standard für alles AutoCloseable sein. Setzen Sie keine Geschäftslogik in finally — es läuft sowohl im Erfolgs- als auch im Fehlerpfad und kann den Unterschied nicht erkennen. Und verwenden Sie kein return in finally — es verwirft stillschweigend die ursprüngliche Exception oder den Rückgabewert, was einer der schwieriger zu diagnostizierenden Fehler ist.
Dokumentieren, was Sie werfen
Wenn eine Methode eine Exception werfen kann, die für Aufrufer wichtig ist — geprüft oder ungeprüft — sagen Sie das im Javadoc:
/**
* Looks up a user by id.
*
* @throws UserNotFoundException if no user with that id exists
* @throws IllegalArgumentException if id is null or blank
*/
public User lookup(String id) { ... }Der Compiler erzwingt dies für geprüfte Exceptions in der Signatur. Für ungeprüfte ist das Javadoc der einzige Vertrag — und Aufrufer brauchen ihn wirklich, wenn die Exception beeinflusst, wie sie die Methode verwenden sollten.
Exceptions für Fehler verwenden, Rückgabewerte für Routineergebnisse
Die zusammenfassende Regel, die die restlichen zusammenhält. Eine Exception sagt etwas ist schiefgelaufen, das ich hier nicht beheben kann. Ein Rückgabewert sagt hier ist das Ergebnis. Wenn "nicht gefunden" Teil des normalen Betriebs ist, geben Sie Optional.empty(), einen boolean oder einen Sentinel zurück. Wenn "die Datenbankverbindung abgebrochen ist", werfen Sie.
Code, der diese Unterscheidung beachtet, ist ruhig: der glückliche Pfad sieht wie eine gerade Linie aus, der ungewöhnliche Pfad ist in einem anderen Block, und der Leser kann auf einen Blick erkennen, welcher welcher ist.
Ein ausgearbeitetes Beispiel
Eine kleine Auftragsverarbeitungsfunktion, die die Praktiken aus diesem Kapitel zusammenführt — Fail-Fast-Validierung, spezifische eingebaute Exception-Typen, Einwickeln mit Ursache und ein einziger Top-Level-Handler, der einmal protokolliert.
Vier Aufrufe, vier verschiedene Pfade. Der erfolgreiche gibt normal zurück. Die beiden IllegalArgumentException-Fälle (Null-Id, Betrag null) werden mit der Meldung gemeldet, die erklärt, was mit der Eingabe falsch war. Der simulierte Service-Fehler taucht als Domänen-OrderProcessingException auf, mit der ursprünglichen IllegalStateException, die über getCause() verknüpft ist. Nichts wird verschluckt, nichts wird zweimal protokolliert, und jeder Fehler sagt genau, welcher Wert ihn verursacht hat.
Was kommt als nächstes
Damit schließt Teil 8 — Sie haben ein funktionierendes Verständnis von Javas Exception-Mechanismus und das Urteilsvermögen, es gut einzusetzen. Der nächste Teil bietet eine eingehende Tour durch Strings — den häufigsten Typ in Java-Code mit Abstand und einen mit mehr Tiefe, als seine Oberfläche vermuten lässt. Weiter zu Java String-Klasse.