Fehler- und Ausnahmebehandlung

Nein, das ist kein Bild von Gipf-Oberfrick sondern...
Beitrag erstellt am: 12.06.19

Einführung

Ausnahmebehandlung und Geheimnisprinzip vertragen sich schlecht, vgl. dazu Moderne Software Architektur, S. 97. Denn um sinnvoll mit auftretenden Ausnahmen umgehen zu können, muss der Verwender einerseits einen gewissen Überblick über den Gesamtkontext und andererseits Kenntnis der Implementierung haben, die hinter einer Schnittstelle steckt. Beides ist mit dem Geheimnisprinzip nur schwer in Einklang zu bringen. Die zentrale Frage lautet: Was ist eine Ausnahme? Insbesondere geht es dabei auch um die Frage, inwieweit es dem Verwender einer Schnittstelle zuzumuten ist, auf Ausnahmen reagieren zu müssen.

TL;DR

Wir unterscheiden sorgfältig zwischen Fehler und Ausnahme! Wir unterscheiden weiter zwischen der Behandlung und der Protokollierung von Fehlern- und Ausnahmen! Fehler gehören zum erwarteten Verhalten einer Schnittstelle bzw. Funktion. Der Aufrufer bzw. Verwender muss mit Fehlern rechnen und sich zwingend darum kümmern. Fehler melden wir stets über Rückgabewerte. Ausnahmen sind, wie ihr Name bereits sagt: aussergewöhnlich. Der Aufrufer bzw. Verwender darf Ausnahmen konsequent ignorieren. Ausnahmen werden von dedizierten Komponenten behandelt.

Definitionen

Fehler

Ein Fehler beschreibt ein fehlerhaftes Ergebnis, das aus Sicht des Verwenders einer Schnittstelle zu erwarten ist. Der Verwender hat daher Vorkehrungen zu treffen, um mit solchen fehlerhaften Ergebnissen umgehen zu können. Doch wann ist ein fehlerhaftes Ergebnis ein zu erwartender Fehler?

Hier hilft eine weitere Frage: Passt das fehlerhafte Ergebnis zur Abstraktionsebene der Schnittstelle? Wenn ja, dann handelt es sich um einen zu erwartenden Fehler: Der Verwender der Schnittstelle muss im normalen Programmfluss damit rechnen und umgehen.

Wenn der Fehler aber nicht zur Abstraktionsebene passt, weil er z.B. ein technisches Problem betrifft, dann handelt es sich um eine Ausnahme, mit der im normalen Programmfluss nicht umgegangen werden muss, siehe nächsten Abschnitt Ausnahme.

Achtung: die übermässige Nutzung von Omnitypes erschwert diese Unterscheidung. Hier hilft das Konzept ausdrucksstarker Domänentypen (Update 15.09.21: siehe hierzu: Domain Primitives)

Erwartete Fehler können prinzipiell auf zwei Arten an den Aufrufer gemeldet werden: als Rückgabewert oder mittels technischer Exception. Ich verzichte mittlerweile auf die Nutzung technischer Exceptions und nutze stattdessen Rückgabewerte:
  • mit Hilfe eines absorbierenden Elements, insbesondere, wenn wir die in den Signaturen unserer Schnittstellen verwendeten Typen als Monoid modellieren. 
  • mit Hilfe funktionaler Konstrukte wie Try, Either oder Optional.

Ausnahme

Eine Ausnahme ist ein Ereignis, mit dem der Verwender unter normalen Umständen nicht zu rechnen hat und daher auch keine Vorkehrungen für dessen Behandlung treffen muss.

Ausnahmen unterbrechen den normalen Kontrollfluss und werden stets durch einen speziellen Ausnahme-Manager behandelt. Ausnahmen sind, wie der Name bereits andeutet, aussergewöhnlich, sie treten selten auf.

Wenn eine Ausnahme häufig auftritt, dann handelt es sich um ein Entwurfsproblem, wir sollten die entsprechende Komponente anders entwerfen.

Was sich zur Laufzeit nicht behandeln lässt, ist stets abnorm: ein syntaktischer Fehler in einer QML-Datei, eine falsch formatierte JSON-Zeichenkette sind immer Anlass für eine Ausnahme. Abnorme Ergebnisse sind in der Regel implementierungsabhängig. Alleine deswegen ist es weder sinnvoll noch möglich, alle abnormen Ergebnisse als Teil der Schnittstelle aufzuzählen.

Vor- und Nachbedingungen (Guards)

Vorbedingungen schützen die aufgerufene Komponente vor unsachgemässen Aufrufen.

Verletzte Vorbedingungen sind abnorm, sie führen somit stets zu einer Ausnahme. Es ist sinnlos, dem Aufrufer die unsachgemässe Verwendung mittels Fehler-Code zu melden, in der Hoffnung, dass dieser es beim nächsten Aufruf besser macht.

Nachbedingungen schützen dagegen den Aufrufer einer Komponente vor einer unsachgemässen Implementierung. In der Regel ist es wenig sinnvoll, Nachbedingungen direkt in der Implementierung zu prüfen. Nachbedingungen sind teil der Definition einer Schnittstelle und somit die ideale Basis für automatisierte Tests.

Ausnahmebehandlung

Beim Auftreten einer Ausnahme stehen uns folgende Varianten zur Verfügung:
  • Protokollieren und weitermachen: Manchmal ist eine Ausnahme aus Sicht des Verwenders gar nicht schlimm; in diesem Fall können wir die Ausnahme protokollieren und die Arbeit fortsetzen.
  • Protokollieren und Schaden begrenzen: Bedeutet eine Ausnahme das endgültige Scheitern, so tun wir folgendes: Wir protokollieren das Problem ausführlich, um dem Entwickler ein Hilfsmittel für die Problemlösung an die Hand zu geben. Dann gilt es, Schadensbegrenzung zu betreiben, indem z.B. noch offene Transaktionen zurückgesetzt und Systemressourcen freigegeben werden. Dann gilt es zu entscheiden, welcher Teil der Anwendung zu beenden ist. Im schlimmsten Fall muss die gesamte Anwendung beendet werden.
  • Abwarten und Wiederholen: Manchmal kann es sinnvoll sein, eine bestimmte Zeit abzuwarten oder eine bestimmte Anzahl von Wiederholungen zu versuchen. In jedem Fall gehört dieses Vorgehen in die Ausnahmebehandlung und hat im normalen Programmablauf nichts zu suchen.
  • Re-Konfiguration: Steht für eine Softwarekomponente eine alternative Implementierung zur Verfügung, so kann versucht werden, diese zu verwenden. Inwieweit diese Strategie innerhalb eines Systems sinnvoll eingesetzt werden kann, muss von Fall zu Fall entschieden werden.

Nach erfolgter Ausnahmebehandlung hat jede Funktion bzw. Methode nur noch zwei mögliche Ausgänge:

  • Normales Ergebnis: die Methode wird ohne Ausnahme beendet. Hierbei kann es sich auch um einen Fehler handeln. Der Aufrufer erfährt nichts von einer möglichen Ausnahmebehandlung.
  • Endgültiges und sicheres Scheitern: Der Aufruf ist endgültig gescheitert. Der Aufrufer hat nur noch die Wahl zwischen Aufgabe und Re-Konfiguration. Weitere Aufrufversuche wären in jedem Fall sinnlos.

Sicherheitsfassade - optimistische versus pessimistische Nutzung von Komponenten

Softwarekomponenten können auf zwei Arten verwendet werden: optimistisch oder pessimistisch. Wird eine Komponente optimistisch verwendet, dann trifft der Aufrufer keinerlei Vorkehrungen gegen das Auftreten von Ausnahmen. Zwischen den involvierten Komponenten entsteht dadurch eine Risikogemeinschaft.

In der Regel können wir die internen Aufrufe einer Software-Komponente optimistisch umsetzen. Da Robustheit ein wichtiges Qualitätsmerkmal eines Systems ist, lassen wir dagegen den optimistischen Aufruf zwischen Softwarekomponenten nur in wohl begründeten Ausnahmen zu; der Standardfall soll hier der pessimistische Aufruf sein.

Beim pessimistischen Aufruf verwenden wir eine Sicherheitsfassade, die mit möglichen Ausnahmen umgehen kann. Die Sicherheitsfassade implementiert die gleiche Schnittstelle wie die zu sichernde Softwarekomponente. Ihre einzige Aufgabe besteht darin, auftretende Ausnahmen gemäss vorstehenden Varianten zu behandeln.

Die Sicherheitsfassade ermöglicht es uns, den normalen Kontrollfluss strikte von der Ausnahmebehandlung zu trennen und dabei auch dafür zu sorgen, dass das Geheimnisprinzip gewahrt bleibt. Zudem stützt diese Art der Ausnahmebehandlung eine iterativ inkrementelle Entwicklung, da wir die nötigen Sicherheitsfassaden Schritt für Schritt ergänzen und deren Implementierung nach und nach verfeinern können. Wer funktionale Programmierung nutzt, dem reicht hierzu übrigens meist eine partiell applizierte Funktion.

Schnittstellen für Diagnose und Reparatur (R&D-Schnittstelle)

Jede Komponente kann eine eigene Schnittstelle für Diagnose und Reparatur (D&R-Schnittstelle) anbieten. Fachliche Komponenten (Komponenten der Kategorie A) verfügen nur selten über eine solche D&R-Schnittstellen.

Für technische Schnittstellen (Komponenten der Kategorie T) ist eine D&R-Schnittstelle dagegen nützlich; sie ermöglicht den Zugriff auf Internas, die über die öffentliche Schnittstelle der Komponente nicht zugänglich sind. Die Diagnose- und Reparatur-Schnittstelle ist lediglich der implementierenden Komponente und der Sicherheitsfassade bekannt, andere Komponenten benötigen sie nicht.

Empfehlungen

  1. Wir unterscheiden konsequent zwischen Fehlern und Ausnahmen. 
  2. Wir entdecken Ausnahmen so früh wie möglich. 
  3. Wir behandeln Fehler sofort. Fehler werden nie weitergereicht! 
  4. Wir verzichten auf das technische Konzept der Exception für Fehler. Wir melden Fehler stattdessen stets mittels Rückgabewert, beispielsweise als absorbierendes Element oder mit Hilfe funktionaler Konstrukte wie Try, Either oder Optional. 
  5. Sämtliche Guards, die eine Softwarekomponente zu ihrem Schutz implementiert (Vorbedingungen), verursachen im Falle einer fehlgeschlagenen Prüfung eine Ausnahme. 
  6. Der optimistische Aufruf zwischen Softwarekomponenten führt dazu, dass die beteiligten Komponenten eine Risikogemeinschaft bilden. Daher fassen wir unsere Komponenten zu sinnvollen Risikogemeinschaften zusammen. Eine solche Risikogemeinschaft formt ihrerseits eine Komponente. 
  7. Wir kapseln den pessimistischen Aufruf einer Komponente mit Hilfe einer Sicherheitsfassade. 
  8. Sicherheitsfassaden fangen Ausnahmen und behandeln sie – sonst niemand. 
  9. Wir kapseln den Zugriff auf den internen Status eines Objekts zu Diagnose- und Reparaturzwecken mittels einer entsprechenden Diagnose- und Reparaturschnittstelle (D&R-Schnittstelle)