golang

Практика Go — Обработка ошибок (1 часть)

  • воскресенье, 10 сентября 2023 г. в 00:00:13
https://habr.com/ru/articles/759840/

Ошибки - это просто значения

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

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

Дозорные ошибки

Первая категория обработки ошибок - это то, что я называю дозорными ошибками ("sentinel errors").

  if err == ErrSomething { ... }

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

В качестве примера можно привести такие значения, как io.EOF, или ошибки низкого уровня, например, константы пакета syscall, такие как syscall.ENOENT.

Существуют даже дозорные ошибки, сигнализирующие о том, что ошибка не произошла, например go/build.NoGoError и path/filepath.SkipDir из path/filepath.Walk.

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

Даже такое благоразумное действие, как использование fmt.Errorf для добавления контекста к ошибке, нарушит проверку на равенство. Вместо этого вызывающая сторона будет вынуждена смотреть на вывод метода Error ошибки, чтобы проверить, соответствует ли он определенной строке.

Никогда не проверяйте вывод error.Error

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

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

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

Дозорные ошибки становятся частью вашего публичного API

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

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

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

Дозорные ошибки создают зависимость между двумя пакетами

Самой серьёзной проблемой, связанной со значениями дозорных ошибок, является то, что они создают зависимость между двумя пакетами от исходного кода. Например, чтобы проверить, равна ли ошибка значению io.EOF, ваш код должен импортировать пакет io.

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

Я работал в крупном проекте, где использовался этот паттерн, и могу сказать, что призрак плохого дизайна в виде цикла импорта не покидал нас.

Вывод: избегайте дозорных ошибок

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

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

Типы ошибок

Типы ошибок - это вторая форма обработки ошибок в Go, которую я хочу обсудить.

  if err, ok := err.(SomeType); ok { ... }

Тип ошибки - это созданный вами тип, реализующий интерфейс ошибок. В данном примере тип MyError отслеживает файл и строку, а также сообщение, объясняющее, что произошло.

  type MyError struct {
    Msg string
    File string
    Line int
  }

  func (e *MyError) Error() string {
    return fmt.Sprintf("%s:%d: %s", e.File, e.Line, e.Msg)
  }

  return &MyError{"Something happened", "server.go", 42}

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

	err := something()
	switch err := err.(type) {
	case nil:
		// call succeeded, nothing to do
	case *MyError:
		fmt.Println("error occurred on line:", err.Line)
	default:
		// unknown error
	}

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

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

  // PathError records an error and the operation
  // and file path that caused it.
  type PathError struct {
    Op   string
    Path string
    Err  error // the cause
  }

  func (e *PathError) Error() string

Проблемы с типами ошибок

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

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

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

Вывод: избегайте типов ошибок

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

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

Непрозрачные ошибки

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

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

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

import "github.com/quux/bar"

func fn() error {
	x, err := bar.Foo()
	if err != nil {
		return err
	}
	// use x
}

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

Предупреждать об ошибках поведения, а не типа

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

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

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

type temporary interface {
	Temporary() bool
}

// IsTemporary returns true if err is temporary.
func IsTemporary(err error) bool {
	te, ok := err.(temporary)
	return ok && te.Temporary()
}

Мы можем передать в IsTemporary любую ошибку, чтобы определить, можно ли её повторить.

Если ошибка не реализует интерфейс temporary, то есть не имеет метода Temporary, то ошибка не является temporary.

Если же ошибка реализует интерфейс temporary, то, возможно, вызывающая сторона может повторить операцию, если Temporary вернет true.

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

Не просто проверяйте ошибки, а обрабатывайте их изящно

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

func AuthenticateRequest(r *Request) error {
	err := authenticate(r.User)
	if err != nil {
		return err
	}
	return nil
}

Очевидно, что пять строк функции можно заменить на

  return authenticate(r.User)

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

Если authenticate вернёт ошибку, то AuthenticateRequest вернёт ошибку своему вызывающему модулю, который, вероятно, сделает то же самое, и так далее. В верхней части программы основное тело программы выведет ошибку на экран или в лог-файл, и всё, что будет выведено, будет выглядеть так: No such file or directory. Нет информации о файле и строке, в которой возникла ошибка. Нет трассировки стека вызовов, приведших к ошибке. Автору такого кода придётся долго заниматься разрезанием своего кода, чтобы выяснить, какой путь кода привёл к ошибке file not found.

В книге Донована и Кернигана "Язык программирования Go" рекомендуется добавлять контекст к пути ошибки с помощью fmt.Errorf

func AuthenticateRequest(r *Request) error {
	err := authenticate(r.User)
	if err != nil {
		return fmt.Errorf("authenticate failed: %v", err)
	}
	return nil
}

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

Аннотирование ошибок

Я хотел бы предложить способ добавления контекста к ошибкам, для чего представлю простой пакет. Его код размещен на github.com/pkg/errors. Пакет errors имеет две основные функции:

// Wrap annotates cause with a message.
func Wrap(cause error, message string) error

Первая функция - Wrap, которая принимает ошибку и сообщение и выдаёт новую ошибку.

// Cause unwraps an annotated error.
func Cause(err error) error

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

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

func ReadFile(path string) ([]byte, error) {
	f, err := os.Open(path)
	if err != nil {
		return nil, errors.Wrap(err, "open failed")
	}
	defer f.Close()

	buf, err := ioutil.ReadAll(f)
	if err != nil {
		return nil, errors.Wrap(err, "read failed")
	}
	return buf, nil
}

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

func ReadConfig() ([]byte, error) {
	home := os.Getenv("HOME")
	config, err := ReadFile(filepath.Join(home, ".settings.xml"))
	return config, errors.Wrap(err, "could not read config")
}

func main() {
	_, err := ReadConfig()
	if err != nil {
		fmt.Println(err)
		os.Exit(1)
	}
}

Если путь кода ReadConfig завершился неудачно, поскольку мы использовали errors.Wrap, то мы получим красивую аннотированную ошибку в стиле K&D (Кернигана и Донована).

could not read config: open failed: open /Users/dfc/.settings.xml: no such file or directory

Поскольку errors.Wrap выдаёт стек ошибок, мы можем просмотреть этот стек для получения дополнительной отладочной информации. Это снова тот же пример, но на этот раз мы заменяем fmt.Println на errors.Print

func main() {
	_, err := ReadConfig()
	if err != nil {
		errors.Print(err)
		os.Exit(1)
	}
}

Мы получим примерно следующее:

readfile.go:27: could not read config
readfile.go:14: open failed
open /Users/dfc/.settings.xml: no such file or directory

Первая строка взята из ReadConfig, вторая - из os.Open части ReadFile, а оставшаяся - из самого пакета os, который не несёт информации о местоположении.

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

// IsTemporary returns true if err is temporary.
func IsTemporary(err error) bool {
	te, ok := errors.Cause(err).(temporary)
	return ok && te.Temporary()
}

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

Обрабатывайте ошибки только один раз

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

func Write(w io.Writer, buf []byte) {
	w.Write(buf)
}

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

Но принятие более одного решения в ответ на одну ошибку также проблематично.

func Write(w io.Writer, buf []byte) error {
	_, err := w.Write(buf)
	if err != nil {
		// annotated error goes to log file
		log.Println("unable to write:", err)

		// unannotated error returned to caller
		return err
	}
	return nil
}

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

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

func Write(w io.Write, buf []byte) error {
	_, err := w.Write(buf)
	return errors.Wrap(err, "write failed")
}

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

Заключение

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

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

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

Наконец, используйте errors.Cause для восстановления основной ошибки при необходимости её проверки.

Далее: 2 часть