Benutzerdefinierte Exceptions in Java
Eigene Exception-Klassen in Java durch Erweiterung von Exception oder RuntimeException für domänenspezifische Fehler definieren.
Die eingebauten Exceptions decken die meisten allgemeinen Fehler ab, wissen aber nichts über Ihre Domäne. Wenn etwas auf eine Weise fehlschlägt, die spezifisch für Ihren Code ist — „Benutzer nicht gefunden", „ungültiger Gutschein", „Konfiguration nicht synchron" — ist es meistens sinnvoll, einen eigenen Exception-Typ zu definieren. Benutzerdefinierte Exceptions sind schnell geschrieben, machen Stack Traces selbsterklärend und erlauben es Aufrufern, genau den Fehler abzufangen, der sie interessiert.
Die Mindestform
Eine benutzerdefinierte Exception ist eine Klasse, die Exception (oder eine ihrer Unterklassen) erweitert. Die kürzeste nützliche Variante:
public class UserNotFoundException extends Exception {
public UserNotFoundException(String message) {
super(message);
}
}Das ist eine vollständige, geprüfte, benutzerdefinierte Exception. Sie können throw new UserNotFoundException("id=42") von überall werfen, und Aufrufer können catch (UserNotFoundException e) verwenden.
Geprüft oder ungeprüft?
Die wichtigste Entscheidung beim Definieren einer Exception-Klasse: Was erweitern Sie?
extends Exception→ geprüft. Der Compiler zwingt Aufrufer, sie zu behandeln oder zu deklarieren.extends RuntimeException→ ungeprüft. Aufrufer können sie behandeln, müssen es aber nicht.
Dieselbe Logik wie bei geprüften vs. ungeprüften Exceptions gilt: Erweitern Sie Exception, wenn Aufrufer realistischerweise wiederherstellen können und Sie sie zwingen möchten, darüber nachzudenken; erweitern Sie RuntimeException, wenn der Fehler einen Bug oder einen Zustand darstellt, aus dem sich kein Aufrufer sinnvoll erholen kann.
Bei Domänen-Exceptions in modernem Java-Code ist RuntimeException die häufigere Wahl — zum Teil, weil geprüfte Exceptions sich nicht gut mit Streams und Lambdas kombinieren lassen, und zum Teil, weil die meisten Domänenfehler ohnehin zu einem einzigen Top-Level-Handler hochblubbern. Beginnen Sie mit RuntimeException, es sei denn, Sie haben einen spezifischen Grund, die Behandlung zu erzwingen.
Die vier Konstruktoren
Konventionell stellt eine Exception-Klasse dieselben vier Konstruktoren wie die eingebauten bereit:
public class ConfigLoadException extends RuntimeException {
public ConfigLoadException() {
super();
}
public ConfigLoadException(String message) {
super(message);
}
public ConfigLoadException(String message, Throwable cause) {
super(message, cause);
}
public ConfigLoadException(Throwable cause) {
super(cause);
}
}Warum alle vier:
- Ohne Argumente — für Werkzeuge und Frameworks, die die Klasse per Reflection untersuchen.
- Nur Nachricht — der häufige Fall in eigenem Code.
- Nachricht + Ursache — zum Einwickeln einer Exception auf niedrigerer Ebene. Der wichtigste Konstruktor.
- Nur Ursache — wenn die Nachricht der Ursache bereits aussagekräftig ist.
Sie müssen nicht alle vier jedes Mal tippen — IDEs generieren sie mit einem Tastendruck — aber die Konstruktoren mit Ursache wegzulassen ist ein echter Verlust. Ohne sie können Sie die zugrunde liegende Exception nicht bewahren, wenn Sie sie einwickeln.
Nützlichen Zustand mitführen
Strings sind in Ordnung, aber benutzerdefinierte Felder sind besser. Wenn der Aufrufer wissen möchte, welcher Benutzer nicht gefunden wurde, machen Sie es zugänglich:
public class UserNotFoundException extends RuntimeException {
private final String userId;
public UserNotFoundException(String userId) {
super("user not found: " + userId);
this.userId = userId;
}
public String getUserId() { return userId; }
}Jetzt kann ein catch-Block etwas mit dem Fehler tun, anstatt nur die Nachricht zu parsen:
catch (UserNotFoundException e) {
metrics.recordMissingUser(e.getUserId());
return Response.notFound();
}Halten Sie die Felder unveränderlich (final) und den Konstruktor minimal. Exceptions werden auf dem Fehlerpfad konstruiert — sie sollten schnell sein und selbst nie werfen.
Mit einer Ursache einwickeln
Die nützlichste Technik bei benutzerdefinierten Exceptions ist das Übersetzen einer Exception auf niedrigerer Ebene in eine domänenspezifische, wobei die ursprüngliche erhalten bleibt:
public Config load(Path p) {
try {
return parser.parse(Files.readString(p));
} catch (IOException e) {
throw new ConfigLoadException("could not read " + p, e);
} catch (ParseException e) {
throw new ConfigLoadException("invalid config in " + p, e);
}
}Der Aufrufer sieht einen einzigen Exception-Typ, der zum Vokabular seiner Schicht passt. Der ursprüngliche Fehler geht nicht verloren — er hängt an getCause() und erscheint in printStackTrace() unter einer Caused by:-Zeile.
So halten Sie Schichten getrennt. Die Config-API gibt keine IOException oder ParseException nach außen; beide werden in etwas übersetzt, das bedeutet: „Das Laden der Konfiguration ist fehlgeschlagen."
Eine kleine Hierarchie
Wenn Sie eine Familie verwandter Fehler haben, geben Sie ihnen einen gemeinsamen Elternteil:
public class PaymentException extends RuntimeException {
public PaymentException(String message) { super(message); }
public PaymentException(String message, Throwable c) { super(message, c); }
}
public class CardDeclinedException extends PaymentException {
public CardDeclinedException(String message) { super(message); }
}
public class InsufficientFundsException extends PaymentException {
public InsufficientFundsException(String message) { super(message); }
}
public class FraudCheckFailedException extends PaymentException {
public FraudCheckFailedException(String message) { super(message); }
}Aufrufer können spezifisch sein (catch (CardDeclinedException)) oder allgemein (catch (PaymentException)), je nach Bedarf. Ein gemeinsamer Elternteil gibt Ihnen auch einen einzigen Import für eine throws-Klausel, wenn die Methode eine davon werfen könnte.
Was zu vermeiden ist
- Erweitern Sie nicht direkt
ThrowableoderError. Gehen Sie immer überExceptionoderRuntimeException. - Überschreiben Sie nicht
getMessage(), um Strings bei jedem Aufruf zu berechnen. Erstellen Sie die Nachricht im Konstruktor und lassen Sie die Elternklasse sie speichern. - Legen Sie keine Logik in die Exception. Sie dient dazu, Informationen zu tragen. Die Wiederherstellung gehört in den catch-Block.
- Verbreiten Sie nicht übermäßig. Jeder neue Exception-Typ ist ein kleiner Vertrag, den Aufrufer jetzt möglicherweise behandeln möchten. Wenn zwei Fehler wirklich dieselbe Behandlung erfordern, wollen sie wahrscheinlich derselbe Typ sein.
Ein ausgearbeitetes Beispiel
Ein kleines Auftragsverarbeitungsmodul mit seiner eigenen Exception-Familie. Die Basisklasse wickelt Fehler auf niedrigerer Ebene ein; die Unterklassen tragen Domänendetails; der Treiber fängt sie auf verschiedenen Spezifitätsniveaus ab, um zu zeigen, wie die Hierarchie die Auswahl ermöglicht.
Der Treiber fängt zuerst EmptyOrderException ab (den spezifischen Fall, den er anders behandeln möchte), dann OrderException als Auffangklausel für die Familie. Wenn die Validierung fehlschlägt, verknüpft die Ursachenkette zurück zur ursprünglichen IllegalStateException, sodass Sie keine Informationen verlieren, wenn Sie auf den Domänentyp übersetzen.
Was kommt als Nächstes
Sie haben jetzt alle Mechanismen. Das abschließende Kapitel befasst sich mit dem Urteilsvermögen — wann zu werfen, wann zu fangen, was zu protokollieren, und welche Muster reifen Exception-Code von defensivem Rauschen unterscheiden. Weiter zu Java Exception Handling Best Practices.