Изящная обработка ошибок в Go

От встроенного интерфейса до собственного пакета с типами и контекстом · 07.12.2018

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

Важно: статья написана в 2018 году, до выхода Go 1.13. Идеи о типизации ошибок и контексте остаются актуальными, но стандартная библиотека с тех пор умеет многое из того, ради чего здесь подключается github.com/pkg/errors. Современные эквиваленты — в конце страницы.

🔍 Что считается ошибкой в Go

Начнём со встроенного типа:

// 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
}

По принципу «не усложнять и не повторяться» это правильный способ: вернуть ошибку слоем выше. Там она будет залогирована и превращена в корректный ответ веб-сервера — в одном месте.

Но у такого кода два изъяна: во встроенных ошибках Go нет трассировки стека, а сама ошибка приходит из внешней зависимости — искать место её возникновения придётся во внутреннем коде проекта.

Оборачивание с трассировкой

Здесь помогает 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())
}

Ошибка обрабатывается на верхнем слое. Идеально? Нет.

Кодом HTTP-ответа всегда возвращается 500, даже когда результат просто не найден. И логируется абсолютно всё — ожидаемые ошибки вроде «не найдено» захламляют логи наравне с настоящими сбоями.

🛠 Решение: собственный пакет ошибок

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

Обозначим три цели:

1
Полноценная трассировка стека
Место возникновения ошибки видно, даже если она родилась во внешней зависимости.
2
Осмысленное логирование
В лог попадает то, что действительно является сбоем инфраструктуры, а не ожидаемые ситуации.
3
Контекст для пользователя
При необходимости ошибка несёт понятные данные — например, что адрес почты указан в неверном формате.

Тип ошибки

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 и сами типы ошибок. Через них можно создавать новые ошибки и оборачивать существующие. Но возникает два вопроса:

Обёртки над библиотечными методами

Стратегию позаимствуем у 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 в точке возникновения
Всегда возвращается 500GetType + switch по типу ошибки на HTTP-код
Логи забиты ожидаемыми ошибкамиЛогируется только NoType — то, чего не предусмотрели
Нечего показать пользователюAddErrorContext с полем и сообщением

🕒 Что изменилось в Go с тех пор

Начиная с Go 1.13 часть задач решается стандартной библиотекой:

Трассировки стека в стандартной библиотеке по-прежнему нет — за ней по-прежнему идут в pkg/errors или в аналоги.

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