GoSuda

Méně obtížné zpracování HTTP chyb + RFC7807

By snowmerak
views ...

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:

  1. Vrácení vhodného chybového kódu
  2. Velké množství psaní logů výsledků
  3. 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 u GET /user/info, pak /user/info bude jeho hodnota.
  • extensions: Doplňkové prvky pro popis chyby, strukturované jako JSON Object.
    • Například, v případě BadRequest může obsahovat uživatelský vstup.
    • Nebo v případě TooManyRequest může obsahovat čas nejnovějšího požadavku.

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.