HTTP-Fehlerbehandlung weniger aufwendig gestalten + RFC7807
Übersicht
Beim Erstellen von http-APIs in der Go-Sprache ist die Fehlerbehandlung am mühsamsten. Typischerweise gibt es solchen Code:
1func(w http.ResponseWriter, r *http.Request) {
2 err := doSomething()
3 if err != nil {
4 http.Error(w, err.Error(), http.StatusInternalServerError)
5 log.Printf("error occurred: %v", err)
6 return
7 }
8 // ...
9}
Wenn es nur wenige APIs gibt, wird es keine besonderen Unannehmlichkeiten verursachen, wenn man es auf diese Weise schreibt. Wenn jedoch die Anzahl der APIs zunimmt und die interne Logik komplexer wird, werden drei Dinge störend:
- Rückgabe eines geeigneten Fehlercodes
- Hohe Anzahl geschriebener Ergebnisprotokolle
- Übermittlung klarer Fehlermeldungen
Hauptteil
Rückgabe eines geeigneten Fehlercodes
Zugegeben, Punkt 1, die Rückgabe eines geeigneten Fehlercodes, ist eine persönliche Beschwerde von mir. Ein erfahrener Entwickler wird sicherlich jedes Mal den richtigen Code finden und einfügen, aber sowohl ich als auch noch unerfahrene Entwickler können Schwierigkeiten haben, einen geeigneten Fehlercode regelmäßig zu verwenden, wenn die Logik komplexer und die Anzahl der Vorgänge größer wird. Es gibt verschiedene Methoden hierfür, und die typischste ist wahrscheinlich, den API-Logikfluss im Voraus zu entwerfen und dann den Code so zu schreiben, dass er die entsprechenden Fehler zurückgibt. Tun Sie das so
Dies erscheint jedoch nicht als die optimale Methode für menschliche Entwickler, die Unterstützung von IDEs (oder Language Servern) erhalten. Da die REST API selbst die Bedeutung der enthaltenen Fehlercodes maximal nutzt, könnte man auch eine andere Methode vorschlagen. Man erstellt eine neue Implementierung der Fehler-Schnittstelle namens HttpError, die StatusCode und Message speichert. Und stellt die folgende Helferfunktion bereit:
1err := httperror.BadRequest("wrong format")
Die BadRequest-Helferfunktion wird einen HttpError zurückgeben, dessen StatusCode auf 400 und dessen Message auf den als Argument übergebenen Wert gesetzt ist. Darüber hinaus wird es natürlich möglich sein, Helferfunktionen wie NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired usw. mittels Autovervollständigung abzurufen und hinzuzufügen. Dies ist schneller, als jedes Mal die vorbereitete Spezifikation zu überprüfen, und zuverlässiger, als jedes Mal Fehlercodes als Zahlen einzugeben. Die Konstanten von http.StatusCode enthalten alles? Pssst.
Viele Ergebnisprotokolle schreiben
Bei einem Fehler wird natürlich ein Log erstellt. Wenn eine API aufgerufen wird und ein Log darüber erstellt wird, ob die Anfrage erfolgreich war oder fehlgeschlagen ist, erhöht sich die Menge des zu schreibenden Codes, wenn man von Anfang an an allen voraussichtlichen Endpunkten Logs erstellt. Dies kann zentral verwaltet werden, indem man den Handler selbst einmal umschließt.
Im Folgenden finden Sie ein Beispiel für das Umwickeln des chi-Routers.
1package chiwrap
2
3import (
4 "errors"
5 "net/http"
6
7 "github.com/go-chi/chi/v5"
8
9 "github.com/gosuda/httpwrap/httperror"
10)
11
12type Router struct {
13 router chi.Router
14 errCallback func(err error)
15}
16
17func NewRouter(errCallback func(err error)) *Router {
18 if errCallback == nil {
19 errCallback = func(err error) {}
20 }
21 return &Router{
22 router: chi.NewRouter(),
23 errCallback: errCallback,
24 }
25}
26
27type HandlerFunc func(writer http.ResponseWriter, request *http.Request) error
28
29func (r *Router) Get(pattern string, handler HandlerFunc) {
30 r.router.Get(pattern, func(writer http.ResponseWriter, request *http.Request) {
31 if err := handler(writer, request); err != nil {
32 he := &httperror.HttpError{}
33 switch errors.As(err, &he) {
34 case true:
35 http.Error(writer, he.Message, he.Code)
36 case false:
37 http.Error(writer, err.Error(), http.StatusInternalServerError)
38 }
39 r.errCallback(err)
40 }
41 })
42}
Die Router-Struktur enthält chi.Router intern, sodass die Funktionen von chi.Router unverändert verwendet werden können. Die Get-Methode überprüft, ob die soeben vorgeschlagene Helferfunktion eine HttpError-Struktur zurückgegeben hat, gibt sie gegebenenfalls zurück und leitet im Fehlerfall den Fehler einheitlich an die Fehler-Callback-Funktion weiter. Dieser Callback wird über den Konstruktor empfangen.
Im Folgenden finden Sie den Code, der unter Verwendung dieses Pakets geschrieben wurde.
1package main
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "log"
9 "net/http"
10
11 "github.com/gosuda/httpwrap/httperror"
12 "github.com/gosuda/httpwrap/wrapper/chiwrap"
13)
14
15func main() {
16 ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
17 defer cancel()
18
19 r := chiwrap.NewRouter(func(err error) {
20 // Router-Log-Test: Fehler aufgetreten: %v
21 log.Printf("Router log test: Error occured: %v", err)
22 })
23 r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
24 name := request.URL.Query().Get("name")
25 if name == "" {
26 // Name ist erforderlich
27 return httperror.BadRequest("name is required")
28 }
29
30 writer.Write([]byte("Hello " + name))
31 return nil
32 })
33
34 svr := http.Server{
35 Addr: ":8080",
36 Handler: r,
37 }
38 go func() {
39 if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
40 // Server konnte nicht gestartet werden: %v
41 log.Fatalf("Failed to start server: %v", err)
42 }
43 }()
44
45 <-ctx.Done()
46 svr.Shutdown(context.Background())
47}
Wie finden Sie es? Wenn Sie einfach nur HttpError als Helferfunktion erstellen und zurückgeben, können Sie im übergeordneten Bereich die entsprechende Fehlercode- und Fehlermeldung zurückgeben und einen Callback registrieren, um für jeden implementierten Dienst angemessene Logs zu erstellen. Bei Bedarf kann dies erweitert werden, um detaillierte Protokollierung mithilfe von RequestID usw. zu ermöglichen.
Übermittlung klarer Fehlermeldungen
Dafür gibt es das Dokument RFC7807. RFC7807 definiert und verwendet hauptsächlich die folgenden Elemente:
type: Eine URI, die den Fehlertyp identifiziert. Dies ist hauptsächlich ein Dokument, das den Fehler beschreibt.title: Eine einzeilige Beschreibung, um welchen Fehler es sich handelt.status: Identisch mit dem HTTP Status Code.detail: Eine für Menschen lesbare, detaillierte Beschreibung des Fehlers.instance: Die URI, an der der Fehler aufgetreten ist. Wenn der Fehler beispielsweise beiGET /user/infoaufgetreten ist, wäre/user/infoder Wert.extensions: Zusätzliche Elemente zur Fehlerbeschreibung, die als JSON-Objekt strukturiert sind.- Zum Beispiel kann bei einem
BadRequestdie Benutzereingabe enthalten sein. - Oder bei einem
TooManyRequestkann der Zeitpunkt der letzten Anfrage enthalten sein.
- Zum Beispiel kann bei einem
Um dies einfach zu verwenden, wird eine neue Datei im selben httperror-Paket wie HttpError erstellt und eine RFC7807Error-Struktur erstellt, die über ein Method-Chaining-Muster erstellt werden kann.
1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
2 return &RFC7807Error{
3 Type: "about:blank", // Standardtyp gemäß RFC7807
4 Title: title,
5 Status: status,
6 Detail: detail,
7 }
8}
9
10func BadRequestProblem(detail string, title ...string) *RFC7807Error {
11 t := "Bad Request"
12 if len(title) > 0 && title[0] != "" {
13 t = title[0]
14 }
15 return NewRFC7807Error(http.StatusBadRequest, t, detail)
16}
17
18func (p *RFC7807Error) WithType(typeURI string) *RFC7807Error { ... }
19func (p *RFC7807Error) WithInstance(instance string) *RFC7807Error { ... }
20func (p *RFC7807Error) WithExtension(key string, value interface{}) *RFC7807Error { ... }
Der Wert "about:blank" für Type ist der Standardwert. Er bezieht sich auf eine nicht existierende Seite. Im Folgenden finden Sie ein Beispiel für die Erstellung eines Fehlers für eine fehlerhafte Anfrage.
1problem := httperror.BadRequestProblem("invalid user id format", "Bad User Input")
2
3problem = problem.WithType("https://example.com/errors/validation")
4 .WithInstance("/api/users/abc")
5 .WithExtension("invalid_field", "user_id")
6 .WithExtension("expected_format", "numeric")
Durch einfaches Method Chaining können strukturierte Fehlermeldungen für den Benutzer generiert werden. Um den oben bereits erstellten zentralisierten Router zu nutzen, können auch die folgenden Methoden unterstützt werden.
1func (p *RFC7807Error) ToHttpError() *HttpError {
2 jsonBytes, err := json.Marshal(p)
3 if err != nil {
4 // Wenn das Marshaling fehlschlägt, auf die Verwendung des Details zurückgreifen
5 return New(p.Status, p.Detail)
6 }
7 return New(p.Status, string(jsonBytes))
8}
Wenn man das obige Beispiel direkt anpasst, sieht es so aus:
1package main
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "log"
9 "net/http"
10
11 "github.com/gosuda/httpwrap/httperror"
12 "github.com/gosuda/httpwrap/wrapper/chiwrap"
13)
14
15func main() {
16 ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
17 defer cancel()
18
19 r := chiwrap.NewRouter(func(err error) {
20 // Router-Log-Test: Fehler aufgetreten: %v
21 log.Printf("Router log test: Error occured: %v", err)
22 })
23 r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
24 name := request.URL.Query().Get("name")
25 if name == "" {
26 // Name ist erforderlich, Ungültige Benutzereingabe
27 return httperror.BadRequestProblem("name is required", "Bad User Input").
28 WithType("https://example.com/errors/validation").
29 WithInstance("/api/echo").
30 WithExtension("invalid_field", "name").
31 WithExtension("expected_format", "string").
32 WithExtension("actual_value", name).
33 ToHttpError()
34 }
35
36 writer.Write([]byte("Hello " + name))
37 return nil
38 })
39
40 svr := http.Server{
41 Addr: ":8080",
42 Handler: r,
43 }
44 go func() {
45 if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
46 // Server konnte nicht gestartet werden: %v
47 log.Fatalf("Failed to start server: %v", err)
48 }
49 }()
50
51 <-ctx.Done()
52 svr.Shutdown(context.Background())
53}
Fazit
Durch die Verwendung eines solchen zentralisierten Routers zur Fehlerbehandlung lässt sich die Belastung reduzieren, jedes Mal Fehlercodes überprüfen und passende Fehlermeldungen verfassen zu müssen. Darüber hinaus kann die Bereitstellung strukturierter Fehlermeldungen unter Verwendung von RFC7807 dem Client helfen, Fehler zu verstehen und zu verarbeiten. Diese Methoden können die Fehlerbehandlung von mit Go geschriebenen HTTP-APIs einfacher und konsistenter gestalten.
Der Code dieses Artikels ist im Repository gosuda/httpwrap verfügbar.