Обработка на 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 и усложняване на вътрешната логика, три неща стават дразнещи.
- Връщане на подходящи кодове за грешка
- Голям брой записи в лога за резултати
- Изпращане на ясни съобщения за грешка
Основна част
Връщане на подходящи кодове за грешка
Разбира се, точка 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 и се оставят логове относно това дали заявката е била успешна или неуспешна, оставянето на логове на всички очаквани точки на прекратяване от началото увеличава броя на редове код за писане. Чрез еднократно обвиване на самия 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}
Структурата на рутера съдържа вътрешно 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", // Тип по подразбиране съгласно 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.