Für Entwickler ist das Programmieren der ganz normale Alltag. Fehlerhandling und Logging klingen da erst einmal selbstverständlich. Doch wie behandle ich welche Art von Fehler und wie protokolliere ich diese dann? Auch Logging ist nicht einfach. Lieber zu viel oder zu wenig und mit welchem Loglevel?
Am 13. September 2019 fand unser interner Konferenztag statt, an dem wir uns Strategien für Fehlerhandling und das richtige Vorgehen für Logging angeschaut haben. Hier gibt es eine kleine Zusammenfassung.
Man stelle sich einen Online-Shop vor. Der Benutzer möchte seine Bestellung abschicken. Was passiert im Hintergrund? Das Backend nimmt die Bestellung entgegen und verarbeitet diese. Dazu muss zuerst das Geld für die Ware über einen Bezahldienst abgebucht werden, danach muss der Benutzer über E-Mail benachrichtigt werden und zu guter Letzt sollte sich der Status der Bestellung in der Datenbank aktualisieren, damit entsprechende Systeme auch die Ware an den Kunden verschicken (siehe Abbildung 1). Was zunächst einfach klingt, kann sehr komplex werden, wenn einer der Schritte fehlschlägt.
Abbildung 1: Verarbeiten eines Bestellvorgangs in einem Online-Shop
Was passiert wenn z.B. der E-Mail Service einen Fehler meldet? Im schlimmsten Fall wurde dem Kunden schon das Geld abgebucht, ihm aber keine Nachricht gesendet und der Bestellstatus wurde ebenfalls nicht aktualisiert. Für den Kunden sieht es nun so aus: Sein Geld ist weg, die Bestellung wurde nie abgeschlossen und eine Bestätigung per E-Mail hat er auch nicht erhalten.
Deshalb ist Fehlerhandling wichtig. Beim Fehlerhandling können unterschiedliche Szenarien verfolgt werden: Retry, Undo, Ignore, Coordinate.
Die Retry-Strategie werden die meisten schon verwendet haben. Ist ein Vorgang nicht erfolgreich oder tritt ein Fehler auf, so wird der Vorgang einfach noch einmal ausgeführt, in der Hoffnung, dass er beim zweiten Mal erfolgreich ist. Was bei manchen Vorgängen sinnvoll sein kann, ist bei anderen eine Katastrophe. Eine Retry-Strategie beim Abbuchen von Geld kann zur Folge haben, dass der gleiche Betrag mehrfach abgebucht wird.
Für solche Probleme bieten APIs und Services mittlerweile Abhilfe unter dem Stichwort Idempotenz. Idempotenz in der Softwareentwicklung bedeutet, dass ein Vorgang mehrfach ausgeführt werden kann, aber immer das gleiche Ergebnis dabei herauskommt.
Für den Fall unserer Buchung bedeutet das: Wenn wir dem Payment Service für jede Abbuchung eine eindeutige ID mitgeben, so können wir die Buchung beliebig oft durchführen und der Service garantiert uns, dass das Geld nur einmal abgebucht wurde.
Für den Fall, dass die Buchung erfolgreich war, aber das Update in die Datenbank fehlgeschlagen ist, sollte natürlich die Buchung über eine Undo-Vorgang rückgängig gemacht werden. Bei einer Geldabbuchung könnte der Undo-Vorgang z.B. eine Rückerstattung des Betrags sein. Dadurch wird dem Kunde kein Geld abgezogen, wenn die Bestellung nicht weiterverarbeitet werden kann.
Für den Fehler beim E-Mailversand kann ganz einfach die Ignore-Strategie gewählt werden. Da es keinen Undo für E-Mails gibt und auch ein Retry eher darin endet, dass der Benutzer mit E-Mails bombardiert werden könnte, sollte der Fehler einfach ignoriert werden. Ignorieren bedeutet aber nicht, nichts zu tun. Denn das Mindeste ist, diesen Fehler zu protokollieren, um später die Ursache zu analysieren.
Die letzte Strategie die sich in manchen Fällen anbietet ist Coordinate. Dazu wird ein unabhängiger Koordinator, der die einzelnen Vorgängen in zwei Phasen (Prepare und Execute) ausführt, verwendet.
Nehmen wir den Payment Service, so wird beim Prepare z.B. die Zahlung vorgemerkt und bei Execute die vorgemerkte Zahlung durchgeführt. Schlägt nun das Senden der E-Mail fehl, so hat der Koordinator im ersten Schritt lediglich die Zahlung über die Prepare-Phase vorgemerkt und kann diese rückgängig machen. War alles erfolgreich so kann er die Execute-Phase ausführen und die Zahlung durchführen.
Selbst wenn das Fehlerhandling sinnvoll gewählt ist, gilt es immer noch, im Nachgang den Fehler analysieren zu können. Dazu ist das Protokollieren unabdingbar. Hierbei stellen sich aber immer wieder unterschiedliche Fragen: Welches Logging Framework, welche Loglevel oder auch wohin loggen?
Bei der Wahl des Logging Frameworks muss zwischen zwei Arten unterschieden werden: Schreibt man eine Bibliothek, die von anderen konsumiert wird, oder schreibt man eine Anwendung.
Wer eine Bibliothek schreibt, möchte sich ungern von einem Logging Framework abhängig machen, da später die ausführende Anwendung dieses vorgibt. Deshalb bietet es sich in diesem Fall an, auf eine Logging Abstraktion wie z.B. LibLog oder CommonLogging zu setzen. Diese erkennen während der Laufzeit, welches Logging Framework die ausführende Applikation verwendet und nutzen dann automatisch dieses. Der Author der Bibliothek muss sich darüber keine Gedanken machen.
Wer eine Anwendung schreibt hat die Qual der Wahl. Was sich in der Praxis bewährt hat ist Serilog, NLog und Log4net. Für jede gibt es unterschiedliche Benchmarks die zeigen, wieso das jeweilige Logging Framework das bessere ist. Am Ende muss man diese Frage für sich selbst beantworten.
Wichtig ist nur: Man sollte am besten ein strukturiertes Logging verwenden. Das bedeutet: Protokollnachrichten werden heute meist als Text geschrieben, welche aber aus verschiedenen Parametern bestehen. Mit strukturiertem Loggen werden diese Parameter noch einmal separat kenntlich gemacht, um später in großen Protokollen besser danach suchen und filtern zu können.
Im nächsten Schritt sollte einheitlich geklärt werden, welche Art von Nachricht welches Loglevel bekommt. Denn dieses entscheidet letztendlich wie wichtig und wie dringend die Nachricht ist. Muss auf diese Nachricht direkt reagiert oder kann sie sogar über einen Filter ignoriert werden?
Die Einteilung kann jedes Projektteam für sich selbst treffen, wichtig ist nur, dass es klar dokumentiert und an das Team kommuniziert wird. Wer eine grobe Einteilung braucht, kann sich an Abbildung 2 orientieren. Diese gibt für die gängigsten Loglevel eine schöne Übersicht um die Kritikalität der Meldungen einzustufen.
Abbildung 2: Definition von Logleveln
Zu guter Letzt muss noch definiert werden, wohin die Protokolle gespeichert werden. Hierbei kommt es ganz auf die Art der Applikation an, ob einfaches Protokollieren auf der Console, in Dateien, Event Log oder Datenbank gewählt werden sollte.
Lokale Protokolle in Dateien oder in Event Log lassen sich nur schwer auswerten, da man Zugriff auf den Rechner braucht. Deshalb ist zentrales Logging z.B. über Application Insights oder den Elastic Stack von Vorteil. Diese bringen nicht nur den Vorteil, dass die Protokolle an eine zentrale Stelle geschickt werden, an welcher diese dann ausgewertet werden können, sondern können noch viel mehr. So können Korrelationen zwischen Aufrufen und Exceptions eingesehen werden, um nachzuvollziehen, wie es zu einem Fehler kam.
Schlussendlich lässt sich sagen: Wer ein Stück Code schreibt, sollte sich immer Gedanken über mögliche Fehlerszenarien machen und die passende Strategie wählen. Logging ist dabei essentiell, um nicht nur Fehler, sondern auch das Verhalten der Anwendung zu analysieren. Das Auswerten von Logs kann aber nur sinnvoll passieren, wenn sich vorher alle einig sind wie und in welchem Detail geloggt werden soll und dieses auch für alle nachvollziehbar dokumentiert ist.