Die Golumne

Don’t panic: Fehlermanagement in der Welt von Go

Frank Müller

© Reneé French (CC BY 3.0 DE) ©SuS Media

Keiner mag Fehler – weder der Entwickler noch der Nutzer einer Software. Und so strengen wir Entwickler uns intensiv an, sie durch gutes Design, sorgfältige Entwicklung und umfangreiches Testen zu vermeiden oder zumindest frühzeitig zu finden. Wie Fehler in der Programmiersprache Go richtig behandelt werden, zeigt Frank Müller in diesem Teil seiner Golumne.

Es gibt immer wieder Fehler, die sich gerade während der Laufzeit nicht vermeiden lassen: Dateien, die zur Software gehören und eigentlich immer da sind, werden versehentlich gelöscht. Der Hauptspeicher wird durch andere Anwendungen aufgebraucht. Netzwerkverbindungen werden abgebrochen oder weisen viel höhere Latenzen als gewöhnlich auf. Externe Anwendungen, mit denen kommuniziert wird, oder Datenbanksysteme fallen aus. Diese Liste könnte ich noch ewig fortsetzen, zu umfangreich ist der Dschungel möglicher Fehlerquellen.Doch wie soll man mit diesen Fehlern umgehen? Verschiedene Sprachen verfolgen hierzu unterschiedliche Ansätze. Sehr üblich sind heutzutage sogenannte Exceptions. Der Name sagt es bereits: Sie werden als Ausnahmen betrachtet. Als etwas, das nicht sein darf, nicht sein kann. Aber auch hier gibt es unterschiedliche Strategien:

Einerseits Laufzeitfehler, die nach dem Motto „Es wird sich schon jemand darum kümmern“ geworfen werden. Andererseits eben Exceptions, die in einigen statischen Sprachen eine sie werfende Funktion bzw. Methode zumindest deklarieren müssen. Ein Aufrufer, der sich nicht um diesen Ausnahmefehler kümmern möchte, muss ihn im Falle einer Exception dann ebenfalls deklarieren.

Dieser Weg schafft etwas Transparenz in der Fehlerbehandlung, verführt aber auch zu zwei möglichen Wegen: Einerseits werden von einer Funktion, die andere mit Exceptions nutzen, diese nur weitergereicht. In diesem Fall wird der Call Stack bis zur Ursache immer tiefer. Andererseits werden größere Blöcke in ein try/catch geklammert und die anschließende Behandlung der auslösenden Exception fällt schwer. Variablen sind teilweise nicht im Scope oder es ist kompliziert, den Verursacher zu identifizieren. Es werden bspw. Daten aus dem Netz in die Dateien A und B geschrieben. Während bei A alles fehlerfrei verläuft, löst B eine IOException aus. Nun gilt es, A noch sauber zu schließen, oder gar ein sauberes Rollback (im Falle einer Datenbank) oder eine entsprechende Meldung (bei einem Netzwerkempfänger) durchzuführen. Hier zeigen sich einige Probleme, die bei undifferenzierter Behandlung auftreten können.

DevOpsCon Istio Cheat Sheet

Free: BRAND NEW DevOps Istio Cheat Sheet

Ever felt like service mesh chaos is taking over? Then our brand new Istio cheat sheet is the right one for you! DevOpsCon speaker Michael Hofmann has summarized Istio’s most important commands and functions. Download FOR FREE now & sort out your microservices architecture!

Fehler in der Welt von Go

Die Entwickler von Go haben jedoch eine andere Vorstellung von Fehlern. Sie betrachten diese als ganz normale Werte, konkret als solche vom Typ error. Das ist ein internes Interface, das nur die Methode Error() string implementiert. Eigene Implementierungen können aber weitere Methoden für zusätzliche Fehlerinformationen im Gepäck haben. Funktionen und Methoden in Go müssen übrigens keine Fehler zurückgeben, das ist optional, etwa bei sehr einfachen Funktionen oder bei der direkten internen Behandlung aller Fehler.

Ansonsten ist es jedoch Konvention, den Fehlerwert als einzigen oder letzten Wert zurückzugeben, bspw. Foo() error oder Bar() (string, error). Liegt kein Fehler vor, ist dieser Rückgabewert einfach nil. Gibt es mehrere Rückgabewerte, ist es Konvention, entweder gültige Werte und dazu den Fehlerwert nil oder alle Werte als Nullwerte sowie den korrekten Fehler als Ergebnis zu liefern. Rückgabewerte zusammen mit einem Fehler sind zu vermeiden.

Nehmen wir als Beispiel eine ganzzahlige Division. Sie soll entweder das korrekte Ergebnis oder einen Fehler zurückgeben. Hierzu ist es notwendig, beide Typen in der Rückgabe zu haben. Gleichzeitig wird für die Erzeugung eines Fehlers das Package errors importiert:

func Divide(a, b int) (int, error) {
  if b == 0 {
    return 0, errors.New("cannot divide by zero")
  }
  return a/b, nil
}

Die beiden Werte werden im Beispiel zwei empfangenden Variablen zugewiesen. Anschließend ist der Fehlerwert auf nil zu prüfen. Sofern das Ergebnis nur innerhalb eines Blocks benötigt wird, gibt es auch einen weiteren Ansatz (Listing 1).

// Variant A.
d, err := Divide(5, 2)
if err != nil {
  // Handle the error.
  ...
}

// Variant B.
if d, err := Divide(5, 2); err == nil {
  // Use result d.
  ...
}

Und so bestehen viele Funktionen in Go aus einer linearen Reihe von Funktionsaufrufen, jeweils gefolgt von den dazugehörigen Prüfungen auf einen Fehler. Dieser Fakt stört so manchen Umsteiger, denn er ist vielfach ungewohnt, führt jedoch zu einer unmittelbaren Reaktion auf Fehler. Sofern die Fehlerkontrolle positiv oder eine eventuelle Maßnahme darauf erfolgreich ist, geht die Ausführung linear weiter (Listing 2).

func Yadda() error {
  if err := Foo(); err != nil {
    return fmt.Errorf("yadda: cannot foo: %v", err)
  }
  b, err := Bar(1234)
  if err != nil {
    return fmt.Errorf("yadda: cannot bar 1234: %v", err)
  }
  ...
  return Baz(b)
}

Es mag anfangs müßig erscheinen, doch funktioniert es sehr gut – u. a. auch im Zusammenspiel mit defer. Dieses Kommando schiebt Funktionen auf einen Ausführungsstapel, der rückwärts zum Verlassen der Funktion ausgeführt wird. In der in Listing 3 gezeigten Funktion zur Dateikopie werden zwei Dateien nacheinander geöffnet. Wenn das fehlerfrei abläuft, wird der Funktionsaufruf zum Schließen auf den Stapel geschoben und kann vergessen werden.

func CopyFile(src, dst string) error {
  fsrc, err := os.Open(src)
  if err != nil {
    return fmt.Errorf("copy: cannot open %s: %v, src", err)
  }
  defer fsrc.Close()

  fdst, err := os.Open(dst)
  if err != nil {
    return fmt.Errorf("copy: cannot open %s: %v", dst, err)
  }
  defer fdst.Close()

  ...
}

Und wie prüfe ich einen Fehler nun genauer? So generisch wie oben gezeigt ließe sich natürlich der String prüfen, den die Methode Error() zurückgibt. Ohne Argumente lassen sich diese in einem Package bereits als exportierte Variablen definieren und so später direkt vergleichen. Leider sind keine aus einem Funktionsaufruf gesetzten Konstanten möglich. Eine weitere Variante ist die Definition eines eigenen Fehlertyps mit einem zusätzlichen Error Code sowie einer Funktion zur Prüfung auf Typ und Code.

func IsError(err error, code int) bool {
  myErr, ok := err.(MyError)
  if !ok {
    return false
  }
  return myErr.Code() == code
}

Zu guter Letzt gibt es noch einige Bibliotheken, die genau diese Funktionalität zur Verfügung stellen. Ein sehr gutes Beispiel ist Package errors.

Panik, was nun?

Bedeutet das nun, dass es keine Ausnahmen in Go gibt? Nein, natürlich nicht. Ein Teilen durch 0 ist weiterhin ein schwerer Fehler. Solche Fehler löst das Laufzeitsystem bei Bedarf selbst aus. Andererseits können auch Funktionen in Bibliotheken das dringende Anliegen haben, eine schwere Ausnahme auszulösen. Hierfür bringt Go den Befehl panic() mit. Ein panic(„ouch“) beendet das Programm umgehend, zusammen mit der Ausgabe der entsprechenden Meldung und der Callstacks der individuellen Prozesse. Ein einfaches Ignorieren und Fortsetzen des Programms wie bei einem Error ist nicht möglich. Damit zeigt sich auch, dass eine Panik nicht für die Anzeige eines einfachen Fehlers gedacht ist.

Dennoch gibt es eine Analogie zum catch, den man von klassischen Exceptions kennt. Ein Beispiel wird zeigen, wie es zum Einsatz kommen kann, denn so manches Mal möchte ich eine Umgebung schaffen, die ein robustes Umfeld für Komponenten herstellt. Die Idee ist es, Events durch eine Reihe von Komponenten verarbeiten zu lassen. Sollte eine Komponente hierbei durch eine Panik wirklich empfindlich aus dem Tritt geraten, soll das einerseits die anderen nicht beeinflussen, andererseits der betroffenen Komponente noch eine Chance zur Erholung gegeben werden. Sie wird also zuerst durch das Interface EventProcessor definiert:

type EventProcessor interface {
  Process(e Event)
  Recover(r interface{}) error
}

Die Methode Recover() wird gleich noch näher betrachtet. Mein Eventbus soll nun erst einmal mehrere dieser dynamisch konfigurierbaren Prozessoren haben und ihnen die Events zur Verarbeitung zuführen (Listing 4).

func (b *Bus) Process(e Event) error {
  var errs []error
  for _, ep := range b.processors {
    if err := b.process(e, ep); err != nil {
      errs = append(errs, err)
    }
  }
  // Create combined error and return it.
  ...
}

Nun wird die Verarbeitung des Events sicher verpackt, denn Go kann mit der Funktion recover() prüfen, ob es etwas zu behandeln gibt. Diese Funktion gibt das zurück, was panic() zuvor als Argument mitgegeben wurde. Und das kann ich dem EventProcessor für seine Prüfung zur Verfügung stellen (Listing 5).

func (b *Bus) process(e Event, ep EventProcessor) (err error) {
  defer func() {
    if r := recover(); r != nil {
      err = ep.Recover(r)
    }
  }()
  ep.Process(e)
  return nil
}

Was hier natürlich noch fehlt, sind Dinge wie ein sauberer Shutdown des EventProcessor, wenn dieser beim Recover() einen Fehler zurückgibt. Aber es zeigt den groben Rahmen, wie die Kombination aus panic(), recover() und defer in einer Bibliothek mit Komponenten für ein robustes System Einsatz finden kann.

Und die Zukunft?

Go 2 wirft seine Schatten voraus. Gerade erst wurden auf der GopherCon mehr Details der Überlegungen vorgestellt, die „Go 2 Draft Designs“ dokumentieren hier den aktuellen Stand. Das Konzept der Fehlerbehandlung wirkt ein wenig wie Exceptions, ohne jedoch diese explizit zu werfen. Auch die Schachtelung ist nicht eminent und für Panics ist das Konzept ebenfalls nicht gedacht. Ziel ist vielmehr eine kürzere Prüfung auf zurückgegebene Fehler, zusammen mit einer oder mehrerer Behandlungen innerhalb einer Funktion. Letzteres entspricht ein wenig dem Gedanken des defer mit dem recover().

func CopyFile(src, dst string) error {
  handle err {
    return fmt.Errorf("copy %s %s: %v", src, dst, err)
  }

  r := check os.Open(src)
  defer r.Close()

  w := check os.Create(dst)
  handle err {
    w.Close()
    os.Remove(dst) // (only if a check fails)
  }

  check io.Copy(w, r)
  check w.Close()
  return nil
}

Listing 6 zeigt, wie mehrere handle sinnvoll eingesetzt werden können und der Quelltext weiterhin schön flach bleibt. Auch hier können Entwickler für ihre Funktionen wieder genau festlegen, ob sie handle mehrfach, nur einmal oder auch gar nicht verwenden möchten.

Der Entwurf wird bereits rege diskutiert. Es wäre eine Abkehr von Gewohntem, was dem Menschen ja oftmals schwerfällt. Andere hingegen sehen die Vereinfachung des Codes durch nur zwei weitere eingängige Schlüsselwörter. Ich persönlich war im ersten Moment auch etwas erschrocken. Doch mit einem Blick auf den Draft, die Gedankengänge, die Vergleiche und schließlich auf den Code – und auf den kommt es schließlich an – kann ich mir diese Änderung im Go der Zukunft sehr gut vorstellen.

Verwandte Themen:

Geschrieben von
Frank Müller
Frank Müller
Der Oldenburger Frank Müller ist seit über dreißig Jahren in der IT zu Hause und im Netz vielfach als @themue anzutreffen. Das Interesse an Googles Sprache Go begann 2009 und führte inzwischen zu einem Buch sowie mehreren Artikeln und Vorträgen zum Thema.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu: