В последнее десятилетие мы успешно пользовались тем, что Go обрабатывает ошибки как значения. Хотя в стандартной библиотеке была минимальная поддержка ошибок: лишь функции errors.New
и fmt.Errorf
, которые генерируют ошибку, содержащую только сообщение — встроенный интерфейс позволяет 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 {
// something went wrong
}
Иногда мы сравниваем ошибку, чтобы узнать контрольное значение и понять, не возникла ли конкретная ошибка.
var ErrNotFound = errors.New("not found")
if err == ErrNotFound {
// something wasn't found
}
Значение-ошибка может быть любого типа, который удовлетворяет определённому в языке интерфейсу ошибок. Программа может использовать утверждение типа или переключатель типа для просмотра значения-ошибки более специфического типа.
type NotFoundError struct {
Name string
}
func (e *NotFoundError) Error() string { return e.Name + ": not found" }
if e, ok := err.(*NotFoundError); ok {
// e.Name wasn't found
}
Добавление информации
Зачастую функция передаёт ошибку вверх по стеку вызовов, добавляя к ней информацию, например, короткое описание того, что происходило в момент возникновения ошибки. Это сделать просто, достаточно сконструировать новую ошибку, включающую в себя текст из предыдущей ошибки:
if err != nil {
return fmt.Errorf("decompress %v: %v", name, err)
}
При создании новой ошибки с помощью fmt.Errorf
мы выбрасываем из исходной ошибки всё, за исключением текста. Как мы видели в примере с QueryError
, иногда нужно определять новый тип ошибки, который содержит исходную ошибку, чтобы сохранить её для анализа с помощью кода:
type QueryError struct {
Query string
Err error
}
Программы могут заглянуть внутрь значения *QueryError
и принять решение на основе исходной ошибки. Иногда это называется «распаковкой» (unwrapping) ошибки.
if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
// query failed because of a permission problem
}
Тип os.PathError
из стандартной библиотеки — ещё пример того, как одна ошибка содержит другую.
Ошибки в Go 1.13
Метод Unwrap
В Go 1.13 в пакетах стандартной библиотеки errors
и fmt
упрощена работа с ошибками, которые содержат другие ошибки. Самым важным является соглашение, а не изменение: ошибка, содержащая другую ошибку, может реализовать метод Unwrap
, который возвращает исходную ошибку. Если e1.Unwrap()
возвращает e2
, то мы говорим, что e1
упаковывает e2
и можно распаковать e1
для получения e2
.
Согласно этому соглашению, можно дать описанный выше тип QueryError
методу Unwrap
, который возвращает содержащуюся в нём ошибку:
func (e *QueryError) Unwrap() error { return e.Err }
Результат распаковки ошибки тоже может содержать метод Unwrap
. Последовательность ошибок, полученных с помощью повторяющихся распаковок, мы называем цепочкой ошибок.
Исследование ошибок с помощью Is и As
В Go 1.13 пакет errors
содержит две новые функции для исследования ошибок: Is
и As
.
Функция errors.Is
сравнивает ошибку со значением.
// Similar to:
// if err == ErrNotFound { … }
if errors.Is(err, ErrNotFound) {
// something wasn't found
}
Функция As
проверяет, относится ли ошибка к конкретному типу.
// Similar to:
// if e, ok := err.(*QueryError); ok { … }
var e *QueryError
if errors.As(err, &e) {
// err is a *QueryError, and e is set to the error's value
}
В простейшем случае функция errors.Is
ведёт себя как сравнение с контрольной ошибкой, а функция errors.As
ведёт себя как утверждение типа. Однако работая с упакованными ошибками, эти функции оценивают все ошибки в цепочке. Давайте посмотрим на вышеприведённый пример распаковки QueryError
для исследования исходной ошибки:
if e, ok := err.(*QueryError); ok && e.Err == ErrPermission {
// query failed because of a permission problem
}
С помощью функции errors.Is
можно записать так:
if errors.Is(err, ErrPermission) {
// err, or some error that it wraps, is a permission problem
}
Пакет 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 {
// Return an error which unwraps to 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"}) {
// err's User field is "someuser".
}
Функция errors.As
также консультирует метод As
при его наличии.
Ошибки и API пакетов
Пакет, который возвращает ошибки (а это делают большинство пакетов), должен описать свойства этих ошибок, на которые может опираться программист. Хорошо спроектированный пакет также будет избегать возвращения ошибок со свойствами, на которые нельзя опираться.
Самое простое: говорить, была ли операция успешной, возвращая, соответственно, значение nil или не-nil. Во многих случаях другой информации не требуется.
Если вам нужно, чтобы функция возвращала индентифицируемое состояние ошибки, например, «элемент не найден», то можно возвращать ошибку, в которую упаковано сигнальное значение.
var ErrNotFound = errors.New("not found")
// FetchItem returns the named item.
//
// If no item with the name exists, FetchItem returns an error
// wrapping ErrNotFound.
func FetchItem(name string) (*Item, error) {
if itemNotFound(name) {
return nil, fmt.Errorf("%q: %w", name, ErrNotFound)
}
// ...
}
Есть и другие паттерны предоставления ошибок, которые вызывающий может семантически изучить. Например, напрямую возвращать контрольное значение, конкретный тип, или значение, которое можно проанализировать с помощью предикативной функции.
В любом случае, не раскрывайте пользователю внутренние подробности. Как упоминалось в главе «Когда стоит упаковывать?», если возвращаете ошибку из другого пакета, то преобразуйте её, чтобы не раскрывать исходную ошибку, если только не собираетесь брать на себя обязательство в будущем вернуть эту конкретную ошибку.
f, err := os.Open(filename)
if err != nil {
// The *os.PathError returned by os.Open is an internal detail.
// To avoid exposing it to the caller, repackage it as a new
// error with the same text. We use the %v formatting verb, since
// %w would permit the caller to unwrap the original *os.PathError.
return fmt.Errorf("%v", err)
}
Если функция возвращает ошибку с упакованным сигнальным значением или типом, то не возвращайте напрямую исходную ошибку.
var ErrPermission = errors.New("permission denied")
// DoSomething returns an error wrapping ErrPermission if the user
// does not have permission to do something.
func DoSomething() {
if !userHasPermission() {
// If we return ErrPermission directly, callers might come
// to depend on the exact error value, writing code like this:
//
// if err := pkg.DoSomething(); err == pkg.ErrPermission { … }
//
// This will cause problems if we want to add additional
// context to the error in the future. To avoid this, we
// return an error wrapping the sentinel so that users must
// always unwrap it:
//
// if err := pkg.DoSomething(); errors.Is(err, pkg.ErrPermission) { ... }
return fmt.Errorf("%w", ErrPermission)
}
// ...
}
Заключение
Хотя мы обсудили всего лишь три функции и форматирующую команду, надеемся, что они помогут сильно улучшить обработку ошибок в программах на Go. Мы надеемся, что упаковка ради предоставления дополнительного контекста станет нормальной практикой, помогающей программистам принимать более взвешенные решения и быстрее находить баги.
Как сказал Расс Кокс (Russ Cox) в своём выступлении на GopherCon 2019, на пути к Go 2 мы экспериментируем, упрощаем и отгружаем. И теперь, отгрузив эти изменения, мы принимаемся за новые эксперименты.
Автор: Макс