Несмотря на то, что в 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) } }
Как вы видите, экспортированный тип и некоторые значения существенно упрощают нашу жизнь при работе с ошибками. Самое приятное здесь то, что по шаблону, при создании ошибки, мы явно указываем ее тип.
Перевод статьи Stupid Gopher: Golang — handling errors gracefully