Go Error Handling Best Practices
Emily Parker
Product Engineer · Leapcell

Die Fehlerbehandlung in Go ist im Grunde nur ein Wert, und die Fehlerbehandlung besteht im Wesentlichen darin, Entscheidungen nach dem Vergleich von Werten zu treffen.
Business-Logik sollte Fehler nur bei Bedarf ignorieren; andernfalls sollten Fehler nicht ignoriert werden.
Theoretisch führt dieses Design dazu, dass Programmierer jeden Fehler bewusst behandeln, was zu robusteren Programmen führt.
In diesem Artikel werden wir über Best Practices für die korrekte Fehlerbehandlung sprechen.
TL;DR
- Fehler nur ignorieren, wenn es die Business-Logik erfordert; andernfalls jeden Fehler behandeln.
- Verwenden Sie das Paket
errors
, um Fehler für Stack-Informationen zu wrappen, Fehlerdetails genauer auszugeben undtrace_id
in verteilten Systemen zu verwenden, um Fehler von derselben Anfrage zu verknüpfen. - Fehler sollten nur einmal behandelt werden, einschließlich Protokollierung oder Implementierung von Fallback-Mechanismen.
- Halten Sie die Ebenen der Fehlerabstraktion konsistent, um Verwirrung zu vermeiden, die durch das Auslösen von Fehlern entsteht, die über dem aktuellen Modulevel liegen.
- Reduzieren Sie die Häufigkeit von
if err != nil
durch Top-Level-Design.
Genaue Fehlerprotokollierung
Fehlerprotokolle sind ein wichtiges Mittel, um uns bei der Fehlersuche zu helfen, daher ist es sehr wichtig, Protokolle auszugeben, die nicht leicht zu Verwirrung führen. Wie können wir err
verwenden, um Stack-Protokolle zu erhalten, die uns bei der Fehlersuche helfen?
Fragen Sie sich oft: Können die auf diese Weise protokollierten Fehler wirklich bei der Fehlersuche helfen?
Wenn wir den Fehler nicht durch einen Blick in das Protokoll genau lokalisieren können, ist das gleichbedeutend damit, den Fehler überhaupt nicht zu protokollieren.
Das Paket github.com/pkg/errors
bietet uns einen Wrapper, der den Stack enthält.
func callers() *stack { const depth = 32 var pcs [depth]uintptr n := runtime.Callers(3, pcs[:]) var st stack = pcs[0:n] return &st } func New(message string) error { return &fundamental{ msg: message, stack: callers(), } }
Das Drucken des Stacks wird erreicht, weil fundamental
das Interface Format
implementiert.
Dann kann fmt.Printf("%+v", err)
die entsprechenden Stack-Informationen ausgeben.
func (f *fundamental) Format(s fmt.State, verb rune) { switch verb { case 'v': if s.Flag('+') { io.WriteString(s, f.msg) f.stack.Format(s, verb) return } fallthrough case 's': io.WriteString(s, f.msg) case 'q': fmt.Fprintf(s, "%q", f.msg) } }
Betrachten wir ein konkretes Beispiel:
func foo() error { return errors.New("etwas ist schief gelaufen") } func bar() error { return foo() // Stack-Informationen an den Fehler anhängen }
Hier ruft foo
errors.New
auf, um einen Fehler zu erzeugen, und dann fügen wir mit bar
eine weitere Aufrufschicht hinzu.
Als nächstes schreiben wir einen Test, um unseren Fehler auszugeben:
func TestBar(t *testing.T) { err := bar() fmt.Printf("err: %+v\n", err) }
Die endgültige gedruckte Ausgabe enthält die Stacks von foo
und bar
.
err: something went wrong golib/examples/writer_good_code/exception.foo E:/project/github/go-lib-new/go-lib/examples/writer_good_code/exception/err.go:8 golib/examples/writer_good_code/exception.bar E:/project/github/go-lib-new/go-lib/examples/writer_good_code/exception/err.go:12 ...
Sie können sehen, dass die erste Zeile unsere Fehlermeldung korrekt ausgibt und der erste Stack-Trace darauf hinweist, wo der Fehler erzeugt wurde.
Mit diesen beiden Fehlerinformationen können wir sowohl die Fehlermeldung als auch den Ursprung des Fehlers sehen.
Fehlerverfolgung in verteilten Systemen
Wir können nun Fehler auf einem einzelnen Rechner genau ausgeben, aber in realen Programmen treten oft viele parallele Situationen auf. Wie können wir sicherstellen, dass Fehler-Stacks zur selben Anfrage gehören? Dies erfordert eine trace_id
.
Sie können eine trace_id
basierend auf Ihren eigenen Anforderungen und dem bevorzugten Format generieren und sie im Kontext setzen.
func CtxWithTraceId(ctx context.Context, traceId string) context.Context { ctx = context.WithValue(ctx, TraceIDKey, traceId) return ctx }
Bei der Protokollierung können Sie CtxTraceID
verwenden, um die traceId
abzurufen.
func CtxTraceID(c context.Context) string { if gc, ok := c.(*gin.Context); ok { // Trace-ID aus der Anfrage von Gin abrufen ... } // aus dem Go-Kontext abrufen traceID := c.Value(TraceIDKey) if traceID != nil { return traceID.(string) } // Eine generieren, wenn die Trace-ID fehlt return TraceIDPrefix + xid.New().String() }
Durch Hinzufügen einer traceID
zu Protokollen können Sie die gesamte Kette von Fehlerprotokollen für eine Anfrage abrufen, was die Schwierigkeit der Suche in Protokollen während der Fehlersuche erheblich reduziert.
Fehler sollten nur einmal behandelt werden
Ein Fehler sollte nur einmal behandelt werden, und die Protokollierung gilt auch als eine Möglichkeit, einen Fehler zu behandeln.
Wenn die Protokollierung an einer Stelle das Problem nicht lösen kann, besteht der richtige Ansatz darin, dem Fehler mehr Kontextinformationen hinzuzufügen, um deutlich zu machen, wo im Programm das Problem aufgetreten ist, anstatt den Fehler mehrfach zu behandeln.
Anfangs hatte ich ein Missverständnis bei der Verwendung von Fehlerprotokollen: Ich dachte, ich sollte Protokolle für einige Business-Fehler ausgeben, wie zum Beispiel:
- Falscher Benutzername/-kennwort
- Falscher SMS-Verifizierungscode des Benutzers
Da diese durch Benutzereingabefehler verursacht werden, müssen wir sie nicht behandeln.
Was wir wirklich beachten müssen, sind Fehler, die durch Fehler im Programm verursacht werden.
Bei normalen Fehlern in der Business-Logik ist es eigentlich nicht notwendig, Protokolle auf Fehlerebene auszugeben; zu viel Fehlermeldung würde nur echte Probleme verdecken.
Fallback für Fehler
Mit den obigen Methoden können wir nun Fehler in realen Projekten genau ausgeben, um bei der Fehlersuche zu helfen. Oft möchten wir jedoch, dass unser Programm sich "selbst heilt" und Fehler adaptiv behebt.
Ein häufiges Beispiel: Nach dem Fehlschlagen des Abrufs aus dem Cache sollten wir auf die Quelldatenbank zurückgreifen.
func GerUser() (*User, error) { user, err := getUserFromCache() if err == nil { return user, nil } user, err = getUserFromDB() if err != nil { return nil, err } return user, nil }
Oder, nachdem eine Transaktion fehlschlägt, möchten wir einen Kompensationsmechanismus einleiten. Nachdem beispielsweise eine Bestellung abgeschlossen wurde, möchten wir eine In-Site-Nachricht an den Benutzer senden.
Es kann vorkommen, dass wir die Nachricht nicht synchron senden können, aber in diesem Szenario ist die Echtzeitanforderung nicht besonders hoch, sodass wir versuchen können, die Nachricht asynchron erneut zu senden.
func CompleteOrder(orderID string) error { // Andere Logik zum Abschließen der Bestellung... message := Message{} err := sendUserMessage(message) if err != nil { asyncRetrySendUserMessage(message) } return nil }
Fehler absichtlich ignorieren
Wenn eine API vermeiden kann, Fehler zurückzugeben, müssen Aufrufer keine Anstrengungen unternehmen, um Fehler zu behandeln. Wenn also ein Fehler generiert wird, der aber keine zusätzlichen Maßnahmen des Aufrufers erfordert, müssen wir keinen Fehler zurückgeben, sondern den Code einfach normal ausführen lassen.
Dies ist das "Null Object Pattern". Ursprünglich sollten wir einen Fehler oder ein Null-Objekt zurückgeben, aber um Aufrufern die Fehlerbehandlung zu ersparen, können wir eine leere Struktur zurückgeben, wodurch die Fehlerbehandlungslogik im Aufrufer übersprungen wird.
Bei der Verwendung von Ereignisbehandlern können wir das "Null Object Pattern" verwenden, wenn ein nicht vorhandenes Ereignis vorliegt.
Beispielsweise möchten wir in einem Benachrichtigungssystem möglicherweise keine tatsächliche Benachrichtigung senden. In diesem Fall können wir ein Null-Objekt verwenden, um Null-Prüfungen in der Benachrichtigungslogik zu vermeiden.
// Notifier-Schnittstelle definieren type Notifier interface { Notify(message string) } // Konkrete Implementierung von EmailNotifier type EmailNotifier struct{} func (n *EmailNotifier) Notify(message string) { fmt.Printf("Senden einer E-Mail-Benachrichtigung: %s\n", message) } // Implementierung der Null-Benachrichtigung type NullNotifier struct{} func (n *NullNotifier) Notify(message string) { // Null-Implementierung, tut nichts }
Wenn eine Methode einen Fehler zurückgibt, wir ihn aber als Aufrufer nicht behandeln möchten, ist es am besten, _
zu verwenden, um den Fehler zu empfangen. Auf diese Weise sind andere Entwickler nicht verwirrt darüber, ob der Fehler vergessen oder absichtlich ignoriert wurde.
func f() { // ... _ = notify() } func notify() error { // ... }
Benutzerdefinierte Fehler wrappen
Transparente Fehler können die Kopplung zwischen Fehlerbehandlung und Fehlerwerterstellung reduzieren, lassen aber nicht zu, dass Fehler effektiv in der Logikbehandlung verwendet werden.
Die Behandlung von Logik basierend auf Fehlern macht den Fehler zu einem Teil der API.
Je nach Fehler müssen wir möglicherweise unterschiedliche Fehlermeldungen für höhere Schichten anzeigen.
Nach Go 1.13 wird empfohlen, errors.Is
zu verwenden, um Fehler zu überprüfen.
Fehlertypen können mit errors.As
überprüft werden, was jedoch bedeutet, dass öffentliche APIs weiterhin sorgfältig gewartet werden müssen.
Gibt es also eine Möglichkeit, die durch diesen Fehlerbehandlungsstil eingeführte Kopplung zu reduzieren?
Sie können Fehlermerkmale in ein einheitliches Interface extrahieren, und Aufrufer können Fehler in dieses Interface casten, um sie zu beurteilen. Das net-Paket behandelt Fehler auf diese Weise.
type Error interface { error Timeout() bool // Ist der Fehler ein Timeout? }
Dann implementiert net.OpError
die entsprechende Timeout
-Methode, um festzustellen, ob der Fehler ein Timeout ist und um eine bestimmte Business-Logik zu behandeln.
Fehlerabstraktionsebenen
Vermeiden Sie das Auslösen von Fehlern, deren Abstraktionsebene höher ist als die des aktuellen Moduls. Wenn beispielsweise beim Abrufen von Daten in der DAO-Schicht die Datenbank keinen Datensatz findet, ist es angebracht, einen RecordNotFound
-Fehler zurückzugeben. Es wäre jedoch nicht angebracht, direkt aus der DAO-Schicht einen APIError
auszulösen, nur um der oberen Schicht die Konvertierung von Fehlern zu ersparen.
Ebenso sollten niedrigere abstrakte Fehler verpackt werden, um der Abstraktion der aktuellen Schicht zu entsprechen. Nach dem Verpacken in der oberen Schicht hat es keine Auswirkungen auf die obere Schicht, wenn die untere Schicht ändern muss, wie sie Fehler behandelt.
Beispielsweise könnte die Benutzeranmeldung anfänglich MySQL als Speicher verwenden. Wenn es keine Übereinstimmung gibt, wäre der Fehler "Datensatz nicht gefunden". Wenn Sie später Redis verwenden, um Benutzer abzugleichen, wäre eine fehlgeschlagene Übereinstimmung ein Cache-Fehler. In diesem Fall möchten Sie nicht, dass die obere Schicht den Unterschied im zugrunde liegenden Speicher spürt, daher sollten Sie konsistent einen Fehler "Benutzer nicht gefunden" zurückgeben.
err != nil
reduzieren
Die Häufigkeit von if err != nil
kann durch Top-Level-Design reduziert werden. Einige Fehlerbehandlungen können auf niedrigeren Ebenen gekapselt werden, und es ist nicht erforderlich, sie oberen Ebenen zugänglich zu machen.
Durch die Reduzierung der zyklomatischen Komplexität von Funktionen können Sie die Anzahl der wiederholten Überprüfungen auf if err != nil
reduzieren. Durch die Kapselung von Funktionslogik muss die äußere Schicht beispielsweise den Fehler nur einmal behandeln.
func CreateUser(user *User) error { // Zur Validierung werfen Sie einfach einen Fehler, anstatt ihn zu verteilen if err := ValidateUser(user); err != nil { return err } }
Sie können den Fehlerstatus auch in eine Struktur einbetten, Fehler innerhalb der Struktur kapseln und den Fehler nur am Ende zurückgeben, wenn etwas schief gelaufen ist, sodass die äußere Schicht ihn einheitlich behandeln kann. Dies vermeidet das Einfügen mehrerer if err != nil
-Prüfungen in die Business-Logik.
Nehmen wir als Beispiel eine Datenkopieraufgabe. Sie übergeben die Quell- und Zielkonfiguration und führen dann die Kopie aus.
type CopyDataJob struct { source *DataSourceConfig destination *DataSourceConfig err error } func (job *CopyDataJob) newSrc() { if job.err != nil { return } if job.source == nil { job.err = errors.New("Quelle ist nil") return } // Quelle instanziieren } func (job *CopyDataJob) newDst() { if job.err != nil { return } if job.destination == nil { job.err = errors.New("Ziel ist nil") return } // Ziel instanziieren } func (job *CopyDataJob) copy() { if job.err != nil { return } // Daten kopieren ... } func (job *CopyDataJob) Run() error { job.newSrc() job.newDst() job.copy() return job.err }
Sie können sehen, dass, sobald ein Fehler auftritt, jeder Schritt in Run
weiterhin ausgeführt wird, aber jede Funktion err
sofort überprüft und zurückkehrt, wenn er gesetzt ist. Nur am Ende wird job.err
an den Aufrufer zurückgegeben.
Obwohl dieser Ansatz die Anzahl von err != nil
in der Hauptlogik reduzieren kann, verteilt er die Überprüfungen tatsächlich nur und reduziert sie nicht wirklich. Daher wird dieser Trick in der realen Entwicklung selten verwendet.
Wir sind Leapcell, Ihre erste Wahl für das Hosting von Go-Projekten.
Leapcell ist die Serverless-Plattform der nächsten Generation für Webhosting, asynchrone Aufgaben und Redis:
Multi-Language Support
- Entwickeln Sie mit Node.js, Python, Go oder Rust.
Stellen Sie unbegrenzt Projekte kostenlos bereit
- Zahlen Sie nur für die Nutzung – keine Anfragen, keine Gebühren.
Unschlagbare Kosteneffizienz
- Pay-as-you-go ohne Leerlaufgebühren.
- Beispiel: 25 $ unterstützt 6,94 Mio. Anfragen bei einer durchschnittlichen Antwortzeit von 60 ms.
Optimierte Entwicklererfahrung
- Intuitive Benutzeroberfläche für mühelose Einrichtung.
- Vollautomatische CI/CD-Pipelines und GitOps-Integration.
- Echtzeitmetriken und -protokollierung für verwertbare Erkenntnisse.
Mühelose Skalierbarkeit und hohe Leistung
- Automatische Skalierung zur einfachen Bewältigung hoher Parallelität.
- Kein operativer Overhead – konzentrieren Sie sich einfach auf das Bauen.
Erfahren Sie mehr in der Dokumentation!
Folgen Sie uns auf X: @LeapcellHQ