От встроенного интерфейса до собственного пакета с типами и контекстом · 07.12.2018
В Go предусмотрена простая модель ошибок, но на практике всё оказывается не так просто. Разберём эффективный способ обработки ошибок и сопутствующие проблемы: что именно считается ошибкой, как выглядит её путь от создания до обработки, где в этом пути изъяны и как их устранить без вреда для дизайна приложения.
github.com/pkg/errors. Современные эквиваленты — в конце страницы.Начнём со встроенного типа:
// The error built-in interface type is the conventional interface for
// representing an error condition, with the nil value representing no error.
type error interface {
Error() string
}
Ошибка — это интерфейс с единственным методом Error, возвращающим строку. Из определения следует, что для создания ошибки достаточно обычной строки. Вот минимально возможное определение:
type MyCustomError string
func (err MyCustomError) Error() string {
return string(err)
}
errors и fmt.import (
"errors"
"fmt"
)
simpleError := errors.New("a simple error")
simpleError2 := fmt.Errorf("an error from a %s string", "formatted")
Достаточно ли простого сообщения для изящной обработки ошибок? Ответ станет очевиден к концу статьи.
Поняв, что такое ошибка, нужно представить её жизненный цикл. Базовое правило: реагировать на ошибку один раз и в одном месте.
// плохой пример: ошибка одновременно обрабатывается и возвращается
func someFunc() (Result, error) {
result, err := repository.Find(id)
if err != nil {
log.Errorf(err)
return Result{}, err
}
return result, nil
}
Что здесь не так? Ошибка обработана дважды: сначала записана в лог, затем возвращена вызывающему коду. Коллега, который воспользуется этой функцией, обработает её ещё раз и залогирует повторно. В системных логах начинается хаос из дублей одной и той же проблемы.
Представим, что приложение состоит из репозитория, интерактора и веб-сервера.
// репозиторий использует внешнюю зависимость orm
func getFromRepository(id int) (Result, error) {
result := Result{ID: id}
err := orm.entity(&result)
if err != nil {
return Result{}, err
}
return result, nil
}
По принципу «не усложнять и не повторяться» это правильный способ: вернуть ошибку слоем выше. Там она будет залогирована и превращена в корректный ответ веб-сервера — в одном месте.
Здесь помогает github.com/pkg/errors. Добавим трассировку стека и сообщение о том, что репозиторий не смог получить результат — не затрагивая исходную ошибку:
import "github.com/pkg/errors"
// репозиторий использует внешнюю зависимость orm
func getFromRepository(id int) (Result, error) {
result := Result{ID: id}
err := orm.entity(&result)
if err != nil {
return Result{}, errors.Wrapf(err, "error getting the result with id %d", id)
}
return result, nil
}
// после оборачивания:
// err.Error() -> error getting the result with id 10: <что пришло из orm>
Функция оборачивает ошибку из ORM и создаёт трассировку стека, сохраняя оригинал доступным. Посмотрим на остальные слои. Интерактор:
func getInteractor(idString string) (Result, error) {
id, err := strconv.Atoi(idString)
if err != nil {
return Result{}, errors.Wrapf(err, "interactor converting id to int")
}
return repository.getFromRepository(id)
}
Верхний слой — веб-сервер:
r := mux.NewRouter()
r.HandleFunc("/result/{id}", ResultHandler)
func ResultHandler(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
result, err := interactor.getInteractor(vars["id"])
if err != nil {
handleError(w, err)
return
}
fmt.Fprintf(w, "%v", result)
}
func handleError(w http.ResponseWriter, err error) {
w.WriteHeader(http.StatusInternalServerError)
log.Errorf(err)
fmt.Fprintf(w, err.Error())
}
Ошибка обрабатывается на верхнем слое. Идеально? Нет.
Одной строки на верхнем слое явно недостаточно. При этом любое обогащение ошибки создаёт связанность между точкой создания и точкой обработки — задача в том, чтобы удержать её минимальной.
Обозначим три цели:
package errors
import (
"fmt"
pkgerrors "github.com/pkg/errors"
)
const (
NoType = ErrorType(iota)
BadRequest
NotFound
// добавляйте любые нужные типы
)
type ErrorType uint
type errorContext struct {
Field string
Message string
}
type customError struct {
errorType ErrorType
originalError error
context errorContext
}
// Error возвращает сообщение customError
func (err customError) Error() string {
return err.originalError.Error()
}
// Unwrap открывает доступ к исходной ошибке для errors.Is и errors.As
func (err customError) Unwrap() error {
return err.originalError
}
// Cause открывает доступ к исходной ошибке для pkg/errors
func (err customError) Cause() error {
return err.originalError
}
// New создаёт новую ошибку заданного типа
func (errorType ErrorType) New(msg string) error {
return customError{errorType: errorType, originalError: pkgerrors.New(msg)}
}
// Newf создаёт новую ошибку заданного типа с форматированным сообщением
func (errorType ErrorType) Newf(msg string, args ...interface{}) error {
return customError{errorType: errorType, originalError: fmt.Errorf(msg, args...)}
}
// Wrap оборачивает существующую ошибку, сохраняя тип
func (errorType ErrorType) Wrap(err error, msg string) error {
return errorType.Wrapf(err, msg)
}
// Wrapf оборачивает существующую ошибку с форматированным сообщением
func (errorType ErrorType) Wrapf(err error, msg string, args ...interface{}) error {
newErr := pkgerrors.Wrapf(err, msg, args...)
return customError{errorType: errorType, originalError: newErr}
}
Unwrap и Cause обязательны. Без них customError обрывает цепочку: errors.Cause вернёт саму обёртку вместо исходной ошибки из ORM, и сравнить её с orm.NoResult уже не получится.Публичными остаются только ErrorType и сами типы ошибок. Через них можно создавать новые ошибки и оборачивать существующие. Но возникает два вопроса:
customError;Стратегию позаимствуем у github.com/pkg/errors — обернём его методы своими:
// New создаёт ошибку без типа
func New(msg string) error {
return customError{errorType: NoType, originalError: pkgerrors.New(msg)}
}
// Newf создаёт ошибку без типа с форматированным сообщением
func Newf(msg string, args ...interface{}) error {
return customError{errorType: NoType, originalError: fmt.Errorf(msg, args...)}
}
// Wrap оборачивает ошибку строкой
func Wrap(err error, msg string) error {
return Wrapf(err, msg)
}
// Cause возвращает исходную ошибку
func Cause(err error) error {
return pkgerrors.Cause(err)
}
// Wrapf оборачивает ошибку с форматированным сообщением, сохраняя тип и контекст
func Wrapf(err error, msg string, args ...interface{}) error {
wrappedError := pkgerrors.Wrapf(err, msg, args...)
if customErr, ok := err.(customError); ok {
return customError{
errorType: customErr.errorType,
originalError: wrappedError,
context: customErr.context,
}
}
return customError{errorType: NoType, originalError: wrappedError}
}
err.(customError) в Wrapf. Благодаря ей тип и контекст переживают любое количество оборачиваний по пути наверх, а ошибка из чужой библиотеки просто получает NoType.// AddErrorContext добавляет к ошибке контекст
func AddErrorContext(err error, field, message string) error {
context := errorContext{Field: field, Message: message}
if customErr, ok := err.(customError); ok {
return customError{
errorType: customErr.errorType,
originalError: customErr.originalError,
context: context,
}
}
return customError{errorType: NoType, originalError: err, context: context}
}
// GetErrorContext возвращает контекст ошибки
func GetErrorContext(err error) map[string]string {
emptyContext := errorContext{}
if customErr, ok := err.(customError); ok && customErr.context != emptyContext {
return map[string]string{
"field": customErr.context.Field,
"message": customErr.context.Message,
}
}
return nil
}
// GetType возвращает тип ошибки
func GetType(err error) ErrorType {
if customErr, ok := err.(customError); ok {
return customErr.errorType
}
return NoType
}
Вернёмся к примеру и подключим новый пакет. Репозиторий:
import "github.com/our_user/our_project/errors"
// репозиторий использует внешнюю зависимость orm
func getFromRepository(id int) (Result, error) {
result := Result{ID: id}
err := orm.entity(&result)
if err != nil {
msg := fmt.Sprintf("error getting the result with id %d", id)
switch err {
case orm.NoResult:
err = errors.NotFound.Wrapf(err, msg)
default:
err = errors.Wrapf(err, msg)
}
return Result{}, err
}
return result, nil
}
Интерактор — здесь ошибка получает не только тип, но и контекст для пользователя:
func getInteractor(idString string) (Result, error) {
id, err := strconv.Atoi(idString)
if err != nil {
err = errors.BadRequest.Wrapf(err, "interactor converting id to int")
err = errors.AddErrorContext(err, "id", "wrong id format, should be an integer")
return Result{}, err
}
return repository.getFromRepository(id)
}
И наконец обработчик на веб-сервере:
func handleError(w http.ResponseWriter, err error) {
var status int
errorType := errors.GetType(err)
switch errorType {
case errors.BadRequest:
status = http.StatusBadRequest
case errors.NotFound:
status = http.StatusNotFound
default:
status = http.StatusInternalServerError
}
w.WriteHeader(status)
// логируем только то, что действительно неожиданно
if errorType == errors.NoType {
log.Errorf(err)
}
fmt.Fprintf(w, "error %s", err.Error())
if errorContext := errors.GetErrorContext(err); errorContext != nil {
fmt.Fprintf(w, "context %v", errorContext)
}
}
Экспортированный тип и несколько значений заметно упрощают работу с ошибками. Самое приятное — по шаблону тип указывается явно в момент создания ошибки, то есть решение о том, чем эта ошибка является, принимает тот слой, который о ней знает больше всех.
| Проблема | Решение |
|---|---|
| Ошибка логируется на каждом слое | Обработка одна — на верхнем слое, остальные только оборачивают |
| Нет трассировки стека | Wrapf из pkg/errors в точке возникновения |
| Всегда возвращается 500 | GetType + switch по типу ошибки на HTTP-код |
| Логи забиты ожидаемыми ошибками | Логируется только NoType — то, чего не предусмотрели |
| Нечего показать пользователю | AddErrorContext с полем и сообщением |
Начиная с Go 1.13 часть задач решается стандартной библиотекой:
fmt.Errorf("...: %w", err) оборачивает ошибку без внешних зависимостей;errors.Is(err, target) заменяет сравнение через Cause;errors.As(err, &target) проверяет тип и достаёт конкретную ошибку — вместо приведения err.(customError);errors.Unwrap(err) разворачивает цепочку на один шаг.Трассировки стека в стандартной библиотеке по-прежнему нет — за ней по-прежнему идут в pkg/errors или в аналоги.