“Wer einen Fehler gemacht hat und ihn nicht korrigiert, begeht einen Zweiten.”
Konfuzius
Die Fehlerbehandlung in der Programmiersprache Java folgt einigen einfachen aber mächtigen Mustern. Dennoch zeigt sich immer wieder die Tendenz, auf erschreckende Weise davon abzuweichen.
Grundsätzlich kann man in der Fehlerbehandlung die zwei Situationen Fehlererkennung und Fehlerkorrektur unterscheiden. Wird im Programmablauf eine Konstellation erkannt, die einen Fehler darstellt, dann wird eine Exception geworfen.
public AncestorTree validate(AncestorTree tree) { if (cycleDetected(tree)) { throw new CyclicTreeException("cycle in tree " + tree.getId()); } return tree; }
In diesem Beispiel wird eine CyclicTreeException
geworfen, weil im Stammbaum ein zyklische Verbindung entdeckt wurde. So etwas sollte nur bei der Existenz von Zeitreisenden auftreten. Die Verarbeitung wird an dieser Stelle abgebrochen und die Exception an die aufrufenden Methoden durchgereicht.
Bei der Fehlerkorrektur fängt eine aufrufende Methoden die Exception und führt Aktionen aus, um auf den Fehler zu reagieren.
public void handleNewTree(AncestorTree tree) { try { saveTree(validate(tree)); } catch (CyclicTreeException e) { rejectTree(e); } }
In diesem Beispiel wird ein valider Stammbaum gespeichert. Tritt aber bei der Validierung ein Fehler, in Form einer CyclicTreeException
auf, dann wird der Stammbaum zurückgewiesen.
Obwohl oder gerade weil dieser Mechanismus für die Fehlerbehandlung sehr einfach erscheint, finden einige Code-Smells immer wieder ihren Weg in den Source Code.
Ein Teil dieser Code-Smells hat seinen Ursprung in der Unterscheidung von Checked und Unchecked Exceptions in Java. Unchecked Exceptions erben alle von der Klasse RuntimeException
, daher werden sie häufig einfach Runtime Exceptions genannt. Sie können in jeder Methode geworfen werden. Checked Exceptions können aber nur in Methoden geworfen werden, die in ihrer Signatur darauf hinweisen.
public AncestorTree validate(AncestorTree tree) throws TreeException { if (cycleDetected(tree)) { throw new CyclicTreeException("cycle in tree " + tree.getId()); } return tree; }
In diesem Beispiel ist die CyclicTreeException
eine Checked Exception, die von TreeException
erbt. In dem throws
Teil der Signatur könnte auch, an Stelle von
, TreeException
Exception
oder CyclicTreeException
stehen. Dies ist nicht nur ein klarer semantischer Unterschied, es hat auch einen starken Einfluss auf die aufrufenden Methoden.
Die TreeException
in der Signatur signalisiert, dass diese Methode Unchecked Exceptions und alle Checked Exceptions werfen kann, die vom Typ TreeException
sind. Beispielsweise sind das
, TreeException
und CyclicTreeException
EmptyTreeException
. Die aufrufende Methode muss diese Exceptions behandeln oder kann sie an eine weitere aufrufende Methode durchreichen. Durchgereichte Checked Exceptions müssen im
Teil der aufrufenden Methode aufgeführt werden.throws
public void handleNewTree(AncestorTree tree) throws TreeException { try { saveTree(validate(tree)); } catch (CyclicTreeException e) { rejectTree(e); } }
In diesem Beispiel wird nur die CyclicTreeException
behandelt und alle anderen TreeException
durchgereicht.
Steht im throws
Teil der Signatur Exception
, dann kann diese Methode alle Checked Exceptions werfen. Sie zwingt damit alle aufrufenden Methoden dies auch zu tun oder alle Exceptions zu behandeln. Dies ist ein starker Code-Smell, weil er gravierende Auswirkungen auf die Code Pflege hat.
public void handleNewTree(AncestorTree tree) { try { saveTree(validate(tree)); } catch (Exception e) { rejectTree(e); } }
Die hier dargestellte Methode fängt Exception
, weil die dazugehörige validate
Methode Exception
wirft. Während der Weiterentwicklung der Software passiert es nun, dass die saveTree
Methode eine IOException
werfen kann. Die neue Version wird produktiv geschaltet und plötzlich werden alle Stammbäume abgelehnt. Die Validierung scheint korrekt zu sein. Irgendwann erkennt jemand, dass hier eine IOException
geworfen wird, weil die Festplatte voll ist. Bei einer validate
Methode mit einer TreeException
und einer saveTree
Methode mit einer IOException
, wäre der Entwickler gezwungen gewesen, die handleNewTree
Methode um eine Behandlung einer IOException
zu ergänzen.
public void handleNewTree(AncestorTree tree) throws IOException { try { saveTree(validate(tree)); } catch (CyclicTreeException e) { rejectTree(e); } }
Dies ist einer der Gründe, warum das Werfen von Exception
aus Methoden tunlichst vermieden werden sollte. Ein anderer wichtiger Grund ist die Tendenz, dass nach geraumer Zeit, immer mehr Methoden Exception
werfen. Dies ist dem Umstand geschuldet, dass Exceptions häufiger durchgereicht als behandelt werden.
Wie das Beispiel mit der Exception
zeigt, sollte immer sehr spezifisch angegeben werden, welche Exceptions geworfen und gefangen werden. In dem Fall, dass die TreeException
gefangen oder geworfen wird, ist der Entwickler auch nur so lange vor bösen Überraschungen sicher, bis es eine neue Unterklasse von TreeException
gibt.
Der Vollständigkeit halber sollte hier aber noch eine Konstellation erwähnt werden bei der die Exception
tatsächlich geworfen wird und wie man darauf verzichten kann.
public interface TreeSaver { AncestorTree save(AncestorTree tree) throws Exception }
Das Interface TreeSaver
wirft hier die Exception
, weil davon ausgegangen wird, dass die implementierenden Klassen eine Checked Exception werfen könnten. Ein traditioneller Fehler ist hier, die Implementierungen auch Exception
werfen zu lassen.
public class FileSaver extends AbstractFileSaver implements TreeSaver { public AncestorTree save(AncestorTree tree) throws Exception { return saveInternal(tree); } }
Die Implementierungen von TreeSaver
müssen jedoch nur die Exceptions angeben, die sie tatsächlich werfen.
public class FileSaver extends AbstractFileSaver implements TreeSaver { public AncestorTree save(AncestorTree tree) throws IOException { return saveInternal(tree); } } public class DatabaseSaver extends AbstractDatabaseSaver implements TreeSaver { public AncestorTree save(AncestorTree tree) throws SQLException{ return saveInternal(tree); } }
Leider bleibt damit das Problem erhalten, dass der Source Code, der mit dem TreeSaver
agieren muss, auf Exception
reagieren muss. Das Dilemma kann vermieden werden, wenn die Implementierungen auf Checked Exceptions verzichten und an dieser Stelle auf Unchecked Exceptions wechseln. So muss der DatabaseSaver
die SQLExceptions
selber verarbeiten und eine eigene Exception bereitstellen.
public class DatabaseSaver extends AbstractDatabaseSaver implements TreeSaver { public AncestorTree save(AncestorTree tree) { try { return saveInternal(tree); } catch (SQLException e) { throw new DatabaseException(e); } } }
Der Ansatz erscheint sinnvoll, weil die tatsächlich geworfenen Checked Exceptions nicht domänenspezifisch für den TreeSaver
sind, sondern einzig der jeweiligen Implementierung zur Laufzeit geschuldet sind.
Aus der Not haben viele APIs mittlerweile eine Tugend gemacht und verwenden nur Unchecked Exceptions.
Ein anderer Code-Smell der die Fehlerbehandlung regelmäßig heimsucht, ist die Organisation Exception und die Fehlercodes. Häufig ausgelöst durch Legacy Systeme werden Exceptions erstellt, die für diverse Fehler zuständig sind und diese durch einen Fehlercode repräsentieren. Dabei wird dann eine allgemeine Lösung für die gesamte Organisation präferiert und eine spezielle Exception etabliert.
public AncestryException extends Exception { public static final int UNKNOWN= 0; public static final int INVALID = 1; public static final int NO_OWNER= 2; public static final int CYCLE_DETECTED = 8; private final int errorCode; public AncestryException(int errorCode, String errorMessage) { super(errorMessage); this.errorCode = errorCode; } public int getErrorCode() { return errorCode; } }
Die AncestryException
sorgt für viele Probleme in der Entwicklung. Sie sorgt zuerst einmal für eine reduzierte Variante der Exception
Problematik. Viele Methoden werfen und fangen die AncestryException
und verwenden den Fehlercode bei der Fehlerkorrektur.
public void handleNewTree(AncestorTree tree) throws AncestryException { try { saveTree(validate(tree)); } catch (AncestryException e) { if (e.getErrorCode() != AncestryException.CYCLE_DETECTED) { throw e; } rejectTree(e); } }
Da hier int
Werte als Fehlercode verwendet werden, kann ein nachlässiger Entwickler statt der Konstanten einfach den numerischen Wert verwenden, statt AncestryException.CYCLE_DETECTED
also einfach die 8
. Oder ein Kollege verwendet eigene Werte, die nicht mit der Liste der Konstanten übereinstimmen. Auch könnte die Sammlung der numerischen Werte durch einen Kopierfehler Doubletten beinhalten.
Dann wird der numerische Wert durch einen Enum ersetzt, der aber die Situation nur bedingt verbessert. Entweder zwingt er die Entwickler, bei jeder neuen Fehlersituation eine neue Enum-Konstante zu etablieren oder, wie häufig beobachtet, es werden nur einige wenige Enum-Konstanten überhaupt verwendet. Beliebt sind dann INTERNAL_ERROR
, UNKNOWN_ERROR
und BUSINESS_ERROR
.
Kompliziertere Varianten dieser Lösung versuchen mit Unterklassen und speziellen Nummernkreisen Überschneidungen und falsche Benutzung zu unterbinden, können aber die grundlegenden Mängel nicht beseitigen.
Ist die Organisation involviert, dann werden manchmal die Exceptions unnötig durch andere Exceptions ersetzt. Dem liegt die Idee zugrunde, dass jedes Modul seine eigenen Domänen Exceptions haben soll. Wenn dann Exceptions aus aufgerufenen Modulen gefangen werden, werden eigene weitergeworfen.
public void handleData(Data data) throws ModulAusgangException { try { eingang.handleData(data); } catch (ModulEingangIoException e) { throw new ModulAusgangException(ModulAusgangException.IO, "io fehler am eingang"); } catch (ModulEingangParseException e) { throw new ModulAusgangException(ModulAusgangException.PARSE, e); } }
Im obigen Beispiel werden die ModulEingangIoException
und die ModulEingangParseException
gefangen und stattdessen eine ModulAusgangException
geworfen. Das Problem mit dieser Lösung ist, dass in beiden Fällen die Verarbeitung des tatsächlichen Fehlers erschwert wird. Im ersten Fall besonders schwer, da die ursprüngliche Exception und ihre Informationen vollständig verworfen wird. Hier steht nur noch der Typ und eine generische Meldung zur Verfügung. Im zweiten Fall werden die Informationen zwar gerettet, aber die Verarbeitung auf höherer Ebene wird erschwert.
public void handleData(Data data) { try { ausgang.handleData(data); } catch (ModulAusgangException e) { Throwable t = e.getCause(); if (t instanceof ModulEingangParseException) { fixParseing(data, ((ModulEingangParseException)t).getErrorCode()); } else if (e.getErrorCode() == ModulAusgangException.PARSE) { cleanInput(data, e.getMessage()); } } }
Die Fehlerbehandlung ist relativ kompliziert geworden, weil nun interpretiert werden muss, was der eigentliche Fehler war. Entweder steht die Information in der ursprünglichen Exception oder in den Daten der gefangenen Exception. Was auch augenfällig ist, die Fehlerbehandlung muss nun nicht nur den Ursprungsfehler kennen, sondern auch die Marotten des zweiten Moduls beachten.
Manchmal wird der Code zur Fehlerbehandlung so kompliziert, dass der ErrorHandler zum Leben erweckt wird. Dann werden alle diese komplizierten Auswertungen in ein Drittsystem ausgelagert. Wie Frankensteins Monster agiert er grobschlächtig ohne genau Kenntnisse der Zusammenhänge. Wenn Systeme wachsen und neue Funktionalitäten und Fehlermöglichkeiten hinzukommen, wird häufig vergessen diesen Mechanismus zu ergänzen. Dann ärgern sich viele Anwender über die unzureichenden Meldungen zu unerwarteten Fehlern.
Zum Ende des Beitrags noch einige sehr kleine aber dafür umso ärgerlichere Code-Smells bei der Fehlerbehandlung.
Für manche einfach erscheinende Fehlerbehandlung wird die Exception gefangen und keine Fehlerbehandlung ausgeführt.
try { handle(data, parse(data.getValue())); } catch (ParseException e) { }
Zum Zeitpunkt der Entwicklung gab es die Anforderung, dass handle
bei fehlerhaften Daten in data.getValue()
nicht aufgerufen werden sollte. Das geschah durch das Werfen der ParseException
in parse
. Da nicht einmal der Fehler in das Log geschrieben wird, fällt dann später nicht auf, wenn eine ParseException
in der handle
Methode den Abbruch wegen einer fehlerhaften Implementierung verursacht.
Auch wenn ein Log Eintrag geschrieben wird, sollte hier weiterhin Misstrauen bestehen. Bei fehlerhaften Daten wird keine echte Fehlerbehandlung vorgenommen und so können in einem späteren Verarbeitungsschritt weitere Fehler auftreten.
Manche Entwickler sind sehr vorsichtig und überall, wo sie einen Fehler vermuten, wird eine Exception gefangen und im Log notiert. Da aber keine Fehlerkorrektur vorgenommen wird, wirft der Entwickler die Exception einfach weiter. Dann passiert es immer wieder, dass eine Exception mehrfach gefangen, ins Log geschrieben und weiter geworfen wird. Das Log wird geschwemmt mit Einträgen zu einem einzigen Fehler. Daher ist die Faustregel, es wird dort der Fehler ins Log geschrieben, wo er auch behandelt wird. Nicht dort wo die Exception erstellt wurde und auch nicht überall dort, wo sie nur durchgereicht wurde.
Wer sich bei dem einem oder anderen Code-Smell ertappt fühlt, dem sei das folgende Zitat ans Herz gelegt.
“Die schlimmsten Fehler werden gemacht in der Absicht, einen begangenen Fehler wieder gut zu machen.”
Jean Paul