16 апреля 2024

🦫 Самоучитель по Go для начинающих. Часть 11. Обработка ошибок. Паника. Восстановление. Логирование

Энтузиаст-разработчик, автор статей по программированию.
Рассмотрим устройство механизма ошибок в Go и методы их обработки, познакомимся с функциями паники и восстановления, а также научимся логировать информацию о состоянии программы с помощью различных логеров.
🦫 Самоучитель по Go для начинающих. Часть 11. Обработка ошибок. Паника. Восстановление. Логирование

Структура ошибок в Go

В отличие от популярных языков, таких как JavaScript, Python, С++, в Go принято взаимодействовать с ошибками через отдельное возвращаемое значение типа error. При этом нулевое значение (nil) говорит о том, что ошибки не возникло. Такой подход позволяет наглядно выделить функции, возвращающие ошибки, и обрабатывать их с использованием обычных синтаксических конструкций.

Тип error представляет собой интерфейс с единственным методом Error, возвращающим текстовое описание ошибки:

        type error interface {
    Error() string
}

    

Идиоматическим способом обработки ошибок является проверка возвращаемого значения с помощью условного оператора if. Проиллюстрируем это на примере функции strconv.Atoi из пакета strconv со следующей сигнатурой: func Atoi(s string) (int, error) Можем видеть, что она возвращает два значения – результат преобразования строки в число и ошибку:

        res, err := strconv.Atoi("123a")
if err != nil {
	fmt.Println(err)
	return
}
fmt.Println(res)

    

Так как мы передали строку, состоящую не только из цифр, то в результате выполнения кода значение err будет отлично от nil, и в консоль будет выведено описание возникшей ошибки: strconv.Atoi: parsing "123a": invalid syntax.

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

Для создания ошибок с произвольным текстом используются функции errors.New() и fmt.Errorf():

        // errors.New() принимает текстовое описание ошибки
if err != nil {
	return errors.New("описание ошибки")
}

// fmt.Errorf() позволяет передать параметры в описание ошибки
param := 0
if err != nil {
	return fmt.Errorf("описание ошибки c параметром типа int: %d", param)
}

    

Часто возникает необходимость создания индивидуальных типов ошибок для обработки конкретных сбоев в программе. Для этого нужно реализовать у этих типов интерфейс error посредством создания метода Error():

        type errorConst string // тип константной ошибки

const ErrConst errorConst = "stack overflow"

func (e errorConst) Error() string {
	return string(e)
}

    

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

В качестве иллюстрации этого метода обработки можно привести тип ParseError из пакета net стандартной библиотеки:

        // ParseError — это тип ошибки синтаксического анализа сетевых адресов.
type ParseError struct {
	// Type — это тип ожидаемой строки, например
	// "IP address", "CIDR address".
	Type string

	// Text — это неверная текстовая строка.
	Text string
}

func (e *ParseError) Error() string {
	return "invalid " + e.Type + ": " + e.Text 
}

    

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

        package net

type Error interface {
    error
    Timeout() bool   // временные ошибки
    Temporary() bool // постоянные ошибки
}

    

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

        // Механизм type assertion для приведения ошибки к типу net.Error
if netErr, ok := err.(net.Error); ok && netErr.Temporary() {
    time.Sleep(1e9)
    continue
}

// Если ошибка временная (Timeout), то выводим её и завершаем программу
if err != nil {
    fmt.Println(err)
    os.Exit(1)
}

    
👨‍💻 Библиотека Go разработчика
Больше полезных материалов вы найдете на нашем телеграм-канале «Библиотека Go разработчика»
🎓 Библиотека Go для собеса
Подтянуть свои знания по Go вы можете на нашем телеграм-канале «Библиотека Go для собеса»
🧩 Библиотека задач по Go
Интересные задачи по Go для практики можно найти на нашем телеграм-канале «Библиотека задач по Go»

Правила именования ошибок

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

Базовое правило именования ошибок заключается в том, что error-переменные начинаются с err или Err, а error-типы заканчиваются на Error:

        var ErrProhibitedBehavior = errors.New("packagename: prohibited behaviour")

type SomeError struct {
	Line, Column int
}

    

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

В описании ошибок следует указывать их примерное местоположение: название пакета, функции или метода. К примеру, в пакете expression текстовое описание ошибки валидации математического выражения может выглядеть так: “expression: invalid format”.

Panic

Panic (паника) – это встроенная функция, которая останавливает поток выполнения программы и запускает механизм паники. Он похож на исключения в C++ и Java, и может быть вызван ошибками runtime, такими как выход за границы массива, деление на ноль, а также напрямую с помощью ключевого слова panic.

Рассмотрим механизм паники на конкретном примере: создадим функцию DivideNums для деления вещественных чисел. Если делитель равен нулю, то вызовем panic с текстом "Division by zero", иначе – вернем результат деления:

        func DivideNums(a, b float32) float32 {
	if b == 0.0 {
		panic("Division by zero")
	}
	return a / b
}

func main() {
	fmt.Println(DivideNums(1, 0)) // panic
	fmt.Println(DivideNums(1, 2)) // Этот код не выполнится
}

    

В случае вызова функции с аргументами 1 и 0 возникнет паника, на экран будет выведено примерно следующее:

        panic: Division by zero

goroutine 1 [running]:
main.DivideNums(...)
        /path/to/folder/main.go:7
main.main()
        /path/to/folder/main.go.go:13 +0x25
exit status 2

    

Разберем процесс паники подробнее. Когда некоторая функция func вызывает panic, в программе происходит следующее:

  1. Останавливается выполнение func.
  2. Вызываются все её внутренние defer-функции.
  3. Запускаются defer-функции, связанные с func вплоть до верхнего уровня в исполняемой горутине.
  4. Программа заканчивает выполнение и выводит ошибку, включая значение аргумента panic.

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

Более подробно горутины будут рассмотрены в последующих частях, в контексте текущей статьи достаточно лишь общего понимания. Для полноценного погружения в тему рекомендуем прочитать материал «Горутины: что такое и как работают».

Recovery

В условиях промышленной разработки часто возникает необходимость обработать панику и вернуть приложение к нормальному выполнению, предотвратив его внезапное завершение. Для этих целей используется механизм восстановления (recovery), реализующийся при помощи встроенной функции recover. Она позволяет восстановить контроль над паникующей горутиной и может быть вызвана только внутри defer-функций.

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

Функция recover возвращает nil, когда горутина не паникует или recover не был напрямую вызван в defer-функции. В иных случаях возвращается значение, отличное от nil. Если какая-либо горутина запаниковала, то вызов recovery досрочно остановит раскручивание стека, вернет аргумент, переданный в panic, и возобновит дальнейшее выполнение программы.

При работе с recover следует помнить два важных правила:

  1. recover() используется только внутри defer-функций
  2. recover() работает только в той горутине, где была вызвана паника

Рассмотрим применение recover на предыдущем примере с функцией DivideNums. На этот раз запустим механизм восстановления после возникновения паники “Division by zero”:

        func DivideNums(a, b float32) float32 {
	defer func() {
		if r := recover(); r != nil {
			fmt.Println("Recovery:", r)
		}
	}()

	if b == 0.0 {
		panic("Division by zero")
	}

	return a / b
}

func main() {
	fmt.Println(DivideNums(10, 0))
	fmt.Println(DivideNums(10, 5))
}

    

После выполнения кода получим следующий вывод:

        Recovery: Division by zero
0
2

    

Давайте детально разберем работу написанной программы. При передаче аргументов (10, 0) в функцию DivideNums возникает паника, которая запускает отложенный вызов defer. Он проверяет возникновение паники с помощью recover(): если значение не равно nil, то паника обрабатывается, на экран выводится сообщение “Recovery: Division by zero”, после чего программа возвращается к нормальному выполнению. Поскольку произошла паника, функция DivideNums() завершается, возвращая значение по умолчанию для float32, равное 0. Далее следует вызов DivideNums с двумя ненулевыми числами, поэтому в результате возвращается ожидаемое значение (2), которое выводится на экран.

Логирование

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

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

Логирование полезно по многим причинам:

  1. Отладка и диагностика ошибок. Логи позволяют разработчикам отслеживать и анализировать ошибки, возникающие в процессе выполнения программы. Записанные данные могут помочь в решении проблем и выявлении их причин.
  2. Мониторинг и анализ производительности. В логах может содержаться информация о времени выполнения различных операций и объеме потребляемых ресурсов. Это позволяет отслеживать производительность приложения и выявлять места, требующие оптимизации.
  3. Аналитика и метрики. Логи могут использоваться для сбора данных о поведении пользователей для анализа их активности, предпочтений. Такая информация нужна для улучшения пользовательского опыта и оптимизации функциональности.
  4. Информационная безопасность. Логирование играет важную роль в обеспечении безопасности приложений, позволяя отслеживать несанкционированный доступ, утечки данных и другие потенциальные угрозы.

Далее рассмотрим различные пакеты для логирования в Go: log, logrus и slog.

Стандартный пакет log

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

Рассмотрим основные функции пакета log:

  1. Print, Println, Printf – работают как аналогичные функции из пакета fmt.
  2. Panic, Panicf, Panicln – работают как функции Print, но после вывода текста вызывают panic().
  3. Fatal, Fatalln, Fatalf – работают как функции Print, но после вывода текста завершают программу путем вызова os.Exit(1).
  4. SetFlags – устанавливает флаги для форматирования. К примеру, флаг Lmicroseconds добавит микросекунды ко времени, Lshortfile укажет короткий путь к файлу, откуда было получено сообщение.
  5. SetOutput – указывает направление вывода логов. Можно указать любой объект, реализующий интерфейс io.Writer: файл, буфер, сетевое соединение и так далее.
  6. SetPrefix – устанавливает префикс для логов.

Продемонстрируем применение этих функций в коде:

        func main() {
	log.Print("сообщение от log.Print")

	log.SetFlags(log.Ldate | log.Lmicroseconds | log.Lshortfile)
	log.Println("сообщение с флагами")

	var buf bytes.Buffer // объявление буфера
	bufLogger := log.New(&buf, "buf:", log.LstdFlags)
	bufLogger.Print("сообщение в буфере buf")

	bufLogger.SetPrefix("bufPrefix:")
	bufLogger.Print("сообщение в буфере buf с префиксом bufPrefix:")
	fmt.Print(buf.String())

	// открытие файла "logfile.log" для логов
	// если файла нет, то он будет создан
	file, err := os.OpenFile("logfile.log", os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
	if err == nil {
		log.SetOutput(file) // перенаправление вывода логов в файл logfile.log
	} else {
		log.Panic("Ошибка открытия файла логов:", err)
	}
	defer file.Close() // отложенное закрытие файла
}

    

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

        2024/03/14 11:52:16 сообщение от log.Print
2024/03/14 11:52:16.222089 main.go:14: сообщение с флагами
buf:2024/03/14 11:52:16 сообщение в буфере buf
bufPrefix:2024/03/14 11:52:16 сообщение в буфере buf с префиксом bufPrefix:

    

Пакет logrus

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

Logrus предоставляет 7 уровней логирования, упорядоченных по возрастанию значимости в программе: Trace, Debug, Info, Warning, Error, Fatal and Panic. Уровень задается с помощью функции SetLevel. Ниже представлен код, демонстрирующий применение всех уровней логирования и их назначение:

        func main() {
	logrus.SetLevel(logrus.TraceLevel)
	logrus.Trace("Для отслеживания определенной информации")
	logrus.Debug("Информация для отладки")
	logrus.Info("Информация о действиях и состоянии программы")
	logrus.Warn("Информация, требующая внимания")
	logrus.Error("Ошибка, которая не приводит к завершению программы")
	logrus.Fatal("Ошибка, вызывающая завершение работы службы или приложения")
	logrus.Panic("Ошибка, вызывающая panic")
}

    

В результате будут выведены цветные префиксы уровней логирования с их описанием:

        TRAC[0000] Для отслеживания определенной информации     
DEBU[0000] Информация для отладки                       
INFO[0000] Информация о действиях и состоянии программы 
WARN[0000] Информация, требующая внимания               
ERRO[0000] Ошибка, которая не приводит к завершению программы 
FATA[0000] Ошибка, вызывающая завершение работы службы или приложения 

    

Отметим, что если указать logrus.SetLevel(logrus.InfoLevel) вместо logrus.SetLevel(logrus.TraceLevel), то сообщения уровней ниже Info, то есть Trace и Debug, выведены не будут.

Logrus позволяет добавить к данным дополнительное описание с помощью функции logrus.WithFields:

        func main() {
	// логирование запуска TCP-сервера на порту 8080
	logrus.WithFields(logrus.Fields{
		"network":  "tcp",
		"address:": ":8080",
	}).Info("Starting server...")
}

    

Приведенный выше код выведет сообщение о старте сервера с указанием двух дополнительных полей: адреса (:8080) и протокола (tcp):

        INFO[0000] Starting server...   address:=":8080" network=tcp

    

Для установки формата вывода логов используется функция logrus.SetFormatter

        func main() {
	logrus.SetFormatter(&logrus.JSONFormatter{}) // JSON формат
	logrus.Info("JSONFormatter INFO message")
	
	logrus.SetFormatter(&logrus.TextFormatter{ // настраиваемый текстовый формат
		DisableColors: true, // отключение цветов
		FullTimestamp: true, // формат полной даты
	})
	logrus.Info("TextFormatter INFO message")
}

    

В результате выполнения кода на первой строке получим сообщение в JSON формате, а на втором – в текстовом с заданными параметрами:

        {"level":"info","msg":"JSONFormatter INFO message","time":"2024-03-14T17:23:45+03:00"}
time="2024-03-14T17:23:45+03:00" level=info msg="TextFormatter INFO message"

    

Пакет slog

Пакет slog был предложен сообществом энтузиастов как альтернатива log, после чего поддержан разработчиками и выпущен в версии Go v1.21 по адресу log/slog. Он обладает обратной совместимостью с log и заимствует из него некоторые функции, но обладает расширенным функционалом и возможностью детальной настройки.

В пакете slog присутствует 4 основных уровня логирования, которые идентифицируются целыми числами с интервалом в 4: Debug (-4), Info (0), Warn (4), Error (8). Такой подход предоставляет пользователям возможность добавить свои уровни между четырьмя стандартными. К примеру, можно создать новый уровень логирования между Debug и Info с целыми значениями в интервале (-4;0).

Функции пакета slog схожи с рассмотренными ранее. Для сравнения разберем их на конкретном примере:

        func main() {
	slogger := slog.New(slog.NewTextHandler(os.Stdout, nil)) // создание логера
	slog.SetDefault(slogger) // установка логера по умолчанию

	slogger.Debug("Debug message")
	slogger.Info("Info message")
	slogger.Warn("Warn message")
	slogger.Error("Error message")

	// Сообщение от старого логера из пакета log будет
	// преобразовано в формат нового логера из пакета slog
	log.Print("Message from old logger")

	// Сообщение с атрибутами в виде пар "ключ-значение"
	slogger.Info(
		"Server started",
		"port", ":8080",
		"network", "tcp",
	)
}

    

В результате выполнения кода в консоль будет выведено следующее:

        time=2024-03-14T18:52:08.437+03:00 level=INFO msg="Info message"
time=2024-03-14T18:52:08.437+03:00 level=WARN msg="Warn message"
time=2024-03-14T18:52:08.437+03:00 level=ERROR msg="Error message"
time=2024-03-14T18:52:08.437+03:00 level=INFO msg="Message from old logger"
time=2024-03-14T18:52:08.437+03:00 level=INFO msg="Server started" port=:8080 network=tcp

    

Синтаксис чередующегося значения ключей для атрибутов удобен, но для часто выполняемых операторов может быть более эффективным использовать тип Attr с методом LogAttrs. Такой подход позволит оптимизировать потребление памяти и обеспечить безопасность типов при указании дополнительных атрибутов. Учитывая это, заменим предыдущий код с выводом сообщения “Server started” на аналогичный с использованием LogAttrs:

        func main() {
	slogger := slog.New(slog.NewTextHandler(os.Stdout, nil)) // создание логера

	// Сообщение с атрибутами в виде пар "ключ-значение"
	slogger.LogAttrs(
		context.Background(),
		slog.LevelInfo,
		"Server started",
		slog.String("port", ":8080"),
		slog.String("network", "tcp"),
	)
}


    

В консоль будет выведено следующее сообщение:

        time=2024-03-14T19:39:59.247+03:00 level=INFO msg="Server started" port=:8080 network=tcp

    

Настройка обработчиков, включая TextHandler и JSONHandler, производится с помощью типа HandlerOptions:

        func main() {
	opts := &slog.HandlerOptions{
		AddSource: true,           // указание пути к файлу
		Level:     slog.LevelInfo, // задание минимального уровня
	}
	slogger := slog.New(slog.NewTextHandler(os.Stdout, opts)) // создание логера

	slogger.Debug("Debug message")
	slogger.Info("Info message")
}

    

После выполнения кода будет выведено только сообщение уровня Info с указанием пути к исполняемому файлу:

        time=2024-03-14T19:24:38.685+03:00 level=INFO source=/home/herman/myfolder/solve/main.go:16 msg="Info message"

    

Подведем итоги

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

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

***

Содержание самоучителя

  1. Особенности и сфера применения Go, установка, настройка
  2. Ресурсы для изучения Go с нуля
  3. Организация кода. Пакеты, импорты, модули. Ввод-вывод текста.
  4. Переменные. Типы данных и их преобразования. Основные операторы
  5. Условные конструкции if-else и switch-case. Цикл for. Вложенные и бесконечные циклы
  6. Функции и аргументы. Области видимости. Рекурсия. Defer
  7. Массивы и слайсы. Append и сopy. Пакет slices
  8. Строки, руны, байты. Пакет strings. Хеш-таблица (map)
  9. Структуры и методы. Интерфейсы. Указатели. Основы ООП
  10. Наследование, абстракция, полиморфизм, инкапсуляция
  11. Обработка ошибок. Паника. Восстановление. Логирование
  12. Обобщенное программирование. Дженерики
  13. Работа с датой и временем. Пакет time
  14. Интерфейсы ввода-вывода. Буферизация. Работа с файлами. Пакеты io, bufio, os
  15. Конкурентность. Горутины. Каналы
  16. Тестирование кода и его виды. Table-driven подход. Параллельные тесты
  17. Основы сетевого программирования. Стек TCP/IP. Сокеты. Пакет net
  18. Протокол HTTP. Создание HTTP-сервера и клиента. Пакет net/http

Комментарии

ВАКАНСИИ

Добавить вакансию
Разработчик C++
Москва, по итогам собеседования

ЛУЧШИЕ СТАТЬИ ПО ТЕМЕ