...und dann kam Polly

Stabilere Anwendungen mit dem Framework Polly
Keine Kommentare

Fehlerbehandlung und stabiler Wiederaufsatz sind die Achillesfersen so mancher Softwareprojekte. Es kann sehr frustrierend sein, wenn vielleicht das Netzwerk kurz aussetzt oder der Speicherplatz auf der Festplatte zur Neige geht. Professionelle Software zeichnet sich dadurch aus, dass sie auch solche Szenarien vorsieht und korrekt behandelt. Noch einfacher geht das allerdings, wenn man hierfür auf ein bestehendes Framework zurückgreifen kann.

Saubere Fehlerbehandlung bzw. ihr Nichtvorhandensein ist ein Problem, mit dem wohl jeder Softwareentwickler schon in irgendeiner Form zu tun hatte. Sei es als Verursacher eines solchen Problems, wenn ein vermeintlich triviales Codefragment plötzlich doch eine Ausnahme verursacht, oder als Betroffener, wenn Code von Kollegen oder Vorgängern mit einem Mal die Stabilität der Anwendung beeinträchtigt. Selbstverständlich sollten wir es allesamt besser wissen, werden ab der ersten Stunde Programmierunterricht darauf gedrillt und gehen mit besten Absichten in jedes Projekt – und dennoch schleichen sich immer wieder Fehler in Anwendungen ein, die darauf zurückzuführen sind, dass man als Entwickler viel zu oft nur den „Happy Case“ durchdenkt und testet und viel zu selten die Ausnahmefälle. Warum das so ist, lässt sich möglicherweise anhand eines trivialen Beispiels plausibel und nachvollziehbar erklären (wenn auch nicht unbedingt entschuldigen). Stellen wir uns eine hypothetische Anwendung vor, die von Zeit zu Zeit oder auch ereignisgesteuert bestimmte Daten im Dateisystem ablegt, um auf diese Weise dem Benutzer zum Beispiel manuelles Speichern abzunehmen oder Wiederherstellungspunkte zu gewährleisten. Das Schreiben dieser Daten geschieht in einer Methode, die wir PersistApplicationData nennen:

private void PersistApplicationData();

Da diese Methode Zugriffe auf das Dateisystem vornimmt, kann das natürlich in gewissen Fällen zu Ausnahmen führen. So könnte beispielsweise kein Speicherplatz mehr auf dem Datenträger vorhanden sein. Eine Datei könnte unerwarteterweise von einer anderen Anwendung gerade exklusiv verwendet werden (Indexierungsdienste und Virenscanner sind hier immer für Überraschungen gut). Oder möglicherweise hat unsere Anwendung die Schreibrechte auf das Verzeichnis aus irgendeinem Grund verloren. Kurzum: Das Dateisystem ist als externe, nicht vollständig kontrollierbare Abhängigkeit zu betrachten, und der Aufruf unserer Methode muss jedenfalls mit einem try-catch-Konstrukt geschützt werden. Jede Verwendung von try-catch zieht unmittelbar die Frage nach sich, welche Art(en) von Exception(s) man nun damit abfangen möchte. Wählt man hier die Basisklasse Exception, sind damit zwar alle Fälle abgedeckt, allerdings ist das meist viel zu generisch. Ausnahmen vom Typ NullReferenceException oder AccessViolationException deuten beispielsweise meist auf Fehler in der Anwendungslogik hin, die wir vermutlich rechtzeitig entdecken und nicht still und heimlich ignorieren wollen. Somit bietet sich doch eher eine spezifische Ausnahmebehandlung an. Nehmen wir für unseren Fall an, dass mit Ausnahmen vom Typ IOException und InvalidOperationException zu rechnen sein wird, was in zwei catch-Blöcken resultiert. Gänzlich ignorieren will man aufgetretene Fehler im Normalfall auch nicht, also erhält jeder der beiden catch-Blöcke zumindest eine Zeile Logging-Code, um Informationen zum Fehler zu protkollieren.

Als Nächstes müssen wir uns darüber Gedanken machen, wie die Anwendung nach dem Auftreten einer Ausnahme weiterlaufen soll. Wir gehen dazu grundsätzlich von einer „Retry-Logik“ aus, d. h. wir nehmen an, dass es sich beim Fehler tatsächlich um eine Ausnahme (im eigentlich Sinn des Wortes) handelt und dieselbe Operation bei einem erneuten Aufruf gelingen könnte. Die Konsequenz daraus ist eine Schleife, die die try-catch-Blöcke und den darin geschützten Aufruf von PersistApplicationData wiederholt aufrufbar macht. Dies kann entweder eine Endlosschleife sein, wenn man gewährleisten kann, dass der Aufruf irgendwann tatsächlich gelingen wird, oder eine Schleife, die nach einer maximalen Anzahl von Versuchen terminiert. Egal für welche Variante man sich entscheidet, es muss auf jeden Fall per Code sichergestellt werden, dass die Schleife im Erfolgsfall auch tatsächlich wieder verlassen wird.

Ein weiteres Detail gilt es allerdings immer noch zu bedenken: Scheitert ein Methodenaufruf aufgrund eines externen Umstands– wird z.B. eine Datei gerade von einem anderen Prozess exklusiv verwendet – so ist die Wahrscheinlichkeit, dass ein sofortiger Wiederaufruf erneut scheitert, erfahrungsgemäß relativ hoch. Es drängt sich daher ein Mechanismus zum Drosseln dieser Programmlogik auf. Im einfachsten Fall handelt es sich dabei um einen Aufruf von Thread.Sleep mit einer konstanten Anzahl von Millisekunden. Oft wählt man für solche Fälle auch einen inkrementellen Ansatz, der bei jedem erneuten Versuch länger als beim vorherigen pausiert, indem man zum Beispiel die Schleifendurchlaufvariable als Multiplikator heranzieht.

Das alles resultiert vermutlich in einem Codefragment, das dem aus Listing 1 recht nahekommt und uns das eigentliche Kernproblem von fehlertolerantem Code vor Augen führt: Aus dem Aufruf einer einzelnen Methode (PersistApplicationData) sind plötzlich zwanzig Zeilen Code geworden, die mit der eigentlichen Applikationslogik nichts zu tun haben und lediglich der Absicherung der Methode dienen. Daraus ergibt sich in weiterer Folge, dass Fehlertoleranz in einer Anwendung, bzw. deren Abwesenheit, oft viel weniger ein technisches als ein psychologisches Problem ist. Sei es, dass man als Entwickler nur ungern den soeben entwickelten und als so elegant empfundenen Code mit derart unleserlichen und wenig ästhetischen Konstrukten verunstalten möchte, oder dass man schlichtweg zu faul ist, um jede potenzielle Problemstelle derart aufwendig abzusichern. Abhilfe schaffen in diesem Fall natürlich generische und wiederverwendbare Codestücke, die man dann an den entsprechenden Stellen verwenden kann. Idealerweise muss man diese generischen und wiederverwendbaren Blöcke nicht einmal selbst implementieren, sondern kann sie in Form eines Frameworks verwenden, das sich in der Praxis bereits bewährt hat und noch viel mehr Anwendungsfälle abdeckt als jene, die uns bisher spontan eingefallen sind.

private static void GuardPersistApplicationData()
{
  const int RETRY_ATTEMPTS = 5;
  for (var i = 0; i < RETRY_ATTEMPTS; i++) {
    try
    {
      Thread.Sleep(i * 100);
      PersistApplicationData();
      // Aufruf erfolgreich => Schleife kann verlassen werden.
      break;
    }
    catch (IOException e)
    {
      Log(e);
    }
    catch (UnauthorizedAccessException e)
    {
      Log(e);
    }
  }
}

Auftritt: Polly

Glücklicherweise existiert mit dem Projekt Polly bereits ein Framework, das genau die Anforderungen erfüllt, die wir soeben erarbeitet haben, und das sich auch schon einige Jahre in der Praxis bewähren durfte. Das NuGet-Paket Polly wurde erstmals am 5. Mai 2013 in der Version 1.0 veröffentlicht, lag zum Zeitpunkt des Verfassens dieses Artikels bereits in der Version 5.9.0 vor und kann mehr als drei Millionen Downloads vorweisen. Neben .NET 4.0 und 4.5 unterstützt Polly mittlerweile sogar .NET Standard 1.1 und steht somit auch für plattformübergreifende Projekte mit .NET Core zur Verfügung. Es handelt sich also ganz offensichtlich um ein sehr ausgereiftes Softwareprojekt, in das schon viel Know-how und praktische Erfahrung gewandert sind.

Nutzen wir diese Erfahrung also auch in unserem fiktiven Softwareprojekt und sehen wir uns an, wie sich die Methode PersistApplicationData mithilfe von Polly absichern und fehlertolerant verwenden lässt. Zuallererst müssen wir unserem Projekt eine Referenz auf das Polly-NuGet-Paket hinzufügen, was sich in Visual Studio am komfortabelsten über die eingebaute Paketverwaltung (Abb. 1) oder in .NET Core mit folgendem Kommandozeilenaufruf erledigen lässt:

dotnet add package Polly

Ein weiteres Indiz für die Ausgereiftheit dieses Frameworks und den tatsächlichen Einsatz in der Praxis ist die Tatsache, dass Polly bzw. das NuGet-Paket auch in einer signierten Variante vorliegt. Dieses Paket trägt den Namen Polly-Signed und lässt sich ebenso einfach dem eigenen Projekt hinzufügen:

dotnet add package Polly-Signed

Nachdem dieser Schritt erfolgreich erledigt wurde, steht im Namensraum Polly die Klasse Policy bereit, mit der sich die vorher explizit implementierten Defensivmechanismen deklarativ formulieren lassen. Die Verwendung dieser Klasse erfolgt grundsätzlich in Form des FluentInterface-Entwurfsmusters und erwartet folgende Informationsblöcke:

  • Handle: Welche Ausnahmen können auftreten und sollen abgefangen werden?
  • Retry: Welche Strategie soll für den Wiederaufsatz verwendet werden?
  • Execute: Dabei handelt es sich um den eigentlichen Code, der geschützt ausgeführt werden soll.

Das wesentliche Unterscheidungsmerkmal liegt dabei in der Strategie, wie mit Fehlern und Ausnahmen umgegangen werden soll. Sehen wir uns also in weiterer Folge eine Auswahl dieser Strategien an.

Abb. 1: Das NuGet-Paket Polly

Bis in alle Ewigkeit

Möchte man die zuvor erwähnte Strategie verfolgen, im Fehlerfall beliebig oft eine Wiederholung anzustreben, bis der Methodenaufruf endlich gelingt, so lässt sich das am einfachsten mit folgender Zeile Code implementieren:

Policy.Handle<Exception>.RetryForever().Execute(PersistApplicationData)

Die Methode Handle erhält als generischen Typparameter die Art der Ausnahme (hier die Basisklasse Exception), dem Aufruf von Execute kann als Action die eigentliche Anwendungslogik in Form der Methode PersistApplicationData übergeben werden und Retry-Forever kümmert sich um den (potenziell endlos) wiederholten Aufruf dieser Methode.

So weit, so einfach, doch natürlich wollen wir auch hier dieselbe Sorgfalt wie im eingangs aufgeführten Beispiel walten lassen und verfallen nicht in die Unsitte, Ausnahmen vom generischen Basistyp Exception zu fangen. Um auch hier wieder mehr als einen Ausnahmetyp behandeln zu können, lässt sich zusätzlich die Methode Or verwenden:

Policy.Handle<IOException>().Or<UnauthorizedAccessException>()...

Auch hier gilt immer noch, dass wir das Auftreten einer Ausnahme nicht vollkommen ignorieren wollen, sondern wie zuvor zumindest einen Logeintrag erzeugen sollten. Zu diesem Zweck lässt sich der Methode RetryForever ein Ausdruck übergeben, der das entsprechende Exception-Objekt als Parameter erhält:

Policy.Handle<Exception>().RetryForever(e => Log(e)) ...

n-mal ist genug!

Eine Reihe der Anforderungen aus unserem Beispiel lässt sich bereits mit den bisher kennen gelernten Polly-Logikblöcken und der RetryForever-Strategie umsetzen. Doch gerade der Ansatz der potenziell endlosen Wiederholung eines Codeblocks mag doch für viele Szenarien eher ungeeignet sein. Wir werfen daher einen Blick auf die etwas flexiblere Strategie Retry. Der gleichlautenden Methode wird dabei als Parameter die maximale Anzahl von Wiederholungen übergeben, und so würde der Aufruf für unser Beispiel mit einem Maximum von zehn Wiederholungen folgendermaßen aussehen:

Policy.Handle<Exception>().Retry(10).Execute(PersistApplicationData);

Ein Aufruf von Retry ohne Angabe eines Parameters bewirkt eine einmalige Wiederholung des Execute-Blocks, entspricht also dem Aufruf von Retry(1). Analog zum Aufruf von RetryForever kann auch beim Aufruf von Retry ein Ausdruck übergeben werden, um aufgetretene Ausnahmen weiter behandeln oder protokollieren zu können. Zusätzlich steht ein weiterer numerischer Parameter zur Verfügung, der angibt, im wievielten Retry-Aufruf man sich gerade befindet:

Retry(count, (e, i) => Log($"Error '{e.Message}' at retry #{i}"))

Gemach, gemach

Eine letzte Anforderung, die aus dem ursprünglichen Beispiel noch offen ist, ist die Möglichkeit der Drosselung des Wiederaufsatzes. Wenig überraschend bietet Polly auch hier eine Lösung mit der Strategie WaitAndRetry bzw. WaitAndRetryForever an. Der einfachste Aufruf von WaitAndRetry geschieht durch die Übergabe einer IEnumerable Collection vom Typ Timespan. Die Anzahl der Elemente in der Collection geben dabei implizit die maximale Anzahl der Versuche an, und die einzelnen Timespan-Elemente bestimmen die Wartezeit vor dem Aufruf. Folgendes Codestück würde beispielsweise maximal zwei Wiederaufsatzversuche unternehmen und dabei zuerst 100 Millisekunden und dann 200 Millisekunden pausieren:

Policy.Handle<Exception>().WaitAndRetry(new []{
  TimeSpan.FromMilliseconds(100), TimeSpan.FromMilliseconds(200) })...

Auch eine dynamische Berechnung dieser Wartezeit lässt sich mit WaitAndRetry selbstverständlich realisieren. Mit der Angabe der maximalen Versuche und eines Ausdrucks, der den entsprechenden Timespan errechnet, ergibt sich beispielsweise folgender Code, der maximal fünf Versuche unternimmt und nach jedem erfolglosen Versuch eine Sekunde länger pausiert als zuvor:

.WaitAndRetry(5, count => TimeSpan.FromSeconds(count))

Will man an dieser Stelle aus irgendeinem Grund dennoch unbegrenzt neue Versuche starten, lässt sich dafür die Methode RetryAndWaitForever verwenden, die erwartungsgemäß ohne den ersten Parameter auskommt:

.WaitAndRetryForever(count => TimeSpan.FromSeconds(count))

Schutzschalter

Abschließend werfen wir noch einen Blick auf die Strategie CircuitBreaker, die von den bisher kennen gelernten Mechanismen etwas abweicht, in der Praxis aber möglicherweise am relevantesten sein könnte. Das Prinzip hinter dieser Strategie bzw. eine Analogie aus der realen Welt ist ein Stromschutzschalter. Das Pendant zum Fehlerstrom, der den Schutzschalter auslöst, ist in der Softwareanwendung erwartungsgemäß die Ausnahme. Passiert eine solche innerhalb eines spezifizierten Intervalls in einer kritischen Menge, wird der Fluss der Anwendung unterbrochen und der entsprechende Anwendungscode (in unserem Fall die Methode PersistApplicationData) schlichtweg nicht mehr aufgerufen. Anders als im Fall des realen Schutzschalters, der manuell wieder aktiviert werden muss, kann sich ein CircuitBreaker-Konstrukt nach einer definierten Zeitspanne wieder erholen und Aufrufe des geschützten Codes erneut erlauben. Auf diese Art und Weise lässt sich ein Codestück, das aktuell Ausnahmen verursacht, automatisiert abschalten; das kann den Vorteil haben, dass eine ohnehin instabile Ressource (Server, Dateisystem…) nicht noch zusätzlicher Last ausgesetzt wird. Listing 2 zeigt die Verwendung der CircuitBreaker-Strategie anhand eines konkreten Beispiels. Der Code in diesem Beispiel bewirkt, dass beim Auftreten von fünf Ausnahmen vom Typ IOException oder UnauthorizedAccessException der virtuelle Schaltkreis unterbrochen und erst nach zwei Minuten wieder aktiviert wird. Anders als bisher verwenden wir die deklarierte Policy nicht statisch, sondern speichern sie in einer Variablen. Der Vorteil liegt nun darin, dass der Aufruf von Execute nun an einer oder mehreren beliebigen anderen Stellen im Code der Anwendung erfolgen kann und die so erzeugte CircuitBreaker-Policy immer und überall automatisch dafür sorgt, dass die Anwendungslogik entweder ausgeführt oder übersprungen wird.

var policy = Policy
  .Handle<IOException>().Or<UnauthorizedAccessException>()
  .CircuitBreaker(5, TimeSpan.FromMinutes(2));
// ...
// ... weitere Anwendungslogik
// ...
policy.Execute(PersistApplicationData);

Vorgeschmack

Selbstverständlich war das nur ein kleiner Auszug der Funktionalität und Logikblöcke, die das Framework Polly zur Verfügung stellt. Alle im Artikel vorgestellten Konzepte sind zum Beispiel auch als asynchrone Varianten ausführbar (RetryForeverAsync, RetryAsync, WaitAndRetryAsync, CircuitBreakerAsync), was ihre Verwendung erfreulicherweise kaum komplizierter macht. Speziell die CircuitBreaker-Strategie bietet noch viele weitere Mechanismen zur Konfiguration, die im GitHub Repository des Projekts jedoch ausführlich dokumentiert werden. Generell ist es vielleicht hilfreich und in jedem Fall interessant, die dort aufgeführten Beispiele und auch die Implementierung der einzelnen Strategien einmal im Detail durchzusehen und mit dem eigenen Wissen und Verständnis rund um das Thema Fehlerbehandlung und Wiederaufsatz abzugleichen. Vielleicht führt es den einen oder anderen ja dazu, selbstgestrickten und vielleicht wenig geliebten Code durch Polly Policies zu ersetzen. Oder umgekehrt: Vielleicht lassen sich Muster und Strategien, die man selbst mühsam identifiziert und entwickelt hat, in dieses Framework einbringen, sodass andere davon profitieren können.

Unsere Redaktion empfiehlt:

Relevante Beiträge

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu:
X
- Gib Deinen Standort ein -
- or -