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

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

Комментарии (13)


  1. tumbler
    09.09.2023 07:22
    +3

    2016 год. Вам не кажется, что с тех пор обработка ошибок в го поменялась?


    1. mrobespierre
      09.09.2023 07:22
      +2

      Нет. В этом большой плюс языка - он консервативный, знания не устаревают. Со времён Errors.Is/As/Wrap ничего нового не добавили, и я надеюсь, не добавят. Так через 10 лет мой нынешний код будет актуальным, а не дремучим легаси.


      1. MountainGoat
        09.09.2023 07:22
        +2

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


      1. comerc Автор
        09.09.2023 07:22
        -4

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


    1. AptRoApt
      09.09.2023 07:22

      А как обрабатываются ошибки сейчас?


      1. dsh2dsh
        09.09.2023 07:22
        +1

        К сожалению, практически также. Только вместо err.(type) хорошим тоном считается использовать errors.Is() или errors.As().


        1. comerc Автор
          09.09.2023 07:22

          В чём разница errors.Is() и errors.As()

          package main
          
          import "errors"
          
          type MyError struct {
            message string
          }
          
          func (e MyError) Error() string {
            return e.message
          }
          
          func main() {
            err := MyError{"My custom error"}
            println(errors.Is(err, MyError{"My custom error"})) // true
            println(errors.As(err, &MyError{})) // true
          }


  1. Abobcum
    09.09.2023 07:22
    -2

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


    1. dopusteam
      09.09.2023 07:22
      +2

      Ошибки бывают разные и для некоторых вполне может быть реализована какая то логика обработки.

      Как показать пользователю краткое описание, не обработав ошибку?

      Дамп чего сделать?


    1. dsh2dsh
      09.09.2023 07:22

      Проблема в том, что некоторые "гении" в виде ошибок возвращают флаги/результат. Например, go-redis возвращает ошибку redis.Nil, если Get не нашел указанный ключ. Т.е. в виде ошибки возвращается валидный результат. Вот и приходится обрабатывать.


    1. SpiderEkb
      09.09.2023 07:22
      +1

      Какого, пардон, пользователя?

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

      Процесс выбирает из БД по каким-то там условиям 20 000 000 записей и каждую обрабатывает по заданному алгоритму.

      9 999 999 записей обработалось нормально. на 10 000 000 записи возникла ошибка. Что делать прикажете? "Интерфейса пользователя" нет - это batch job (фоновое задание). Валить в задание в дамп? А как же оставшиеся 10 000 000 записей? Их кто будет обрабатывать? И когда?

      И вот тут должна включаться логика обработки ошибки.

      • Серьезность ошибки

      • Возможность продолжения работы с оставшимися данными

      • Максимально подробное логирование ошибки - где, что и почему случилось

      Для всего этого ошибка должна содержать информацию, более полную, нежели "что-то пошло не так". В данном примере - как минимум причина неудачи + ключ записи которую не удалось обработать. Зафиксировали (залогировали) ошибку и пошли дальше. В итоге получим отчет - столько-то записей обработано, такие-то не обработались по таким-то причинам.

      На нашей платформе, например, принято использовать т.н. "структурированную ошибку" - 7 символов код + до 5-ти "параметров" по 10 символов каждый. Плюс т.н. "message file" - специальные таблицы с текстовыми расшифровками ошибок и системные API, которые для заданной структурированной ошибки вернут ее полный текст с подстановкой параметров. Это вне зависимости от языка. Это системное.

      Естественно, message файлы можно дополнять своими ошибками. При необходимости, ошибки можно складывать в стек ошибок (и на выходе его раскручивать).

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


    1. mrobespierre
      09.09.2023 07:22

      Вот ходим в чужую апишку, работает она в целом норм. Но где-то каждый миллионный раз возвращает какую-то 500-ую ошибку. И мы это знаем, и они это знают, но считают это нормальным. Обычный retry вернёт валидный результат. И вот ловлю я эту ошибку, делаю retry чтобы с ней справиться. А мой пользователь, который мне деньги платит, он даже не знает что мы ходим в какую-то внешнюю апишку. И как ему справляться с этим на уровне интерфейса, и чем это лучше моего retry в обработке 500-ой на моей стороне?


  1. manyakRus
    09.09.2023 07:22

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

    Как надо:
    Логгер Logrus вместе с текстом пишет имя файла .go, имя функции и номер строки в файле где выводится этот лог - вот так надо делать :-) использовать правильный логгер