GoSuda

По-малко досадно обработване на HTTP грешки + RFC7807

By snowmerak
views ...

Общ преглед

При създаването на HTTP API на Go, най-досадното нещо е обработката на грешки. Типичен пример е следният код:

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}

Ако API-тата не са много, този начин на писане може да не е особено неудобен. Въпреки това, с нарастването на броя на API-тата и усложняването на вътрешната логика, три неща стават дразнещи:

  1. Връщане на подходящ код за грешка
  2. Голям брой записвания на резултатни логове
  3. Изпращане на ясни съобщения за грешки

Основна част

Връщане на подходящ код за грешка

Разбира се, точка 1, връщането на подходящ код за грешка, е мое лично оплакване. Опитните разработчици вероятно ще намират и въвеждат правилния код всеки път, но аз, както и все още неопитните разработчици, може да срещнем трудности при последователното използване на подходящи кодове за грешки, тъй като логиката става по-сложна и броят на случаите нараства. За това съществуват различни методи, като най-типичният е проектирането на потока на логиката на API предварително и след това писането на код, който връща подходящи грешки. Направете го така

Въпреки това, това не изглежда като оптимален метод за човешкия разработчик, който се ползва от помощта на IDE (или Language Server). Освен това, тъй като REST API използва максимално значението на кодовете за грешки, може да се предложи и друг подход. Създава се нова имплементация на интерфейса за грешки (error), наречена HttpError, която съхранява StatusCode и Message. След това се предоставя следната помощна функция:

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

Помощната функция BadRequest ще върне HttpError с StatusCode 400 и Message, зададен като аргумент. В допълнение към това, разбира се, ще бъде възможно да се търсят и добавят помощни функции като NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired и други чрез функцията за автоматично довършване. Това е по-бързо, отколкото да се проверява подготвеният дизайн всеки път, и по-стабилно, отколкото да се въвеждат кодове за грешки като числа всеки път. Всичко е в константите на http.StatusCode? Шшшт

Голям брой записвания на резултатни логове

При възникване на грешка, естествено се записва лог. Когато API е извикано и се записва лог за успешно или неуспешно завършване на заявката, записването на лог от началото до всички очаквани крайни точки увеличава броя на написания код. Това може да се управлява централно, като се обвие самият хендлър.

Следва пример за обвиване на 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}

Структурата Router съдържа chi.Router вътрешно, така че да използва функциите на chi.Router директно. В метода Get се проверява дали е върната структурата HttpError, върната от помощната функция, която току-що предложихме по-горе, и се връща по подходящ начин, а в случай на error, тя се предава на функцията за обратно извикване за грешки. Това обратно извикване се получава чрез конструктора.

Следва код, написан с помощта на този пакет:

 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}

Какво мислите? Просто връщайки HttpError като помощна функция, можете да върнете подходящ код за грешка и съобщение в по-високия обхват и да обработите това, като регистрирате функция за обратно извикване, за да записвате подходящи логове за всяка услуга за имплементация. Ако е необходимо допълнително, можете да разширите това, за да позволите подробно логване, използвайки RequestID и други.

Изпращане на ясни съобщения за грешки

За тази цел съществува документът RFC7807. RFC7807 обикновено дефинира и използва следните елементи:

  • type: URI, идентифициращ типа грешка. Обикновено това е документ, описващ грешката.
  • title: Едноредово описание на това каква е грешката.
  • status: Еквивалентно на HTTP Status Code.
  • detail: Подробно, четимо описание на грешката.
  • instance: URI, където е възникнала грешката. Например, ако грешка е възникнала в GET /user/info, то /user/info ще бъде неговата стойност.
  • extensions: Второстепенни елементи за описание на грешката, съставени във формат JSON Object.
    • Например, в случай на BadRequest, може да бъде включен потребителският вход.
    • Или, в случай на TooManyRequest, може да бъде включено времето на последната заявка.

За лесно използване, в същия пакет httperror като HttpError се създава нов файл и се създава структура RFC7807Error, която може да бъде създадена чрез верижно извикване на методи.

 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" за Type е стойност по подразбиране. Означава страница, която не съществува. По-долу е даден пример за създаване на грешка за лоша заявка.

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

Със просто верижно извикване на методи може да се генерира структурирано съобщение за грешка за потребителя. Също така, за да се използва централизираният рутер, написан по-рано, може да се поддържа следният метод:

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}

Ако използваме това директно и модифицираме горния пример, той ще изглежда така:

 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}

Заключение

Чрез използването на такъв централизиран рутер за обработка на грешки може да се намали тежестта от постоянното проверяване на кодове за грешки и писане на подходящи съобщения за грешки. Освен това, като се използва RFC7807 за предоставяне на структурирани съобщения за грешки, може да се помогне на клиентите да разбират и обработват грешките. По този начин обработката на грешки в HTTP API, написани на Go, може да стане по-проста и последователна.

Кодът на тази статия може да бъде намерен в хранилището gosuda/httpwrap.