GoSuda

HTTP-virheiden käsittely vaivattomammin + RFC7807

By snowmerak
views ...

Yleiskatsaus

Go-kielellä http api -rajapintoja luotaessa hankalinta on virheenkäsittely. Tyypillisesti tällainen koodi esiintyy.

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}

Jos API-rajapintoja ei ole kovin montaa, tällä tavoin kirjoittaminen ei todennäköisesti aiheuta erityistä hankaluutta. Kuitenkin API-rajapintojen määrän kasvaessa ja sisäisen logiikan monimutkaistuessa kolme asiaa alkaa ärsyttää.

  1. Asianmukaisen virhekoodin palauttaminen
  2. Suuri määrä tuloslokeja
  3. Selkeän virhesanoman lähettäminen

Pääsisältö

Asianmukaisen virhekoodin palauttaminen

Toki kohta 1, asianmukaisen virhekoodin palauttaminen, on henkilökohtainen valitukseni. Kokenut kehittäjä löytää ja lisää asianmukaisen koodin joka kerta, mutta minä ja vielä kokemattomat kehittäjät voivat kokea vaikeuksia käyttää säännöllisesti sopivaa virhekoodia, kun logiikka monimutkaistuu ja toistojen määrä kasvaa. Tähän on useita tapoja, ja tyypillisin on suunnitella API-logiikan kulku etukäteen ja kirjoittaa koodi palauttamaan asianmukainen virhe. Tehkää niin

Mutta tämä ei vaikuta optimaaliselta tavalta ihmissuunnittelijalle, joka saa apua IDE:ltä (tai Language Serveriltä). Lisäksi, koska REST API itsessään hyödyntää mahdollisimman paljon virhekoodiin sisältyvää merkitystä, voidaan ehdottaa toista tapaa. Luodaan uusi HttpError-virhe (error) -rajapinnan toteutus, joka tallentaa StatusCode- ja Message-tiedot. Ja tarjotaan seuraava apufunktio.

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

BadRequest-apufunktio palauttaa HttpError-rakenteen, jonka StatusCode on 400 ja Message asetettu annetun argumentin arvoksi. Tämän lisäksi on luonnollisesti mahdollista hakea ja lisätä apufunktioita kuten NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired automaattisen täydennysominaisuuden avulla. Tämä on nopeampaa kuin suunnitteludokumentin tarkistaminen joka kerta, ja vakaampaa kuin virhekoodin syöttäminen numerona joka kerta. http.StatusCode -vakioissa ne ovat kaikki? Hiljaa

Suuri määrä tuloslokeja

Virheen tapahtuessa loki on luonnollisesti tallennettava. Kun API kutsutaan ja loki tallennetaan siitä, onko pyyntö onnistunut vai epäonnistunut, lokin tallentaminen alusta kaikkiin oletettuihin lopetuspisteisiin lisää kirjoitettavan koodin määrää. Tämä voidaan hallita keskitetysti kääriytyen itse käsittelijän ympärille.

Seuraavassa on esimerkki chi-reitittimen kääriytyksestä.

 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}

Reititinrakenne sisältää chi.Router-rakenteen sisällään, ja se on konfiguroitu käyttämään chi.Router-rakenteen toimintoja sellaisenaan. Get-metodissa tarkistetaan, palautettiinko äsken ehdotettu apufunktion palauttama HttpError-rakenne, ja se palautetaan asianmukaisesti; jos kyseessä on error, se välitetään virhepalautusfunktiolle yhtenäisesti. Tämä palautusfunktio vastaanotetaan konstruktorin kautta.

Seuraavassa on koodi, joka on kirjoitettu hyödyntäen tätä pakettia.

 1package main
 2
 3import (
 4	"bytes"
 5	"context"
 6	"errors"
 7	"io"
 8	"log"
 9	"net/http"
10	"os" // Import os
11    "os/signal" // Import signal
12    "syscall" // Import 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)
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)
42		}
43	}()
44
45    <-ctx.Done()
46    svr.Shutdown(context.Background())
47}

Miltä tämä vaikuttaa? Palauttamalla yksinkertaisesti HttpError-rakenteen apufunktiona, ylemmällä tasolla voidaan palauttaa asianmukainen virhekoodi ja sanoma vastauksena, ja rekisteröimällä takaisinkutsufunktio voidaan käsitellä asianmukainen loki jokaiselle toteutetulle palvelulle. Tarvittaessa tätä voidaan laajentaa, jotta yksityiskohtainen lokitus on mahdollista käyttämällä esimerkiksi RequestID-tunnusta.

Selkeän virhesanoman lähettäminen

Tätä varten on olemassa dokumentti RFC7807. RFC7807 määrittelee yleensä seuraavat elementit käytettäviksi.

  • type: URI, joka tunnistaa virhetyypin. Usein dokumentti, joka kuvaa virhettä.
  • title: Yhden rivin kuvaus siitä, mikä virhe on kyseessä.
  • status: Sama kuin HTTP Status Code.
  • detail: Ihmisen luettavissa oleva yksityiskohtainen kuvaus kyseisestä virheestä.
  • instance: Virheen tapahtumisen URI. Esimerkiksi jos virhe tapahtui osoitteessa GET /user/info, sen arvo olisi /user/info.
  • extensions: Virhettä kuvaavat lisäelementit, jotka on muodostettu JSON Object -muodossa.
    • Esimerkiksi BadRequest-tapauksessa voi sisältyä käyttäjän syöte.
    • Tai TooManyRequest-tapauksessa voi sisältyä viimeisimmän pyynnön ajankohta.

Helppokäyttöisyyden vuoksi luodaan uusi tiedosto HttpError-rakenteen kanssa samassa httperror-paketissa ja luodaan RFC7807Error-rakenne, joka voidaan luoda metodiketjutuskuviolla.

 1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
 2	return &RFC7807Error{
 3		Type:   "about:blank", // Default type as per RFC7807 // Oletustyyppi RFC7807:n mukaisesti
 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 } // Placeholder implementation // Paikkamerkkitoteutus
19func (p *RFC7807Error) WithInstance(instance string) *RFC7807Error { return p } // Placeholder implementation // Paikkamerkkitoteutus
20func (p *RFC7807Error) WithExtension(key string, value interface{}) *RFC7807Error { return p } // Placeholder implementation // Paikkamerkkitoteutus

Type-kentän "about:blank" on oletusarvo. Se tarkoittaa olematonta sivua. Alla on esimerkki virheen luomisesta virheelliselle pyynnölle.

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

Yksinkertaisella metodiketjutuksella voidaan luoda strukturoitu virhesanoma käyttäjälle. Lisäksi yllä aiemmin kirjoitettua keskitettyä reititintä voidaan käyttää tukemalla seuraavaa metodia.

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 // Jos marshaling epäonnistuu, palataan käyttämään vain detail-kenttää
5		return New(p.Status, p.Detail)
6	}
7	return New(p.Status, string(jsonBytes))
8}

Käyttämällä tätä sellaisenaan yllä olevan esimerkin muokkaus näyttää tältä.

 1package main
 2
 3import (
 4	"bytes"
 5	"context"
 6	"errors"
 7	"io"
 8	"log"
 9	"net/http"
10	"os" // Import os
11    "os/signal" // Import signal
12    "syscall" // Import syscall
13    "encoding/json" // Import json
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)
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)
49		}
50	}()
51
52    <-ctx.Done()
53    svr.Shutdown(context.Background())
54}

Johtopäätös

Käsittelemällä virheitä tällä tavoin keskitetyllä reitittimellä voidaan vähentää taakkaa virhekoodin tarkistamisesta ja asianmukaisen virhesanoman kirjoittamisesta joka kerta. Lisäksi hyödyntämällä RFC7807:ää strukturoidun virhesanoman tarjoamiseen voidaan auttaa asiakasta ymmärtämään ja käsittelemään virhettä. Näillä menetelmillä voidaan tehdä Go-kielellä kirjoitetun HTTP API:n virheenkäsittelystä entistä yksinkertaisempaa ja johdonmukaisempaa.

Kyseisen artikkelin koodi on saatavilla gosuda/httpwrap -repositoriossa.