среда, 30 октября 2019 г.

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

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

type QueryError struct {
    Query string
    Err   error
}

func (e *QueryError) Error() string { 
    return e.Query + ": " + e.Err.Error() 
}

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

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

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

Ошибки до Go 1.13


Проверки ошибок

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

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

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

var ErrNotFound = errors.New("not found")

if err == ErrNotFound {
    // что-то не найдено
}

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

type NotFoundError struct {
    Name string
}

func (e *NotFoundError) Error() string { 
    return e.Name + ": not found" 
}

if e, ok := err.(*NotFoundError); ok {
    // e.Name не найдено
}

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

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

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

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

type QueryError struct {
    Query string
    Err   error
}

Программы могут заглянуть внутрь значения *QueryError, чтобы принимать решения на основе подлежащей ошибки. Иногда вы встретите, что это называется "разворачиванием" ("unwrapping") ошибки.

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

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

Ошибки в Go 1.13


Метод Unwrap

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

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

func (e *QueryError) Unwrap() error { return e.Err }

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

Проверка ошибок с Is и As

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

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

// Похоже на:
//   if err == ErrNotFound { … }
if errors.Is(err, ErrNotFound) {
    // что-то не найдено
}

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

// Похоже на:
//   if e, ok := err.(*QueryError); ok { … }
var e *QueryError
if errors.As(err, &e) {
    // err является *QueryError, 
    // а в e устанавливается значение ошибки
}

В простейшем случае функция errors.Is ведет себя как сравнение с дозорной ошибкой, а error.As действует как утверждение типа. Однако при работе с обернутыми ошибками эти функции учитывают все ошибки в цепочке. Еще раз посмотрим на приведенный выше пример развертывания QueryError, чтобы изучить основную ошибку:

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

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

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

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

Оборачивание ошибок с %w

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

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

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

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

Оборачивание ошибки с %w делает ее доступной для errors.Is и errors.As:

err := fmt.Errorf("access denied: %w”, ErrPermission)
...
if errors.Is(err, ErrPermission) ...

Оборачивать ли ошибки?

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

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

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

err := pkg.LookupUser(...)
if errors.Is(err, sql.ErrNoRows) …

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

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

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

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

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

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 в err равно "someuser".
}

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

Ошибки и пакеты API

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

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

Если мы хотим, чтобы функция возвращала идентифицируемое условие ошибки, такое как "item not found", мы могли бы вернуть ошибку, оборачивающую дозорную ошибку.

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

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

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

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

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

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

Заключение

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


Читайте также:


Комментариев нет:

Отправить комментарий