Exceptions – To check or not to check?
Glaubenskriege gibt es in der Weltgeschichte viele und man könnte meinen, dass die technische Welt davor gefeit ist, denn in einer mathematisch genau beschreibbaren Wissenschaft scheint dafür kein Platz zu sein. Doch ausgerechnet der beliebten Programmiersprache Java ist es gelungen, mit ihrer Art der Ausnahmebehandlung fast so etwas wie einen Glaubenskrieg auszulösen. Das Konzept der Ausnahmebehandlung existiert zwar in vielen modernen Programmiersprachen, aber Java schlägt in einer Hinsicht einen Sonderweg. Der Stein des Anstoßes sind die Checked und Unchecked Exceptions.
Und obwohl Robert Martin dazu schreibt "Die Debatte ist vorbei" [1] und damit die Debatte für Checked Exceptions meint, wird immer noch über die Frage gestritten, welche der beiden Varianten die meisten Vorteile hat und am besten genutzt werden sollte. Natürlich können beide Lager sowohl Vorteile ihrer als auch Nachteile der anderen Meinung nennen.
Und vielleicht ist die richtige Position nicht entweder ganz gegen Checked Exceptions zu sein oder mit ganzem Herzen dafür, stattdessen liegt die Wahrheit irgendwo dazwischen. Dieser Artikel diskutiert nach einer Einführung in das Thema Exceptions die Vor- und Nachteile dieser beiden Arten von Ausnahmen, um dann einige Vorgehensweisen und Architekturen für den Umgang mit Exceptions vorzustellen.
Exceptions in Java
Es wäre zuerst einmal die Frage zu klären, was Exceptions eigentlich sind. Im Folgenden werden nur die grundlegenden Konzepte besprochen, die für den restlichen Artikel von Bedeutung sind. Für weitere Informationen sei z. B. auf Bruce Eckel verwiesen [2]. Zuerst einmal ist eine Ausnahme etwas, das nicht der Regel entspricht.
Abb. 1 zeigt den Ablauf eines Java-Programms bei der Verwendung von Exceptions. Wir bestimmen einen Bereich unseres Codes, in dem eine Ausnahme auftreten kann. Eine Ausnahme könnte bspw. ausgelöst werden, wenn wir eine Datei lesen möchten und diese Datei nicht existiert. Normalerweise können wir davon ausgehen, dass die Datei vorhanden ist. Wurde sie aber aus Versehen gelöscht, darf das Programm nicht versuchen, sich normal zu verhalten. Der Code, der eine Ausnahme fängt und verarbeitet, darf natürlich nur in Ausnahmefällen ausgeführt werden.
Im Normalfall ist die Datei vorhanden und kann gelesen und verarbeitet werden. Existiert die Datei jedoch nicht, müssen wir den Normalablauf an einer bestimmten Stelle abbrechen. Und zwar bevor irgendwelche kritischen Aktionen ausgeführt werden, die wegen des Fehlers nicht mehr durchlaufen werden dürfen. Genau dazu dienen die Exceptions. Wenn die Datei fehlt, ist das ein Ausnahmefall. Dann wird eine Ausnahme (oder auf Englisch: exception) geworfen. Sobald die Ausnahme geworfen wird, wird die Regel-Verarbeitung abgebrochen – der nachfolgende Code wird nicht mehr ausgeführt.
Allerdings müssen wir irgendwie auf den Ausnahmefall reagieren. Dazu dient das Fangen einer Ausnahme. Man kann sich eine Exception also vorstellen wie ein Ballspiel: Wenn das Problem auftritt, wird der Ball geworfen und von jemandem gefangen, der genau weiß, was im Fehlerfall zu tun ist. Der Code, der die Ausnahme fängt, könnte in unserem Beispiel eine E-Mail an den Administrator senden oder einfach eine neue Datei erstellen. Wie genau eine Ausnahme behandelt wird, hängt sehr von dem entsprechenden Anwendungsfall ab. Wichtig ist nur, dass der Programmierer auf das Auftreten einer Ausnahme reagiert und in irgendeiner Art und Weise geeignete Gegenmaßnahmen ergreift.
Listing 1 zeigt ein Beispiel, wie das in Java aussehen kann.
Listing 1: Exceptions in Java
public static void main(String[] args) {
System.out.println("Interessanter Code");
try {
throwException();
System.out.println("Wird nur ausgeführt, wenn keine Ausnahme geworfen wurde.");
} catch (IllegalArgumentException ex) {
System.out.println("Ausnahme gefangen: " +
ex.getMessage());
}
System.out.println("Noch mehr interessanter Code");
}
public static void throwException() {
int i = 1;
if (i == 0) {
throw new IllegalArgumentException(
"i darf nicht 0 sein!");
}
}
Die Methode throwException() prüft, ob die Variable i gleich 0 ist. Dies definieren wir in dem kleinen Beispiel als Ausnahmefall und wir werfen eine Ausnahme des Typs IllegalArgumentException mit einer Fehlermeldung. Da wir wissen, dass der Methodenaufruf eine Ausnahme erzeugen kann, verpacken wir den Aufruf in der main-Methode in ein try-catch.
Das Programm versucht, die Anweisungen in dem try-Zweig auszuführen. Wenn dies nicht gelingt, wird die Verarbeitung des Codes sofort abgebrochen und das Programm springt in den catch-Zweig. Dieser enthält den Code für die Ausnahmebehandlung. Das Programm kann also einen der beiden oben beschriebenen Wege einschlagen: Im Normalfall wird der Code im try-Zweig abgearbeitet und im Ausnahmefall wird der Weg über den catch-Zweig genommen. Der Programmierer definiert also einen Code-Block, in dem ein Problem auftreten könnte.
Unterschied zwischen Checked und Unchecked Exceptions
Bei dieser grundlegenden Vorgehensweise kennt Java zwei Arten von Exceptions: die geprüfte (checked) und die ungeprüfte (unchecked) Exception. Mit den Errors gibt es noch eine dritte Art, die aber für unsere Betrachtungen keine Rolle spielt und nur der Vollständigkeit halber kurz erwähnt werden soll. Abb. 2 zeigt die grundlegende Vererbungshierarchie der Exceptions in Java.
Alle Exception-Klassen erben von java.lang.Throwable. Interessant sind deren Unterklassen. Alle Klassen, die von der Klasse Exception erben, sind sog. Checked Exceptions. Zu dieser Gruppe von Exceptions gehört bspw. die Klasse IOException. Diese kann bei der Dateiverarbeitung ausgelöst werden. Wenn die IOException nicht abgefangen und verarbeitet wird, erhalten wir den Compiler-Fehler "Unhandled exception type IOException", d. h. die IOException muss auf jeden Fall abgefangen und behandelt werden.
Es gibt zwei Möglichkeiten: Die IOException kann sofort in derselben Methode abgefangen werden, in der sie auch geworfen wurde. Das ist jedoch eher unüblich. Üblicher ist, was in Listing 2 zu sehen ist: Wir geben die Exception mittels throws weiter an die aufrufende Methode.
Listing 2: Die throws-Klausel
public static void throwException() throws IOException {
int i = 1;
if (i == 0) {
throw new IOException("i darf nicht 0 sein!");
}
}
Mit der throws-Klausel gibt eine Methode der Außenwelt zu verstehen, dass sie eine Exception weitergibt. Damit weiß die aufrufende Methode, dass sie mit dem Auftreten einer solchen Exception nicht nur rechnen muss, sondern dass sie sie auch explizit behandeln muss. Eine throws-Klausel darf in einer Reihe von Methodenaufrufen mehrmals auftreten, aber irgendeine Methode muss die Exception schließlich mit catch abfangen und darauf reagieren.
Das gilt für alle Klassen, die Exceptions direkt erweitern. Die einzige Ausnahme ist die Klasse RuntimeException, die in Java bereits vordefiniert ist. Alle Klassen, die RuntimeException erweitern, müssen nicht unbedingt behandelt werden. Sie müssen nicht mithilfe der throws-Klausel explizit weitergegeben werden und sie müssen nicht von den aufrufenden Methoden abgefangen und verarbeitet werden. Dies sind die sog. Unchecked oder ungeprüften Ausnahmen.
Die Klasse Error schließlich bezeichnet Fehler, die als extrem schwer oder sogar unmöglich zu behandeln gelten, wie z. B. OutOfMemoryError.
Vor- und Nachteile von Checked Exceptions
Der Sinn und Unsinn von Checked Exceptions wird seit vielen Jahren ausführlich diskutiert. In einem Interview verteidigte James Gosling, der Entwickler von Java, das Konzept der geprüften Ausnahmen gegen alle Widerstände [3]. Seiner Meinung nach sind geprüfte Ausnahmen eine Voraussetzung für die Entwicklung von wirklich robuster Software.
Ein großer Vorteil von Checked Exceptions ist die Tatsache, dass bereits die Deklaration der Methode angibt, mit welchen Exceptions man zu rechnen hat, und die Behandlung der Exceptions erzwungen wird. Somit ist es bei Checked Exceptions nicht möglich, Ausnahmen zu übersehen, denn der Compiler überprüft das – vorausgesetzt natürlich, dass tatsächlich alle benötigten Exceptions korrekt erzeugt und geworfen werden. Wenn der Programmierer eine Ausnahme ignoriert, muss er sich explizit dafür entscheiden, aber er kann die Ausnahme nicht übersehen.
Es scheint so, als sei diese Hilfe des Compilers eine wichtige Voraussetzung für die Erstellung robuster Software. Aber genau diese Tatsache bringt auch einen der größten Nachteile mit sich: Man erzeugt eine Abhängigkeit zwischen dem Interface einer Klasse und der Implementierung. Und genau das soll durch die Verwendung von Interfaces schließlich vermieden werden. Das Beispiel in Listing 3 veranschaulicht die Problematik. Die Methode tueEtwas() gibt an, dass sie eine IOException wirft. Genau so muss sie auch im Interface deklariert werden.
Listing 3: Geprüfte Exceptions und Interfaces
public class KlasseMitExceptions implements MeinInterface {
public void tueEtwas() throws IOException {
// tue etwas mit Dateien und wirf eine
// IOException.
throw new IOException();
}
}
public interface MeinInterface {
public void tueEtwas() throws IOException;
}
Die throws-Klausel darf im Interface nicht weggelassen werden. Ein Interface soll jedoch auf verschiedene Arten implementiert werden können und selbst vollkommen unabhängig von dieser sein. Durch die verpflichtende Verwendung der throws-Klausel jedoch binden wir die Methode im Interface an die IOException, die bei der Dateiverarbeitung auftreten kann. Beschließen wir nun, dass wir gerne eine Implementierung mit einer Datenbank testen möchten statt mit einer Datei, müssen wir auf eine SQLException reagieren und eine IOException kann nicht mehr geworfen werden. Somit muss die Methodendeklaration im Interface selbst verändert werden, wie das Beispiel in Listing 4 zeigt.
Listing 4: Geprüfte Exceptions und Interfaces
public interface MeinInterface {
public void tueEtwas() throws SQLException;
}
Das Interface enthält also bereits eine Bindung an die verwendete Technologie und damit verlieren wir genau die Flexibilität, die wir durch die Verwendung von Interfaces schließlich erhalten möchten.
Robert Martin macht darauf aufmerksam, dass die throws-Klausel auch gegen ein allgemein anerkanntes und wichtiges Architekturprinzip verstößt: das Open-Closed-Prinzip, das besagt, dass Änderungen lokal begrenzt sein sollen [4]. Denn wenn eine Exception mittels throws an die aufrufende Methode weitergereicht, aber erst drei Methoden später gefangen und behandelt wird, müssen bei Änderungen der Exception in sämtlichen beteiligten Methoden zwischen dem Auslösepunkt der Ausnahme und der catch-Anweisung die throws-Klausel angepasst werden. Eine Veränderung auf einer niedrigeren Ebene hat also Auswirkungen auf die höheren Ebenen des Programms. Und damit sind die Änderungen nicht mehr lokal begrenzt wie es das Open-Closed-Prinzip vorschreibt.
Als weiterer Nachteil ist zu nennen, dass der Code unübersichtlicher werden kann, da es geschehen kann, dass eine Methode fünf oder sechs geprüfte Ausnahmen deklariert, die dann alle irgendwo behandelt werden müssen. Das bläht die Deklarationen der Methoden sehr auf und der Code wird überfrachtet mit catch-Zweigen.
Oder der Programmierer macht es sich leicht und fängt mit einem catch-Zweig einfach die Oberklasse Exception ab, was jedoch auch wiederum den Sinn der Ausnahmebehandlung in Frage stellt. Zudem kann man argumentieren, dass der Programmierer dazu verleitet wird, irgendwann nur noch Ausnahmebehandlung zu schreiben, ohne die Ausnahmen tatsächlich zu behandeln, d. h. die catch-Zweige bleiben einfach leer.
Allerdings wird durch die throws-Klausel bei Checked Exceptions automatisch eine Dokumentation der möglichen Ausnahmen, die eine Methode werfen kann, frei Haus mitgeliefert. Wenn man sich auf Unchecked Exceptions verlässt, muss diese Dokumentation von Hand geschrieben werden, weil man sonst Gefahr läuft, dass wichtige Ausnahmen untergehen und nicht behandelt werden. Selbst wenn bei geprüften Ausnahmen keine vernünftige Java-Doc formuliert wird, so dokumentiert der Code doch eindeutig, welche Ausnahmen von der Methode weitergegeben werden [5].
Außerdem muss man sagen, dass außer Java keine andere weit verbreitete Programmiersprache das Konzept der Checked Exceptions kennt. In der Dokumentation von Kotlin wird explizit erklärt, warum in dieser auf der JVM basierenden Sprache keine geprüften Ausnahmen möglich sind [6]. Dennoch ist es auch in diesen Sprachen möglich, robuste Software zu schreiben [1]. Bezeichnend ist auch, dass moderne Java-Frameworks wie Spring vollkommen auf den Einsatz von Checked Exceptions verzichten [7].
Einer der namhaftesten Gegner von Checked Exceptions ist Bruce Eckels, doch auch auf der Seite der Befürworter gibt es bekannte Namen wie den Autor Eliotte Rusty Harold, der das Konzept in seinem Blog vehement verteidigt [8]. Seine Hauptargumente für die geprüften Ausnahmen sind die erhöhte Sicherheit und die Dokumentation.
Einsatz von Checked und Unchecked Exceptions
Auch wenn verschiedene Quellen anderer Meinung sind, so lässt sich bei genauerer Betrachtung der Vor- und Nachteile nur schwer ein klarer Sieger benennen und so fragt man sich, welche der beiden Exception-Arten man am besten einsetzt. Dustin Marx beantwortet diese Frage folgendermaßen: "A mix of checked and unchecked exceptions is ‚effective‘, but checked exceptions should only be used for ‚recoverable conditions‘ and should not be used ‚unnecessarily‘." [9].
Die Entscheidung, welche Art von Exception verwendet werden soll, sollte also von der Frage abhängig gemacht werden, ob der Aufrufer sich von der Exception erholen, also ob er sie effizient behandeln kann. Nicht jede Ausnahme kann vernünftig behandelt werden. Wenn dem Aufrufer nur die Möglichkeit bleibt, eine Ausnahme zu loggen oder eine Benachrichtigung auf dem Bildschirm auszugeben, ist eine Checked Exception nicht sinnvoll.
Anders sieht es aus, wenn z. B. eine Verbindung zu einem Server aufgebaut werden soll. Dieser Verbindungsaufbau kann fehlschlagen, weil es gerade Probleme mit der Verbindung gibt oder weil der Server gerade überlastet oder temporär nicht erreichbar ist. In einer solchen Situation kann es sinnvoll sein, einige Sekunden zu warten und dann einen weiteren Versuch zu starten und erst nach einer bestimmten Anzahl von fehlgeschlagenen Versuchen eine Fehlermeldung auszugeben. Das wäre eine sinnvolle Ausnahmebehandlung, auf welche man den Aufrufer mittels throws aufmerksam machen könnte.
Die zweite Edition von Joshua Blochs Standardwerk "Effective Java" enthält zwei für dieses Thema relevante Kapitel, deren Überschriften schon vieles über den richtigen Umgang mit Checked Exceptions aussagen: Item 58 ("Use checked exceptions for recoverable conditions and runtime exceptions for programming errors") und Item 59 ("Avoid unnecessary use of checked exceptions") [10]. Es ergibt einfach keinen Sinn, jede Ausnahme als Checked Exception zu deklarieren, der Programmierer sollte stattdessen die Vor- und Nachteile abwägen. Generell gilt, dass die meisten Ausnahmen tatsächlich solche sind, die dem Aufrufer keine effektive Möglichkeiten der Behandlung bieten wie in dem obigen Beispiel mit der Serververbindung beschrieben. In diesen Fällen sollte man den Unchecked Exceptions den Vorzug geben, um die beschriebenen Nachteile der Checked Exceptions zu vermeiden.
Mit einigen geschickten Vorgehensweisen und Architekturen können die Vorteile beider Ausnahmearten teilweise miteinander kombiniert werden.
Das Problem der instabilen Methodensignaturen
Eines der größten Probleme mit geprüften Ausnahmen sind die bereits beschriebenen instabilen Methodensignaturen: Wenn die Ausnahmen mittels throws weitergegeben werden, müssen eventuell Anpassungen am Interface vorgenommen werden, wenn sich die geworfenen Exceptions verändern. Dies bricht das Open-Closed-Prinzip und verursacht Änderungen, die nicht lokal begrenzt sind. Doch ist dies eher ein Symptom eines anderen Problems: ungenügende Abstraktion.
Joshua Bloch beschreibt dieses Phänomen: Eine Methode sollte eine Exception werfen, wenn sie einen Fehler erwarten kann. Aber diese Ausnahme sollte reflektieren, was die Methode tut, nicht wie sie es tut [5]. Im Beispiel oben (Listing 3) gibt die Methode an, eine IOException zu werfen, die bekanntermaßen an Dateiverarbeitung gebunden ist. Die Probleme tauchen auf, wenn die Dateien bspw. durch eine Datenbank ersetzt werden sollen.
Doch damit gibt die Signatur der Methode an, wie die Methode ihre Arbeit durchführt, nämlich entweder mit Dateien oder mit einer Datenbank. Und genau das sollte eine Methode nicht nach außen hin preisgeben. Stattdessen ist es sinnvoller, die technologiespezifische Exception so zu kapseln, dass man nach außen hin nicht sehen kann, welche Exception tatsächlich geworfen wurde und welche Technologie verwendet wird. Listing 5 zeigt ein einfaches Minimal-Beispiel.
Die Methode gibt nach außen hin nur noch bekannt, dass sie eine DataException wirft. Diese Ausnahme definieren wir selbst und somit ist sie vollkommen unabhängig von irgendeiner verwendeten Technologie. Wenn der hier angedeutete Dateizugriff nun durch eine Datenbankoperation ersetzt werden soll, ist diese Änderung lokal auf die Methode beschränkt, denn sie kann die SQLException genauso abfangen wie im Beispiel die IOException. Änderungen an der Signatur der Methode sind nicht notwendig. Somit werden auch keinerlei Implementierungsdetails mehr nach außen getragen.
Es ist also sinnvoll, einige wenige Basis-Ausnahmen zu definieren, deren Namen aussagekräftig genug sind, so dass sich der Leser den wichtigsten Zweck daraus erschließen kann. Die konkreten Gründe für das Scheitern der Operation entnimmt man dann sowieso der Ausnahme selbst und eventuell darin enthaltenen Kontextinformationen. So wird das Open-Closed-Prinzip durch sinnvolle Kapselung nicht durchbrochen. [2] schlägt vor, für jedes Paket eine eigene Basis-Exception zu definieren.
Listing 5: Technologieunabhängige Methodendeklaration
public void save() throws DataException {
// Mache etwas mit Dateien
try {
saveInFile();
} catch (IOException e) {
throw new DataException();
}
}
private void saveInFile() throws IOException {
throw new IOException();
}
public class DataException extends Exception {
}
Generische Exceptions
Eine Möglichkeit, die Vorteile der Checked Exception zu nutzen und gleichzeitig die Preisgabe von Implementierungsdetails zu vermeiden, ist die Verwendung von Generischen Exceptions. Interessanterweise ist es nämlich möglich, für eine throws-Klausel einen generischen Typ zu verwenden. Und genau dies macht man sich hier zunutze wie das Beispiel in Listing 6 zeigt.
Das Interface wird mit einer Unterklasse von Exception typisiert und bei einer konkreten Implementierung des Interface muss dann eine konkrete Unterklasse von Exception angegeben werden. Somit sagt das Interface nur noch, dass die entsprechende Methode eine Exception werfen muss und erst die Implementierung gibt die tatsächlich geworfene Exception an. Wenn nun eine Implementierung geschrieben werden soll, die Datenbanken mit JDBC verwendet und somit eine SQLException wirft, muss das Interface nicht verändert werden. Der Compiler bemerkt trotzdem, dass hier eine Checked Exception geworfen wird und verhält sich wie gewohnt. Der Aufrufer wird also zur Behandlung gezwungen.
Der Nachteil dieser Lösung liegt zum einen ganz klar darin, dass der Code durch die generischen Elemente komplexer und holpriger wird. Zum anderen muss man sich bei der Deklaration des Interfaces auf eine Anzahl von Exceptions festlegen. Dies hat sich bei mir jedoch noch nie als wirkliches Problem erwiesen, man sollte diese Tatsache aber auf jeden Fall im Kopf behalten.
Listing 6: Generische Exceptions
public interface GenericExceptionInterface <E extends Exception> {
void doSomething() throws E;
}
public class MyClassUsingGenericException implements GenericExceptionInterface<IOException> {
@Override
public void doSomething() throws IOException {
// Tue etwas interessantes mit Dateien.
}
}
Exception Tunnelling
Diese Technik wurde von Bruce Eckel vorgeschlagen [2]. Beim Tunneln wickelt man eine Exception in eine Unchecked Exception ein [10]. Wie in Listing 7 zu sehen ist, erhält die Klasse ExceptionWrapper im Konstruktor ein Objekt vom Typ Exception, erweitert aber selbst RuntimeException, so dass die eingewickelte Exception nach außen hin nicht sichtbar ist und wir mit einer ungeprüften Ausnahme arbeiten. Möchten wir wieder auf die enthaltene Checked Exception zugreifen, rufen wir die Methode rethrow() auf, welche die Original-Exception wirft, die wir dann wie gewohnt abfangen und behandeln können.
Diese Technik ist interessant im Kontext mit Architekturen wie der Schichtenarchitektur. In vielen Anwendungen findet man ein Szenario wie in Abb. 3 dargestellt. Das Framework in einer Persistenz-Schicht wirft eine Checked Exception namens PersistenceException. Sie wird erst in der Präsentationsschicht mithilfe einer Fehlermeldung verarbeitet. Auf dem Weg durch die Schichten muss nun jede Schicht die Exception mittels throws weitergeben.
Bruce Eckel beschreibt die geprüften Ausnahmen als besonders problematisch in solchen großen und mehrschichtigen Systemen: "Examination of small programs leads to the conclusion that requiring exception specifications could both enhance developer productivity and enhance code quality, but experience with large software projects suggests a different result – decreased productivity and little or no increase in code quality."
Um diesen unschönen Nebeneffekt in der Schichtenarchitektur zu beseitigen, kann das erwähnte Tunnelling angewendet werden. Dies ist in Abb. 4 dargestellt. Die unterste Schicht wickelt die Exception in eine RuntimeException ein und diese wird nach oben gereicht. Dabei verschwindet die throws-Klausel, da ja schließlich mit einer Unchecked Exception gearbeitet wird. Die obere Schicht ruft dann einfach die Methode rethrow() auf und erhält somit wieder die originale Ausnahme. Da generell keine framework-spezifischen Ausnahmen durch die Schichten nach oben durchgereicht werden sollten, um keine unerwünschten Abhängigkeiten zu erzeugen, ist diese Technik bei der Verwendung von Frameworks auf alle Fälle wünschenswert.
Diese Architektur ist offen für Erweiterungen und Varianten. So kann der ExceptionWrapper weitere Informationen enthalten wie den Ort, an dem die Exception aufgetreten ist, die Zeit oder weitere Informationen über den Kontext. Sie kann also mit dem ExceptionEnrichment erweitert werden, das weiter unten vorgestellt wird. Ebenso könnte man in einen Wrapper mehrere Exceptions einwickeln und diese dann en bloc verarbeiten, um nur einmal zwei Erweiterungsmöglichkeiten zu nennen.
Listing 7: Tunneln einer geprüften Ausnahme
public class ExceptionWrapper extends RuntimeException {
private Exception originalException;
public ExceptionWrapper(Exception originalException) {
this.originalException = originalException;
}
public void rethrow() throws Exception {
throw originalException;
}
}
Exception Handler
Diese Technik soll die Ausnahmen in Klassen einteilen je nach Art ihrer Behandlung. Abb 5. zeigt den grundlegenden Aufbau. Eine Exception wird in einen ExceptionHandler gepackt. Dies ist ein Interface, das von verschiedenen Klassen mit jeweils unterschiedlichen Vorgehensweisen zum Umgang mit der enthaltenen Ausnahme implementiert wird.
Das Interface ExceptionHandler enthält im einfachsten Fall nur eine Methode handle(). Diese muss natürlich so konzipiert werden, dass sie von allen für die entsprechende Anwendung sinnvollen Handler vernünftig implementiert werden kann (vgl. Listing 8).
Listing 8: Der ExceptionHandler
public interface ExceptionHandler {
public void handle(final Exception e,
final String errorMessage);
}
Im Beispiel soll es nun drei verschiedene Implementierungen geben: IgnoringHandler, WrappingHandler und CollectingHandler. IgnoringHandler tut genau das, was der Name aussagt: Die Ausnahme wird einfach ignoriert (vgl. Listing 9). Warum eine eigene Klasse schreiben für etwas, das man ganz einfach im Code sowieso tun kann? Dies hat den Vorteil, dass man das Ignorieren einer Ausnahme wirklich explizit ausdrücken muss. Der Grund kann dann bspw. in dem Parameter message angegeben werden. Dies ist ein Unterschied zum einfachen Leerimplementieren eines catch-Zweigs, was auch leicht als Faulheit ausgelegt werden kann.
Listing 9: Der IgnoringHandler
public class IgnoringHandler implements ExceptionHandler{
public void handle(final Exception e,
final String message) {
// Nichts tun, sondern Exception
// einfach ignorieren.
}
}
Der WrappingHandler wickelt die Ausnahme ein und kann somit bspw. für das ExceptionTunneling eingesetzt werden (vgl. Listing 10).
Listing 10: Der WrappingHandler
public class WrappingHandler implements ExceptionHandler{
public void handle(Exception e, String message){
throw new RuntimeException(message, e);
}
}
Und der CollectingHandler sammelt eine Menge von Ausnahmen in einer Liste und ermöglicht deren Verarbeitung in einem einzigen Block (vgl. Listing 11).
Listing 11: Der CollectingHandler
public class CollectingHandler implements ExceptionHandler {
List<Exception> exceptions = new ArrayList<>();
public List<Exception> getExceptions() {
return this.exceptions;
}
public void handle(Exception e, String message) {
this.exceptions.add(e);
}
}
Eine Möglichkeit, diese Technik anzuwenden, zeigt Listing 12. Die Komponente erhält im Konstruktor eine Referenz auf ein Objekt, welches das Interface ExceptionHandler mit Leben füllt. An dieser Stelle bietet sich natürlich die Anwendung eines Dependency-Injection-Frameworks wie Spring an [7]. In der Methode processStream() wird eine IOException geworfen, die in der Methode processFile abgefangen und an den Handler übergeben wird. Abhängig von der Implementierung wird nun die entsprechende Behandlung durchgeführt. Besonders charmant an dieser Lösung ist, dass man durch einfaches Austauschen der Referenz im Attribut exceptionHandler ein anderes Ausnahmebehandlungsverhalten einbauen kann, ohne dass der Programmierer den Code anfassen und verändern muss.
Listing 12: Beispiel für die Anwendung der Handler
public class MyFileProcessingComponent{
private final ExceptionHandler exceptionHandler;
public MyFileProcessingComponent(ExceptionHandler exceptionHandler) {
super();
this.exceptionHandler = exceptionHandler;
}
public void processFile(String fileName){
FileInputStream input = null;
try{
input = new FileInputStream(fileName);
processStream(input);
} catch (IOException ex){
this.exceptionHandler.handle(ex,
"error processing file: " + fileName);
}
}
private void processStream(InputStream input)
throws IOException{
// Mach was mit dem Stream.
}
}
Exception Enrichment
Der Informationsgehalt von Ausnahmen in Java ist standardmäßig eher gering. Das Anreichern von Ausnahmen um weitere Informationen kann helfen, dieses Manko zu umgehen. Auch diese Technik bietet zahlreiche Variationsmöglichkeiten und soll im Folgenden nur prinzipiell vorgestellt werden. Die Klasse ErrorInfo enthält Attribute, um welche Ausnahmen erweitert werden sollen, wie z. B. Kontextinformationen, Fehlermeldungen, Fehlercodes oder Schlüssel für die Internationalisierung. In verteilten Systemen schreibe ich auch gerne ein Attribut hinein, welche mir Informationen über die Komponente gibt, in welcher der Fehler aufgetreten ist. Je nach Anwendungsfall gibt es hier also zahlreiche Varianten. Im Idealfall ist diese Klasse unveränderlich wie hier im Beispiel in Listing 13 zu sehen. Die Exceptionklasse enthält dann ein Objekt dieser Klasse mit den entsprechenden Informationen. Denkbar wäre auch, dass die Exception eine Liste mit mehreren Info-Objekten enthält. So könnte man dann den gesamten StackTrace mit mehr Kontextinformationen versehen. Wie auch immer diese Technik implementiert wird, man erhält auf jeden Fall in der Exception schon alle wichtigen Informationen, die man auf höherer Ebene vielleicht für die Behandlung oder für das Logging benötigt.
Listing 13: Exception Enrichment
public class ErrorInfo {
private final String errorContext;
private final String errorCode;
private final String errorText;
private final String i18nKey;
private ErrorInfo(String contextCode,
String errorCode,
String errorText,
String i18nKey){
this.errorContext = contextCode;
this.errorCode = errorCode;
this.errorText = errorText;
this.i18nKey = i18nKey;
}
Fazit
Das Behandeln von Ausnahmen ist eine Wissenschaft für sich, die jedoch oftmals eher stiefmütterlich behandelt wird. In vielen Kursen, Lehrgängen und Büchern wird das richtige Behandeln von Ausnahmen überhaupt nicht diskutiert. Dabei ist dieses Sprachelement sehr vielfältig einsetzbar und trägt in einem großen Umfang zu einer robusten und stabilen Anwendung bei.
Das Herzstück zum sinnvollen Einsatz von Exceptions ist, den Unterschied zwischen Checked und Unchecked Exceptions zu verstehen. Dabei begibt man sich natürlich unter Umständen in einen Glaubenskrieg, wobei ich nur empfehlen kann, da neutral zu bleiben und die Vor- und Nachteile beider Lösungen gegeneinander abzuwägen – gerne auch in Kombination mit einer oder mehreren hier vorgestellten Techniken.
Die in diesem Artikel vorgestellten Techniken zur Arbeit mit Exceptions sollen nur einige grundlegende Ideen verdeutlichen. Alle hier vorgestellten Techniken und Architekturen können und sollten natürlich an den entsprechenden Anwendungsfall angepasst werden. Zudem können sie auch in vielfältiger Weise miteinander kombiniert werden.
- R. Martin: Clean Code: A Handbook of Agile Software Craftsmanship. Prentice Hall
- B. Eckel: Thinking in Java. Prentice Hall
- Failure and exception
- R. Martin: Clean Architecture: A Craftsman’s Guide to Software Structure and Design. Prentice Hall
- IBM: The exceptions debate
- Kotlin Documentation
- Spring Documentation
- Bruce Eckel is wrong
- D. Marx: Effective Exception Handling is Covered Effectively in Effective Java
- Exception Tunneling