Golang — изящная обработка ошибок

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

Для начала, необходимо понять, что именно считается ошибкой в go.

Затем рассмотрим весь процесс, от создания ошибки до ее обработки и проанализируем возможные изъяны.

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

Что считается ошибкой в 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)
}

То получу наипростейшее определение ошибки.

Внимание: Это всего лишь пример. Создать ошибку можно при использовании стандартных пакетов go (fmt и errors):

import (
  "errors"
  "fmt"
)
simpleError := errors.New("a simple error")
simpleError2 := fmt.Errorf("an error from a %s string", "formatted")

Достаточно ли простого сообщения для изящной обработки ошибок? Давайте ответим на этот вопрос в конце статьи, проанализировав предложенное решение.

Поток ошибок

Поняв, что такое ошибка, необходимо будет визуализировать поток в его жизненном цикле.

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

Ответ на вопрос “почему” дан в примере ниже:

// bad example of handling and returning the error at the same time
func someFunc() (Result, error) {
 result, err := repository.Find(id)
 if err != nil {
   log.Errorf(err)
return Result{}, err
 }
return result, nil
}

Что же не так с этим кодом?

Во-первых, ошибка обрабатывалась дважды. Сначала через логирование, а затем через возвращение ее оператору вызова функции.

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

Представьте себе, что приложение состоит из трех разных уровней: репозитория, интерактора и веб-сервера:

// The repository uses an external depedency 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"
// The repository uses an external depedency 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
}
// after the error wraping the result will be
// err.Error() -> error getting the result with id 10: whatever it comes from the 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)
  }
fmt.Fprintf(w, result)
}
func handleError(w http.ResponseWriter, err error) {
   w.WriteHeader(http.StatusIntervalServerError)
   log.Errorf(err)
   fmt.Fprintf(w, err.Error())
}

Как вы видите, ошибка обрабатывается на верхнем слое. Идеально? Нет. Вы, должно быть, уже заметили, кодом HTTP-ответа всегда возвращается 500. Кроме того, мы всегда логируем ошибки. Ряд ошибок (“не найден результат” и т.д.) просто захламляют логи.

Решение

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

Все мы знаем, что если мы вносим что-то новое в ошибку, мы каким-то образом будем вызывать зависимость в тех точках, где создается и обрабатывается ошибка.

Давайте обозначим 3 главные цели идеального решения:

  • Обеспечивает хорошую трассировку стека ошибок.
  • Регистрирует ошибку (напр., уровень сетевой инфраструктуры).
  • При необходимости может предоставить пользователю контекстные данные об ошибке (Пример: адрес почты указан в неправильном формате).

Для начала создадим тип ошибки:

package errors
const(
  NoType = ErrorType(iota)
  BadRequest
  NotFound
  //add any type you want
)
type ErrorType uint
type customError struct {
  errorType ErrorType
  originalError error
  contextInfo map[string]string
}
// Error returns the mssage of a customError
func (error customError) Error() string {
   return error.originalError.Error()
}
// New creates a new customError
func (type ErrorType) New(msg string) error {
   return customError{errorType: type, originalError: errors.New(msg)}
}
// New creates a new customError with formatted message
func (type ErrorType) Newf(msg string, args ...interface{}) error {
   err := fmt.Errorf(msg, args...)
   return customError{errorType: type, originalError: err}
}
// Wrap creates a new wrapped error
func (type ErrorType) Wrap(err error, msg string) error {
   return type.Wrapf(err, msg)
}
// Wrap creates a new wrapped error with formatted message
func (type ErrorType) Wrapf(err error, msg string, args ...interface{}) error {
   newErr := errors.Wrapf(err, msg, args..)
   return customError{errorType: errorType, originalError: newErr}
}

Атрибуты public присваиваются только для ErrorType и типов ошибок. Можно создавать новые ошибки или оборачивать существующие.

Но возникает сразу два вопроса.

Как проверить тип ошибки без экспорта customError?

Как добавить/получить контекстные данные по ошибках (даже уже существующим) из внешних зависимостей?

Позаимствуем стратегию отсюда github.com/pkg/errors. Первым делом обернем библиотечные методы.

// New creates a no type error
func New(msg string) error {
   return customError{errorType: NoType, originalError: errors.New(msg)}
}
// Newf creates a no type error with formatted message
func Newf(msg string, args ...interface{}) error {
   return customError{errorType: NoType, originalError: errors.New(fmt.Sprintf(msg, args...))}
}
// Wrap wrans an error with a string
func Wrap(err error, msg string) error {
   return Wrapf(err, msg)
}
// Cause gives the original error
func Cause(err error) error {
   return errors.Cause(err)
}
// Wrapf wraps an error with format string
func Wrapf(err error, msg string, args ...interface{}) error {
   wrappedError := errors.Wrapf(err, msg, args...)
   if customErr, ok := err.(customError); ok {
      return customError{
         errorType: customErr.errorType,
         originalError: wrappedError,
         contextInfo: customErr.contextInfo,
      }
   }
   return customError{errorType: NoType, originalError: wrappedError}
}

Теперь создадим собственные методы для обработки контекста и типа универсальных ошибок:

// AddErrorContext adds a context to an error
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, contextInfo: context}
   }
   return customError{errorType: NoType, originalError: err, contextInfo: context}
}
// GetErrorContext returns the error context
func GetErrorContext(err error) map[string]string {
   emptyContext := errorContext{}
   if customErr, ok := err.(customError); ok || customErr.contextInfo != emptyContext  {
      return map[string]string{"field": customErr.context.Field, "message": customErr.context.Message}
   }
   return nil
}
// GetType returns the error type
func GetType(err error) ErrorType {
   if customErr, ok := err.(customError); ok {
      return customErr.errorType
   }
   return NoType
}

Вернемся к нашему примеру и воспользуемся новым пакетом ошибок:

import "github.com/our_user/our_project/errors"
// The repository uses an external depedency 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.Wrapf(err, msg);
    default: 
        err = errors.NotFound(err, msg); 
    }
return Result{}, err
  }
return result, nil
}
// after the error wraping the result will be
// err.Error() -> error getting the result with id 10: whatever it comes from the orm

Теперь интерактор:

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.AddContext(err, "id", "wrong id format, should be an integer)
    return Result{}, err
  }
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)
  }
fmt.Fprintf(w, result)
}
func handleError(w http.ResponseWriter, err error) {
   var status int
   errorType := errors.GetType(err)
   switch errorType {
     case BadRequest:
      status = http.StatusBadRequest
     case 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())
   errorContext := errors.GetContext(err)
   if errorContext != nil {
     fmt.Printf(w, "context %v", errorContext)
   }
}

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