Всем привет. Меня зовут Алексей Бурмистров, я 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.

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

Комментарии (5)


  1. GrimAnEye
    31.05.2023 19:08
    +3

    От статьи ожидал увидеть несколько иного, а не только предложения "структурируйте вывод журнала":

    • Необычных ситуации возникновения и последующей обработки ошибок

    • Чем руководствоваться при подготовке статических значений ошибок - писать заранее константы с текстом или хардкодом в месте создания ошибки? Как выделить атомарно-полезный текст вывода ошибки?

    • Насколько объемным должен быть журнал - вывод ошибки от библиотеки и трассировка должны быть по умолчанию, но что можно, и нужно ли, указывать дополнительно?

    • Распределение по уровням журнала - допустим библиотека журнала предоставляет несколько уровней (о ней ниже) записи, для удовлетворения всех нужд - Debug, Error, Warning, Info, Panic, а приложение - много-модульный web-сервис, в котором может происходить много операций одновременно с разными частями. Падения и грубые ошибки попадают в категории Err и Panic. А как распределить информационные ошибки - нужно ли регистрировать каждый чих, для отслеживания движения пользователя по системе? Ошибка авторизации в аккаунте - это Warning или Info?

    Для журнала использую библиотеку https://github.com/uber-go/zap - она базируется на создании структурированного вывода ошибки, заранее объявляя поле пользовательского сообщения, поля для ошибки, при необходимости - автоматически добавляет поле с трассировкой. Немаловажным является, что и расширений куча, за счет того, что можно добавить hook-и при возникновении некого события и выполнить некое дополнительное действие. Таким образом у меня выполняется управление файлами журнала (аля logrotate) и уведомление на почту при падениях приложения.


  1. tzlom
    31.05.2023 19:08
    +2

    А трассировку вы как формируете?


  1. bash77
    31.05.2023 19:08
    +2

    понимаю конечно о чем вы, но
    как по мне: ENOTFOUND, EINTERNAL... выглядит вырвиглазно.


  1. imicah
    31.05.2023 19:08

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

    Так если есть вещи которые важны при конструировании (обязательное поле Err и Message), может все таки есть смысл завести конструктор? Зачем валидировать линтером то, что можно повалидировать компилятором?


  1. alpha4
    31.05.2023 19:08

    Всё что вы описали - слабо аргументировано вызывает много вопросов. Например, вы пишете

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


    Но в Go ошибки обычно передаются через fmt.Errorf("%w .....") и наследование публичного объекта-ошибки. И как бы этого достаточно и для тестов зависящих от ошибок, и для собственно обработки ошибок, и для преобразовывания ошибки в HTTP-статус, и даже для расчёта сообщения для пользователя. В вашем примере можно было было бы просто обернуть ошибку и прокинуть на верх, либо вообще ни как её не обрабатывать. И затем на самом верху стека, в хендлере, сделать проверку
    if errors.Is(err, pgx.ErrNoRows) {

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

    Далее, вы пишете про graylog и говорите что прикрепляете к ошибке стек трейс чтобы удовлетворить greylog-у. Но greylog вовсе не самое распространённо и далеко не самое лучшее решение, есть другие, и большинство из них вообще не предполагает наличия стек-трейса для сообщения в журнал. Для стектрейсов есть sentry, которая позволяет программисту вообще не иметь дело со стек трейсами и ничего про них не знать.