Хотя 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.Errof(err)
   return Result{}, err
 }
  return result, nil
}

В чем проблема с этим фрагментом кода?

Что ж, мы обрабатываем ошибку дважды, сначала регистрируя ее, а затем возвращая ее вызывающей стороне этой функции.

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

Итак, представьте, что у нас есть 3 уровня уровня в нашем приложении: репозиторий, интерактор и веб-сервер:

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

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

Мое решение

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

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

Итак, давайте рассмотрим решение, определяющее 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.Errof(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}
}

Как видите, я публикую только 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)
   }
}

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

Есть ли у вас предложения? Комментарий ниже.

репозиторий github: https://github.com/henrmota/errors-handling-example