GoSuda

Endnu mindre besværlig håndtering af HTTP Error + RFC7807

By snowmerak
views ...

Oversigt

Når man opretter HTTP API'er i Go-sproget, er fejlhåndtering det mest besværlige. Et typisk eksempel er denne kode.

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}

Hvis der kun er få API'er, kan det skrives på denne måde, uden at det nødvendigvis er besværligt. Dog, efterhånden som antallet af API'er stiger, og den interne logik bliver mere kompleks, bliver tre ting generende.

  1. Returnering af passende fejlkoder
  2. Stort antal log-udskrifter
  3. Afsendelse af tydelige fejlmeddelelser

Hoveddel

Returnering af passende fejlkoder

Selvfølgelig er punkt 1, returnering af passende fejlkoder, en personlig anke fra min side. En erfaren udvikler vil finde den passende kode og indsætte den korrekt hver gang, men både jeg og stadig uerfarne udviklere kan have svært ved konsekvent at bruge den passende fejlkode, efterhånden som logikken bliver kompleks, og antallet af tilfælde stiger. Der findes flere metoder til dette, og det mest typiske er at designe API-logikkens flow på forhånd og derefter skrive koden til at returnere passende fejl. Gør det

Men dette synes ikke at være den optimale metode for en menneskelig udvikler, der modtager hjælp fra en IDE (eller Language Server). Desuden, da REST API'en i sig selv udnytter betydningen indlejret i fejlkoderne fuldt ud, kan en anden tilgang foreslås. Ved at oprette en ny implementering af error-interfacet kaldet HttpError, der gemmer StatusCode og Message. Og følgende helper-funktioner leveres.

1err := httperror.BadRequest("wrong format")

Helper-funktionen BadRequest vil returnere en HttpError, hvor StatusCode er sat til 400, og Message er sat til den værdi, der modtages som argument. Udover dette kan helper-funktioner såsom NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired osv. selvfølgelig søges efter og tilføjes ved hjælp af autofuldførelsesfunktionen. Dette er hurtigere end at kontrollere det udarbejdede design-dokument hver gang, og vil være mere stabil end at indtaste fejlkoden som et tal hver gang. Du mener, det hele er i http.StatusCode-konstanterne? Shh

Stort antal log-udskrifter

Når en fejl opstår, skrives der selvfølgelig logge. Når en API kaldes, og der skrives logge om, hvorvidt anmodningen lykkedes eller mislykkedes, øger det mængden af kode, der skal skrives, at skrive logge fra starten til alle forventede slutpunkter. Dette kan administreres centralt ved at wrappe selve handleren én gang.

Følgende er et eksempel på, hvordan man wrapper chi-routeren.

 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}

Router-strukturen indeholder chi.Router internt og er konfigureret til at bruge chi.Router's funktionalitet direkte. Hvis man ser på Get-metoden, efter at have kontrolleret, om den HttpError-struktur, der returneres af den helper-funktion, vi lige foreslog ovenfor, er blevet returneret, returneres den passende, og i tilfælde af en error videregives den samlet til fejl-callback-funktionen. Denne callback modtages via konstruktøren.

Følgende er kode skrevet ved hjælp af denne pakke.

 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    ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
20    defer cancel()
21
22	r := chiwrap.NewRouter(func(err error) {
23		log.Printf("Router log test: Error occured: %v", err) // Fejl opstod
24	})
25	r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
26		name := request.URL.Query().Get("name")
27		if name == "" {
28			return httperror.BadRequest("name is required")
29		}
30
31		writer.Write([]byte("Hello " + name))
32		return nil
33	})
34
35	svr := http.Server{
36		Addr:    ":8080",
37		Handler: r,
38	}
39	go func() {
40		if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
41			log.Fatalf("Failed to start server: %v", err) // Kunne ikke starte server
42		}
43	}()
44
45    <-ctx.Done()
46    svr.Shutdown(context.Background())
47}

Hvordan er det? Ved blot at oprette og returnere HttpError som en helper-funktion, kan man i det overordnede scope håndtere returnering af svar med passende fejlkoder og meddelelser og registrere en callback for at efterlade passende logge for hver implementeret service. Derudover, hvis nødvendigt, kan det udvides ved at bruge RequestID osv. for at muliggøre detaljeret logning.

Afsendelse af tydelige fejlmeddelelser

Et dokument til dette formål er RFC7807. RFC7807 definerer og bruger primært følgende elementer.

  • type: En URI, der identificerer fejltypen. Dette er typisk et dokument, der beskriver fejlen.
  • title: En enkelt linje, der beskriver, hvilken fejl det er.
  • status: Identisk med HTTP Status Code.
  • detail: En detaljeret, menneskelæselig beskrivelse af den pågældende fejl.
  • instance: Den URI, hvor fejlen opstod. For eksempel, hvis en fejl opstod ved GET /user/info, vil /user/info være værdien.
  • extensions: Supplerende elementer til at beskrive fejlen, struktureret som et JSON Object.
    • For eksempel, i tilfælde af BadRequest, kan brugerens input inkluderes.
    • Eller i tilfælde af TooManyRequest, kan tidspunktet for den seneste anmodning inkluderes.

For at gøre dette nemt at bruge, oprettes en ny fil i httperror-pakken, samme sted som HttpError, en RFC7807Error-struktur oprettes, og det gøres muligt at oprette den ved hjælp af et metode-chaining-mønster.

 1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
 2	return &RFC7807Error{
 3		Type:   "about:blank", // Standardtype ifølge 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 { /* ... */ return p }
19func (p *RFC7807Error) WithInstance(instance string) *RFC7807Error { /* ... */ return p }
20func (p *RFC7807Error) WithExtension(key string, value interface{}) *RFC7807Error { /* ... */ return p }

Type's "about:blank" er standardværdien. Det betyder en side, der ikke eksisterer. Nedenfor er et eksempel på oprettelse af en fejl vedrørende en ugyldig anmodning.

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 simpel metode-chaining kan strukturerede fejlmeddelelser til brugeren oprettes. Desuden, for at bruge den centraliserede router, der tidligere er skrevet ovenfor, kan følgende metode understøttes.

1func (p *RFC7807Error) ToHttpError() *HttpError {
2	jsonBytes, err := json.Marshal(p)
3	if err != nil {
4		// Hvis marshalling mislykkes, falder vi tilbage til kun at bruge detail
5		return New(p.Status, p.Detail)
6	}
7	return New(p.Status, string(jsonBytes))
8}

Ved at bruge dette direkte, hvis eksemplet ovenfor ændres, bliver det således.

 1package main
 2
 3import (
 4	"bytes"
 5	"context"
 6	"encoding/json" // Tilføjet import til json
 7	"errors"
 8	"io"
 9	"log"
10	"net/http"
11	"os"
12	"os/signal"
13	"syscall"
14
15	"github.com/gosuda/httpwrap/httperror"
16	"github.com/gosuda/httpwrap/wrapper/chiwrap"
17)
18
19func main() {
20    ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
21    defer cancel()
22
23	r := chiwrap.NewRouter(func(err error) {
24		log.Printf("Router log test: Error occured: %v", err) // Fejl opstod
25	})
26	r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
27		name := request.URL.Query().Get("name")
28		if name == "" {
29			return httperror.BadRequestProblem("name is required", "Bad User Input").
30                WithType("https://example.com/errors/validation").
31                WithInstance("/api/echo").
32                WithExtension("invalid_field", "name").
33                WithExtension("expected_format", "string").
34                WithExtension("actual_value", name).
35                ToHttpError()
36		}
37
38		writer.Write([]byte("Hello " + name))
39		return nil
40	})
41
42	svr := http.Server{
43		Addr:    ":8080",
44		Handler: r,
45	}
46	go func() {
47		if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
48			log.Fatalf("Failed to start server: %v", err) // Kunne ikke starte server
49		}
50	}()
51
52    <-ctx.Done()
53    svr.Shutdown(context.Background())
54}

Konklusion

Ved at håndtere fejl på denne måde ved hjælp af en centraliseret router, kan byrden ved at kontrollere fejlkoden hver gang og skrive passende fejlmeddelelser reduceres. Desuden, ved at udnytte RFC7807 ved at levere strukturerede fejlmeddelelser, kan det hjælpe klienten med at forstå og håndtere fejlen. Gennem disse metoder kan fejlhåndteringen af HTTP API'er skrevet i Go-sproget gøres mere enkel og konsistent.

Koden fra denne artikel kan findes i gosuda/httpwrap repository'et.