GoSuda

Obsługa HTTP errors w sposób mniej uciążliwy + RFC7807

By snowmerak
views ...

Przegląd

Podczas tworzenia API HTTP w języku Go, najbardziej uciążliwą kwestią jest obsługa Error. Przykładowo, istnieje taki kod:

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}

Jeżeli liczba API nie jest duża, pisanie w ten sposób nie będzie szczególnie uciążliwe. Jednakże, wraz ze wzrostem liczby API i komplikacją wewnętrznej Logiki, trzy kwestie stają się problematyczne.

  1. Zwracanie odpowiedniego Error Code
  2. Duża liczba wpisów w Logu wyników
  3. Wysyłanie klarownych Error Message

Treść główna

Zwracanie odpowiedniego Error Code

Oczywiście punkt 1, czyli zwracanie odpowiedniego Error Code, jest moją osobistą uwagą. Doświadczony Developer z pewnością zawsze znajdzie i poprawnie umieści odpowiedni Code, jednakże ja, podobnie jak wciąż niedoświadczeni Developerzy, możemy napotkać trudności w konsekwentnym stosowaniu odpowiednich Error Code w miarę komplikacji Logiki i wzrostu liczby przypadków. Istnieje na to wiele metod, a najbardziej typową byłoby zaprojektowanie przepływu Logiki API z wyprzedzeniem, a następnie napisanie Code, który zwraca odpowiedni Error. Proszę tak zrobić

Jednakże nie wydaje się to optymalną metodą dla ludzkiego Developera korzystającego ze wsparcia IDE (lub Language Server). Ponadto, ponieważ sam REST API maksymalnie wykorzystuje znaczenie zawarte w Error Code, można zaproponować inną metodę. Polega ona na utworzeniu nowej implementacji interfejsu error o nazwie HttpError, która będzie przechowywać StatusCode i Message. Następnie udostępnia się następującą funkcję pomocniczą.

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

Funkcja pomocnicza BadRequest zwróci HttpError z StatusCode ustawionym na 400 i Message ustawionym na wartość przekazaną jako argument. Oprócz tego, oczywiście, można będzie wyszukiwać i dodawać funkcje pomocnicze takie jak NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired itp. za pomocą funkcji autouzupełniania. Będzie to szybsze niż każdorazowe sprawdzanie przygotowanej dokumentacji projektowej i bardziej stabilne niż każdorazowe wpisywanie Error Code w postaci liczbowej. Są one wszystkie w stałych wartościach http.StatusCode? Cii

Duża liczba wpisów w Logu wyników

W przypadku wystąpienia Error, naturalnie zapisywany jest Log. Kiedy API jest wywoływane i zapisywany jest Log dotyczący tego, czy żądanie zakończyło się sukcesem czy niepowodzeniem, zapisywanie Logów na wszystkich przewidywanych punktach zakończenia od początku zwiększa liczbę wierszy Code do napisania. Można temu zaradzić, owijając sam Handler i zarządzając Logowaniem centralnie.

Poniżej przedstawiono przykład owijania 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}

Struktura Routera zawiera w sobie chi.Router, dzięki czemu jest skonfigurowana do korzystania z funkcji chi.Router. Metoda Get sprawdza, czy zwrócono strukturę HttpError zwróconą przez zaproponowaną powyżej funkcję pomocniczą, a następnie zwraca ją odpowiednio. W przypadku error, Error jest zbiorczo przekazywany do funkcji Callback Error. Ten Callback jest przyjmowany przez konstruktor.

Poniżej znajduje się Code napisany z wykorzystaniem tego Package.

 1package main
 2
 3import (
 4	"bytes"
 5	"context"
 6	"errors"
 7	"io"
 8	"log"
 9	"net/http"
10	"os" // Tutaj brakuje importu dla signal i syscall, dodaję je dla kompletności
11	"os/signal"
12	"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}

Jak to się prezentuje? Po prostu tworząc i zwracając HttpError jako funkcję pomocniczą, można zwrócić odpowiedni Error Code i Message w wyższym Scope, jednocześnie umożliwiając centralne zarządzanie Logowaniem poprzez rejestrację Callbacka, co pozwala na zapisywanie odpowiednich Logów dla każdej implementowanej usługi. W razie potrzeby można to rozszerzyć, aby umożliwić szczegółowe Logowanie przy użyciu elementów takich jak RequestID.

Wysyłanie klarownych Error Message

Dokumentem służącym temu celowi jest RFC7807. RFC7807 zazwyczaj definiuje i wykorzystuje następujące elementy:

  • type: URI identyfikujący typ Erroru. Zazwyczaj jest to dokument opisujący Error.
  • title: Jednowierszowy opis tego, jaki Error wystąpił.
  • status: Identyczny z HTTP Status Code.
  • detail: Szczegółowy opis Erroru, czytelny dla człowieka.
  • instance: URI, pod którym wystąpił Error. Na przykład, jeżeli Error wystąpił przy GET /user/info, to /user/info będzie tą wartością.
  • extensions: Elementy pomocnicze do opisu Erroru, zorganizowane w formie JSON Object.
    • Na przykład, w przypadku BadRequest może zawierać dane wejściowe użytkownika.
    • Lub w przypadku TooManyRequest może zawierać czas ostatniego żądania.

Aby ułatwić jego użycie, można utworzyć nową strukturę RFC7807Error w Package httperror, w tej samej lokalizacji co HttpError, i umożliwić jej tworzenie za pomocą wzorca Method Chaining.

 1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
 2	return &RFC7807Error{
 3		Type:   "about:blank", // Domyślny type zgodnie z 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 { /* ... */ return p } // Dodano zwracanie p dla metody
19func (p *RFC7807Error) WithInstance(instance string) *RFC7807Error { /* ... */ return p } // Dodano zwracanie p dla metody
20func (p *RFC7807Error) WithExtension(key string, value interface{}) *RFC7807Error { /* ... */ return p } // Dodano zwracanie p dla metody

"about:blank" dla Type jest wartością domyślną. Oznacza nieistniejącą stronę. Poniżej znajduje się przykład tworzenia Erroru dla nieprawidłowego żądania.

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

Za pomocą prostego Method Chaining można tworzyć ustrukturyzowane Error Message dla użytkownika. Ponadto, aby skorzystać z wcześniej napisanego scentralizowanego Routera, można dodać następującą metodę.

1func (p *RFC7807Error) ToHttpError() *HttpError {
2	jsonBytes, err := json.Marshal(p)
3	if err != nil {
4		// Jeżeli marshalowanie zakończy się niepowodzeniem, należy powrócić do użycia jedynie detail.
5		return New(p.Status, p.Detail)
6	}
7	return New(p.Status, string(jsonBytes))
8}

Używając tego bezpośrednio, powyższy przykład można zmodyfikować w ten sposób.

 1package main
 2
 3import (
 4	"bytes"
 5	"context"
 6	"errors"
 7	"io"
 8	"log"
 9	"net/http"
10	"os" // Tutaj brakuje importu dla signal i syscall, dodaję je dla kompletności
11	"os/signal"
12	"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.BadRequestProblem("name is required", "Bad User Input").
29                WithType("https://example.com/errors/validation").
30                WithInstance("/api/echo").
31                WithExtension("invalid_field", "name").
32                WithExtension("expected_format", "string").
33                WithExtension("actual_value", name).
34                ToHttpError()
35		}
36
37		writer.Write([]byte("Hello " + name))
38		return nil
39	})
40
41	svr := http.Server{
42		Addr:    ":8080",
43		Handler: r,
44	}
45	go func() {
46		if err := svr.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
47			log.Fatalf("Failed to start server: %v", err)
48		}
49	}()
50
51    <-ctx.Done()
52    svr.Shutdown(context.Background())
53}

Podsumowanie

Dzięki wykorzystaniu scentralizowanego Routera do obsługi Errorów można zmniejszyć obciążenie związane z każdorazowym sprawdzaniem Error Code i pisaniem odpowiednich Error Message. Ponadto, wykorzystując RFC7807 do dostarczania ustrukturyzowanych Error Message, można pomóc Clientowi w zrozumieniu i obsłudze Errorów. Dzięki tym metodom obsługa Errorów w API HTTP napisanych w języku Go może stać się prostsza i bardziej spójna.

Kod z niniejszego artykułu jest dostępny do weryfikacji w Repo gosuda/httpwrap.