Научитесь обрабатывать ошибки, как опытный разработчик Go

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

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

Что такое ошибка в Go?

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

Тип error — это встроенный тип интерфейса 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(), возвращающий строку, считается типом error.

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

А теперь без лишних слов давайте рассмотрим различные стратегии обработки ошибок в Go.

Ошибки часового

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

var CustomError = errors.New("this is fine")

Теперь, когда мы определили нашу сигнальную ошибку, мы собираемся проанализировать два способа обработки этих типов ошибок.

Проверка ошибки. Вывод ошибки

Представьте, что мы используем определенный метод, который может возвращать ошибку CustomError при некоторых обстоятельствах, которые мы хотим обрабатывать иначе, чем ошибки любого другого типа. Один из подходов, который может прийти нам на ум, — это сравнение строк ошибок, выводимых с помощью error.Error().

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

func makeRequest(...) error {
    // ...
    // This method may return the CustomError.
    err := imaginary.Request("GET", url, payload)
    // We just want to specifically handle the CustomError error. We'll
    // ignore the rest of errors in this example.
    if err.Error() == imaginary.CustomError.Error() {
        // ...
    }
}

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

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

Проверка значения ошибки с помощью errors.Is

Начиная с Go 1.13, лучшей альтернативой для обработки ошибок дозорного является использование функции errors.Is из стандартной библиотеки. Теперь функция выглядит следующим образом:

func makeRequest(...) error {
   // ...
   // This method may return the CustomError.
   err := apipkg.Request("GET", url, payload)
   // Check whether the err value is the same as sentinel CustomError.
   if errors.Is(err, imaginary.CustomError) {
      // ..
   }
}

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

Основное преимущество этого подхода по сравнению с проверкой вывода error.Error() заключается в том, что здесь мы сравниваем полученную ошибку со значением CustomError. Это не только чище, но и наш код автоматически становится более надежным, так как любое изменение строкового значения CustomError не приведет к критическому изменению нашего кода.

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

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

var (
    ErrFoodNotFound = errors.New("food not found")
    foods           = make(map[string]food)
)

func FindFood(name string) (food, error) {
    f, ok := foods[name]
    if !ok {
        return food{}, fmt.Errorf("food %v, error: %v", name, ErrFoodNotFound)
    }

    return f, nil
}

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

Пользовательские типы ошибок

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

type CustomError struct{}

func (c CustomError) Error() string {
    return "this is fine"
}

До Go 1.13 пользователь был вынужден использовать утверждение типа для проверки типа ошибки, но с новым обновлением пакета ошибок теперь у нас есть функция errors.As для проверки типа ошибки.

// Before go 1.13 we needed to apply type assertion to check the error type.
 if _, ok := err.(CustomError); ok {
     // ...
 }
 
 if errors.As(err, &CustomError{}) {
     // ...
 }

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

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

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

Упаковка ошибок

Когда Go 1.13 был выпущен, сопровождающие решили расширить метод fmt.Errorf для поддержки нового глагола %w, который в основном выполняет перенос ошибок под капотом аналогично методу Wrap больше не поддерживаемого пакета github.com/pkg/errors.

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

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

var (
    ErrFoodNotFound = errors.New("food not found")
    foods           = make(map[string]food)
)

func FindFood(name string) (food, error) {
    f, ok := foods[name]
    if !ok {
        return food{}, fmt.Errorf("food %v, error: %w", name, ErrFoodNotFound)
    }

    return f, nil
}

Вы должны обратить внимание, чтобы определить небольшую разницу. Это просто добавление аргумента %w при создании ошибки. Эта новая ошибка будет иметь динамическое значение, но сохранит исходную ошибку ErrFoodNotFound. Таким образом, мы можем использовать следующее для обработки обернутых ошибок:

err := FindFood("bananas")
// Handle the error if it's an ErrFoodNotFound one.
if errors.Is(err, ErrFoodNotFound) {
    // ...
}

Здесь мы можем наблюдать весь потенциал использования errors.Is, последовательно разворачивая первый элемент и ища ошибку, соответствующую второму аргументу. Таким образом, мы могли бы добавить столько уровней переноса, сколько захотим, используя %wи при этом иметь возможность сравнивать его с errors.Is.

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

Заключение

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

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

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

Надеюсь, вам понравилась статья, и вы узнали что-то новое о том, как обрабатывать ошибки в Go.

Ресурсы