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}

Структура маршрутизатора содержит 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 и сделайте возможным ее создание с использованием паттерна цепочки методов (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")

Можно создавать структурированные сообщения об ошибках для пользователя с помощью простой цепочки методов (method chaining). Кроме того, для использования централизованного маршрутизатора, написанного выше, можно поддерживать следующий метод.

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.