GoSuda

Kevésbé bosszantó HTTP hibakezelés + RFC7807

By snowmerak
views ...

Áttekintés

Amikor Go nyelven HTTP API-t hozunk létre, a legbosszantóbb dolog a hibakezelés. Jellemzően az alábbihoz hasonló kódot találunk:

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}

Ha csak néhány API-ról van szó, akkor valószínűleg nem okoz különösebb kényelmetlenséget az ilyen módon történő írás. Azonban az API-k számának növekedésével és a belső logika bonyolulttá válásával három dolog válik zavaróvá:

  1. Megfelelő hibakód visszaadása
  2. Nagyszámú eredménynapló írása
  3. Világos hibaüzenetek küldése

Fő rész

Megfelelő hibakód visszaadása

Természetesen az 1. pont, a megfelelő hibakód visszaadása, az én személyes panaszom. Egy tapasztalt fejlesztő minden alkalommal megtalálja és beilleszti a megfelelő kódot, de én is, és a még tapasztalatlan fejlesztők is nehézségekbe ütközhetnek a megfelelő hibakódok rendszeres használatában, ahogy a logika bonyolulttá válik és a hívások száma növekszik. Erre több módszer is létezik, és a legjellemzőbb az, hogy előre megtervezzük az API logikai folyamatát, majd úgy írjuk meg a kódot, hogy az megfelelő hibát adjon vissza. Tegye meg ezt

Ez azonban nem tűnik optimális módszernek az emberi fejlesztők számára, akik IDE (vagy Language Server) segítségét veszik igénybe. Továbbá, mivel maga a REST API a lehető legnagyobb mértékben kihasználja a hibakódokba foglalt jelentést, más megközelítést is javasolhatunk. Hozzunk létre egy új HttpError nevű error interfész implementációt, amely tárolja a StatusCode és a Message értékeket. Ezután biztosítsuk a következő segédfüggvényt:

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

A BadRequest segédfüggvény egy HttpError-t fog visszaadni, amelynek StatusCode-ja 400, Message-je pedig a paraméterként kapott érték. Ezen kívül természetesen lekérdezhetők és hozzáadhatók az automatikus kiegészítés funkcióval olyan segédfüggvények, mint a NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired stb. Ez gyorsabb lesz, mint minden alkalommal ellenőrizni az előkészített tervezési dokumentációt, és stabilabb, mint minden alkalommal számként beírni a hibakódot. Azt mondja, hogy minden benne van a http.StatusCode konstansokban? Psszt

Nagyszámú eredménynapló írása

Hiba esetén természetesen naplóbejegyzés készül. Amikor az API-t meghívják, és naplózzák, hogy a kérés sikeres volt-e vagy sikertelen, a kód mennyisége megnő, ha a kezdetektől fogva minden várható kilépési pontnál naplózunk. Ezt úgy lehet kezelni, hogy magát a Handler-t beburkoljuk, lehetővé téve a központi kezelést.

Az alábbiakban egy példa látható a chi router beburkolására:

 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}

A Router struktúra belsőleg tartalmaz egy chi.Router-t, így a chi.Router funkciói változatlanul felhasználhatók. A Get metódusban ellenőrizzük, hogy a fent javasolt segédfüggvény által visszaadott HttpError struktúra került-e visszaadásra, majd megfelelően visszaadjuk azt. Ha error történik, akkor azt egységesen továbbítjuk a hiba visszahívási függvénynek. Ez a visszahívás a konstruktoron keresztül kerül bemenetként.

Az alábbiakban bemutatjuk a csomag felhasználásával írt kódot:

 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 gondol? Ha egyszerűen egy HttpError-t adunk vissza segédfüggvényként, akkor a felső scope-ban megfelelő hibakóddal és üzenettel válaszolhatunk, és regisztrálhatunk egy callback-et, hogy minden implementált szolgáltatáshoz megfelelő naplókat írjunk. Ezenkívül, ha további bővítésre van szükség, akkor olyan részletes naplózás is lehetséges, mint a RequestID használata.

Világos hibaüzenetek küldése

Ehhez az RFC7807 dokumentum áll rendelkezésre. Az RFC7807 elsősorban a következő elemeket határozza meg:

  • type: Az URI, amely azonosítja a hiba típusát. Főleg a hibát leíró dokumentumra mutat.
  • title: Egy soros leírás arról, hogy milyen hibáról van szó.
  • status: Megegyezik a HTTP Status Code-dal.
  • detail: Részletes, ember által olvasható leírás a hibáról.
  • instance: A hiba előfordulási URI-ja. Például, ha a hiba a GET /user/info során történt, akkor az érték /user/info lesz.
  • extensions: JSON Object formában szervezett kiegészítő elemek a hiba leírására.
    • Például BadRequest esetén tartalmazhatja a felhasználói bevitelt.
    • Vagy TooManyRequest esetén tartalmazhatja a legutóbbi kérés időpontját.

Ennek könnyebb használata érdekében létrehozunk egy új fájlt az HttpError-rel azonos helyen, az httperror csomagban, és létrehozzuk az RFC7807Error struktúrát, amelyet metódusláncolási mintával hozhatunk létre.

 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 { ... }

A Type mező "about:blank" értéke az alapértelmezett. Ez egy nem létező oldalt jelent. Az alábbiakban egy példa látható egy helytelen kéréshez tartozó hiba létrehozására.

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

Egyszerű metódusláncolással strukturált hibaüzeneteket hozhatunk létre a felhasználó számára. Ezenkívül a fentebb már megírt centralizált router használatához a következő metódusokat támogathatjuk:

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}

Ha ezt változatlanul felhasználjuk, a fenti példa a következőképpen módosul:

 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}

Következtetés

Azáltal, hogy centralizált routert használunk a hibák kezelésére, csökkenthetjük a hibakódok minden alkalommal történő ellenőrzésének és a megfelelő hibaüzenetek írásának terhét. Továbbá, az RFC7807 felhasználásával strukturált hibaüzeneteket biztosítva, segíthetünk az ügyfeleknek a hibák megértésében és kezelésében. Ezekkel a módszerekkel egyszerűbbé és következetesebbé tehetjük a Go nyelven írt HTTP API-k hibakezelését.

A cikk kódja a gosuda/httpwrap repository-ban található.