По-удобна обработка на HTTP грешки + RFC7807

Общ преглед
Когато създавате 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-тата и усложняването на вътрешната логика, три неща започват да ви притесняват:
- Връщане на подходящ код за грешка
- Голям брой записи в журнала
- Изпращане на ясно съобщение за грешка
Основна част
Връщане на подходящ код за грешка
Първата точка, връщането на подходящ код за грешка, е моето лично неудобство. Опитен разработчик би намирал подходящия код и би го вписвал правилно всеки път, но както и аз, така и по-неопитните разработчици може да срещнат затруднения да използват последователно подходящи кодове за грешка, когато логиката стане сложна и броят на извикванията нарасне. По този въпрос съществуват различни подходи, като най-типичният е предварително проектиране на логическия поток на API и след това писане на кода така, че да връща подходящите грешки. Направете така
Този подход обаче не изглежда оптимален за човешки разработчик, който разчита на помощта на IDE (или Language Server). Освен това, тъй като самият REST API максимално използва значението, заложено в кодовете за грешка, може да се предложи друг подход. Създава се нов тип грешка, реализиращ интерфейса error, наречен HttpError, който съхранява StatusCode и Message. След това се предоставят помощни функции като следната:
1err := httperror.BadRequest("wrong format")
Помощната функция BadRequest ще върне HttpError, в който StatusCode е зададен на 400, а Message е стойността, получена като аргумент. Освен нея, разбира se, могат да се създадат и помощни функции като NotImplement, ServiceUnavailable, Unauthorized, PaymentRequired и други, които могат да се разглеждат и добавят чрез функцията за автоматично допълване. Това е по-бързо от проверка на предварително подготвен проектен документ при всяка грешка и по-надеждно от въвеждане на числови кодове за грешка всеки път. Всичко това е в константите http.StatusCode? Тссс
Голям брой записи в журнала
При възникване на грешка, естествено, се записва съобщение в журнала. Когато се записва информация за извикването на API и за това дали заявката е успешна или неуспешна, записването на журнал във всяка очаквана точка на завършване на функцията увеличава значително обема на кода. Чрез обвиване на самия обработчик (handler) можем да управляваме това централно.
Следва пример за обвиване на маршрутизатора 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, тя се предава на функцията за обратно извикване (callback). Тази функция се получава през конструктора.
Следва код, написан с помощта на този пакет.
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 кода на състоянието.detail: Подробно описание на грешката, четимо от човек.instance: URI, при което е възникнала грешката. Например, ако грешката е възникнала приGET /user/info, стойността ще бъде/user/info.extensions: Допълнителни елементи за описание на грешката, структурирани като JSON обект.- Например, при
BadRequestможе да се включи въведеното от потребителя. - Или при
TooManyRequestможе да се включи времето на най-последната заявка.
- Например, при
За да се използва лесно, в пакета httperror, в същата директория като HttpError, се създава нов файл, в който се дефинира структурата RFC7807Error, а конструирането се позволява чрез верижно извикване на методи (method chaining).
1func NewRFC7807Error(status int, title, detail string) *RFC7807Error {
2 return &RFC7807Error{
3 Type: "about:blank", // Стойност по подразбиране според 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 // Ако сериализацията се провали, използваме само 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.