Méně obtížné zpracování HTTP chyb + RFC7807
Přehled
Při vytváření http API v jazyce Go je nejotravnější zpracování chyb. Typickým příkladem je tento kód:
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}
Pokud by API nemělo mnoho funkcí, nebylo by to příliš nepohodlné. Avšak s rostoucím počtem API a složitostí vnitřní logiky se stanou problematickými tři věci:
- Vrácení vhodného chybového kódu
- Velké množství psaní logů výsledků
- Odesílání jasných chybových zpráv
Hlavní část
Vrácení vhodného chybového kódu
Bod 1, vrácení vhodného chybového kódu, je samozřejmě moje osobní stížnost. Zkušený vývojář by vždy našel a použil vhodný kód, ale já i méně zkušení vývojáři můžeme mít potíže s konzistentním používáním vhodných chybových kódů, jak se logika stává složitější a počet operací se zvyšuje. Existuje několik přístupů k tomuto problému, a nejběžnějším je navrhnout tok logiky API předem a poté napsat kód tak, aby vracel vhodné chyby. Udělejte to tak
To se však nezdá být optimálním řešením pro lidského vývojáře, který využívá pomoc IDE (nebo Language Serveru). Vzhledem k tomu, že samotné REST API maximálně využívá význam chybových kódů, lze navrhnout jiný přístup. Vytvoříme novou implementaci rozhraní error nazvanou HttpError, která bude ukládat StatusCode a Message. A poskytneme následující pomocnou funkci:
1err := httperror.BadRequest("wrong format")
Pomocná funkce BadRequest vrátí HttpError s StatusCode nastaveným na 400 a Message nastaveným na hodnotu předanou jako argument. Kromě toho bude samozřejmě možné vyhledávat a přidávat pomocné funkce jako NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired atd. pomocí funkce automatického dokončování. To bude rychlejší než pokaždé kontrolovat připravenou specifikaci a stabilnější než pokaždé zadávat číselné chybové kódy. http.StatusCode konstanty jsou tam všechny? Pšššt
Velké množství psaní logů výsledků
Při výskytu chyby se samozřejmě zaznamenává log. Když je voláno API a zaznamenává se, zda byl požadaveš úspěšný nebo neúspěšný, zaznamenávání logů od začátku do všech očekávaných koncových bodů vede k velkému množství kódu. To lze spravovat centrálně obalením samotného handleru.
Následuje příklad obalení routeru chi:
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}
Struktura Router obsahuje chi.Router interně, což umožňuje použití funkcí chi.Router beze změny. Metoda Get kontroluje, zda byla vrácena struktura HttpError, kterou jsem právě navrhl, a pokud ano, vrátí ji vhodně. V případě error je chyba hromadně předána funkci zpětného volání pro chyby. Toto zpětné volání je přijímáno prostřednictvím konstruktoru.
Následuje kód napsaný s využitím tohoto balíčku:
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}
Co si o tom myslíte? Pouhým vytvořením a vrácením HttpError jako pomocné funkce můžete zajistit, aby nadřazený scope vracel vhodný chybový kód a zprávu, a zároveň registrovat zpětné volání pro zpracování logů specifických pro každou implementovanou službu. V případě potřeby by bylo možné to rozšířit o podrobné logování pomocí RequestID a podobně.
Odesílání jasných chybových zpráv
Pro tento účel existuje dokument RFC7807. RFC7807 primárně definuje a používá následující prvky:
type: URI identifikující typ chyby. Obvykle se jedná o dokument popisující chybu.title: Jednořádkový popis toho, o jakou chybu se jedná.status: Stejný jako HTTP Status Code.detail: Lidsky čitelný podrobný popis dané chyby.instance: URI, kde k chybě došlo. Například, pokud k chybě došlo uGET /user/info, pak/user/infobude jeho hodnota.extensions: Doplňkové prvky pro popis chyby, strukturované jako JSON Object.- Například, v případě
BadRequestmůže obsahovat uživatelský vstup. - Nebo v případě
TooManyRequestmůže obsahovat čas nejnovějšího požadavku.
- Například, v případě
Pro snadné použití vytvoříme nový soubor ve stejném balíčku httperror jako HttpError a vytvoříme strukturu RFC7807Error, kterou lze vytvořit pomocí vzoru řetězení metod.
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" pro Type je výchozí hodnota. Znamená to neexistující stránku. Níže je příklad vytvoření chyby pro chybný požadavek.
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")
Pomocí jednoduchého řetězení metod lze generovat strukturované chybové zprávy pro uživatele. Kromě toho, pro použití s dříve napsaným centralizovaným routerem, lze podporovat následující metodu:
1func (p *RFC7807Error) ToHttpError() *HttpError {
2 jsonBytes, err := json.Marshal(p)
3 if err != nil {
4 // Pokud se serializace nepodaří, vrátíme se k použití pouze detailu
5 return New(p.Status, p.Detail)
6 }
7 return New(p.Status, string(jsonBytes))
8}
Použijeme-li to přímo a upravíme výše uvedený příklad, bude to vypadat takto:
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}
Závěr
Centralizované zpracování chyb pomocí routeru takto může snížit zátěž spojenou s neustálou kontrolou chybových kódů a psaním vhodných chybových zpráv. Kromě toho poskytování strukturovaných chybových zpráv pomocí RFC7807 může klientům pomoci s pochopením a zpracováním chyb. Tímto způsobem lze zpracování chyb v HTTP API napsaném v jazyce Go zjednodušit a sjednotit.
Kód k tomuto článku naleznete v repozitáři gosuda/httpwrap.