← Назад на главную

Обработка ошибок и аварий в Go

1. Обработка ошибок (Errors)

Ошибка в Go указывает на то, что конкретная задача не может быть успешно завершена. Это ожидаемая и обрабатываемая ситуация.

Идиомы обработки ошибок

В отличие от таких языков, как Python, Java или Ruby, которые используют модель исключений (exception) с блоками try/catch, Go использует возможность функций возвращать несколько значений. Стандартной идиомой является возвращение ошибки в качестве последнего возвращаемого значения. Рассмотрим базовый пример функции Concat, которая объединяет строки:

func Concat(parts ...string) (string, error) {
if len(parts) == 0 {
return "", errors.New("no strings supplied") // Возврат ошибки
}
return strings.Join(parts, " "), nil // Исправлен разделитель на пробел
}

Обработка такой ошибки в вызывающем коде выглядит следующим образом:

package main

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

func main() {
args := os.Args[1:] // Args[0] - это имя программы
if result, err := Concat(args...); err != nil {
fmt.Printf("Error: %s\n", err) // Обработка ошибки
} else {
fmt.Printf("Concatenated string: '%s'\n", result) // Обработка успеха
}
}

func Concat(parts ...string) (string, error) {
if len(parts) == 0 {
return "", errors.New("no strings supplied")
}
return strings.Join(parts, " "), nil
}

Ключевой момент здесь - использование оператора if, который позволяет выполнить присваивание result, err := Concat(...) перед проверкой условия err != nil. Область видимости переменных result и err ограничена этим блоком if/else.

Минимизация использования nil

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

result, err := Concat(args...)
fmt.Printf("Concatenated string: '%s'\n", result)
Совет: Для создания простых ошибок используйте errors.New("message"). Для создания форматированной строки ошибки используйте fmt.Errorf("Error: %s", reason).

Пользовательские типы ошибок

Интерфейс error в Go чрезвычайно прост:

type error interface {
Error() string
}

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

Например, ошибка парсера, содержащая информацию о строке и символе:

type ParseError struct {
Message string // Сообщение об ошибке
Line, Char int // Позиция в файле
}

func (p *ParseError) Error() string {
format := "%s on Line %d, Char %d"
return fmt.Sprintf(format, p.Message, p.Line, p.Char)
}
Такой тип позволяет не только вывести сообщение, но и программно проанализировать место ошибки.

Переменные ошибок

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

Рассмотрим пример функции, имитирующей отправку запроса, которая может вернуть одну из двух ошибок:

package main

import (
"errors"
"fmt"
"math/rand"
)

// Объявляем переменные ошибок на уровне пакета
var ErrTimeout = errors.New("The request timed out")
var ErrRejected = errors.New("The request was rejected")

var random = rand.New(rand.NewSource(35))

func SendRequest(req string) (string, error) {
// Имитация разных исходов вызова
switch random.Int() % 3 {
case 0:
return "Success", nil
case 1:
return "", ErrRejected
default:
return "", ErrTimeout
}
}

func main() {
response, err := SendRequest("Hello")
// Обрабатываем конкретный тип ошибки (Timeout) путем сравнения
for err == ErrTimeout {
fmt.Println("Timeout. Retrying.")
response, err = SendRequest("Hello")
}
if err != nil {
fmt.Println(err) // Любая другая ошибка (например, ErrRejected)
} else {
fmt.Println(response)
}
}
Этот подход прост и эффективен, так как ошибки создаются только один раз (при инициализации пакета), а их проверка сводится к простому сравнению err == ErrTimeout.

2. Система аварий (Panics)

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

Отличия аварий от ошибок

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

package main

import (
"errors"
"fmt"
)

var ErrDivideByZero = errors.New("Can't divide by zero")

// Функция возвращает ошибку - ожидаемое поведение
func precheckDivide(a, b int) (int, error) {
if b == 0 {
return 0, ErrDivideByZero
}
return a / b, nil
}

// Функция не проверяет входные данные, что приводит к аварии
func divide(a, b int) int {
return a / b // Паника, если b == 0
}

func main() {
// Этот вызов обработает ошибку нормально
_, err := precheckDivide(1, 0)
if err != nil {
fmt.Printf("Error: %s\n", err)
}
// Этот вызов приведет к аварийному завершению
divide(2, 0)
}

Возбуждение аварий

Для возбуждения аварии используется встроенная функция panic(interface{}). В нее можно передать что угодно, но идиоматически правильным является передача значения, реализующего интерфейс error (например, созданного через errors.New). Это упрощает последующую обработку аварии.

// Правильный способ возбудить аварию
panic(errors.New("Авария!"))

Восстановление после аварий

Go предоставляет механизм для перехвата аварии и восстановления нормального выполнения программы. Он основан на связке ключевых слов defer и recover.

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

Базовый пример восстановления:

package main

import (
"errors"
"fmt"
)

func main() {
// Отложенная анонимная функция для перехвата аварии
defer func() {
if err := recover(); err != nil { // Вызов recover
fmt.Printf("Trapped panic: %s (%T) \n", err, err)
}
}()
yikes() // Вызов функции, которая вызовет panic
}

func yikes() {
panic(errors.New("something bad happened"))
}
Более сложный пример показывает, как восстановиться после аварии, закрыть ресурсы и вернуть ошибку:

package main

import (
"os"
)

func OpenCSV(filename string) (file *os.File, err error) {
// Объявляем именованные возвращаемые значения, чтобы иметь к ним доступ в замыкании.
defer func() {
if r := recover(); r != nil {
file.Close() // Гарантированно закрываем файл при аварии
err = r.(error) // Преобразуем recovered value в error и возвращаем
}
}()
file, err = os.Open(filename)
if err != nil {
return file, err
}
RemoveEmptyLines(file) // Функция, которая может вызвать panic
return file, err
}

// RemoveEmptyLines - пример функции, которая может вызвать panic
func RemoveEmptyLines(file *os.File) {
// Реализация удаления пустых строк из CSV файла
// В данном примере всегда вызывает panic для демонстрации
panic("error in RemoveEmptyLines")
}

// (Предполагается, что RemoveEmptyLines всегда вызывает panic для этого примера)

Важно: Объявляйте отложенные функции как можно ближе к началу вмещающей функции, чтобы было понятно, какая логика очистки будет выполнена.

Аварии и горутины

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

Рассмотрим простой эхо-сервер:

package main

import (
"bufio"
"errors"
"fmt"
"net"
)

func listen() {
listener, err := net.Listen("tcp", ":1026")
if err != nil {
fmt.Println("Failed to open port on 1026")
return
}
for {
conn, err := listener.Accept()
if err != nil {
fmt.Println("Error accepting connection")
continue
}
// Запускаем обработчик каждого соединения в отдельной горутине
go handle(conn)
}
}

func handle(conn net.Conn) {
defer func() {
if r := recover(); r != nil {
fmt.Printf("Recovered from panic: %v\n", r)
}
conn.Close()
}()

reader := bufio.NewReader(conn)
data, err := reader.ReadBytes('\n')
if err != nil {
fmt.Println("Failed to read from socket.")
return
}
response(data, conn) // Вызов функции, которая может запаниковать
}

func response(data []byte, conn net.Conn) {
defer func() {
if r := recover(); r != nil {
fmt.Printf("Panic in response: %v\n", r)
}
}()

panic(errors.New("Failure in response!"))
// Авария в горутине!
conn.Write(data)
}

Авария в response "убьет" всю программу. Чтобы этого избежать, необходимо обрабатывать аварии внутри каждой сопрограммы, где они могут возникнуть.

func handle(conn net.Conn) {
// Добавляем обработчик паники ВНУТРИ горутины
defer func() {
if err := recover(); err != nil {
fmt.Printf("Fatal error in goroutine: %s", err)
}
conn.Close() // Гарантированно закрываем соединение
}()

reader := bufio.NewReader(conn)
data, err := reader.ReadBytes('\n')
if err != nil {
fmt.Println("Failed to read from socket.")
return // defer все равно выполнится и закроет conn
}
response(data, conn)
}
Для упрощения этой задачи можно создать специальную функцию-обертку для запуска сопрограмм:

package safely

import "log"

type GoDoer func() // Тип для функции, запускаемой в сопрограмме

func Go(todo GoDoer) {
go func() {
defer func() {
if err := recover(); err != nil {
log.Printf("Panic in safely.Go: %s", err)
}
}()
todo() // Выполняем переданную функцию
}()
}
Использование:

// Вместо: go message()
safely.Go(message)

func message() {
println("Inside goroutine")
panic(errors.New("oops"))
}

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

Итоги

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

  1. Ошибки - это часть нормального потока выполнения. Используйте множественный возврат, возвращайте осмысленные значения вместе с ошибкой, создавайте переменные ошибок для частых сценариев и пользовательские типы для передачи дополнительного контекста.
  2. Аварии - это чрезвычайные ситуации. Используйте их для уведомлении о неожиданных ошибках. Всегда передавайте в panic значение типа error.
  3. Восстанавливайтесь от аварий с помощью defer и recover, чтобы грациозно закрывать ресурсы и, по возможности, преобразовывать аварию в обычную ошибку.
  4. Помните о горутинах. Авария, не перехваченная внутри горутины, приведет к краху всей программы. Обеспечьте обработку аварий на верхнем уровне каждой горутины, которая в этом нуждается.

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

Спасибо за ваше время и внимание! Ваша поддержка очень важна для меня! Если вам понравилась статья, пожалуйста, поставьте лайк этой статье на моем канале Дзен

Подпишитесь на мой Телеграм-канал, чтобы быть в курсе новых статей.

Удачи!