Go оборачивание ошибок

Александр Тихоненко

Александр Тихоненко


Ведущий разработчик трайба «Автоматизация бизнес-процессов» МТС Диджитал

Механизм обработки ошибок в Go отличается от обработки исключений в большинстве языков программирования, ведь в Golang ошибки исключениями не являются. Если говорить в целом, то ошибка в Go — это возвращаемое значение с типомerror, которое демонстрирует сбой. А с точки зрения кода — интерфейс. В качестве ошибки может выступать любой объект, который этому интерфейсу удовлетворяет.

Выглядит это так:

type error interface {  
    Error() string
}

В данной статье мы рассмотрим наиболее популярные способы работы с ошибками в Golang.

  1. Как обрабатывать ошибки в Go?
  2. Создание ошибок
  3. Оборачивание ошибок
  4. Проверка типов с Is и As
  5. Сторонние пакеты по работе с ошибками в Go
  6. Defer, panic and recover
  7. После изложенного

Чтобы обработать ошибку в Golang, необходимо сперва вернуть из функции переменную с объявленным типом error и проверить её на nil:

if err != nil {
	return err
}

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

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

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

Создание ошибок

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

  • errors.New
  • fmt.Errorf

Метод errors.New() создаёт ошибку, принимая в качестве параметра текстовое сообщение.

package main

import (
	"errors"
	"fmt"
)

func main() {
	err := errors.New("emit macho dwarf: elf header corrupted")
	fmt.Print(err)
}

С помощью метода fmt.Errorf можно добавить дополнительную информацию об ошибке. Данные будут храниться внутри одной конкретной строки.

package main

import (
	"fmt"
)

func main() {
	const name, id = "bueller", 17
	err := fmt.Errorf("user %q (id %d) not found", name, id)
	fmt.Print(err)
}

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

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

Поскольку Error — это интерфейс, можно создать удовлетворяющую ему структуру с собственными полями. Тогда на вызывающей стороне этими самыми полями можно будет оперировать.

package main

import (
  "fmt"
)

type NotFoundError struct {
  UserId int
}

func (err NotFoundError) Error() string {
  return fmt.Sprintf("user with id %d not found", err.UserId)
}

func SearchUser(id int) error {
  // some logic for search
  // ...
  // if not found
  var err NotFoundError
  err.UserId = id
  return err
}

func main() {
  const id = 17
  err := SearchUser(id)
  if err != nil {
     fmt.Println(err)
     //type error checking
     notFoundErr, ok := err.(NotFoundError)
     if ok {
        fmt.Println(notFoundErr.UserId)
     }
  }
}

Представим другую ситуацию. У нас есть метод, который вызывает внутри себя ещё один метод. В каждом из них проверяется своя ошибка. Иногда требуется в метод верхнего уровня передать сразу обе эти ошибки.

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

Также для оборачивания ошибок в fmt.Errorf есть плейсхолдер %w, который и позволяет произвести такую упаковку.:

package main

import (
	"errors"
	"fmt"
	"os"
)

func main() {
	err := openFile("non-existing")
	if err != nil {
		fmt.Println(err.Error())
		// get internal error
		fmt.Println(errors.Unwrap(err))
	}
}

func openFile(filename string) error {
	if _, err := os.Open(filename); err != nil {
		return fmt.Errorf("error opening %s: %w", filename, err)
	}
	return nil
}

Проверка типов с Is и As

В Go 1.13 в пакете Errors появились две функции, которые позволяют определить тип ошибки — чтобы написать тот или иной обработчик:

  • errors.Is
  • errors.As

Метод errors.Is, по сути, сравнивает текущую ошибку с заранее заданным значением ошибки:

package main

import (
	"errors"
	"fmt"
	"io/fs"
	"os"
)

func main() {
	if _, err := os.Open("non-existing"); err != nil {
		if errors.Is(err, fs.ErrNotExist) {
			fmt.Println("file does not exist")
		} else {
			fmt.Println(err)
		}
	}
}

Если это будет та же самая ошибка, то функция вернёт true, если нет — false.

errors.As проверяет, относится ли ошибка к конкретному типу (раньше надо было явно приводить тип ошибки к тому типу, который хотим проверить):

package main

	import (
	"errors"
	"fmt"
	"io/fs"
	"os"
)

func main() {
	if _, err := os.Open("non-existing"); err != nil {
		var pathError *fs.PathError
		if errors.As(err, &pathError) {
			fmt.Println("Failed at path:", pathError.Path)
		} else {
			fmt.Println(err)
		}
	}
}

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

Сторонние пакеты по работе с ошибками в Go

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

В качестве примера можно посмотреть на пакет pkg/errors. Одной из его способностей является логирование stack trace:

package main

import (
	"fmt"
	"github.com/pkg/errors"
)

func main() {
	err := errors.Errorf("whoops: %s", "foo")
	fmt.Printf("%+v", err)
}
	// Example output:
	// whoops: foo
	// github.com/pkg/errors_test.ExampleErrorf
	//         /home/dfc/src/github.com/pkg/errors/example_test.go:101
	// testing.runExample
	//         /home/dfc/go/src/testing/example.go:114
	// testing.RunExamples
	//         /home/dfc/go/src/testing/example.go:38
	// testing.(*M).Run
	//         /home/dfc/go/src/testing/testing.go:744
	// main.main
	//         /github.com/pkg/errors/_test/_testmain.go:102
	// runtime.main
	//         /home/dfc/go/src/runtime/proc.go:183
	// runtime.goexit
	//         /home/dfc/go/src/runtime/asm_amd64.s:2059

Defer, panic and recover

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

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

Для работы с такими ошибками существует механизм «defer, panic, recover»

Defer

Defer помещает все вызовы функции в стек приложения. При этом отложенные функции выполняются в обратном порядке — независимо от того, вызвана паника или нет. Это бывает полезно при очистке ресурсов:

package main

import (
    "fmt"
    "os"
)

func main() {
    f := createFile("/tmp/defer.txt")
    defer closeFile(f)
    writeFile(f)
}

func createFile(p string) *os.File {
    fmt.Println("creating")
    f, err := os.Create(p)
    if err != nil {
        panic(err)
    }
    return f
}

func writeFile(f *os.File) {
    fmt.Println("writing")
    fmt.Fprintln(f, "data")
}

func closeFile(f *os.File) {
    fmt.Println("closing")
    err := f.Close()
    if err != nil {
        fmt.Fprintf(os.Stderr, "error: %vn", err)
        os.Exit(1)
    }
}

Panic

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

Например, Golang будет «паниковать», когда число делится на ноль:

panic: runtime error: integer divide by zero
goroutine 1 [running]:
main.divide(0x0)
        C:/Users/gabriel/articles/Golang Error handling/Code/panic/main.go:16 +0xe6
main.divide(0x1)
        C:/Users/gabriel/articles/Golang Error handling/Code/panic/main.go:17 +0xd6
main.divide(0x2)
        C:/Users/gabriel/articles/Golang Error handling/Code/panic/main.go:17 +0xd6
main.divide(0x3)
        C:/Users/gabriel/articles/Golang Error handling/Code/panic/main.go:17 +0xd6
main.divide(0x4)
        C:/Users/gabriel/articles/Golang Error handling/Code/panic/main.go:17 +0xd6
main.divide(0x5)
        C:/Users/gabriel/articles/Golang Error handling/Code/panic/main.go:17 +0xd6
main.main()
        C:/Users/gabriel/articles/Golang Error handling/Code/panic/main.go:11 +0x31
exit status 2

Также панику можно вызвать явно с помощью метода panic(). Обычно его используют на этапе разработки и тестирования кода — а в конечном варианте убирают.

Recover

Эта функция нужна, чтобы вернуть контроль при панике. В таком случае работа приложения не прекращается, а восстанавливается и продолжается в нормальном режиме.

Recover всегда должна вызываться в функции defer. ​​Чтобы сообщить об ошибке как возвращаемом значении, вы должны вызвать функцию recover в той же горутине, что и паника, получить структуру ошибки из функции восстановления и передать её в переменную:

package main

import (
	"errors"
	"fmt"
)

func A() {
	defer fmt.Println("Then we can't save the earth!")
	defer func() {
		if x := recover(); x != nil {
			fmt.Printf("Panic: %+vn", x)
		}
	}()
	B()
}

func B() {
	defer fmt.Println("And if it keeps getting hotter...")
	C()
}

func C() {
	defer fmt.Println("Turn on the air conditioner...")
	Break()
}

func Break() {
	defer fmt.Println("If it's more than 30 degrees...")
	panic(errors.New("Global Warming!!!"))
}

func main() {
	A()
}

После изложенного

Можно ли игнорировать ошибки? В теории — да. Но делать это нежелательно. Во-первых, наличие ошибки позволяет узнать, успешно ли выполнился метод. Во-вторых, если метод возвращает полезное значение и ошибку, то, не проверив её, нельзя утверждать, что полезное значение корректно.

Надеемся, приведённые методы обработки ошибок в Go будут вам полезны. Читайте также статью о 5 главных ошибках Junior-разработчика, чтобы не допускать их в начале своего карьерного пути.

Привет, хабровчане! Уже сегодня в ОТУС стартует курс «Разработчик Golang» и мы считаем это отличным поводом, чтобы поделиться еще одной полезной публикацией по теме. Сегодня поговорим о подходе Go к ошибкам. Начнем!

Освоение прагматической обработки ошибок в вашем Go-коде

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

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

Теперь, когда мы расчистили себе путь, поехали!

Подход Go к обработке ошибок — одна из самых спорных и неправильно используемых фич. В этой статье вы узнаете подход Go к ошибкам, и поймете, как они работают “под капотом”. Вы изучите несколько различных подходов, рассмотрите исходный код Go и стандартную библиотеку, чтобы узнать, как обрабатываются ошибки и как с ними работать. Вы узнаете, почему утверждения типа (Type Assertions) играют важную роль в их обработке, и увидите предстоящие изменения в обработке ошибок, которые планируется ввести в Go 2.

Вступление

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

Если по какой-то причине ваша функция может дать сбой, вам, вероятно, следует вернуть из нее предварительно объявленный error-тип. По соглашению, возврат ошибки сигнализирует вызывающей стороне о проблеме, а возврат nil не считается ошибкой. Таким образом, вы дадите вызывающему понять, что возникла проблема, и ему нужно разобраться с ней: кто бы ни вызвал вашу функцию, он знает, что не должен полагаться на результат до проверки на наличие ошибки. Если ошибка не nil, он обязан проверить ее и обработать (логировать, возвращать, обслуживать, вызвать какой-либо механизм повторной попытки/очистки и т. д.).


(3 // обработка ошибки
5 // продолжение)

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


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

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


Пустой идентификатор темен и полон ужасов.

У Go действительно есть panic и recover механизмы, которые также описаны в другом подробном посте в блоге Go. Но они не предназначены для имитации исключений. По словам Дейва, «Когда вы паникуете в Go — вы действительно паникуете: это не проблема кого-то другого, это уже геймовер». Они фатальны и приводят к сбою в вашей программе. Роб Пайк придумал поговорку «Не паникуйте», которая говорит сама за себя: вам, вероятно, следует избегать эти механизмы и вместо них возвращать ошибки.

«Ошибки — значения».
«Не просто проверяйте наличие ошибок, а элегантно их обрабатывайте»
«Не паникуйте»
все поговорки Роба Пайка

Под капотом

Интерфейс ошибки

Под капотом тип error — это простой интерфейс с одним методом, и если вы с ним не знакомы, я настоятельно рекомендую просмотреть этот пост в официальном блоге Go.


интерфейс error из исходного кода

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

Давайте рассмотрим несколько таких подходов.

Встроенная структура errorString

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


Источник: исходный код Go

Вы можете лицезреть ее упрощенную реализацию здесь. Все, что она делает, это содержит string, и эта строка возвращается методом Error. Эта стринговая ошибка может быть нами отформатирована на основе некоторых данных, скажем, с помощью fmt.Sprintf. Но кроме этого, она не содержит никаких других возможностей. Если вы применили errors.New или fmt.Errorf, значит вы уже использовали ее.


(13// вывод:)

попробуйте

github.com/pkg/errors

Другой простой пример — пакет pkg/errors. Не путать со встроенным пакетом errors, о котором вы узнали ранее, этот пакет предоставляет дополнительные важные возможности, такие как обертка ошибок, развертка, форматирование и запись стек-трейса. Вы можете установить пакет, запустив go get github.com/pkg/errors.

В тех случаях, когда вам нужно прикрепить стек-трейс или необходимую информацию об отладке к вашим ошибкам, использование функций New или Errorf этого пакета предоставляет ошибки, которые уже записываются в ваш стек-трейс, и вы так же можете прикрепить простые метаданные, используя его возможности форматирования. Errorf реализует интерфейс fmt.Formatter, то есть вы можете отформатировать его, используя руны пакета fmt ( %s, %v, %+v и т. д.).


(//6 или альтернатива)

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

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

Работа с ошибками

Утверждение типа

Утверждения типа (Type Assertion) играют важную роль при работе с ошибками. Вы будете использовать их для извлечения информации из интерфейсного значения, а поскольку обработка ошибок связана с пользовательскими реализациями интерфейса error, реализация утверждений на ошибках является очень удобным инструментом.

Его синтаксис одинаков для всех его целей —  x.(T), если  x имеет тип интерфейса.  x.(T) утверждает, что  x не равен nil и что значение, хранящееся в x, относится к типу T. В следующих нескольких разделах мы рассмотрим два способа использования утверждений типа — с конкретным типом T и с интерфейсом типа T.


(2//сокращенный синтаксис, пропускающий логическую переменную ok
3//паника: преобразование интерфейса: интерфейс {} равен nil, а не string
6//удлиненный синтаксис с логической переменной ok
8//не паникует, вместо этого присваивает ok false, когда утверждение ложно
9// теперь мы можем безопасно использовать s как строку)

песочница: panic при укороченном синтаксисе, безопасный удлинённый синтаксис

Дополнительное примечание, касающееся синтаксиса: утверждение типа может использоваться как с укороченным синтаксисом (который паникует при неудачном утверждении), так и с удлиненным синтаксисом (который использует логическое значение OK для указания успеха или неудачи). Я всегда рекомендую брать удлиненный вместо укороченного, так как я предпочитаю проверять переменную OK, а не разбираться с паникой.

Утверждение с интерфейсом типа T

Выполнение утверждения типа x.(T) с интерфейсом типа T подтверждает, что x реализует интерфейс T. Таким образом, вы можете гарантировать, что интерфейсное значение реализует интерфейс, и только если это так, вы сможете использовать его методы.


(5…// утверждаем, что x реализует интерфейс resolver
6…// здесь мы уже можем безопасно использовать этот метод)

Чтобы понять, как это можно использовать, давайте снова взглянем на pkg/errors. Вы уже знаете этот пакет ошибок, так что давайте углубимся в errors.Cause(err error) error функцию.

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


источник: pkg/errors

Функция получает значение ошибки, и она не может предполагать, что получаемый ею err аргумент является ошибкой-оберткой (поддерживаемой Cause методом). Поэтому перед вызовом метода Cause необходимо убедиться, что вы имеете дело с ошибкой, которая реализует этот метод. Выполняя утверждение типа в каждой итерации цикла for, вы можете убедиться, что cause переменная поддерживает метод Cause, и может продолжать извлекать из него внутренние ошибки до тех пор, пока не найдете ошибку, у которой нет Cause.

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

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

Наконец, если вам нужен только один метод, иногда удобнее сделать утверждение на анонимном интерфейсе, содержащем только метод, на который вы полагаетесь, т. е. v, ok := x.(interface{ F() (int, error) }). Использование анонимных интерфейсов может помочь отделить ваш код от возможных зависимостей и защитить его от возможных изменений в интерфейсах.

Утверждение с конкретным типом T и Type Switch

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

Первый — это второй вариант утверждения типа: выполняется утверждение типа x.(T) с конкретным типом T. Он утверждает, что значение x имеет тип T, или оно может быть преобразовано в тип T.


(2//мы можем использовать v как mypkg.SomeErrorType)

Другой — это шаблон Type Switch. Type Switch объединяют оператор switch с утверждением типа, используя зарезервированное ключевое слово type. Они особенно распространены в обработке ошибок, где знание основного типа переменной ошибки может быть очень полезным.


(3// обработка…
5// обработка…)

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


(7// обработка…
9// обработка…)

Type Switch различают *MyStruct и MyStruct. Поэтому, если вы не уверены, имеете ли вы дело с указателем или фактическим экземпляром структуры, вам придется предоставить оба варианта. Более того, как и в случае с обычными switch, кейсы в Type Switch не проваливаются, но в отличие от обычных Type Switch, использование fallthrough запрещено в Type Switch, поэтому вам придется использовать запятую и предоставлять обе опции, что легко забыть.

Подытожим

Вот и все! Теперь вы знакомы с ошибками и должны быть готовы к устранению любых ошибок, которые ваше приложение Go может выбросить (или фактически вернуть) на ваш путь!
Оба пакета errors представляют простые, но важные подходы к ошибкам в Go, и, если они удовлетворяют вашим потребностям, они являются отличным выбором. Вы можете легко реализовать свои собственные структуры ошибок и пользоваться преимуществами обработки ошибок Go, комбинируя их с pkg/errors.

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

Что дальше?

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

В следующей версии Go 2 этому уделяется много внимания, и вы уже можете взглянуть на черновой вариант. Кроме того, во время dotGo 2019 Марсель ван Лохуизен провел отличную беседу на тему, которую я просто не могу не рекомендовать — «Значения ошибок GO 2 уже сегодня».

Очевидно, есть еще множество подходов, советов и хитростей, и я не могу включить их все в один пост! Несмотря на это, я надеюсь, что вам он понравился, и я увижу вас в следующем выпуске серии «Перед тем как приступать к Go»!

А теперь традиционно ждем ваши комментарии.

Обработка ошибок 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. Ожидается, что оборачивание для обеспечения дополнительного контекста станет обычным явлением, помогая программам принимать лучшие решения и помогая программистам быстрее находить ошибки.


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

  • Основы Go: ошибки
  • Эффективный Go: ошибки
  • Go 1.13 заметки о релизе

Возвращаемые ошибки принято проверять при каждом вызове:

import "log"

response, err := DoHTTPCall()
if err != nil {
    log.Println(err)
}

// только после проверки на ошибку можно делать что-то с объектом response

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

Оборачивание ошибок — важная часть написания кода на Go. Это позволяет явно видеть трейс вызова и место возникновения ошибки. Для оборачивания используется функция fmt.Errorf:

package main

import (
    "errors"
    "fmt"
)

// для простоты примера опускаем аргументы запроса и ответа
func DoHTTPCall() error {
    err := SendTCP()
    if err != nil {
        // оборачивается в виде "[название метода]: %w". %w — это плейсхолдер для ошибки
        return fmt.Errorf("send tcp: %w", err)
    }

    return nil
}

var errTCPConnectionIssue = errors.New("TCP connect issue")

func SendTCP() error {
    return errTCPConnectionIssue
}

func main() {
    fmt.Println(DoHTTPCall()) // send tcp: TCP connect issue
}

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

err := DoHTTPCall()
if err != nil {
    if errors.Is(err, errTCPConnectionIssue) {
        // в случае ошибки соединения ждем 1 секунду и пытаемся сделать запрос снова
        time.Sleep(1 * time.Second)
        return DoHTTPCall()
    }

    // обработка неизвестной ошибки
    log.Println("unknown error on HTTP call", err)
}

errors.Is подходит для проверки статичных ошибок, хранящихся в переменных. Иногда нужно проверить не конкретную ошибку, а целый тип. Для этого используется функция errors.As:

package main

import (
    "errors"
    "log"
    "time"
)

// ошибка подключения к базе данных
type ConnectionErr struct{}

func (e ConnectionErr) Error() string {
    return "connection err"
}

func main() {
    // цикл подключения к БД. Пытаемся 3 раза, если не удалось подсоединиться с первого раза.
    tries := 0
    for {
        if tries > 2 {
            log.Println("Can't connect to DB")
            break
        }

        err := connectDB()
        if err != nil {
            // если ошибка подключения, то ждем 1 секунду и пытаемся снова
            if errors.As(err, &ConnectionErr{}) {
                log.Println("Connection error. Trying to reconnect...")
                time.Sleep(1 * time.Second)
                tries++
                continue
            }

            // в противном случае ошибка критичная, логируем и выходим из цикла
            log.Println("connect DB critical error", err)
        }

        break
    }
}

// для простоты функция всегда возвращает ошибку подключения
func connectDB() error {
    return ConnectionErr{}
}

Вывод программы спустя 3 секунды:

Connection error. Trying to reconnect...
Connection error. Trying to reconnect...
Connection error. Trying to reconnect...
Can't connect to DB

Задание

Какая-то функция возвращает критичные и некритичные ошибки:

// некритичная ошибка валидации
type nonCriticalError struct{}

func (e nonCriticalError) Error() string {
    return "validation error"
}

// критичные ошибки
var (
    errBadConnection = errors.New("bad connection")
    errBadRequest    = errors.New("bad request")
)

Реализуйте функцию GetErrorMsg(err error) string, которая возвращает текст ошибки, если она критичная. В случае неизвестной ошибки возвращается строка unknown error:

GetErrorMsg(errors.New("bad connection")) // "bad connection"
GetErrorMsg(errors.New("bad request")) // "bad request"
GetErrorMsg(nonCriticalError{}) // ""
GetErrorMsg(errors.New("random error")) // "unknown error"
Упражнение не проходит проверку — что делать? 😶

Если вы зашли в тупик, то самое время задать вопрос в «Обсуждениях». Как правильно задать вопрос:

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

В моей среде код работает, а здесь нет 🤨

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

Мой код отличается от решения учителя 🤔

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

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

Прочитал урок — ничего не понятно 🙄

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

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

Нашли ошибку? Есть что добавить? Пулреквесты приветствуются https://github.com/hexlet-basics

Порой обучение продвигается с трудом. Сложная теория, непонятные задания… Хочется бросить. Не сдавайтесь, все сложности можно преодолеть. Рассказываем, как

Не понятна формулировка, нашли опечатку?

Выделите текст, нажмите ctrl + enter и опишите проблему, затем отправьте нам. В течение нескольких дней мы улучшим формулировку или исправим опечатку

Что-то не получается в уроке?

Загляните в раздел «Обсуждение»:

  1. Изучите вопросы, которые задавали по уроку другие студенты — возможно, ответ на ваш уже есть
  2. Если вопросы остались, задайте свой. Расскажите, что непонятно или сложно, дайте ссылку на ваше решение. Обратите внимание — команда поддержки не отвечает на вопросы по коду, но поможет разобраться с заданием или выводом тестов
  3. Мы отвечаем на сообщения в течение 2-3 дней. К «Обсуждениям» могут подключаться и другие студенты. Возможно, получится решить вопрос быстрее!

Подробнее о том, как задавать вопросы по уроку

Понравилась статья? Поделить с друзьями:
  • Global python ошибка
  • Global freeze ошибка высокое давление
  • Global freeze gf35 ошибки
  • Global cfg not found igo ошибка
  • Glo ошибки индикация