16 апреля 2024

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

Энтузиаст-разработчик, автор статей по программированию. Сфера интересов - backend, web 3.0, кибербезопасность.
Рассмотрим устройство механизма ошибок в 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. Обработка ошибок. Паника. Восстановление. Логирование

МЕРОПРИЯТИЯ

Комментарии

ВАКАНСИИ

Добавить вакансию
PHP Developer
от 200000 RUB до 270000 RUB
Golang разработчик (middle)
от 230000 RUB до 300000 RUB

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