The Go Blog

Работа с ошибками в Go 1.13

Дамиен Нил и Джонатан Амстердам
17 октября 2019

Введение

Подход Go к обработке ошибок как значений хорошо служил нам в течение последнего десятилетия. Несмотря на то, что поддержка ошибок в стандартной библиотеке была минимальной — всего лишь функции errors.New и fmt.Errorf, создающие ошибки, содержащие только сообщение, — встроенный интерфейс error позволяет Go-программистам добавлять любую необходимую информацию. Для этого требуется лишь тип, реализующий метод Error:

<code>type QueryError struct {
  Query string
  Err   error
}
func (e *QueryError) Error() string { return e.Query + ": " + e.Err.Error() }
</code>

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

Шаблон, при котором одна ошибка содержит другую, настолько распространён в коде на Go, что, после обширного обсуждения, в Go 1.13 была добавлена явная поддержка этого механизма. В этой записи описываются дополнения к стандартной библиотеке, обеспечивающие такую поддержку: три новые функции в пакете errors и новый форматирующий спецификатор для fmt.Errorf.

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

Ошибки до Go 1.13

Анализ ошибок

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

<code>if err != nil {
  // что-то пошло не так
}
</code>

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

<code>var ErrNotFound = errors.New("not found")
if err == ErrNotFound {
  // что-то не было найдено
}
</code>

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

<code>type NotFoundError struct {
  Name string
}
func (e *NotFoundError) Error() string { return e.Name + ": not found" }
if e, ok := err.(*NotFoundError); ok {
  // e.Name не был найден
}
</code>

Добавление информации

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

<code>if err != nil {
  return fmt.Errorf("decompress %v: %v", name, err)
}
</code>

Создание нового ошибки с помощью fmt.Errorf отбрасывает всё, кроме текста, из исходной ошибки. Как мы видели выше с QueryError, иногда может потребоваться определить новый тип ошибки, который содержит внутреннюю ошибку, сохранив её для последующего анализа кодом. Вот ещё раз тип QueryError:

<code>type QueryError struct {
  Query string
  Err   error
}
</code>

Программы могут проверять значение *QueryError, чтобы принимать решения на основе внутренней ошибки. Такое поведение иногда называют «раскрытием» ошибки.

<code>if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
  // запрос не удался из-за проблемы с правами доступа
}
</code>

Тип os.PathError из стандартной библиотеки также является примером ошибки, содержащей другую ошибку.

Ошибки в Go 1.13

Метод Unwrap

Go 1.13 вводит новые возможности в стандартные пакеты errors и fmt, чтобы упростить работу с ошибками, содержащими другие ошибки. Самое важное из этого — это соглашение, а не изменение: ошибка, содержащая другую, может реализовывать метод Unwrap, который возвращает внутреннюю ошибку. Если e1.Unwrap() возвращает e2, то говорят, что e1 обёртывает e2, и что можно раскрыть e1, чтобы получить e2.

Следуя этому соглашению, мы можем добавить типу QueryError метод Unwrap, который будет возвращать содержащуюся в нём ошибку:

<code>func (e *QueryError) Unwrap() error { return e.Err }
</code>

Результат раскрытия ошибки может сам по себе иметь метод Unwrap; мы называем последовательность ошибок, полученных при повторном раскрытии, цепочкой ошибок.

Анализ ошибок с помощью Is и As

Стандартный пакет errors в Go 1.13 включает две новые функции для анализа ошибок: Is и As.

Функция errors.Is сравнивает ошибку с заданным значением.

<code>// Аналогично:
//   if err == ErrNotFound { … }
if errors.Is(err, ErrNotFound) {
  // что-то не было найдено
}
</code>

Функция As проверяет, является ли ошибка определённым типом.

<code>// Аналогично:
//   if e, ok := err.(*QueryError); ok { … }
var e *QueryError
// Примечание: *QueryError — это тип ошибки.
if errors.As(err, &e) {
  // err является *QueryError, и e устанавливается в значение ошибки
}
</code>

В простейшем случае функция errors.Is ведёт себя как сравнение с sentinel-ошибкой, а функция errors.As — как утверждение типа. Однако при работе с обёрнутыми ошибками эти функции учитывают все ошибки в цепочке. Рассмотрим снова пример выше, где раскрывается QueryError для проверки внутренней ошибки:

<code>if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
  // query failed because of a permission problem
}
</code>

Используя функцию errors.Is, мы можем записать это следующим образом:

<code>if errors.Is(err, ErrPermission) {
  // err, или какая-либо ошибка, обёрнутая в неё, является проблемой с правами доступа
}
</code>

Пакет errors также включает новую функцию Unwrap, которая возвращает результат вызова метода Unwrap ошибки или nil, если у ошибки отсутствует метод Unwrap. Однако обычно предпочтительнее использовать errors.Is или errors.As, поскольку эти функции анализируют всю цепочку ошибок за один вызов.

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

Обёртывание ошибок с помощью %w

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

<code>if err != nil {
  return fmt.Errorf("decompress %v: %v", name, err)
}
</code>

В Go 1.13 функция fmt.Errorf поддерживает новый спецификатор %w. Если этот спецификатор присутствует, то ошибка, возвращаемая fmt.Errorf, будет иметь метод Unwrap, возвращающий аргумент %w, который должен быть ошибкой. Во всех остальных аспектах %w идентичен %v.

<code>if err != nil {
  // Возвращает ошибку, которая раскрывается в err.
  return fmt.Errorf("decompress %v: %w", name, err)
}
</code>

Обёртывание ошибки с помощью %w делает её доступной для функций errors.Is и errors.As:

<code>err := fmt.Errorf("access denied: %w", ErrPermission)
...
if errors.Is(err, ErrPermission) ...
</code>

Следует ли обёртывать

При добавлении дополнительного контекста к ошибке — либо с помощью fmt.Errorf, либо путём реализации пользовательского типа — необходимо решить, должна ли новая ошибка обёртывать исходную. На этот вопрос нет единого ответа; он зависит от контекста, в котором создаётся новая ошибка. Обёртывайте ошибку, чтобы предоставить её вызывающим сторонам. Не обёртывайте ошибку, если это приведёт к раскрытию деталей реализации.

Например, представим функцию Parse, которая читает сложную структуру данных из io.Reader. Если возникает ошибка, мы хотим сообщить номер строки и столбца, где она произошла. Если ошибка возникает при чтении из io.Reader, мы захотим обернуть эту ошибку, чтобы позволить проверить основную проблему. Поскольку вызывающая сторона предоставила io.Reader функции, имеет смысл предоставить ошибку, возникшую при его использовании.

Напротив, функция, делающая несколько вызовов к базе данных, вероятно, не должна возвращать ошибку, которая раскрывается в результат одного из этих вызовов. Если используемая функцией база данных является деталью реализации, то раскрытие таких ошибок нарушает абстракцию. Например, если функция LookupUser вашего пакета pkg использует пакет database/sql из Go, она может столкнуться с ошибкой sql.ErrNoRows. Если вы возвращаете эту ошибку с помощью fmt.Errorf("accessing DB: %v", err) то вызывающая сторона не сможет получить доступ к sql.ErrNoRows. Но если функция вместо этого возвращает fmt.Errorf("accessing DB: %w", err), тогда вызывающая сторона может разумно написать

<code>err := pkg.LookupUser(...)
if errors.Is(err, sql.ErrNoRows) …
</code>

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

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

Настройка проверки ошибок с помощью методов Is и As

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

Например, рассмотрим эту ошибку, вдохновлённую пакетом ошибок Upspin, который сравнивает ошибку с шаблоном, учитывая только поля, которые не равны нулю в шаблоне:

<code>type Error struct {
  Path string
  User string
}
func (e *Error) Is(target error) bool {
  t, ok := target.(*Error)
  if !ok {
    return false
  }
  return (e.Path == t.Path || t.Path == "") &&
  (e.User == t.User || t.User == "")
}
if errors.Is(err, &Error{User: "someuser"}) {
  // поле User ошибки равно "someuser".
}
</code>

Функция errors.As аналогично обращается к методу As, если он присутствует.

Ошибки и API пакетов

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

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

Если мы хотим, чтобы функция возвращала определённое условие ошибки, например «элемент не найден», мы можем вернуть ошибку, обёрнутую в сенситивное значение.

<code>var ErrNotFound = errors.New("not found")
// FetchItem возвращает именованный элемент.
//
// Если элемент с таким именем не существует, FetchItem возвращает ошибку
// обёрнутую в ErrNotFound.
func FetchItem(name string) (*Item, error) {
  if itemNotFound(name) {
    return nil, fmt.Errorf("%q: %w", name, ErrNotFound)
  }
  // ...
}
</code>

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

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

<code>f, err := os.Open(filename)
if err != nil {
  // *os.PathError, возвращаемый os.Open, является внутренней деталью.
  // Чтобы избежать его раскрытия вызывающей стороне, перепакуйте его как новую
  // ошибку с тем же текстом. Мы используем форматный спецификатор %v, поскольку
  // %w позволил бы вызывающей стороне раскрыть оригинальный *os.PathError.
  return fmt.Errorf("%v", err)
}
</code>

Если функция определена как возвращающая ошибку, которая оборачивает некоторый sentinel или тип, не возвращайте базовую ошибку напрямую.

<code>var ErrPermission = errors.New("permission denied")
// DoSomething возвращает ошибку, оборачивающую ErrPermission, если пользователь
// не имеет разрешения на выполнение чего-либо.
func DoSomething() error {
  if !userHasPermission() {
    // Если мы вернём ErrPermission напрямую, вызывающие стороны могут начать
    // полагаться на точное значение ошибки, написав код вроде этого:
    //
    //     if err := pkg.DoSomething(); err == pkg.ErrPermission { … }
    //
    // Это вызовет проблемы, если мы захотим добавить дополнительный
    // контекст к ошибке в будущем. Чтобы избежать этого, мы
    // возвращаем ошибку, оборачивающую sentinel, так что пользователи всегда
    // должны раскрывать её:
    //
    //     if err := pkg.DoSomething(); errors.Is(err, pkg.ErrPermission) { ... }
    return fmt.Errorf("%w", ErrPermission)
  }
  // ...
}
</code>

Заключение

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

Как сказал Расс Кок в своей ключевой речи на GopherCon 2019, на пути к Go 2 мы экспериментируем, упрощаем и выпускаем. Теперь, когда мы выпустили эти изменения, мы с нетерпением ждем последующих экспериментов.

Следующая статья: Go Modules: v2 и далее
Предыдущая статья: Публикация Go Modules
Индекс блога

GoRu.dev Golang на русском

На сайте представлена адаптированная под русский язык документация языка программирования Golang