Robuste Webanwendungen schreiben Die verlorene Kunst der Ausnahmebehandlung

Als Entwickler möchten wir, dass die von uns erstellten Anwendungen im Fehlerfall stabil sind, aber wie erreichen Sie dieses Ziel? Wenn Sie glauben, dass der Hype, sind Mikrodienste und ein ausgeklügeltes Kommunikationsprotokoll die Antwort auf all Ihre Probleme oder vielleicht ein automatischer DNS-Failover. Während dieses Zeug seinen Platz hat und für eine interessante Konferenzpräsentation sorgt, ist die etwas weniger glamouröse Wahrheit, dass das Erstellen einer robusten Anwendung mit Ihrem Code beginnt. Selbst gut durchdachten und getesteten Anwendungen fehlt jedoch häufig eine wichtige Komponente für ausfallsicheren Code - die Ausnahmebehandlung.

Gesponserter Inhalt

Dieser Inhalt wurde von Engine Yard in Auftrag gegeben und vom Tuts + Team geschrieben und / oder bearbeitet. Unser Ziel mit gesponserten Inhalten ist es, relevante und objektive Tutorials, Fallstudien und inspirierende Interviews zu veröffentlichen, die unseren Lesern einen echten erzieherischen Wert bieten und die Erstellung nützlicherer Inhalte ermöglichen.

Ich bin immer wieder erstaunt darüber, wie unterausgenutzte Ausnahmebehandlung dazu neigt, sogar in ausgereiften Codebasen zu sein. Schauen wir uns ein Beispiel an.


Was kann möglicherweise schief gehen?

Angenommen, wir haben eine Rails-App, und unter anderem können Sie mit dieser App eine Liste der neuesten Tweets für einen Benutzer abrufen. Unsere TweetsController könnte so aussehen:

Klasse TweetsController < ApplicationController def show person = Person.find_or_create_by(handle: params[:handle]) if person.persisted? @tweets = person.fetch_tweets else flash[:error] = "Unable to create person with handle: #person.handle" end end end

Und das Person Das von uns verwendete Modell könnte dem folgenden ähneln:

Klasse Person < ActiveRecord::Base def fetch_tweets client = Twitter::REST::Client.new do |config| config.consumer_key = configatron.twitter.consumer_key config.consumer_secret = configatron.twitter.consumer_secret config.access_token = configatron.twitter.access_token config.access_token_secret = configatron.twitter.access_token_secret end client.user_timeline(handle).map|tweet| tweet.text end end

Dieser Code scheint absolut vernünftig zu sein, es gibt Dutzende von Apps, bei denen Code genauso wie dieser in der Produktion ist, aber schauen wir uns ein wenig genauer an.

  • find_or_create_by ist eine Rails-Methode, es ist keine "Knall" -Methode, also sollte es keine Ausnahmen geben, aber wenn wir uns die Dokumentation anschauen, können wir feststellen, dass diese Methode aufgrund der Funktionsweise dieser Methode eine ActiveRecord :: RecordNotUnique Error. Dies passiert nicht oft, aber wenn unsere Anwendung eine anständige Menge an Verkehr hat, geschieht dies wahrscheinlicher als erwartet (ich habe es schon oft gesehen)..
  • Zu diesem Thema kann jede Bibliothek, die Sie verwenden, unerwartete Fehler aufgrund von Fehlern in der Bibliothek selbst auslösen. Rails bildet dabei keine Ausnahme. Abhängig von unserem Niveau der Paranoia erwarten wir unsere find_or_create_by Jeder unerwartete Fehler kann jederzeit geworfen werden (eine gesunde Paranoia ist eine gute Sache, wenn es darum geht, robuste Software zu entwickeln). Wenn wir keine globalen Methoden zur Behandlung unerwarteter Fehler haben (wir werden dies weiter unten besprechen), möchten wir diese möglicherweise einzeln behandeln.
  • Dann ist da person.fetch_tweets Dadurch wird ein Twitter-Client instanziiert und versucht, Tweets abzurufen. Dies ist ein Netzwerkanruf und ist anfällig für alle Arten von Fehlern. Wir möchten vielleicht die Dokumentation lesen, um die möglichen Fehler herauszufinden, die wir erwarten könnten, aber wir wissen, dass Fehler nicht nur hier möglich sind, sondern durchaus wahrscheinlich (z. B. ist die Twitter-API nicht in Betrieb, eine Person mit diesem Handle könnte dies tun) nicht existieren etc.). Wenn Sie bei den Netzwerkanrufen keine Ausnahmebehandlungslogik verwenden, müssen Sie Probleme haben.

Unser winziger Code hat einige gravierende Probleme. Versuchen wir es besser zu machen.


Die richtige Menge der Ausnahmebehandlung

Wir wickeln unsere find_or_create_by und schieben Sie es in die Person Modell:

Klasse Person < ActiveRecord::Base class << self def find_or_create_by_handle(handle) begin Person.find_or_create_by(handle: handle) rescue ActiveRecord::RecordNotUnique Rails.logger.warn  "Encountered a non-fatal RecordNotUnique error for: #handle"  retry rescue => e Rails.logger.error "Beim Versuch, Person zu finden oder zu erstellen, ist ein Fehler aufgetreten: # Handle, # e.message # e.backtrace.join (" \ n ")" am Ende Ende Ende

Wir haben das erledigt ActiveRecord :: RecordNotUnique Laut der Dokumentation wissen wir jetzt, dass wir entweder eine bekommen Person Objekt oder Null Wenn etwas schief läuft. Dieser Code ist jetzt solide, aber was ist mit dem Abrufen unserer Tweets?

Klasse Person < ActiveRecord::Base def fetch_tweets client.user_timeline(handle).map|tweet| tweet.text rescue => e Rails.logger.error "Fehler beim Abrufen von Tweets für: # Handle, # e.message # e.backtrace.join (" \ n ")" null Ende des privaten Def-Clients @client || = Twitter :: REST :: Client.new do | config | config.consumer_key = configatron.twitter.consumer_key config.consumer_secret = configatron.twitter.consumer_secret config.access_token = configatron.twitter.access_token config.access_token_secret = configatron.twitter.access_token_secret end end end

Wir drängen den Twitter-Client auf seine eigene private Methode und da wir nicht wussten, was schief gehen könnte, wenn wir die Tweets abrufen, retten wir alles.

Möglicherweise haben Sie irgendwo gehört, dass Sie immer bestimmte Fehler finden sollten. Dies ist ein lobenswertes Ziel, aber die Leute interpretieren es oft falsch: "Wenn ich etwas nicht fangen kann, kann ich nichts fangen". In der Realität sollten Sie, wenn Sie etwas bestimmtes nicht fangen können, alles fangen! Auf diese Weise haben Sie zumindest die Möglichkeit, etwas zu tun, auch wenn Sie nur den Fehler protokollieren und erneut ausgeben möchten.

Nebenbei das OO-Design

Um unseren Code robuster zu machen, mussten wir umgestalten und jetzt ist unser Code wohl besser als zuvor. Sie können Ihren Wunsch nach flexiblerem Code verwenden, um Ihre Entwurfsentscheidungen zu informieren.

Nebenbei beim Testen

Jedes Mal, wenn Sie einer Methode eine Ausnahmebehandlungslogik hinzufügen, ist dies auch ein zusätzlicher Pfad durch diese Methode und muss getestet werden. Es ist wichtig, den außergewöhnlichen Weg zu testen, vielleicht mehr als den glücklichen Weg. Wenn auf dem glücklichen Weg etwas schief geht, haben Sie jetzt die Zusatzversicherung Rettung blockieren, um zu verhindern, dass Ihre App umfällt. Jede Logik innerhalb des Rettungsblocks selbst hat jedoch keine solche Versicherung. Testen Sie Ihren außergewöhnlichen Pfad gut, so dass dumme Dinge wie die Eingabe eines Variablennamens im Rettung Blockieren Sie nicht, dass Ihre Anwendung explodiert (dies ist mir so oft passiert - ernsthaft, testen Sie einfach Ihre Rettung Blöcke).


Was tun mit den Fehlern, die wir fangen?

Ich habe diese Art von Code unzählige Male im Laufe der Jahre gesehen:

begin widgetron.create rescue # muss nichts enden

Wir retten eine Ausnahme und machen nichts damit. Das ist fast immer eine schlechte Idee. Wenn Sie ein Produktionsproblem in sechs Monaten debuggen und herausfinden wollen, warum Ihr Widgetron nicht in der Datenbank angezeigt wird, werden Sie sich nicht daran erinnern, dass unschuldige Kommentare und stundenlange Frustration folgen werden.

Schlucke keine Ausnahmen! Zumindest sollten Sie alle Ausnahmen protokollieren, die Sie feststellen, zum Beispiel:

begin foo.bar rescue => e Rails.logger.error "# e.message # e.backtrace.join (" \ n ")" end

Auf diese Weise können wir die Protokolle durchsuchen und die Ursache und den Stack des Fehlers verfolgen.

Besser noch, Sie können einen Fehlerüberwachungsdienst wie Rollbar verwenden, was ziemlich nett ist. Das hat viele Vorteile:

  • Ihre Fehlermeldungen sind nicht mit anderen Protokollmeldungen durchsetzt
  • Sie erhalten Statistiken darüber, wie oft der gleiche Fehler aufgetreten ist (damit Sie herausfinden können, ob es sich um ein ernstes Problem handelt oder nicht)
  • Sie können zusammen mit dem Fehler zusätzliche Informationen senden, um das Problem zu diagnostizieren
  • Sie erhalten Benachrichtigungen (per E-Mail, Pagerduty usw.), wenn Fehler in Ihrer App auftreten
  • Sie können Implementierungen nachverfolgen, um zu sehen, wann bestimmte Fehler eingeführt oder behoben wurden
  • usw.
begin foo.bar rescue => e Rails.logger.error "# e.message # e.backtrace.join (" \ n ")" Rollbar.report_exception (e) end

Sie können natürlich einen Überwachungsdienst wie oben beschrieben protokollieren und verwenden.

Wenn dein Rettung Block ist das Letzte in einer Methode, ich empfehle eine explizite Rückgabe:

def my_method begin foo.bar rescue => e Rails.logger.error "# e.message # e.backtrace.join (" \ n ")" Rollbar.report_exception (e) kein Ende

Sie möchten möglicherweise nicht immer zurückkehren Null, Manchmal sind Sie mit einem Nullobjekt möglicherweise besser aufgehoben, oder was auch immer im Kontext Ihrer Anwendung sinnvoll ist. Die konsequente Verwendung von expliziten Rückgabewerten erspart jedem eine Menge Verwirrung.

Sie können auch den gleichen Fehler oder einen anderen Fehler in Ihrem beheben Rettung Block. Ein Muster, das ich oft als nützlich empfinde, ist, die vorhandene Ausnahme in eine neue zu packen und diese anzuheben, um die ursprüngliche Stack-Spur nicht zu verlieren (ich habe sogar einen Edelstein dafür geschrieben, da Ruby diese Funktionalität nicht standardmäßig bereitstellt.) ). Wenn wir später in einem Artikel über externe Dienste sprechen, werde ich Ihnen zeigen, warum dies nützlich sein kann.


Fehler global behandeln

In Rails können Sie angeben, wie Anforderungen für Ressourcen eines bestimmten Formats (HTML, XML, JSON) mithilfe von verarbeitet werden Antworten auf und Antworten mit. Ich sehe selten Apps, die diese Funktionalität korrekt verwenden, schließlich, wenn Sie keine Antworten auf Alles blockieren funktioniert gut und Rails rendert Ihre Vorlage korrekt. Wir haben unseren Tweets Controller über / tweets / yukihiro_matz und erhalten Sie eine HTML-Seite mit den neuesten Tweets von Matzs. Die Leute vergessen oft, dass es sehr einfach ist, ein anderes Format derselben Ressource anzufordern, z. /tweets/yukihiro_matz.json. An diesem Punkt wird Rails tapfer versuchen, eine JSON-Darstellung von Matzs Tweets zurückzugeben, aber es wird nicht gut funktionieren, da die Ansicht dafür nicht existiert. Ein ActionView :: MissingTemplate Der Fehler wird erhöht und unsere App wird auf spektakuläre Weise in die Luft gesprengt. Und JSON ist ein legitimes Format. In einer Anwendung mit hohem Datenaufkommen erhalten Sie genauso wahrscheinlich eine Anfrage /tweets/yukihiro_matz.foobar. Tuts + erhält diese Anfragen immer (wahrscheinlich von Bots, die versuchen, clever zu sein).

Die Lektion lautet: Wenn Sie nicht beabsichtigen, eine legitime Antwort für ein bestimmtes Format zurückzugeben, beschränken Sie Ihre Controller auf den Versuch, Anforderungen für diese Formate zu erfüllen. Im Falle unseres TweetsController:

Klasse TweetsController < ApplicationController respond_to :html def show… respond_to do |format| format.html end end end

Wenn wir nun Anfragen nach falschen Formaten erhalten, werden wir relevanter ActionController :: UnknownFormat Error. Unsere Controller fühlen sich etwas enger an, was eine großartige Sache ist, wenn es darum geht, sie robuster zu machen.

Fehler auf die Schiene bringen

Das Problem, das wir jetzt haben, ist, dass unsere Anwendung trotz unseres semantisch erfreulichen Fehlers immer noch im Gesicht unserer Benutzer auftaucht. Hier kommt die globale Ausnahmebehandlung ins Spiel. Manchmal führt unsere Anwendung zu Fehlern, auf die wir konsistent reagieren möchten, egal woher sie kommen (wie unsere ActionController :: UnknownFormat). Es gibt auch Fehler, die durch das Framework ausgelöst werden können, bevor einer unserer Codes zum Tragen kommt. Ein perfektes Beispiel dafür ist ActionController :: RoutingError. Wenn jemand eine URL anfordert, die nicht existiert, z / tweets2 / yukihiro_matz, Es gibt keinen Ort, an dem wir diesen Fehler mithilfe der herkömmlichen Ausnahmebehandlung beheben können. Hier ist Rails ausnahmen_app kommt herein.

Sie können eine Rack-App in konfigurieren application.rb aufgerufen werden, wenn ein Fehler erzeugt wird, den wir nicht behandelt haben (wie unser ActionController :: RoutingError oder ActionController :: UnknownFormat). Normalerweise sehen Sie diese Einstellung, wenn Sie Ihre Routen-App als ausnahmen_app, Definieren Sie dann die verschiedenen Routen für die Fehler, die Sie behandeln möchten, und leiten Sie sie an einen speziellen Fehlercontroller weiter, den Sie erstellen. So unser application.rb würde so aussehen:

… Config.exceptions_app = self.routes… 

Unsere routen.rb wird dann folgendes enthalten:

… Übereinstimmung mit '/ 404' => 'error # not_found', über:: alle Übereinstimmung mit '/ 406' => 'error # not_acceptable', über:: alle Übereinstimmung mit '/ 500' => 'error # internal_server_error', via: :alles… 

In diesem Fall unser ActionController :: RoutingError würde von der abgeholt werden 404 Route und die ActionController :: UnknownFormat wird vom abgeholt 406 Route. Es gibt viele mögliche Fehler, die auftauchen können. Aber solange Sie mit den üblichen umgehen (404, 500, 422 usw.) Um mit zu beginnen, können Sie andere hinzufügen, wenn und wann sie auftreten.

In unserem Fehlercontroller können wir nun die relevanten Vorlagen für jede Art von Fehler zusammen mit unserem Layout (wenn es sich nicht um eine 500 handelt) rendern, um das Branding aufrechtzuerhalten. Wir können die Fehler auch protokollieren und an unseren Überwachungsdienst senden. Die meisten Überwachungsdienste werden jedoch automatisch an diesen Prozess angeschlossen, sodass Sie die Fehler nicht selbst senden müssen. Wenn nun unsere Anwendung explodiert, geschieht dies sanft, mit dem richtigen Statuscode (abhängig vom Fehler) und einer Seite, auf der wir dem Benutzer eine Vorstellung darüber geben können, was passiert ist und was er tun kann (Support kontaktieren) - eine unendlich bessere Erfahrung. Noch wichtiger ist, dass unsere App viel solider erscheint (und tatsächlich auch sein wird).

Mehrere Fehler desselben Typs in einem Controller

In jedem Rails-Controller können wir spezifische Fehler definieren, die global innerhalb dieses Controllers behandelt werden sollen (unabhängig davon, in welcher Aktion sie ausgeführt werden). Dies geschieht über rescue_from. Die Frage ist, wann man sie benutzt retten von? Normalerweise finde ich, dass es ein gutes Muster ist, es für Fehler zu verwenden, die in mehreren Aktionen auftreten können (beispielsweise den gleichen Fehler in mehr als einer Aktion). Wenn ein Fehler nur durch eine Aktion erzeugt wird, behandeln Sie ihn über die herkömmliche beginnen ... retten ... ende Wenn jedoch der gleiche Fehler an mehreren Stellen wahrscheinlich ist und wir ihn auf dieselbe Weise behandeln möchten, ist dies ein guter Kandidat für einen retten von. Sagen wir unser TweetsController hat auch eine erstellen Aktion:

Klasse TweetsController < ApplicationController respond_to :html def show… respond_to do |format| format.html end end def create… end end

Lassen Sie uns auch sagen, dass diese beiden Aktionen einem begegnen können TwitterFehler und wenn, dann wollen wir dem Benutzer mitteilen, dass etwas mit Twitter nicht stimmt. Das ist wo retten von kann sehr praktisch sein:

Klasse TweetsController < ApplicationController respond_to :html rescue_from TwitterError, with: twitter_error private def twitter_error render :twitter_error end end

Jetzt müssen wir uns keine Sorgen darüber machen, wie wir damit umgehen, und sie werden viel sauberer aussehen und wir können / sollten natürlich unseren Fehler protokollieren und / oder unseren Fehlerüberwachungsdienst innerhalb der twitter_error Methode. Wenn du benutzt retten von richtig, es kann nicht nur helfen, Ihre Anwendung robuster zu gestalten, sondern auch Ihren Controller-Code sauberer machen. Dies macht es einfacher, Ihren Code zu warten und zu testen, wodurch Ihre Anwendung noch ein bisschen widerstandsfähiger wird.


Verwenden externer Dienste in Ihrer Anwendung

Es ist schwierig, heutzutage eine bedeutende Anwendung zu schreiben, ohne eine Reihe externer Services / APIs zu verwenden. Im Falle unseres TweetsController, Twitter kam über einen Ruby-Edelstein ins Spiel, der die Twitter-API einschließt. Idealerweise würden wir alle unsere externen API-Aufrufe asynchron ausführen. In diesem Artikel wird jedoch nicht auf asynchrone Verarbeitung eingegangen, und es gibt viele Anwendungen, die zumindest einige API / Netzwerk-Aufrufe prozessieren.

Netzwerkanrufe zu tätigen ist eine äußerst fehleranfällige Aufgabe und eine gute Ausnahmebehandlung ist ein Muss. Sie können Authentifizierungsfehler, Konfigurationsprobleme und Verbindungsfehler erhalten. Die von Ihnen verwendete Bibliothek kann eine beliebige Anzahl von Codefehlern erzeugen, und dann kommt es zu langsamen Verbindungen. Ich überfliege diesen Punkt, aber er ist so wichtig, da Sie langsame Verbindungen nicht mit Ausnahmebehandlung behandeln können. Sie müssen Timeouts in Ihrer Netzwerkbibliothek entsprechend konfigurieren. Wenn Sie einen API-Wrapper verwenden, stellen Sie sicher, dass dieser Hooks enthält, um Timeouts zu konfigurieren. Es gibt keine schlechtere Erfahrung für einen Benutzer, als warten zu müssen, ohne dass Ihre Anwendung darauf hinweist, was passiert. Fast alle vergessen, die Zeitüberschreitungen entsprechend zu konfigurieren (ich weiß, dass ich es getan habe).

Wenn Sie einen externen Dienst an mehreren Stellen in Ihrer Anwendung verwenden (z. B. mehrere Modelle), setzen Sie große Teile Ihrer Anwendung der gesamten Fehlerlandschaft aus, die produziert werden kann. Dies ist keine gute Situation. Was wir tun wollen, ist die Begrenzung unserer Gefährdung, und eine Möglichkeit, dies zu erreichen, besteht darin, den Zugang zu unseren externen Diensten hinter einer Fassade zu hinterlassen, alle Fehler dort zu retten und einen semantisch passenden Fehler erneut hervorzuheben (dies zu erheben TwitterFehler worüber wir gesprochen haben, falls Fehler auftreten, wenn wir versuchen, die Twitter-API zu treffen). Wir können dann Techniken wie verwenden retten von Um mit diesen Fehlern fertig zu werden, setzen wir keine großen Teile unserer Anwendung einer unbekannten Anzahl von Fehlern aus externen Quellen aus.

Eine noch bessere Idee könnte sein, Ihre Fassade zu einer fehlerfreien API zu machen. Geben Sie alle erfolgreichen Antworten so wie sie sind und geben Sie nils oder null Objekte zurück, wenn Sie einen Fehler retten (wir müssen uns immer noch mit einigen der oben genannten Methoden über die Fehler protokollieren). Auf diese Weise müssen wir nicht verschiedene Steuerflussarten miteinander kombinieren (Ausnahme: Kontrollfluss im Vergleich zu…), wodurch der Code wesentlich sauberer wird. Lassen Sie uns beispielsweise unseren Twitter-API-Zugriff in eine TwitterClient Objekt:

class TwitterClient attr_reader: client def initialize @client = Twitter :: REST :: Client.new do | config | config.consumer_key = configatron.twitter.consumer_key config.consumer_secret = configatron.twitter.consumer_secret config.access_token = configatron.twitter.access_token config.access_token_secret = configatron.twitter.access_token_secret end end defeste_tweets (). Karte | tweet | tweet.text rescue => e Rails.logger.error "# e.message # e.backtrace.join (" \ n ")" am Ende

Wir können das jetzt tun: TwitterClient.new.latest_tweets ('yukihiro_matz'), irgendwo in unserem Code, und wir wissen, dass es niemals einen Fehler erzeugen wird, oder vielmehr wird der Fehler niemals darüber hinaus verbreitet TwitterClient. Wir haben ein externes System isoliert, um sicherzustellen, dass Störungen in diesem System unsere Hauptanwendung nicht beeinträchtigen.


Was aber, wenn ich eine ausgezeichnete Testabdeckung habe??

Wenn Sie über einen gut getesteten Code verfügen, empfehle ich Sie mit großer Sorgfalt. Es wird einen langen Weg in Richtung einer robusteren Anwendung erfordern. Eine gute Testsuite kann jedoch oft ein falsches Sicherheitsgefühl bieten. Gute Tests können Ihnen dabei helfen, sich zuversichtlich umzustrukturieren und Sie vor Regression zu schützen. Sie können Tests jedoch nur für Dinge schreiben, die Sie erwarten. Bugs sind von Natur aus unerwartet. Um unser Tweets-Beispiel zu verwenden, bis wir einen Test für unser Team schreiben fetch_tweets Methode wo client.user_timeline (handle) wirft einen Fehler auf und zwingt uns dazu, a Rettung Wenn Sie den Code blockieren, werden alle unsere Tests grün sein und unser Code wäre fehleranfällig geblieben.

Beim Schreiben von Tests entbindet uns nicht die Verantwortung, einen kritischen Blick auf unseren Code zu werfen, um herauszufinden, wie dieser Code möglicherweise brechen kann. Auf der anderen Seite kann diese Art der Auswertung definitiv dazu beitragen, bessere und umfassendere Testsuiten zu erstellen.


Fazit

Elastische Systeme entstehen nicht vollständig aus einer Wochenend-Hack-Session. Eine Anwendung robust zu gestalten, ist ein fortlaufender Prozess. Sie entdecken Fehler, beheben sie und schreiben Tests, um sicherzustellen, dass sie nicht wiederkommen. Wenn Ihre Anwendung aufgrund eines Ausfalls eines externen Systems ausfällt, isolieren Sie das System, um sicherzustellen, dass der Fehler nicht erneut schneien kann. Ausnahmebehandlung ist Ihr bester Freund, wenn es darum geht. Sogar die fehleranfälligste Anwendung kann zu einer robusten werden, wenn Sie im Laufe der Zeit gute Ausnahmebehandlungsmethoden anwenden.

Natürlich ist die Behandlung von Ausnahmen nicht das einzige Werkzeug in Ihrem Arsenal, wenn es darum geht, Anwendungen widerstandsfähiger zu machen. In den folgenden Artikeln werden wir über asynchrone Verarbeitung sprechen, wie und wann sie angewendet werden soll und was sie tun kann, um Ihre Anwendung fehlertolerant zu machen. Wir werden auch einige Tipps zur Bereitstellung und zur Infrastruktur betrachten, die erhebliche Auswirkungen haben können, ohne die Bank in Bezug auf Geld und Zeit zu beeinträchtigen.