DevGang
Авторизоваться

Обработка ошибок в веб-приложениях на Go должна быть удобнее!

В этом посте я опишу паттерн обработки ошибок, который показался мне довольно элегантным при написании REST, gRPC и других сервисов на Go. При написании этого поста я преследовал три цели:

  1. Объяснить паттерн, который я реализовал для нескольких клиентов, чтобы другие, разрабатывающие на той же кодовой базе, поняли его.
  2. Дать другим образец, который они могут захотеть реализовать в своих собственных приложениях.
  3. Получить отзывы. Есть ли лучший шаблон, который я еще не видел? Есть ли изменения, которые я могу внести в этот шаблон, чтобы сделать его лучше?

Для простоты примеры, которые я буду обсуждать, будут частью REST API, использующего простые коды состояния HTTP. Но те же принципы можно использовать и для gRPC-сервисов, и даже для произвольных кодов ошибок в CLI-инструментах. Или даже все вышеперечисленное одновременно, о чем я расскажу в одной из следующих статей.

Проблема

Прежде чем я объясню, какой паттерн я использую, позвольте мне объяснить, что он заменяет, чтобы мы могли понять, какие проблемы он призван решить.

Давайте рассмотрим простой HTTP-обработчик, использующий паттерн HandlerFunc стандартной библиотеки, который просто извлекает запись виджета из базы данных и предоставляет ее клиенту в формате JSON.

func (s *Service) GetWidget(w http.ResponseWriter, r *http.Request) {
	if err := r.ParseForm(); err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	id, err := strconv.Atoi(r.Form.Get("widget_id"))
	if err != nil {
		http.Error(w, err.Error(), http.StatusBadRequest)
		return
	}
	widget, err := s.db.GetWidget(id)
	if err != nil {
		if errors.Is(err, sql.ErrNoRows) {
			http.Error(w, err.Error(), http.StatusNotFound)
			return
		}
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	widgetJSON, err := json.Marshal(widget)
	if err != nil {
		http.Error(w, err.Error(), http.StatusInternalServerError)
		return
	}
	w.Header.Set("Content-Type", "application/json")
	w.Write(widgetJSON)
}

Хотя этот пример должен быть более или менее реалистичным, он намеренно слишком упрощен по сравнению с тем, что я обычно встречаю в производственных службах. В частности, я никогда не видел, чтобы http.Error() использовался в реальном сервисе. Гораздо более вероятно, что у вас есть собственный формат ошибки, которую вы хотите отправить обратно. Возможно, вы используете ответ на ошибку в формате JSON, с дополнительным контекстом ошибки, внутренними кодами ошибок и т. д. А может быть, вы хотите отобразить ошибку в виде HTML. В любом случае, я предположу, что ваше приложение заменяет вызов http.Error() на что-то более сложное. Что, скорее всего, означает, что ваш код еще более раздражающий и повторяющийся, чем тот, что я показал выше.

Не говоря уже об этом, позвольте мне назвать несколько конкретных проблем, которые я вижу в приведенном выше коде:

  • Обработка ошибок повторяется и не отличается особой идиоматичностью. Go (не)известен своей идиомой if err != nil { return err }. Однако здесь мы даже не можем ее использовать, потому что сигнатура HandlerFunc не возвращает ошибку. Вместо этого для каждой ошибки мы должны (а) обслужить и (б) отдельно вернуть её.
  • Мы должны явно обрабатывать статус HTTP для каждого случая ошибки. Если у вас десятки или сотни обработчиков (а у вас, скорее всего, так и есть), это быстро станет повторяющимся и чреватым ошибками. Здесь нет никакого DRY. В одном обработчике, как этот, может быть, это не так уж и важно. Но было бы неплохо, если бы у нас был какой-то стандартный код состояния HTTP для ошибки - возможно, 500 / Internal Server Error.
  • Этот обработчик должен заниматься внутренними вопросами базы данных. В частности, он проверяет, не получили ли мы ошибку sql.ErrNoRows. HTTP-обработчик должен быть полностью независим от базы данных, поэтому эта деталь не должна быть здесь раскрыта. От этого уродливого тесного соединения мы можем избавиться.

А что, если вместо этого...

А что, если:

  • для каждой ошибки мы можем просто использовать return err, и произойдет то, что нужно? Ошибка будет приведена к нужному формату и отправлена пользователю?
  • магия, которая выдает ошибку, должна знать, какой HTTP-статус нужно установить? 400 - недействительный ввод, 404 - не найден, 401 - несанкционированный доступ и т. д.?
  • хранилище данных, будь то база данных SQL, или MongoDB, или файловая система, просто сообщало бы нам "эта ошибка означает «не найдено»", и это можно было бы автоматически преобразовать в 404 вместо того, чтобы обработчик разбирался в деталях реализации?

Паттерн, который я собираюсь описать, дает нам всё это. Но не только это, он позволяет использовать ряд других появляющихся паттернов, которые также являются довольно мощными. Я упомяну некоторые из них в конце, а позже, возможно, напишу о некоторых из них более подробно (дайте мне знать, если это вас заинтересует).

Идиоматическая обработка ошибок

Все три описанных мной поведения, которые нам нужны, зависят от двух вещей, первая из которых - "идиоматическая обработка ошибок". Нам нужно иметь возможность просто применять return err в наших обработчиках. К сожалению, стандартная библиотека не дает нам такой возможности. Но некоторые сторонние фреймворки дают. Самый популярный из них, с которым я знаком, - labstack echo, чей HandlerFunc выглядит следующим образом:

type HandlerFunc func(c Context) error

Но я не считаю, что вам нужно использовать такой тяжелый фреймворк, как Echo, только для того, чтобы получить удобные примитивы для обработки ошибок. Поэтому вы можете сделать это сами. Вот простой шаблон функции адаптера, который вы можете использовать:

// customHandler converts an error-returning handler to a standard http.HandlerFunc.
func customHandler(f func(http.ResponseWriter, *http.Request) error) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		err := f(w, r)
		if err != nil {
			http.Error(w, err.Error(), http.StatusInternalServerError) // Wait, alwyas 500? More on that later
		}
	}
}

С такой функцией адаптера наш предыдущий обработчик упрощается до:

func (s *Service) GetWidget(w http.ResponseWriter, r *http.Request) error {
	if err := r.ParseForm(); err != nil {
		return err
	}
	id, err := strconv.Atoi(r.Form.Get("widget_id"))
	if err != nil {
		return err
	}
	widget, err := s.db.GetWidget(id)
	if err != nil {
		return err
	}
	widgetJSON, err := json.Marshal(widget)
	if err != nil {
		return err
	}
	w.Header.Set("Content-Type", "application/json")
	w.Write(widgetJSON)
}

Конечно, это требует того, чтобы мы действительно использовали функцию адаптера при настройке наших маршрутов:

mux.Handle("/widget", customHandler(s.GetWidget))

Конечно, мы также фактически сломали эту конечную точку. Теперь она воспринимает все ошибки как внутренние ошибки сервера. Так что мы займемся этим дальше.

Но сначала эксперимент, над которым я работаю!

Прежде всего я хочу рассказать об экспериментальной библиотеке, над которой я работаю, в надежде, что в конечном итоге она станет официальным предложением для стандартной библиотеки (хотя я думаю, что шансы на то, что она будет принята, невелики), чтобы расширить определение типа http.HandlerFunc, включив в него необязательное возвращаемое значение ошибки. Библиотека находится по адресу gitlab.com/flimzy/httpe, и она добавляет варианты WithError в http.Handler, http.HandlerFunc, ServeHTTP и связанные с ними промежуточные программы. Он основан на работе, которую я использовал в течение многих лет с клиентами, но теперь живет в своей собственной отдельной библиотеке для легкого внедрения по желанию.

Если вы решите использовать эту библиотеку, новая версия обработчика останется неизменной, но вместо вызова customHandler можно будет сделать следующее:

import "gitlab.com/flimzy/httpe"

mux.Handle("/widget", httpe.ToHandler(s.GetWidget))

Основное преимущество библиотеки httpe перед собственным обработчиком заключается в том, что она обеспечивает поддержку адаптеров промежуточного ПО, а также смешивание стандартных и поддерживающих ошибки обработчиков с некоторым скрытым распространением ошибок. Но это выходит за рамки данной статьи.

Как обрабатывать различные HTTP-статусы?

Второе, от чего зависят эти улучшения, - это способ указать HTTP-статус. Мы заметили, что, хотя новый шаблон обработчика упрощает обработку ошибок, он также нарушает ее, рассматривая все ошибки как 500 / Internal Server Error (или любой произвольный статус, который вы зададите в своей функции customHandler). Давайте разберемся с этим.

Ошибки - это интерфейсы

Напомним, что в Go тип ошибки - это интерфейсный тип, определяемый как:

type error interface {
	Error() string
}

Это очень важно, поскольку позволяет создавать собственные типы ошибок. Более того, для наших целей мы можем расширить тип ошибки, включив в него другие методы.

Мы хотим воспользоваться обеими этими возможностями, чтобы создать пользовательский тип ошибки, включающий HTTP-статус, и добавить метод для отображения этого статуса. Вот простой пользовательский тип, который мы будем использовать:

type statusError struct {
	error
	status int
}

Теперь это уже "полный" тип ошибки. Он уже удовлетворяет интерфейсу error в силу встраивания типа error (поэтому его методы переходят в методы нашего типа). И он включает в себя код состояния. Но нам нужна еще пара деталей, чтобы сделать его полным. Во-первых, давайте добавим метод Unwrap, чтобы errors.Unwrap и связанные с ним errors.Is, errors.As и т. д. работали правильно:

func (e statusError) Unwrap() error {
	return e.error
}

А теперь мы также хотим добавить метод для отображения включенного состояния. Строго говоря, в этом нет необходимости. Вы можете получить код статуса, преобразовав тип ошибки обратно к типу statusError с помощью утверждения типа, или с помощью errors.Is или errors.As. Но это немного громоздко и требует экспорта поля (если только все ваше приложение не находится в одном пакете - я очень надеюсь, что это не так!) Кроме того, раскрывая статус через метод интерфейса, мы можем использовать несколько реализаций нашего пользовательского типа ошибки, что я практически всегда и делаю. Так что давайте добавим эту деталь:

func (e statusError) HTTPStatus() int {
	return e.status
}

Теперь вы можете назвать свой метод как угодно. Я остановился на HTTPStatus, после того как сначала использовал просто Status(), потому что это менее двусмысленно, но все же достаточно коротко, чтобы не раздражать. С таким же успехом вы можете использовать любой другой метод (или несколько методов). Например, вам может понадобиться JSONRPCStatus(), если вы создаете сервис JSON-RPC. Или если вы создаете gRPC-сервис, то для вас уже определен интерфейс: GRPCStatus() *status.Status.

Использование нашего пользовательского типа ошибки

Теперь, когда у нас есть тип statusError, давайте включим его в наш обработчик, чтобы не нарушать обработку кода состояния:

func (s *Service) GetWidget(w http.ResponseWriter, r *http.Request) error {
	if err := r.ParseForm(); err != nil {
		return statusError{error: err, status: http.StatusBadRequest}
	}
	id, err := strconv.Atoi(r.Form.Get("widget_id"))
	if err != nil {
		return statusError{error: err, status: http.StatusBadRequest}
	}
	widget, err := s.db.GetWidget(id)
	if err != nil {
		return statusError{error: err, status: http.StatusInternalServerError}
	}
	widgetJSON, err := json.Marshal(widget)
	if err != nil {
		return statusError{error: err, status: http.StatusInternalServerError}
	}
	w.Header.Set("Content-Type", "application/json")
	w.Write(widgetJSON)
}

Итак, теперь мы (в основном) не нарушаем коды состояния. Единственное исключение - вызов базы данных. Мы рассматриваем все ошибки как статус 500, в то время как отсутствующий виджет мы должны рассматривать как 404. Решение заключается в том, чтобы сделать наш слой доступа к данным осведомленным об этих новых типах ошибок:

func (db *DB) GetWidget(id int) (*Widget, error) {
	widget, err := db.Get(/* ... */)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, statusError{error: err, status: http.StatusNotFound}
	}
	if err != nil {
		return nil, statusError{error: err, status: http.StatusInternalServerError}
	}
	return widget, nil
}

И последнее: нам нужно обновить наш customHandler, чтобы он понимал этот новый тип ошибки:

// customHandler converts an error-returning handler to a standard http.HandlerFunc.
func customHandler(f func(http.ResponseWriter, *http.Request) error) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		err := f(w, r)
		if err != nil {
			var status int
			var statusErr interface {
				error
				HTTPStatus() int
			}
			if errors.As(err, &statusErr) {
				status = statusErr.HTTPStatus()
			}
			http.Error(w, err.Error(), status)
		}
	}
}

Итак... теперь мы вернулись к полнофункциональному обработчику виджетов. И мы также отделили логику работы с базами данных от HTTP-обработчика. Так что это победа.

Дальнейшие улучшения

Но наш обработчик по-прежнему выглядит довольно уродливо, с кучей повторяющихся структур customErrror{}. Кроме того, и наш обработчик, и наш слой доступа к данным зависят от конкретного типа statusError. Который даже не экспортируется, что подразумевает, что наш слой доступа к данным и обработчик находятся в одном пакете. Мы действительно не хотим этого. Поэтому давайте перенесем наш пользовательский тип ошибки в свой собственный пакет. И добавим удобную описательную функцию-конструктор.

package apperr // Use a descriptive name

type statusError struct {
	error
	status int
}

func (e statusError) Unwrap() error   { return e.error }
func (e statusError) HTTPStatus() int { return e.status }

func WithHTTPStatus(err error, status int) error {
	return statusError{
		error: err,
		status: int,
	}
}

Теперь наш обработчик можно обновить до более читабельного вида:

func (s *Service) GetWidget(w http.ResponseWriter, r *http.Request) error {
	if err := r.ParseForm(); err != nil {
		return apperr.WithStatus(err, http.StatusBadRequest)
	}
	id, err := strconv.Atoi(r.Form.Get("widget_id"))
	if err != nil {
		return apperr.WithStatus(err, http.StatusBadRequest)
	}
	widget, err := s.db.GetWidget(id)
	if err != nil {
		return err // No call to apperr.WithStatus here, as we trust the db has already set the appropriate status code for us
	}
	widgetJSON, err := json.Marshal(widget)
	if err != nil {
		return apperr.WithStatus(err, http.StatusBadRequest)
	}
	w.Header.Set("Content-Type", "application/json")
	w.Write(widgetJSON)
}

Установка состояния по умолчанию

Мы можем сделать еще одно большое улучшение в этой настройке: Установить статус по умолчанию.

На самом деле, вы могли заметить, что наша улучшенная функция customHandler не имеет статуса по умолчанию. Это означает, что если мы передадим ему ошибку, не содержащую HTTP-статус, он попытается обслужить HTTP-ответ со статусом 0. Вероятно, это не идеально.

Давайте решим эту проблему, добавив в пакет apperr вспомогательную функцию, которую можно использовать и из других мест:

package apperr

// HTTPStatus returns the HTTP status included in err. If err is nil, this
// function returns 0. If err is non-nil, and does not include an HTTP status,
// a default value of [net/http.StatusInternalServerError] is returned.
func HTTPStatus(err error) int {
	if err == nil {
	return 0
	}
	var statusErr interface {
		error
		HTTPStatus() int
	}
	if errors.As(err, &statusErr) {
		return statusErr.HTTPStatus()
	}
	return http.StatusInternalServerError
}

С этой новой функцией в кармане наш пользовательский обработчик может быть упрощен и улучшен:

// customHandler converts an error-returning handler to a standard http.HandlerFunc.
func customHandler(f func(http.ResponseWriter, *http.Request) error) http.HandlerFunc {
	return func(w http.ResponseWriter, r *http.Request) {
		err := f(w, r)
		if err != nil {
			http.Error(w, err.Error(), apperr.HTTPStatus(err))
		}
	}
}

И наш обработчик также можно улучшить, опустив вызов apierr.WithStatus, когда мы хотим получить стандартный статус 500 / Internal Server Error.

Дополнительные усовершенствования

На самом деле это только начало того, что может дать использование стандартного расширения ошибок в приложении и соответствующих обработчиков.

Следующие области, которые я обычно улучшаю в приложениях, где я применяю этот паттерн, - это добавление пары стандартных промежуточных модулей.

Промежуточное ПО для ведения журналов

Промежуточное ПО для ведения журнала с сопутствующей ошибкой, если она уместна, - отличное дополнение. И это избавляет от "необходимости" регистрировать ошибку каждый раз, когда она происходит - просто передайте ее вызывающей стороне, и пусть промежуточное ПО регистрирует ее за вас. Вот упрощенный пример, использующий сигнатуры функций в gitlab.com/flimzy/httpe:

func loggingMiddleware(logger *slog.Logger) func(next httpe.HandlerWithError) httpe.HandlerWithError {
	return httpe.HandlerWithErrorFunc(func (w http.ResponseWriter, r *http.Request) error {
		err := next.ServeHTTPWithError(w, r)
		status := http.StatusOK
		if err != nil {
			status = apperr.HTTPStatus(err)
			logger.Error("request served with error", "status", status, "request", r, "error", err)
		} else {
			logger.Info("request served", "status", status, "request", r)
		}
	})
}

Не забывайте, что обработчик может вызвать w.WriteHeader со статусом, отличным от того, что содержится в err (или даже в отсутствии ошибки). Поэтому надежная реализация будет проверять и это.

Промежуточное ПО с поддержкой ошибок

Улучшением по сравнению с функцией customHandler, показанной выше, является перенос обслуживания ошибок в промежуточное ПО. Для этого потребуется пакет httpe или аналогичная реализация, которая может работать с промежуточными программами.

func serveErrors() func(next httpe.HandlerWithError) httpe.HandlerWithError {
	return httpe.HandlerWithErrorFunc(func (w http.ResponseWriter, r *http.Request) error {
		err := next.ServeHTTPWithError(w, r)
		if err != nil {
			http.Error(w, err.Error(), apperr.Status(err))
		}
	})
}

Промежуточное ПО с возвратом ошибок

Большинство веб-приложений имеют такую функцию (или должны!), но версия, работающая с обработчиками возврата ошибок, может быть полезной, так как она просто должна преобразовывать панические состояния в ошибки, а не обслуживать их напрямую:

func serveErrors() func(next httpe.HandlerWithError) httpe.HandlerWithError {
	return httpe.HandlerWithErrorFunc(func (w http.ResponseWriter, r *http.Request) (err error) {
		defer func() {
			if r := recover(); r != nil {
				switch t := r.(type) {
					case error:
						err = t
					default:
						err = fmt.Errorf("%v")
				}
			}
		}()
		return next.ServeHTTPWithError(w, r)
	})
}

Коды ошибок, характерные для домена

Лучше, чем повсеместно устанавливать HTTP-статусы ошибок для всего приложения, определить собственные коды ошибок, специфичные для домена. Для небольшого приложения, предназначенного только для работы в Интернете, может быть достаточно кодов состояния HTTP, но в большинстве реальных приложений это не так. Всё остальное в этом шаблоне все равно можно использовать с собственными кодами ошибок, специфичными для домена. Просто сделайте так, чтобы ваши пользовательские типы ошибок также возвращали соответствующие HTTP (или JSON-RPC, или gRPC, или еще какие-нибудь...) коды. После такого изменения наш описанный выше метод работы с базой данных может выглядеть следующим образом:

func (db *DB) GetWidget(id int) (*Widget, error) {
	widget, err := db.Get(/* ... */)
	if errors.Is(err, sql.ErrNoRows) {
		return nil, apperror.ErrWidgetNotFound
	}
	if err != nil {
		return nil, err
	}
	return widget, nil
}

Затем различные вызывающие программы могут самостоятельно проверять ошибки, если это необходимо:

internalCode := apperror.Code(err) // The internal error code

httpStatus := apperror.HTTPStatus(err) // The HTTP status

Точную реализацию apperror.ErrWidgetNotFound и связанных с ним функций я оставляю на усмотрение читателя.

Предостережения

Такой подход не лишен некоторых недостатков. Стоит назвать несколько из них.

  1. Это нестандартно. Очевидно. За переход от обработчика, возвращающего ошибки, к стандартному обработчику приходится платить многословностью. Хотя, похоже, многие люди с радостью платят эту цену в виде принятия тяжелого фреймворка, который предлагает это преимущество (наряду с другими, конечно).
  2. Теперь существует два способа отправки ответов клиенту. Это меня раздражает. Но я пока не нашел никакого способа обойти это. На практике это не кажется большой проблемой, но все же нужно иметь это в виду. Вы можете установить HTTP-статус ответа, либо вызвав w.WriteHeader(), либо вернув ошибку. Очевидно, что каждый ответ может иметь только один статус. И если вы вызываете w.WriteHeader(), то этот статус обычно имеет приоритет (если только вы не реализовали свой собственный http.ResponseWriter с другим поведением).
  3. Он делает определенное поведение неявным. Или, по крайней мере, может. Например, показанная функция apperr.HTTPStatus() возвращает статус по умолчанию для ошибок, не содержащих статус. Хотя я считаю, что это имеет смысл и является преимуществом, это немного "волшебно", и может удивить того, кто не знаком с паттерном. Это также может сбить с толку, когда вы видите apperr.WithStatus(err, http.StatusNotFound) в первый раз. Хотя при простом чтении должно быть очевидно, что это включение HTTP-статуса с ошибкой, не видно, какой еще код потребляет этот статус, и как он используется. Разумеется, цель этого поста - помочь решить эту проблему.

Прочие ограничения

Это ни в коем случае не универсальное решение. Я столкнулся с несколькими очевидными ограничениями в различных приложениях:

  • Он не предоставляет никакого эргономичного способа указать статус, отличный от 200 и не являющийся ошибкой (например, 201). Для этого все равно приходится прибегать к w.WriteHeader().
  • Для некоторых приложений лучше использовать сигнатуру функции, например func(*http.Request) (any, error), в которой ответ (скорее всего, в виде JSON) является первым возвращаемым аргументом.

Что же дальше?

В самом начале я упомянул, что этот шаблон имеет тенденцию поддаваться дополнительным улучшениям. Позвольте мне просто упомянуть некоторые из них, не вдаваясь в подробности. Если вы захотите получить более подробное объяснение по любому из них, дайте мне знать.

  • Включайте в ошибки дополнительные метаданные. Трассировка стека - это очевидный вариант, который, к примеру, прекрасно предоставляется на github.com/pkg/errors. Расширьте свое промежуточное ПО для логирования, чтобы извлекать трассировку стека и включать ее в журналы.
  • Скрывайте сообщения об ошибках для определенных статусов HTTP. Я обычно пишу свое промежуточное ПО для обслуживания ошибок, чтобы оно возвращало клиенту просто Internal Server Error при любом статусе 500, чтобы избежать риска потенциального сообщения конфиденциальной информации, что может произойти при некоторых несанированных ошибках. HTTP-статусы 401 и 403 также являются хорошими кандидатами для этого.
  • По аналогии с маскировкой некоторых ошибок, возможно, вы хотите предоставить пользователям вашего приложения удобную версию сообщения об ошибке, одновременно записывая в журнал все подробности, которые ошибка содержала изначально. Добавьте строковый метод Public() к таким ошибкам и отправьте версию Public() пользователям, а версию Error() - в журнал.
  • HTTP-статусы недостаточно подробны для вас? Может быть, вам нужно различать статусы widget not found и user not found? Вы можете создать свои собственные внутренние статусы/коды ошибок, которые будут использоваться внутри компании и будут преобразовываться в общий HTTP-статус.
  • Ищите способы упростить свой код. Например, в примере с обработчиком рассмотрите возможность переноса вызовов r.ParseForm и strconv.Atoi в общую функцию или используйте библиотеку валидации, например github.com/go-playground/validator/v10, вместо вызовов strconv, которая возвращает ошибку со статусом 400. Тогда ваш обработчик может просто передать эту ошибку.

Существует ли лучший способ?

Я не знаю.

Я всегда нахожусь в поиске лучшего способа сделать что-то.

Если вы знаете лучший образец, или даже небольшие возможности улучшить этот образец, пожалуйста, дайте мне знать! Я с удовольствием поучусь у вас!

Благодарю за прочтение!

Источник:

#Golang
Комментарии
Чтобы оставить комментарий, необходимо авторизоваться

Присоединяйся в тусовку

В этом месте могла бы быть ваша реклама

Разместить рекламу