GoSuda

En mindre tungvint måte å håndtere HTTP-feil på + RFC7807

By snowmerak
views ...

Oversikt

Når man oppretter http-APIer i Go-språket, er feilhåndtering det mest plagsomme. Et typisk eksempel er denne koden.

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 det ikke er mange APIer, vil denne måten å skrive kode på sannsynligvis ikke være spesielt upraktisk. Men ettersom antallet APIer øker og den interne logikken blir mer kompleks, vil tre ting bli irriterende.

  1. Riktig retur av feilkode
  2. Mange logger som skal skrives
  3. Tydelig feilmeldingsoverføring

Hoveddel

Riktig retur av feilkode

Selvfølgelig er punkt 1, riktig retur av feilkode, en personlig klage fra min side. En erfaren utvikler vil finne og bruke den riktige koden hver gang, men både jeg og uerfarne utviklere kan oppleve vanskeligheter med å bruke den passende feilkoden regelmessig etter hvert som logikken blir mer kompleks og antall forekomster øker. Det finnes flere metoder for dette, og den mest representative er sannsynligvis å utforme API-logikkflyten på forhånd og deretter skrive kode som returnerer passende feil. Gjør det

Dette virker imidlertid ikke som den optimale metoden for en menneskelig utvikler som mottar hjelp fra en IDE (eller Language Server). Dessuten, siden REST API i seg selv utnytter betydningen i feilkoder maksimalt, kan en annen metode foreslås. Opprett en ny implementasjon av error-grensesnittet kalt HttpError, som lagrer StatusCode og Message. Deretter tilbys følgende hjelpefunksjoner.

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

BadRequest-hjelpefunksjonen vil returnere en HttpError med StatusCode satt til 400 og Message satt til verdien som ble mottatt som argument. I tillegg til dette vil det selvfølgelig være mulig å søke etter og legge til hjelpefunksjoner som NotImplement, ServiceUnavailable, Unauthorized og PaymentRequired ved hjelp av autofullføringsfunksjonen. Dette vil være raskere enn å sjekke den forberedte designspesifikasjonen hver gang, og mer stabilt enn å skrive inn feilkoden som et tall hver gang. http.StatusCode-konstantene inneholder alt, sier du? Shh

Mange logger som skal skrives

Når en feil oppstår, er det naturlig å logge den. Når en API kalles, og man logger om forespørselen var vellykket eller mislyktes, vil det å logge fra starten til alle forventede sluttpunkter føre til at det skrives mye kode. Dette kan sentralt administreres ved å omslutte selve handleren.

Følgende er et eksempel som omslutter en chi-ruter:

 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}

Ruter-strukturen inneholder en chi.Router internt, og er konfigurert til å bruke funksjonene til chi.Router direkte. Hvis du ser på Get-metoden, sjekkes det om HttpError-strukturen som returneres av hjelpefunksjonen som nettopp ble foreslått ovenfor, er returnert, og den returneres deretter på passende måte. Hvis det er en error, videresendes den samlet til feil-callback-funksjonen. Denne callback-en mottas via konstruktøren.

Følgende er kode skrevet ved hjelp av denne pakken:

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

Hva synes du? Hvis du bare oppretter og returnerer HttpError som en hjelpefunksjon, kan du registrere en callback for å behandle feil, slik at du kan returnere en respons med passende feilkode og melding i det overordnede skopet, og logge passende meldinger for hver implementert tjeneste. I tillegg, hvis det er nødvendig, kan det utvides til å tillate detaljert logging ved hjelp av RequestID og lignende.

Tydelig feilmeldingsoverføring

For dette formålet finnes dokumentet RFC7807. RFC7807 definerer og bruker hovedsakelig følgende elementer:

  • type: En URI som identifiserer feiltypen. Dette er vanligvis et dokument som beskriver feilen.
  • title: En enlinjes beskrivelse av hva slags feil det er.
  • status: Identisk med HTTP Status Code.
  • detail: En detaljert, menneskelig lesbar beskrivelse av feilen.
  • instance: URI-en der feilen oppstod. For eksempel, hvis en feil oppstod ved GET /user/info, vil /user/info være verdien.
  • extensions: Sekundære elementer for å beskrive feilen, strukturert som et JSON Object.
    • For eksempel, i tilfelle av BadRequest, kan brukerens input inkluderes.
    • Eller i tilfelle av TooManyRequest, kan det inkludere tidspunktet for den siste forespørselen.

For å lette bruken av dette, oppretter vi en ny fil i httperror-pakken, på samme sted som HttpError, og oppretter RFC7807Error-strukturen, slik at den kan opprettes ved hjelp av et metodekjedingmønster.

 1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
 2	return &RFC7807Error{
 3		Type:   "about:blank", // Default type as per 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 { ... }

"about:blank" for Type er standardverdien. Det betyr en tom side. Nedenfor er et eksempel på feiloppretting for en ugyldig forespørsel.

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")

Enkel metodekjeding muliggjør generering av strukturerte feilmeldinger for brukeren. I tillegg kan følgende metode støttes for å bruke den sentraliserte ruteren som ble skrevet tidligere.

1func (p *RFC7807Error) ToHttpError() *HttpError {
2	jsonBytes, err := json.Marshal(p)
3	if err != nil {
4		// If marshaling fails, fall back to just using the detail
5		return New(p.Status, p.Detail)
6	}
7	return New(p.Status, string(jsonBytes))
8}

Ved å bruke dette direkte, kan eksemplet ovenfor endres slik:

 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		log.Printf("Router log test: Error occured: %v", err)
21	})
22	r.Get("/echo", func(writer http.ResponseWriter, request *http.Request) error {
23		name := request.URL.Query().Get("name")
24		if name == "" {
25			return httperror.BadRequestProblem("name is required", "Bad User Input").
26                WithType("https://example.com/errors/validation").
27                WithInstance("/api/echo").
28                WithExtension("invalid_field", "name").
29                WithExtension("expected_format", "string").
30                WithExtension("actual_value", name).
31                ToHttpError()
32		}
33
34		writer.Write([]byte("Hello " + name))
35		return nil
36	})
37
38	svr := http.Server{
39		Addr:    ":8080",
40		Handler: r,
41	}
42	go func() {
43		if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
44			log.Fatalf("Failed to start server: %v", err)
45		}
46	}()
47
48    <-ctx.Done()
49    svr.Shutdown(context.Background())
50}

Konklusjon

Ved å bruke en slik sentralisert ruter for feilhåndtering, kan man redusere byrden med å sjekke feilkoder og skrive passende feilmeldinger hver gang. I tillegg, ved å tilby strukturerte feilmeldinger ved hjelp av RFC7807, kan klienter lettere forstå og håndtere feil. Gjennom disse metodene kan feilhåndteringen i HTTP APIer skrevet i Go-språket gjøres enklere og mer konsistent.

Koden for denne artikkelen kan finnes i gosuda/httpwrap repository.