GoSuda

Łatwiejsze zarządzanie błędami HTTP + RFC7807

By snowmerak
views ...

개요

W języku Go, podczas tworzenia API HTTP, najbardziej uciążliwe jest zarządzanie błędami. Typowym przykładem jest poniższy 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śli liczba API jest niewielka, pisanie w ten sposób nie powinno sprawiać większych problemów. Jednakże, w miarę wzrostu liczby API i komplikacji wewnętrznej logiki, trzy kwestie stają się irytujące:

  1. Zwracanie odpowiedniego kodu błędu
  2. Duża liczba zapisywanych logów wyników
  3. Przekazywanie jasnej wiadomości o błędzie

본론

적절한 에러 코드 반환

Oczywiście, punkt 1, czyli zwracanie odpowiedniego kodu błędu, jest moją osobistą pretensją. Doświadczony deweloper z pewnością znajdzie i za każdym razem poprawnie wstawi odpowiedni kod, ale zarówno ja, jak i mniej doświadczeni deweloperzy, mogą mieć trudności z konsekwentnym używaniem właściwych kodów błędów, gdy logika staje się skomplikowana, a wywołań jest dużo. Istnieje wiele metod radzenia sobie z tym, a najbardziej typową jest zaprojektowanie przepływu logiki API z wyprzedzeniem, a następnie napisanie kodu w taki sposób, aby zwracał odpowiednie błędy. Proszę tak robić

Jednakże, nie wydaje się to optymalną metodą dla ludzkiego dewelopera korzystającego z pomocy IDE (lub Language Server). Ponadto, ponieważ REST API maksymalnie wykorzystuje znaczenie zawarte w kodach błędów, można zaproponować inne podejście. Polega ono na stworzeniu nowej implementacji interfejsu błędu (error) o nazwie HttpError, która będzie przechowywać StatusCode i Message. Ponadto, udostępniamy 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żliwe będzie wyszukiwanie i dodawanie funkcji pomocniczych, takich jak NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired, za pomocą funkcji autouzupełniania. Będzie to szybsze niż każdorazowe sprawdzanie przygotowanej specyfikacji i bardziej niezawodne niż ciągłe wprowadzanie kodów błędów w postaci liczbowej. (Mówisz, że to wszystko jest w stałych http.StatusCode? Ciii...)

많은 결과 로그 작성 수

W przypadku wystąpienia błędu naturalnie pozostawiamy log. Kiedy API jest wywoływane, zapisywanie logów na początku i we wszystkich przewidywanych punktach końcowych, aby rejestrować, czy żądanie zakończyło się sukcesem czy niepowodzeniem, zwiększa ilość kodu do napisania. Można to scentralizować, otaczając sam Handler, co pozwala na centralne zarządzanie.

Poniżej znajduje się przykład opakowania 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 jest zaprojektowana tak, aby wewnętrznie zawierała chi.Router i mogła wykorzystywać jego funkcje. W metodzie Get sprawdzane jest, czy zwrócono strukturę HttpError zwróconą przez zaproponowaną wcześniej funkcję pomocniczą, a następnie jest ona odpowiednio zwracana; jeśli jest to zwykły error, jest on zbiorczo przekazywany do funkcji zwrotnej błędu. Ta funkcja zwrotna jest wprowadzana za pomocą konstruktora.

Poniżej znajduje się kod napisany z wykorzystaniem tego pakietu:

 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}

Co o tym sądzicie? Po prostu zwracając HttpError stworzony przez funkcję pomocniczą, można zwrócić odpowiedź z właściwym kodem błędu i wiadomością w wyższym zakresie, a także zarejestrować funkcję zwrotną, aby można było obsługiwać odpowiednie logi dla każdej zaimplementowanej usługi. Dodatkowo, w razie potrzeby, można to rozszerzyć, umożliwiając szczegółowe logowanie przy użyciu np. RequestID.

명확한 에러 메시지 전송

Dokumentem referencyjnym dla tego celu jest RFC7807. RFC7807 zazwyczaj definiuje i wykorzystuje następujące elementy:

  • type: URI identyfikujący typ błędu. Zazwyczaj jest to dokument opisujący błąd.
  • title: Jednowierszowy opis, o jakim błędzie mowa.
  • status: To samo co HTTP Status Code.
  • detail: Szczegółowy opis błędu, który jest czytelny dla człowieka.
  • instance: URI, pod którym wystąpił błąd. Na przykład, jeśli błąd wystąpił pod GET /user/info, to /user/info będzie tą wartością.
  • extensions: Dodatkowe elementy służące do opisu błędu, skonstruowane w formie JSON Object.
    • Na przykład, w przypadku BadRequest, mogą być zawarte dane wejściowe użytkownika.
    • Lub w przypadku TooManyRequest, może być zawarty czas ostatniego żądania.

Aby ułatwić korzystanie z tego, tworzymy nową strukturę RFC7807Error w pakiecie httperror, w tej samej lokalizacji co HttpError, i umożliwiamy jej tworzenie za pomocą wzorca łańcuchowego wywoływania metod (method chaining).

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

"about:blank" dla Type jest wartością domyślną. Oznacza to brak strony. Poniżej znajduje się przykład tworzenia błędu 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 łańcuchowego wywoływania metod można generować ustrukturyzowane wiadomości o błędach dla użytkownika. Ponadto, aby wykorzystać wcześniej napisany scentralizowany router, można zaimplementować następującą metodę:

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}

Używając tego bezpośrednio, powyższy przykład można zmodyfikować w następujący sposób:

 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}

결론

Korzystając z tak scentralizowanego routera do obsługi błędów, można zmniejszyć obciążenie związane z każdorazowym sprawdzaniem kodów błędów i pisaniem odpowiednich wiadomości o błędach. Ponadto, wykorzystując RFC7807 do dostarczania ustrukturyzowanych wiadomości o błędach, można pomóc klientom w zrozumieniu i obsłudze błędów. Metody te mogą uczynić obsługę błędów w API HTTP napisanym w języku Go bardziej wygodną i spójną.

Kod dla tego artykułu można znaleźć w repozytorium gosuda/httpwrap.