CodeGym /Kurse /Go SELF /Wrapping-Strategie: was wir fur Logs behalten

Wrapping-Strategie: was wir fur Logs behalten

Go SELF
Level 55, Lektion 1
Verfügbar

1. Warum „dem Benutzer err.Error() anzeigen“ eine schlechte Gewohnheit ist

Wenn Sie eine CLI-Anwendung schreiben, ist es sehr leicht, nach dem Prinzip zu leben: „Wenn es absturzt, drucken wir err und fertig.“ In kleinen Lernprogrammen wirkt das sogar ehrlich: „Na, da ist doch der Fehler, was will man mehr?“ Aber in der realen Welt hat ein Fehler mindestens drei Zielgruppen, und jede hat andere Erwartungen.

Der Benutzer will eine kurze und verstandliche Erklarung, was er falsch gemacht hat (oder was das Programm nicht tun konnte). Skripte und Automatisierung wollen ein stabiles Verhalten (meistens exit code und ein vorhersagbares Fehlerformat). Und der Entwickler will Details: welche Datei, welche Operation, welche Ursache, und am besten so, dass man sie in den Logs wiederfindet.

Lassen Sie uns dieses Bild als Schema festhalten:

flowchart TD
    U[Benutzer] -->|liest| MSG[Kurze Meldung in stderr]
    S[Skript/CI] -->|liest| CODE[Exit code]
    D[Entwickler] -->|liest| LOG[Logs/Diagnose]

    ERR[Fehler in Go] --> MSG
    ERR --> CODE
    ERR --> LOG

Die Hauptidee: ein und derselbe Fehler soll drei Kanale gleichzeitig bedienen, aber nicht mit demselben Text. Wenn Sie dem Benutzer „alles geben, was Sie wissen“, kann das wie ein Dump der Innereien aussehen und manchmal sogar wie ein Informationsleck (Pfade im Dateisystem, interne Formate, Tabellennamen und so weiter). Und wenn Sie den Fehler zu fruh in einen String „plattmachen“, nehmen Sie sich selbst die Moglichkeit, spater noch zu verstehen, was genau passiert ist.

2. %w und %v: außen ahnlich, aber vertraglich verschieden

Auf der Ebene der Syntax wirkt der Unterschied zwischen %w und %v in fmt.Errorf fast lacherlich: ein Buchstabe, aber so viel Dramatik wie in einer Serie mit 8 Staffeln. Aus architektonischer Sicht ist das aber wirklich wichtig: %w macht die Ursache fur die programmgesteuerte Analyse uber errors.Is/errors.As zuganglich, wahrend %v sie in Text verwandelt und „den Weg zuruck abschneidet“.

Wenn Sie einen Fehler mit %w wrappen, sagen Sie dem aufrufenden Code gewissermaßen: „Ich erlaube dir, hineinzuschauen und auf Basis der Ursache zu entscheiden.“ Das ist bequem, aber gefahrlich: Wenn innen sql.ErrNoRows, *os.PathError oder etwas aus einer Drittanbieterbibliothek steckt, kann sich der außere Code darauf verlassen. Und danach die Bibliothek zu wechseln ist fast so „angenehm“ wie ohne Kartons auf einen anderen Planeten umzuziehen. In der Go-Dokumentation und in Artikeln zu Best Practices wird dieser Gedanke direkt formuliert: Wrapping macht einen konkreten Fehler zu einem Teil der API, und wenn Sie dieses Verhalten nicht unterstutzen wollen, sollten Sie besser nicht wrappen.

Ein Mini-Beispiel auf den Punkt gebracht:

package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("not found")

func loadTask(id int) error {
	// %w: die Ursache bleibt erhalten
	return fmt.Errorf("load task %d: %w", id, ErrNotFound)
}

Und hier die „plattgemachte“ Variante:

package main

import (
	"errors"
	"fmt"
)

var ErrNotFound = errors.New("not found")

func loadTaskHidden(id int) error {
	// %v: die Ursache ist nur noch Text, Is/As sehen sie nicht
	return fmt.Errorf("load task %d: %v", id, ErrNotFound)
}

Beide Varianten geben fur den Menschen fast das gleiche aus. Der Unterschied zeigt sich, wenn Sie errors.Is(err, ErrNotFound) verwenden oder einen typisierten Fehler mit errors.As herausziehen wollen. Und genau hier kommen wir zur strategischen Frage: Wem und wofur wollen wir erlauben, in den Fehler „hineinzuschauen“?

3. Wo man Kontext erganzt: Anwendungsschichten und das Feld Op

Wenn Sie fremde Logs lesen, merken Sie schnell eine einfache Sache: „Lesefehler“ ohne Kontext ist kein Fehler, sondern ein Ratsel. In einem guten System tragt ein Fehler fast immer den Kontext der Operation, damit sichtbar ist, an welchem Schritt alles schiefging: „load tasks“, „parse args“, „write output“, „open data file“.

Der lustige Teil ist, dass Anfanger meist das Gegenteil machen: Entweder sie fugen gar keinen Kontext hinzu, oder sie tun es so ubertrieben, dass am Ende „read: read: read: read: permission denied“ herauskommt. Darum ist es sinnvoll, nicht in Strings, sondern in Strukturen zu denken: Wir haben Schichten (Befehl → Anwendungslogik → Speicher → Betriebssystem), und jede Schicht kann ein kleines semantisches Label hinzufugen, darf den Fehler aber nicht in einen Roman verwandeln.

Das Schichtenmodell unserer Lern-CLI-Anwendung (nennen wir sie der Einfachheit halber tasker):

flowchart LR
    CMD[cmd/tasker: command] --> APP[app: usecase]
    APP --> ST[storage: read/write]
    ST --> OS[os/fs/json: system causes]

Nehmen wir an, wir haben bereits AppError (aus der vorherigen Vorlesung) mit den Feldern Kind, Op, Err. Dann lautet die Grundidee so: Op beschreibt „was auf dieser Ebene getan wurde“, und Err speichert die Ursache (moglicherweise schon mit weiterem Wrapping).

Ein kurzer, praktischer Konstruktor als Beispiel (damit man nicht alles von Hand schreiben muss):

package apperr

type Kind int

const (
	KindInternal Kind = iota
	KindValidation
	KindNotFound
	KindIO
)

type AppError struct {
	Kind Kind
	Op   string
	Err  error
}

func (e *AppError) Error() string {
	if e == nil {
		return "<nil>"
	}
	if e.Err == nil {
		return e.Op
	}
	if e.Op == "" {
		return e.Err.Error()
	}
	return e.Op + ": " + e.Err.Error()
}

func (e *AppError) Unwrap() error { return e.Err }

Hier ist wichtig, dass Unwrap() Err zuruckgibt, und damit konnen errors.Is/errors.As die Ursachenkette entlanggehen.

4. Wann man die Ursache behalten und wann man Details verstecken sollte

In der Wrapping-Strategie gibt es ein einfaches Kriterium: Wenn der aufrufende Code auf Basis der Ursache eine Entscheidung treffen kann und soll, behalten wir die Ursache als Ursache (wrappen via %w oder Unwrap). Wenn die Ursache dagegen ein internes Implementierungsdetail ist, das nicht nach außen „durchsickern“ soll, sollte man sie lieber in Text umwandeln oder durch einen stabileren fachlichen Sinn ersetzen.

Es ist nutzlich, eine kleine Tabelle im Kopf zu haben (das ist keine „letzte Wahrheit“, aber ein guter Start):

Situation Was passiert ist Ursache wrappen? Warum
Der Benutzer hat einen Dateipfad ubergeben und wir konnten die Datei nicht offnen permission denied, file not found haufig ja die Ursache gehort zur außeren Welt des Benutzers
Wir verwenden eine interne DB/ein internes Format, und das ist ein Implementierungsdetail sql.ErrNoRows, json.SyntaxError aus einer internen Datei haufig nein (oder nur fur Logs) sonst hangt externer Code von unseren internen Details ab
Fehler bei der Argumentvalidierung „limit muss > 0 sein“ meist nicht notwendig Entscheidungen werden uber KindValidation getroffen, die Ursache ist sekundar
Unerwarteter Fehler „etwas ist kaputtgegangen“ ja, aber dem Benutzer vorsichtig anzeigen wir brauchen die Analyse, aber der Benutzer braucht keinen Dump

Achten Sie darauf: „wrappen oder nicht wrappen“ geht nicht darum, ob man die Ursache im Text sehen will. Den Text kann man ohnehin zusammensetzen. Es geht darum, ob wir die Ursache zu einem Teil des programmgesteuerten Vertrags machen. Wenn Sie einen Fehler zuruckgeben, der zum Beispiel sql.ErrNoRows wrappt, kann externer Code anfangen, errors.Is(err, sql.ErrNoRows) zu schreiben, und dann mussen Sie dieses Verhalten unterstutzen, sonst „brechen Sie die Clients“.

Aus demselben Grund gilt: Wenn os.Open *os.PathError zuruckgegeben hat, und dieser Typ nur ein Implementierungsdetail Ihrer Funktion ist, dann sollten Sie ihn besser nicht als unwrap-Kette nach außen geben, sondern ohne %w „neu verpacken“.

5. Nutzermeldung und Diagnose sind verschiedene Entscheidungen

Jetzt kommt eine wichtige Ubung fur das Gehirn: Stellen Sie sich vor, ein Fehler ist ein Koffer. Im Koffer liegt eine Menge Dinge (Ursachen, Kontext, eingebettete Fehler). Der Benutzer ist die Person am Check-in-Schalter, die fragt: „Was ist passiert?“. Der Entwickler ist ein Sicherheitsbeamter, der den Koffer offnen und verstehen muss, was drin ist.

Wenn Sie dem Benutzer antworten: „Hier ist der ganze Koffer, bitte sehr“, bekommt er jede Menge unklare Details. Wenn Sie antworten: „Ich weiß nichts“, nehmen Sie sich selbst die Moglichkeit zur Analyse. Darum ist das normale CLI-Muster: Die Nutzermeldung wird nach dem Sinn gebaut (Kind, manchmal Op), wahrend die Ursache im Fehler fur die Logs erhalten bleibt.

Ein kleiner Entwurf einer Funktion, die eine „menschenfreundliche“ Meldung erzeugt (wir machen hier kein perfektes UX-Worterbuch — wichtig ist der Grundsatz):

package apperr

import (
	"errors"
)

func UserMessage(err error) string {
	if err == nil {
		return ""
	}

	var ae *AppError
	if errors.As(err, &ae) {
		switch ae.Kind {
		case KindValidation:
			return "ungultige Eingabe"
		case KindNotFound:
			return "nicht gefunden"
		case KindIO:
			return "I/O-Fehler"
		default:
			return "interner Fehler"
		}
	}

	return "interner Fehler"
}

Beachten Sie, dass wir dem Benutzer nicht ae.Err.Error() ausgeben. Das ist nicht deshalb so, weil wir „geizig“ sind, sondern weil es instabil und oft zu technisch ist. Dafur konnen wir in den Logs ruhig ae.Err und sogar die ganze Fehlerkette speichern, damit der Entwickler verstehen kann, was passiert ist.

Und hier beginnt die Wrapping-Strategie zu spielen: Wir konnen die Ursache uber %w speichern, ohne sie dem Benutzer direkt zu zeigen. Also sind „wrap for diagnostics“ und „show for UX“ zwei verschiedene Entscheidungen, und es ist sinnvoll, sie getrennt zu treffen.

6. Praxis mit CLI tasker: verschiedene Wrapping-Politiken

Stellen wir uns vor, unsere Anwendung speichert Aufgaben in einer Datei (ein typisches und verstandliches Modell fur ein Lern-CLI). Der Pfad zur Datei kommt aus der Konfiguration oder einem Flag, aber schon das Vorhandensein der Datei ist eine I/O-Frage — also ein ideales Beispiel fur die Wrapping-Strategie.

Beginnen wir mit der Funktion zum Laden von Aufgaben. Wir vereinfachen: Wir lesen die Datei komplett und parsen JSON (Streaming-JSON und komplexe Formate behandeln wir hier nicht — uns interessieren die Fehler).

package storage

import (
	"encoding/json"
	"os"
)

type Task struct {
	ID    int    `json:"id"`
	Title string `json:"title"`
	Done  bool   `json:"done"`
}

func LoadTasks(path string) ([]Task, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, err
	}

	var tasks []Task
	if err := json.Unmarshal(data, &tasks); err != nil {
		return nil, err
	}
	return tasks, nil
}

Dieser Code funktioniert „technisch“, sagt aber nichts uber den Sinn. Danach haben wir zwei Varianten der Wrapping-Politik.

Variante A: Der Pfad ist Teil des Vertrags, also kann man OS-Ursachen behalten

Wenn der Dateipfad vom Benutzer vorgegeben wird, dann gehoren Fehler wie permission denied oder no such file zu seiner Welt. In diesem Fall kann man die Ursache behalten, damit die obere Schicht zum Beispiel zwischen „Datei existiert nicht“ und „keine Rechte“ unterscheiden kann. Dann darf die Storage-Schicht mit %w wrappen und einen verstandlichen Op hinzufugen.

package storage

import (
	"fmt"
	"os"
)

func LoadTasks(path string) ([]Task, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("read tasks file %s: %w", path, err)
	}
	// ...
	return nil, nil
}

Jetzt konnen errors.Is mit os.ErrNotExist oder os.ErrPermission (uber die entsprechenden fs-Fehler) auf der oberen Ebene theoretisch funktionieren. Das ist nur dann normal, wenn Sie bereit sind, dies als Teil des Verhaltens Ihrer Anwendung zu betrachten.

Variante B: Die interne Datei ist ein Implementierungsdetail, also leaken wir keine OS-Fehler

Wenn die Datei strikt intern ist (zum Beispiel immer ~/.tasker/tasks.json), und Sie nicht wollen, dass jemand von außen Entscheidungen auf Basis von *os.PathError trifft, dann kann man den Systemfehler fur die außere Schicht in Text „plattmachen“, ihn aber intern in Ihrem AppError fur Logs behalten.

package storage

import (
	"encoding/json"
	"fmt"
	"os"

	"example/tasker/apperr"
)

func LoadTasks(path string) ([]Task, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, &apperr.AppError{
			Kind: apperr.KindIO,
			Op:   "load tasks",
			// Die Ursache ist da, aber die Entscheidung uber ihre "Offentlichkeit" ist Ihre.
			Err: fmt.Errorf("read file: %v", err), // %v: wir geben kein unwrap nach außen
		}
	}

	var tasks []Task
	if err := json.Unmarshal(data, &tasks); err != nil {
		return nil, &apperr.AppError{
			Kind: apperr.KindInternal,
			Op:   "decode tasks",
			Err:  err, // kann so bleiben oder ebenfalls kontrolliert werden
		}
	}

	return tasks, nil
}

Achten Sie auf die Feinheit: Hier haben wir den Systemfehler „plattgemacht“ (%v), damit externer Code nicht nach Typen wie *os.PathError mit errors.Is sucht und nicht von Details abhangig wird. Diese Logik entspricht der Empfehlung: „Interne Fehler nicht wrappen, wenn Sie nicht wollen, dass sie Teil der API werden.“

Gleichzeitig haben wir aber immer noch strukturierten Kontext mit KindIO und Op, und die obere Schicht kann eine Entscheidung auf der Ebene „Das ist I/O“ treffen, ohne um den Fehlertext herumzutanzen.

Wie man „read: read: read“ vermeidet: eine eindeutige Prasisierung pro Schicht

Wenn man anfängt, Fehler sauber zu wrappen, kommt fast zwangslaufig der Moment, in dem ein Log so aussieht, als ware das Programm im Wort read steckengeblieben und kame nicht mehr heraus. Das ist eine normale Wachstumskrankheit: Sie fugen ehrlich auf jedem Schritt Kontext hinzu, aber der Kontext ist derselbe.

Hier hilft Disziplin: Jede Schicht fugt nur den Kontext hinzu, der fur diese Schicht einzigartig ist. In der Storage-Schicht lautet Op „load tasks“. In der App-Schicht „add task“ oder „list tasks“. In der Cmd-Schicht „parse args“ oder „run command“. Und wenn der Kontext keine neue Information bringt, dann sollte man ihn lieber gar nicht hinzufugen.

Ein Mini-Beispiel in der App-Schicht, in dem wir den Sinn des Fehlers nicht andern, sondern nur „wo es passiert ist“ hinzufugen:

package app

import (
	"fmt"

	"example/tasker/apperr"
	"example/tasker/storage"
)

type Service struct {
	Path string
}

func (s Service) List() ([]storage.Task, error) {
	tasks, err := storage.LoadTasks(s.Path)
	if err != nil {
		// Kontext auf Usecase-Ebene hinzufugen und die Ursache behalten:
		return nil, &apperr.AppError{
			Kind: apperr.KindOf(err), // angenommen, KindOf existiert bereits
			Op:   "list tasks",
			Err:  fmt.Errorf("%w", err), // die Ursachekette bleibt erhalten
		}
	}
	return tasks, nil
}

Beachten Sie: Wenn storage.LoadTasks bereits AppError{Kind: KindIO, Op: "load tasks"} zuruckgegeben hat, dann fugt die App-Schicht Op: "list tasks" hinzu. Am Ende entsteht fur die Logs eine gute Kette: „list tasks: load tasks: read file: permission denied“. Fur den Benutzer konnen Sie hingegen etwas wie „I/O-Fehler“ oder eine freundlichere Formulierung anzeigen — aber das ist eine separate Entscheidung und muss nicht mit err.Error() identisch sein.

7. Typische Fehler

Fehler Nr. 1: %w aus Gewohnheit „uberall“ verwenden, ohne zu verstehen, dass es ein offener Vertrag ist.
Der gefahrlichste Teil von %w ist, dass er wie „einfach das bessere %v“ wirkt. Aber %w geht nicht um schoneren Text, sondern darum, dass Sie dem externen Code erlauben, die Ursache uber errors.Is/errors.As zu analysieren. Damit wird Ihre interne Wahl der Bibliothek oder des Formats zu einer Verpflichtung. Das ist so, als wurde man versprechen: „Wir wechseln niemals die Tur“, nur weil jemand schon einen passenden Schlussel hat.

Fehler Nr. 2: Den Fehler zu fruh „plattmachen“ und die Diagnose verlieren.
Manchmal schreibt ein Entwickler tief unten fmt.Errorf("something failed: %v", err), und spater oben mochte er zwischen not found und permission denied unterscheiden. Dieser Wunsch erfullt sich nicht, weil Sie die Moglichkeit fur errors.Is/errors.As selbst abgeschnitten haben. Wenn Sie sowohl Kontrolle als auch einen sauberen UX-Stil wollen, behalten Sie die Ursache, entscheiden aber getrennt, was dem Benutzer gezeigt wird.

Fehler Nr. 3: Op und Benutzermeldung in einen einzigen String mischen.
Op ist gut, wenn er kurz und technisch ist: „load tasks“, „parse args“, „write output“. Wenn Sie dort „Bitte geben Sie einen gultigen Dateipfad an, sonst …“ hineinschreiben, entsteht ein unvorhersagbarer Mischmasch fur Logs und die Idee eines stabilen UX bricht zusammen. Der Benutzertest sollte von einer eigenen Funktion gebaut werden, und Op sollte dem Entwickler helfen.

Fehler Nr. 4: Den gleichen Kontext auf jeder Ebene duplizieren und dadurch „read: read: read“ erhalten.
Wenn sich Kontext wiederholt, ist er kein Kontext mehr, sondern Rauschen. Wenn die Storage-Schicht bereits „load tasks“ gesagt hat, macht es fur die App-Schicht keinen Sinn, noch einmal „load tasks“ zu schreiben. Fugen Sie nur den Sinn hinzu, der fur die jeweilige Ebene einzigartig ist, sonst werden die Logs lang, ohne informativer zu werden.

Fehler Nr. 5: Dem Benutzer das rohe err.Error() aus unteren Schichten zeigen.
Untere Schichten enthalten oft Systemdetails: Pfade, interne Formate, seltsame Formulierungen der Standardbibliothek. Fur die Diagnose ist das nutzlich, fur den Benutzer aber fast immer storend und manchmal unsicher. Viel zuverlassiger ist es, eine kurze Meldung auf Basis von Kind zu drucken und die Details in den Logs zu lassen (mit Op und der Ursache im Inneren des Fehlers).

1
Aufgabe
Go SELF, Level 55, Lektion 1
Gesperrt
Fehlerverfolgung
Fehlerverfolgung
1
Aufgabe
Go SELF, Level 55, Lektion 1
Gesperrt
Operationslabel
Operationslabel
1
Aufgabe
Go SELF, Level 55, Lektion 1
Gesperrt
Öffentlicher Modus
Öffentlicher Modus
1
Aufgabe
Go SELF, Level 55, Lektion 1
Gesperrt
Kontextleiter
Kontextleiter
Kommentare
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION