Как обрабатывать ошибки в Golang – рассказываем на собственном примере

485eaf5f15da956aab5b9e6ba2bbf1fe.png

Всем привет. Меня зовут Алексей Бурмистров, я senior Golang разработчик в Quadcode. В процессе разработки биллинга, мы столкнулись с различными типами ошибок, которые могут возникать во время выполнения программы. В данной статье я хочу поделиться нашим опытом в структурировании и обработке этих ошибок, а также представить подходы, которые мы применили для их эффективной обработки и диагностики. Наша основная цель заключается в создании понятных и легко обрабатываемых ошибок, которые гарантируют надежную работу биллинга.

Ошибки это один из самых важных аспектов любого языка программирования. То, как обрабатываются ошибки, влияет на приложения многими способами. То, как определяются ошибки в Golang, немного отличается от таких языков как Java, Python, Javascript. В Go ошибки — это значения.​

Свой тип ошибки

Весь код, связанный с обработкой ошибок, размещен в корне нашего приложения. Это сделано для предотвращения конфликтов со стандартным пакетом errors. Такой подход делает обработку ошибок приложения более явной и не лишает возможности использовать стандартную библиотеку без применения алиасов.

​Основой всего является тип Error— конкретное представление ошибки, который реализует стандартный интерфейс error. Он имеет несколько полей, некоторые из которых могут быть не заданы:

type errorCode string

// Коды ошибок приложения.
const (
 ...
ENOTFOUND errorCode = "not_found"
EINTERNAL errorCode = "internal"
...
)

type Error struct {
// Вложенная ошибка
Err error `json:"err"`
// Дополнительный контекст ошибки
Fields map[string]interface{}
// Код ошибки.
Code errorCode `json:"code"`
// Сообщение об шибке, которое понятно пользователю.
Message string `json:"message"`
// Выполняемая операция
Op string `json:"op"`
}
  • Op обозначает выполняемую операцию. Оно представляет собой строку, которая содержит имя метода или функции такие как repo.User, convert, Auth.Login и так далее.

  • Message содержит сообщение или ключ перевода ошибки, которое можно показать пользователю.

  • Code — это конкретный тип ошибки. Это обеспечивает стандартизацию и однозначность в обработке ошибок, позволяя легко идентифицировать и классифицировать ошибки. Список возможных типов может быть расширен при необходимости, обеспечивая гибкость и возможность добавления новых типов ошибок по мере развития всего приложения. Еще это позволяет нам точно определить статусы ответов API, связанные с каждым типом ошибки.

  • Fields представляет собой данные, связанные с ошибкой. Эти данные могут содержать идентификаторы объектов, параметры запросов или любую другую информацию, которая может быть полезной для понимания причины ошибки.

  • Err содержит вложенную ошибку, которая может быть связана с текущей ошибкой. Это может быть ошибка, возвращаемая внешней библиотекой или наша собственная Error

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

Создание ошибки

Для создания ошибки мы решили не делать отдельный конструктор, потому как структура не такая объемная. Для того чтобы разработчики не ошиблись при создании ошибки (например забыли & или не создали ошибку без Err и Message), мы используем собственный линтер для golangci-lint

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

func (r *userRepository) User(ctx context.Context, id int) (*User, error) {
const op = "userRepository.User"
...
}

Еcли нам нужно только добавить op к ошибке для передачи на верхний уровень мы можем воспользоваться вспомогательными функциями OpError или OpErrorOrNil

...
var user User
err := db.QueryRow(ctx, query, id)
if err != nil {
 if errors.Is(err, pgx.ErrNoRows) {
  return nil, &app.Error{Op: op,Code: app.ENOTFOUND, Message: "user not found"}
 }
 return app.OpError(op, err)
}
...

Обработка ошибок​

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

Для проверки Code есть вспомогательная функция ErrorCode которая возвращает код ошибки, если это была ошибка приложения, или EINTERNAL если это была другая.

switch ErrorCode(err) {
case ENOTFOUND:
...
case EINTERNAL:
...
}

Если нам нужен полный доступ к Error можно воспользоваться стандартной библиотекой errors.

appErr := &Error{}
if errors.As(err, appErr) {
 ...
}

Использование поля Code позволяет понятно преобразовывать ошибки в HTTP-статус. Для этого можно создать map, где ключами будут значения Code, а значениями соответствующие HTTP-статусы.

Примерное преобразования ошибки в HTTP-статус:

var codeToHTTPStatusMap = map[errorCode]int{
   ENOTFOUND: http.StatusNotFound,
   EINTERNAL: http.StatusInternalServerError,
   // Другие соответствия кодов ошибок и HTTP-статусов
}

func ErrCodeToHTTPStatus(err error) int {
   code := ErrorCode(err)
   if v, ok := codeToHTTPStatusMap[code]; ok {
       return v
   }
  
   // Возвращаем стандартный HTTP-статус для неизвестных ошибок
   return http.StatusInternalServerError
}

Теперь, чтобы получить соответствующий HTTP-статус для ошибки, достаточно вызвать функцию ErrCodeToHTTPStatus и передать ошибку ей. Она вернет соответствующий HTTP-статус. Если код ошибки не найден, будет возвращен стандартный HTTP-статус http.StatusInternalServerError.

Анализ и диагностика​

При анализе ошибок, возникающих в нашем приложении, мы полагаемся на информацию, которую мы записываем в логи. Однако, когда ошибки логируются в виде строк, это значительно затрудняет поиск и анализ. Так как мы структурируем логи для отправки в graylog, мы логируем ошибки в виде объектов, содержащих следующую информацию:

  • code: тип ошибки, чтобы понять её характер;

  • msg: сообщение из Err.Error ();

  • fields: контекст, добавленный к ошибке;

  • trace: стек трассировки операций.

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

goroutine 1 [running]:
testing.(*InternalExample).processRunResult(0xc000187aa8, {0x0, 0x0}, 0x0?, 0x0, {0x1043760e0, 0x1043b8d88})
       /opt/homebrew/Cellar/go/1.19.4/libexec/src/testing/example.go:91 +0x45c
testing.runExample.func2()
       /opt/homebrew/Cellar/go/1.19.4/libexec/src/testing/run_example.go:59 +0x14c
panic({0x1043760e0, 0x1043b8d88})
       /opt/homebrew/Cellar/go/1.19.4/libexec/src/runtime/panic.go:890 +0x258
app.foo(...)
       app/errors_test.go:336
app.bar()
       app/errors_test.go:341 +0x38
app.baz()
       app/errors_test.go:345 +0x24
app.ExampleTrace()
       app/errors_test.go:350 +0x24
testing.runExample({{0x1042f8cd5, 0xc}, 0x1043b8528, {0x1042fcab9, 0x19}, 0x0})
       /opt/homebrew/Cellar/go/1.19.4/libexec/src/testing/run_example.go:63 +0x2ec
testing.runExamples(0xc000187e00, {0x10450e080, 0x1, 0x0?})
       /opt/homebrew/Cellar/go/1.19.4/libexec/src/testing/example.go:44 +0x1ec
testing.(*M).Run(0xc00014a320)
       /opt/homebrew/Cellar/go/1.19.4/libexec/src/testing/testing.go:1728 +0x934
main.main()

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

["ExampleRun", "baz", "bar", "foo"]

Стек трассировки операций легко читается и содержит только доменную логику, что является важным для нас.

Для логирования ошибок мы используем пакет go.uber.org/zap. Для него мы сделали вспомогательную функцию Error(err error) zap.Field, которая позволяет нам легко логировать ошибку в виде объекта.

Пример использования данной функции выглядит следующим образом:

func foo() {
   ...
   if err != nil {
       logger.Error("something gone wrong", Error(err))
   }
}

Пример вывода ошибки в логе может выглядеть следующим образом:

{"level":"error","msg":"something gone wrong","error":{"msg":"user not found","code":"not_found","trace":["userRepository.User"],"fields":{"user_id":"65535"}}}

Финальные выводы

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

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

Если вы хотите ознакомиться с примерами кода, вы можете найти их в GitHub.

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

© Habrahabr.ru