Vähemmän vaivalloinen HTTP-virheiden käsittely + RFC7807
Yleiskatsaus
Go-kielessä http API:en luomisen yhteydessä työläin osa on virheiden käsittely. Tyypillisesti koodi näyttää tältä:
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 monta, tällainen toteutus ei välttämättä aiheuta suuria ongelmia. Kuitenkin API-rajapintojen määrän kasvaessa ja sisäisen logiikan monimutkaistuessa kolme asiaa alkaa häiritä:
- Asianmukaisen virhekoodin palauttaminen
- Suuri määrä logimerkintöjä
- Selkeän virhesanoman lähettäminen
Johdanto
Asianmukaisen virhekoodin palauttaminen
Toki kohta 1, asianmukaisen virhekoodin palauttaminen, on henkilökohtainen valitukseni. Kokeneet kehittäjät löytävät ja käyttävät asianmukaisia koodeja joka kerta, mutta minä ja vielä kokemattomat kehittäjät voimme kohdata vaikeuksia käyttää johdonmukaisesti oikeita virhekoodeja logiikan monimutkaistuessa ja käyttökertojen lisääntyessä. Tähän on useita lähestymistapoja, ja tyypillisin on todennäköisesti suunnitella API-logiikan kulku etukäteen ja kirjoittaa koodi palauttamaan asianmukainen virhe. Tehkää niin
Tämä ei kuitenkaan vaikuta optimaaliselta tavalta ihmiskehittäjälle, joka saa apua IDE:ltä (tai Language Serveriltä). Lisäksi, koska REST API itsessään hyödyntää virhekoodien merkitystä mahdollisimman paljon, voidaan ehdottaa toista lähestymistapaa. Luodaan uusi HttpError-virhe (error) -rajapinnan toteutus, joka tallentaa StatusCode:n ja Message:n. Ja tarjotaan seuraava apufunktio:
1err := httperror.BadRequest("wrong format")
BadRequest-apufunktio palauttaa HttpError:n, jossa StatusCode on 400 ja Message on asetettu argumenttina annettuun arvoon. Tämän lisäksi on luonnollisesti mahdollista hakea ja lisätä automaattisen täydennyksen avulla apufunktioita kuten NotImplement, ServiceUnavailable, Unauthorized ja PaymentRequired. Tämä on nopeampaa kuin tarkistaa valmisteltu suunnitelma joka kerta, ja vakaampaa kuin syöttää virhekoodeja numeroina joka kerta. http.StatusCode-konstanteissa on kaikki? Hiljaa
Suuri määrä logimerkintöjä
Virheen tapahtuessa on luonnollista tallentaa logi. Kun API-kutsu tehdään ja pyyntö onnistuu tai epäonnistuu, logien tallentaminen jokaisen odotetun päätepisteen alusta asti lisää kirjoitettavan koodin määrää. Tämä voidaan hallita keskitetysti käärivällä itse handlerin.
Seuraavassa on esimerkki chi-reitittimen kääreestä:
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}
Router-rakenne sisältää sisäisesti chi.Router:n, mikä mahdollistaa chi.Router:n toimintojen käytön sellaisenaan. Get-metodissa tarkistetaan, onko yllä ehdotettu apufunktio palauttanut HttpError-rakenteen, ja palautetaan se asianmukaisesti. Jos kyseessä on error, se välitetään yhtenäisesti virhepalautusfunktiolle. Tämä takaisinkutsu vastaanotetaan konstruktorin kautta.
Seuraavassa on tämän paketin avulla kirjoitettu koodi:
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}
Mitä mieltä olette? Kun HttpError palautetaan apufunktiona, ylempi laajuus voi palauttaa vastauksen asianmukaisella virhekoodilla ja -sanomalla, ja palvelukohtainen loki voidaan käsitellä rekisteröimällä takaisinkutsu asianmukaisen lokin tallentamiseksi. Tarvittaessa sitä voidaan laajentaa niin, että RequestID:tä tms. käytetään yksityiskohtaisempaan lokitukseen.
Selkeän virhesanoman lähettäminen
Tätä varten on olemassa RFC7807-dokumentti. RFC7807 määrittelee ja käyttää pääasiassa seuraavia elementtejä:
type: URI, joka tunnistaa virhetyypin. Yleensä dokumentti, joka kuvaa virhettä.title: Yhden rivin kuvaus siitä, mikä virhe on.status: Sama kuin HTTP Status Code.detail: Ihmisen luettavissa oleva yksityiskohtainen kuvaus virheestä.instance: URI, jossa virhe tapahtui. Esimerkiksi, jos virhe tapahtuiGET /user/info-kutsussa, sen arvo olisi/user/info.extensions: JSON Object -muotoiset lisäelementit virheen kuvaamiseksi.- Esimerkiksi
BadRequest-tapauksessa se voi sisältää käyttäjän syötteen. - Tai
TooManyRequest-tapauksessa se voi sisältää viimeisimmän pyynnön ajankohdan.
- Esimerkiksi
Jotta tätä olisi helppo käyttää, luodaan uusi tiedosto httperror-pakettiin, samaan paikkaan kuin HttpError, ja luodaan RFC7807Error-rakenne, joka voidaan luoda metodiketjutuksen avulla.
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 { ... }
Type:n "about:blank" on oletusarvo. Se tarkoittaa olemassa olematonta sivua. Alla on esimerkki virheen luomisesta virheellistä pyyntöä varten.
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 jäsenneltyjä virhesanomia käyttäjälle. Lisäksi yllä aiemmin kirjoitetun keskitetyn reitittimen käyttämiseksi voidaan tukea seuraavia metodeja.
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}
Jos yllä olevaa esimerkkiä muokataan käyttämällä tätä sellaisenaan, siitä tulee tällainen.
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}
Johtopäätös
Keskistettyä reititintä käyttämällä virheiden käsittelyssä voidaan vähentää taakkaa, joka liittyy virhekoodien jatkuvaan tarkistamiseen ja asianmukaisten virhesanomien kirjoittamiseen. Lisäksi RFC7807:ää hyödyntämällä voidaan tarjota strukturoituja virhesanomia, mikä auttaa asiakkaita ymmärtämään ja käsittelemään virheitä. Tämä lähestymistapa voi tehdä Go-kielellä kirjoitettujen HTTP API:en virheiden käsittelystä yksinkertaisempaa ja johdonmukaisempaa.
Tämän artikkelin koodi on saatavilla gosuda/httpwrap -arkistossa.