GoSuda

Ako spracovať HTTP chyby menej neprehľadne a pohodlnejšie + RFC7807

By snowmerak views ...

Prehľad

Pri vytváraní HTTP API v jazyku Go je najotravnejšia práca spracovanie chýb. Typicky vyzerá kód takto:

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}

Ak je API len niekoľko, písanie takýmto spôsobom nebude nijako nepohodlné. Keď sa však počet API zvyšuje a vnútorná logika sa stáva zložitejšou, tri veci začnú byť rušivé:

  1. Vracanie vhodného chybového kódu
  2. Veľký počet zápisov do logu
  3. Odosielanie jasnej chybovej správy

Hlavná časť

Vracanie vhodného chybového kódu

Samozrejme, bod 1, vracanie vhodného chybového kódu, je mojou osobnou sťažnosťou. Skúsený vývojár nájde vhodný kód a vždy ho správne vloží, ale ja aj ešte neskúsení vývojári môžeme mať ťažkosti s pravidelným používaním vhodného chybového kódu, keď logika končí zložitejšie a počet volaní narastá. Existuje na to viacero spôsobov, najtypickejší je vopred navrhnúť tok logiky API a potom napísať kód, ktorý vracia vhodnú chybu. Urobte tak

Toto však nevyzerá ako optimálny prístup pre ľudského vývojára, ktorý používa IDE (alebo Language Server). Keďže samotné REST API maximálne využíva význam obsiahnutý v chybových kódoch, môžeme navrhnúť iný spôsob. Vytvoríme novú implementáciu rozhrania chyby (error) s názvom HttpError, ktorá uchováva StatusCode a Message. Potom poskytneme pomocné funkcie ako nasledujúca.

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

Funkcia BadRequest vráti HttpError, ktorého StatusCode nastaví na 400 a Message na hodnotu prijatú ako argument. Okrem toho môžeme samozrejme pomocou automatického dopĺňania vyhľadávať a pridávať ďalšie pomocné funkcie, ako sú NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired a podobne. To je rýchlejšie než kontrolovanie pripraveného návrhu pri každom použití a stabilnejšie než zadávanie chybového kódu ako čísla pri každom volaní. Konštanty http.StatusCode obsahujú všetko, hovoríte? Ticho

Veľký počet zápisov do logu

Pri výskyte chyby sa samozrejme zapisuje log. Pri zapisovaní logu o tom, či bolo volanie API úspešné alebo neúspešné, znamená zapisovať log na každom očakávanom ukončovacom bode od začiatku veľa kódu. Ak celý handler obalíme, môžeme logovanie spravovať centrálne.

Nasleduje príklad obaľovania routera 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}

Štruktúra routera má vo vnútri chi.Router, takže je nakonfigurovaná tak, aby sa priamo používali funkcie chi.Router. Pri pohľade na metódu Get sa kontroluje, či bola vrátená štruktúra HttpError, ktorú vracia pomocná funkcia navrhnutá vyššie, a podľa toho sa vhodne odpovie. Ak ide o bežnú error, jednotne sa odovzdá chybovej callback funkcii. Tento callback sa prijíma cez konštruktor.

Nasleduje kód napísaný s použitím tohto balíka.

 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}

Čo si o tom myslíte? Ak len vytvoríte HttpError pomocou pomocných funkcií a vrátite ho, vyššia úroveň môže odpovedať vhodným chybovým kódom a správou. Zároveň registrovaním callbacku môže každá implementovaná služba zapisovať vhodný log. Ak je to potrebné, môžete to ďalej rozšíriť a použiť napríklad RequestID na podrobné logovanie.

Odosielanie jasnej chybovej správy

Na tento účel existuje dokument RFC7807. RFC7807 definuje a používa hlavne nasledujúce prvky:

  • type: URI identifikujúci typ chyby. Zvyčajne je to dokument, ktorý chybu popisuje.
  • title: Jednoriadkový popis toho, o akú chybu ide.
  • status: Rovnaký ako HTTP Status Code.
  • detail: Podrobný popis chyby čitateľný pre človeka.
  • instance: URI, na ktorom chyba nastala. Napríklad ak chyba nastala pri GET /user/info, hodnotou bude /user/info.
  • extensions: Doplnkový prvok na opis chyby, tvorený JSON objektom.
    • Napríklad pri BadRequest môže obsahovať vstup používateľa.
    • Alebo pri TooManyRequest môže obsahovať čas najnovšej požiadavky.

Na jednoduché používanie vytvoríme nový súbor v balíku httperror, rovnako ako pri HttpError, vytvoríme štruktúru RFC7807Error a umožníme jej vytváranie vzorom reťazenia metód.

 1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
 2	return &RFC7807Error{
 3		Type:   "about:blank", // Predvolený typ podľa 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 { ... }

Hodnota "about:blank" v Type je predvolená. Označuje stránku, ktorá neexistuje. Nižšie je príklad vytvorenia chybu pri nesprávnej požiadavke.

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

Jednoduchým reťazením metód môžete vytvoriť štruktúrovanú chybovú správu pre používateľa. Aby sa dal použiť centralizovaný router vytvorený vyššie, môžeme podporiť aj nasledujúcu metódu.

1func (p *RFC7807Error) ToHttpError() *HttpError {
2	jsonBytes, err := json.Marshal(p)
3	if err != nil {
4		// Ak marshaling zlyhá, použije sa len detail
5		return New(p.Status, p.Detail)
6	}
7	return New(p.Status, string(jsonBytes))
8}

Použitím tejto metódy a úpravou predchádzajúceho príkladu dostaneme:

 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áver

Používaním centralizovaného routera na spracovanie chýb sa znižuje záťaž spojená s kontrolou chybových kódov a písaním vhodných chybových správ. Použitím RFC7807 na poskytovanie štruktúrovaných chybových správ môžeme navyše klientovi pomôcť chybu pochopiť a spracovať. Týmito prístupmi možno spracovanie chýb v HTTP API napísanom v jazyku Go urobiť jednoduchším a konzistentnejším.

Zdrojový kód tohto článku môžete nájsť v repozitári gosuda/httpwrap.