Virheiden käsittelyn helpottaminen HTTP-virheissä + RFC7807

Yleiskatsaus
Go-kielellä HTTP-rajapintoja luotaessa suurin vaiva on virheiden käsittely. Tyypillisesti koodi kirjoitetaan tällaisena:
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 rajapintoja on vain muutama, tällainen kirjoitustapa ei aiheuta erityistä haittaa. Kun rajapintojen määrä kuitenkin kasvaa ja sisäinen logiikka monimutkaistuu, kolme asiaa alkaa ärsyttää.
- Sopivan virhekoodin palauttaminen
- Lokikirjausten suuri määrä
- Selkeän virheviestin lähettäminen
Pääosa
Sopivan virhekoodin palauttaminen
Ensimmäinen kohta, sopivan virhekoodin palauttaminen, on tosin pelkästään minun henkilökohtainen valitukseni. Kokenut kehittäjä löytää sopivan koodin ja lisää sen aina oikein, mutta minulle ja vielä kokemattomille kehittäjille voi olla vaikeaa käyttää sopivia virhekoodeja johdonmukaisesti, kun logiikka monimutkaistuu ja kutsujen määrä kasvaa. Tähän on useita ratkaisuja, joista tyypillisin on suunnitella rajapinnan logiikkavirta etukäteen ja kirjoittaa koodi palauttamaan sopiva virhe. Tehkää niin
Tämä ei kuitenkaan vaikuta optimaaliselta tavalta ihmiskehittäjälle, joka käyttää IDE:tä (tai Language Serveriä). Koska REST-rajapinta itsessään hyödyntää virhekoodeihin sisältyvää merkitystä mahdollisimman tehokkaasti, voidaan ehdottaa myös toista lähestymistapaa. Luodaan uusi error-rajapinnan toteutus nimeltä HttpError, joka tallentaa StatusCode- ja Message-kentät. Lisäksi tarjotaan seuraavanlaisia apufunktioita.
1err := httperror.BadRequest("wrong format")
Funktio BadRequest palauttaa HttpError-olion, jonka StatusCode on 400 ja Message on asetettu argumentin arvoon. Muita apufunktioita, kuten NotImplement, ServiceUnavailable, Unauthorized ja PaymentRequired, voidaan tietysti lisätä ja hakea automaattisen täydennyksen avulla. Tämä on nopeampaa kuin tarkistaa valmis suunnitteludokumentti joka kerta, ja se on luotettavampaa kuin syöttää virhekoodi numerona joka kerta. Kaikkihan on http.StatusCode-vakioissa, sanotte? Pst
Lokikirjausten suuri määrä
Virheen sattuessa lokia kirjoitetaan luonnollisesti. Kun kirjataan, onnistuiko vai epäonnistuiko rajapinnan kutsu, lokin kirjaaminen jokaiseen ennakoituun lopetuskohtaan alusta alkaen lisää kirjoitettavan koodin määrää. Käärimällä itse käsittelijän voidaan hallita tätä keskitetysti.
Seuraavassa on esimerkki chi-reitittimen käärimisestä.
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}
Reitittimen rakenne sisältää chi.Router-olion, joten chi.Router:n toiminnallisuus on käytettävissä sellaisenaan. Get-metodissa tarkistetaan, onko palautettu virhe edellä ehdotetun apufunktion palauttama HttpError-rakenne. Jos on, se palautetaan sopivasti, ja muussa tapauksessa kaikki error-virheet välitetään yhtenäisesti virhe-callback-funktiolle. Tämä callback saadaan konstruktorin kautta.
Seuraavassa on koodi, joka on kirjoitettu tätä pakettia hyödyntäen.
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? Jos luodaan pelkästään HttpError apufunktioina ja palautetaan se, ylemmän tason scope voi palauttaa vastauksen sopivalla virhekoodilla ja viestillä. Jokaiselle palvelulle voidaan lisäksi rekisteröidä callback, joka kirjaa sopivat lokit. Tarvittaessa tätä voidaan laajentaa esimerkiksi RequestID-tunnisteilla, jotta lokitus on yksityiskohtaisempaa.
Selkeän virheviestin lähettäminen
Tähän tarkoitukseen on olemassa dokumentti RFC7807. RFC7807 määrittelee pääasiassa seuraavat elementit:
type: URI, joka tunnistaa virhetyypin. Yleensä se on dokumentti, joka kuvaa virhettä.title: lyhyt yhden rivin kuvaus siitä, minkä virheen kyseessä on.status: sama kuin HTTP-statuskoodi.detail: ihmisen luettavissa oleva yksityiskohtainen kuvaus kyseisestä virheestä.instance: URI, jossa virhe tapahtui. Esimerkiksi jos virhe tapahtui kohdassaGET /user/info, arvo on/user/info.extensions: JSON-oliona muodostuva virhettä kuvaava lisäelementti.- Esimerkiksi
BadRequest-tapauksessa siihen voidaan sisällyttää käyttäjän syöte. - Tai
TooManyRequest-tapauksessa siihen voidaan sisällyttää viimeisimmän pyynnön ajankohta.
- Esimerkiksi
Jotta tätä olisi helppo käyttää, luodaan httperror-pakettiin, samaan paikkaan kuin HttpError, uusi tiedosto, jossa määritellään RFC7807Error-rakenne. Lisäksi sen luominen mahdollistetaan metodiketjutusmallilla.
1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
2 return &RFC7807Error{
3 Type: "about:blank", // 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 { ... }
19func (p *RFC7807Error) WithInstance(instance string) *RFC7807Error { ... }
20func (p *RFC7807Error) WithExtension(key string, value interface{}) *RFC7807Error { ... }
Type-kentän "about:blank" on oletusarvo, ja se tarkoittaa tyhjää sivua. Alla on esimerkki väärään pyyntöön liittyvän virheen luomisesta.
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 käyttäjälle jäsennelty virheviesti. Lisäksi, jotta aiemmin kirjoitettua keskitettyä reititintä voidaan hyödyntää, voidaan tukea seuraavaa metodia.
1func (p *RFC7807Error) ToHttpError() *HttpError {
2 jsonBytes, err := json.Marshal(p)
3 if err != nil {
4 // Jos marshalointi epäonnistuu, käytetään pelkästään detail-kenttää
5 return New(p.Status, p.Detail)
6 }
7 return New(p.Status, string(jsonBytes))
8}
Käytetään tätä sellaisenaan ja muokataan yllä olevaa esimerkkiä, jolloin saadaan seuraava tulos.
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
Kun virheitä käsitellään tällaisella keskitetyllä reitittimellä, voidaan vähentää taakkaa, joka liittyy virhekoodien tarkistamiseen ja sopivien virheviestien kirjoittamiseen joka kerta. Lisäksi RFC7807:n käyttö jäsenneltyjen virheviestien tarjoamiseen auttaa asiakasta ymmärtämään virheet ja käsittelemään ne. Näiden menetelmien avulla Go-kielellä kirjoitetun HTTP-rajapinnan virheenkäsittelystä voidaan tehdä entistä yksinkertaisempaa ja yhtenäisempää.
Tämän kirjoituksen koodi on saatavilla gosuda/httpwrap -repositoriossa.