Mindre besvärlig HTTP-felhantering + RFC7807
Översikt
När man skapar HTTP API:er i Go-språket är felhantering det mest besvärliga. Ett typiskt exempel är följande kod.
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}
Om det inte finns många API:er, kommer det förmodligen inte att vara särskilt obekvämt att skriva på detta sätt. Men när antalet API:er ökar och den interna logiken blir mer komplex, blir tre aspekter irriterande.
- Lämplig felkodsåtergivning
- Stort antal loggskrivningar
- Tydlig felmeddelandesändning
Huvuddel
Lämplig felkodsåtergivning
Punkt 1, lämplig felkodsåtergivning, är förvisso en personlig invändning från min sida. Erfarna utvecklare kommer sannolikt att hitta och konsekvent tillämpa lämpliga koder varje gång, men jag och andra mindre erfarna utvecklare kan ha svårt att regelbundet använda korrekta felkoder när logiken blir mer komplex och antalet anrop ökar. Det finns flera metoder för detta, och den mest framträdande är förmodligen att först designa API-logikflödet och sedan skriva koden för att returnera lämpliga fel. Gör så
Detta verkar dock inte vara den optimala metoden för mänskliga utvecklare som får hjälp av en IDE (eller Language Server). Dessutom, eftersom REST API i sig utnyttjar betydelsen av felkoder maximalt, kan en annan metod föreslås. Man skapar en ny implementering av error-interfacet kallad HttpError, som lagrar StatusCode och Message. Sedan tillhandahålls följande hjälpfunkttion.
1err := httperror.BadRequest("wrong format")
BadRequest-hjälpfunktionen kommer att returnera en HttpError med StatusCode satt till 400 och Message satt till det värde som mottogs som argument. Utöver detta kommer det naturligtvis att vara möjligt att söka och lägga till andra hjälpfunkttioner som NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired med hjälp av autofyllningsfunktionen. Detta kommer att vara snabbare än att varje gång kontrollera den förberedda designspecifikationen och mer stabilt än att manuellt ange felkoder varje gång. http.StatusCode-konstanterna innehåller redan allt, säger du? Tyst
Stort antal loggskrivningar
Vid fel uppstår, loggas de naturligtvis. När ett API anropas och loggar ska skapas för att indikera om begäran lyckades eller misslyckades, ökar antalet rader kod som måste skrivas om loggar läggs in från början till alla förväntade slutpunkter. Detta kan hanteras centralt genom att omsluta själva handlern.
Följande är ett exempel på hur man omsluter chi-routern.
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 // Om ett fel uppstår vid hanteringen av begäran
32 if err := handler(writer, request); err != nil {
33 he := &httperror.HttpError{}
34 // Kontrollera om felet är av typen HttpError
35 switch errors.As(err, &he) {
36 case true:
37 // Om det är en HttpError, använd dess meddelande och statuskod
38 http.Error(writer, he.Message, he.Code)
39 case false:
40 // Annars, använd ett generiskt internt serverfel
41 http.Error(writer, err.Error(), http.StatusInternalServerError)
42 }
43 // Anropa fel-callback-funktionen med det ursprungliga felet
44 r.errCallback(err)
45 }
46 })
47}
Router-strukturen innehåller en intern chi.Router för att bibehålla dess funktionalitet. I Get-metoden kontrolleras det om den HttpError-struktur som returneras av den nyss föreslagna hjälpfunkttionen har returnerats, och om så är fallet returneras den korrekt. Om det är ett error skickas det konsekvent till fel-callback-funktionen. Denna callback tas emot via konstruktorn.
Följande är kod skriven med detta paket.
1package main
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "log"
9 "net/http"
10 "os"
11 "os/signal"
12 "syscall"
13
14 "github.com/gosuda/httpwrap/httperror"
15 "github.com/gosuda/httpwrap/wrapper/chiwrap"
16)
17
18func main() {
19 // Skapa en kontext som kan avbrytas vid SIGINT eller SIGTERM
20 ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
21 // Se till att avbryta kontexten när funktionen avslutas
22 defer cancel()
23
24 // Skapa en ny router med en fel-callback-funktion
25 r := chiwrap.NewRouter(func(err error) {
26 // Logga fel som inträffar i routern
27 log.Printf("Router log test: Error occured: %v", err)
28 })
29 // Lägg till en GET-rutt för "/echo"
30 r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
31 // Hämta värdet för frågeparametern "name"
32 name := request.URL.Query().Get("name")
33 // Om "name" är tomt, returnera ett BadRequest-fel
34 if name == "" {
35 return httperror.BadRequest("name is required")
36 }
37
38 // Skriv "Hello " följt av namnet till svarsskrivaren
39 writer.Write([]byte("Hello " + name))
40 // Returnera nil för att indikera framgång
41 return nil
42 })
43
44 // Skapa en HTTP-server
45 svr := http.Server{
46 Addr: ":8080", // Serveradress
47 Handler: r, // Serverns handler är vår anpassade router
48 }
49 // Starta servern i en separat goroutine
50 go func() {
51 // Försök att lyssna och hantera anrop
52 if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
53 // Logga ett fatal fel om servern misslyckas att starta, och det inte är ErrServerClosed
54 log.Fatalf("Failed to start server: %v", err)
55 }
56 }()
57
58 // Vänta tills kontexten är klar (t.ex. vid avbrottssignal)
59 <-ctx.Done()
60 // Stäng av servern elegant
61 svr.Shutdown(context.Background())
62}
Vad tycker du? Genom att helt enkelt skapa och returnera en HttpError via en hjälpfunkttion, kan man i ett högre skop svara med lämpliga felkoder och meddelanden, samt registrera callbacks för att logga lämpliga meddelanden för varje implementerad tjänst. Om ytterligare behov uppstår, kan detta utökas för att möjliggöra detaljerad loggning med exempelvis RequestID.
Tydlig felmeddelandesändning
För detta ändamål finns dokumentet RFC7807. RFC7807 definierar och använder huvudsakligen följande element:
type: En URI som identifierar feltypen. Detta är främst ett dokument som beskriver felet.title: En enradsbeskrivning av felet.status: Identiskt med HTTP Status Code.detail: En detaljerad, mänskligt läsbar beskrivning av felet.instance: URI:n där felet uppstod. Om ett fel uppstod vidGET /user/info, skulle dess värde vara/user/info.extensions: Ytterligare element för att beskriva felet, strukturerade som ett JSON Object.- Till exempel, vid
BadRequestkan användarens inmatning inkluderas. - Eller, vid
TooManyRequestkan tidpunkten för den senaste begäran inkluderas.
- Till exempel, vid
För att underlätta användningen skapar vi en ny fil i httperror-paketet, på samma plats som HttpError, och genererar en RFC7807Error-struktur som kan skapas med en metodkedjemönster.
1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
2 return &RFC7807Error{
3 Type: "about:blank", // Standardtyp enligt 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 { ... } // Metod för att ställa in feltyp
19func (p *RFC7807Error) WithInstance(instance string) *RFC7807Error { ... } // Metod för att ställa in instans-URI
20func (p *RFC7807Error) WithExtension(key string, value interface{}) *RFC7807Error { ... } // Metod för att lägga till tillägg
Standardvärdet för Type är "about:blank", vilket indikerar en sida som inte finns. Nedan visas ett exempel på felgenerering för en ogiltig begäran.
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")
Med enkel metodkedjning kan strukturerade felmeddelanden skapas för användaren. Dessutom, för att använda den tidigare beskrivna centraliserade routern, kan följande metod stödjas.
1func (p *RFC7807Error) ToHttpError() *HttpError {
2 // Försök att serialisera RFC7807Error till JSON-byte
3 jsonBytes, err := json.Marshal(p)
4 if err != nil {
5 // Om serialiseringen misslyckas, fall tillbaka till att endast använda detaljen
6 return New(p.Status, p.Detail)
7 }
8 // Returnera en ny HttpError med status och JSON-strängen som meddelande
9 return New(p.Status, string(jsonBytes))
10}
Genom att använda detta direkt och modifiera exemplet ovan blir det så här.
1package main
2
3import (
4 "bytes"
5 "context"
6 "errors"
7 "io"
8 "log"
9 "net/http"
10 "os"
11 "os/signal"
12 "syscall"
13
14 "github.com/gosuda/httpwrap/httperror"
15 "github.com/gosuda/httpwrap/wrapper/chiwrap"
16)
17
18func main() {
19 // Skapa en kontext som kan avbrytas vid SIGINT eller SIGTERM
20 ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
21 // Se till att avbryta kontexten när funktionen avslutas
22 defer cancel()
23
24 // Skapa en ny router med en fel-callback-funktion
25 r := chiwrap.NewRouter(func(err error) {
26 // Logga fel som inträffar i routern
27 log.Printf("Router log test: Error occured: %v", err)
28 })
29 // Lägg till en GET-rutt för "/echo"
30 r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
31 // Hämta värdet för frågeparametern "name"
32 name := request.URL.Query().Get("name")
33 // Om "name" är tomt, returnera ett BadRequestProblem-fel i RFC7807-format
34 if name == "" {
35 return httperror.BadRequestProblem("name is required", "Bad User Input").
36 WithType("https://example.com/errors/validation").
37 WithInstance("/api/echo").
38 WithExtension("invalid_field", "name").
39 WithExtension("expected_format", "string").
40 WithExtension("actual_value", name).
41 ToHttpError() // Konvertera RFC7807Error till HttpError
42 }
43
44 // Skriv "Hello " följt av namnet till svarsskrivaren
45 writer.Write([]byte("Hello " + name))
46 // Returnera nil för att indikera framgång
47 return nil
48 })
49
50 // Skapa en HTTP-server
51 svr := http.Server{
52 Addr: ":8080", // Serveradress
53 Handler: r, // Serverns handler är vår anpassade router
54 }
55 // Starta servern i en separat goroutine
56 go func() {
57 // Försök att lyssna och hantera anrop
58 if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
59 // Logga ett fatal fel om servern misslyckas att starta, och det inte är ErrServerClosed
60 log.Fatalf("Failed to start server: %v", err)
61 }
62 }()
63
64 // Vänta tills kontexten är klar (t.ex. vid avbrottssignal)
65 <-ctx.Done()
66 // Stäng av servern elegant
67 svr.Shutdown(context.Background())
68}
Slutsats
Genom att använda en centraliserad router för felhantering kan man minska bördan av att varje gång kontrollera felkoder och skriva lämpliga felmeddelanden. Dessutom, genom att tillhandahålla strukturerade felmeddelanden med hjälp av RFC7807, kan klienter få hjälp att förstå och hantera fel. Dessa metoder kan göra felhanteringen i HTTP API:er skrivna i Go-språket enklare och mer konsekvent.
Koden för denna artikel finns i [gosuda/httpwrap]-repositoryt.