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

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

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

Note

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

Тип error

Ранее мы уже упоминали тип error вскользь. Действительно, конструкция if err != nil {} является чуть ли не визитной карточкой языка и встречается в каждой программе на Go.

Тип error — это интерфейсный тип:

type error interface {
    Error() string //  Этот метод должен возвращать текст ошибки. 
} 

Благодаря тому, что функции могут возвращать несколько значений, ошибки легко попадают в их ряды. Использование error в последнем возвращаемом значении функции — очень распространённый в Go паттерн. Если возвращаемое значение ошибки равно nil, то функция завершилась корректно. В противном случае остальные возвращенные значения функции использовать нельзя и нужно обработать ошибку.

Рассмотрим пример:

if data, err := os.ReadFile(`nothing.txt`); err != nil {
    // будет вызван метод 'Error() string', который преобразует ошибку в строку
    fmt.Println(err)
} else {
    fmt.Println(string(data))
} 

Обычно в Go используется паттерн «ранний выход», поэтому код выше лучше использовать так:

func ReadTextFile() (string,error) {
    if data, err := os.ReadFile(`nothing.txt`); err != nil {
        // будет вызван метод 'Error() string', который преобразует ошибку в строку
        fmt.Println(err)
        return err
    }   
    fmt.Println(string(data))
    return string(data), nil 
} 

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

Если запустить такой код с отсутствующим файлом nothing.txt, программа выведет open nothing.txt: no such file or directory. Возвращаемое значение data никак не обрабатывается.

В стандартной библиотеке Go есть пакет errors для работы с ошибками. Чтобы создать переменную типа error, нужно вызвать функцию New, которая принимает в параметре строку. Например, в коде выше можем создать собственную ошибку, а не возвращать полученную из функции.

func ReadTextFile()  (string, error) {
    if data, err := os.ReadFile(`nothing.txt`); err != nil {
        // будет вызван метод 'Error() string', который преобразует ошибку в строку
        fmt.Println(err)
        return errors.New("some_file_process_func: read file error")
    }   
    fmt.Println(string(data))
    return string(data), nil
 
}

Note

errors.New() не принимает форматированную строку с параметрами, только одну строку с сообщением.

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

Поэтому наиболее часто используется создание ошибок статически, на этапе инициализации модуля:

// Статически создаем ошибку.
var ErrFileReading = errors.New("read_text_file: read file error") //  хорошей практикой является начинать текст ошибки с названия пакета, где она объявлена, так будет проще найти ее
 
func ReadTextFile() (string, error) {
    data, err := os.ReadFile(`nothing.txt`)
    if err != nil {
        // будет вызван метод 'Error() string', который преобразует ошибку в строку
        fmt.Println(err)
        return "", ErrFileReading
    }   
    fmt.Println(string(data))
    return string(data), nil
} 

Если нужно сформировать ошибку с использованием дополнительной информации, можно применять функцию fmt.Errorf, которая работает как fmt.Sprintf, но возвращает вместо строки ошибку. В Go принято начинать текст ошибок со строчной буквы, так как ошибки могут объединяться друг с другом.

func ReadTextFileByName(filename string) (string, error) {
    data, err := os.ReadFile(filename)
    if err != nil {
        // вернём ошибку на русском языке
        return ``, fmt.Errorf(`не удалось прочитать файл (%s): %v`, filename, err)
    }
    return string(data), nil
} 

Сравнение ошибок

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

Ошибки можно сравнивать так же, как и другие переменные:

data, err := ReadTextFile()
// Проверяем, что была возвращена ошибка
if err != nil {
    if err == ErrFileReading {
        fmt.Println("unable read file")
        return 
    }
    fmt.Println("unknown error")
    return
}

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

Далее узнаем, как разрешить эту проблему.

Обёртывание ошибок

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

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

Начиная с версии 1.13, Go даёт возможность «упаковывать», или «обёртывать», ошибки (wrapping error). Это означает, что можно создавать новую ошибку поверх старой, но сохранять возможность восстановить оригинальную ошибку. Это может понадобиться для создания собственных типов ошибок на основе уже существующих. Ниже приводится пример.

Вспомните пример с чтением файла конфигурации, где происходит замена одной ошибки на другую. Текст оригинальной ошибки остаётся, но восстановить её тип невозможно. Чтобы обернуть ошибку, нужно использовать спецификатор %w для функции Errorf.

Исправим %v на %w в функции ReadTextFile:

func ReadTextFile(filename string) (string, error) {
    data, err := os.ReadFile(filename)
    if err != nil {
        // возвратим ошибку на русском языке
        return "", fmt.Errorf(`не удалось прочитать файл (%s): %w`, filename, err)
    }
    return string(data), nil
} 

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

Кроме того, в пакете github.com/pkg/errors есть функции Wrap и Wrapf, которые создают обёрнутую ошибку.

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

// Статически создаём ошибку.
var ErrFileReading = errors.New("read_text_file: read file error") 
 
func ReadTextFileByName(filename string) (string, error) {
    if data, err := os.ReadFile(filename); err != nil {
        // будет вызван метод 'Error() string', который преобразует ошибку в строку
        fmt.Println(err)
        return errors.Wrapf(ErrFileReading, "file not exist %s", filename)
    }   
    fmt.Println(string(data))
    return string(data), nil
} 

А теперь в месте вызова нашей функции сравним:

if errors.Is(err, ErrFileReading) {
    // что-то делаем
    
} 

Функция Is сравнивает ошибки, причём даже обёрнутые! Благодаря этому можно обрабатывать обёрнутые ошибки и передавать в них какую-то дополнительную информацию.

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

Cобственные типы ошибок

Иногда в ошибке, кроме текста, нужно передать дополнительную информацию. Разберём пример, в котором вместе с текстом ошибки будем возвращать время её возникновения:

import (
    "fmt"
    "time"
)
// Создадим собственный тип, который удовлетворяет интерфейсу error
// TimeError — тип для хранения времени и текста ошибки.
type TimeError struct {
    Time time.Time
    Text string
}
 
// Error добавляет поддержку интерфейса error для типа TimeError.
func (te TimeError) Error() string {
    return fmt.Sprintf("%v: %v", te.Time.Format(`2006/01/02 15:04:05`), te.Text)
}
 
// NewTimeError возвращает переменную типа TimeError c текущим временем.
func NewTimeError(text string) TimeError {
    return TimeError{
        Time: time.Now(),
        Text: text,
    }
}
 
func testFunc(i int) error {
    // несмотря на то что NewTimeError возвращает тип TimeError,
    // у testFunc тип возвращаемого значения равен error
    if i == 0 {
        return NewTimeError(`параметр в testFunc равен 0`)
    }
    return nil
}
 
func main() {
    if err := testFunc(0); err != nil {
        fmt.Println(err)
    }
}

Cобственные типы ошибок

Иногда в ошибке, кроме текста, нужно передать дополнительную информацию. Разберём пример, в котором вместе с текстом ошибки будем возвращать время её возникновения:

import (
    "fmt"
    "time"
)
// Создадим собственный тип, который удовлетворяет интерфейсу error
// TimeError — тип для хранения времени и текста ошибки.
type TimeError struct {
    Time time.Time
    Text string
}
 
// Error добавляет поддержку интерфейса error для типа TimeError.
func (te TimeError) Error() string {
    return fmt.Sprintf("%v: %v", te.Time.Format(`2006/01/02 15:04:05`), te.Text)
}
 
// NewTimeError возвращает переменную типа TimeError c текущим временем.
func NewTimeError(text string) TimeError {
    return TimeError{
        Time: time.Now(),
        Text: text,
    }
}
 
func testFunc(i int) error {
    // несмотря на то что NewTimeError возвращает тип TimeError,
    // у testFunc тип возвращаемого значения равен error
    if i == 0 {
        return NewTimeError(`параметр в testFunc равен 0`)
    }
    return nil
}
 
func main() {
    if err := testFunc(0); err != nil {
        fmt.Println(err)
    }
}

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

2021/05/28 11:33:00: Параметр в testFunc равен 0 

Если ошибкой может выступать переменная любого интерфейсного типа error, значит, можно использовать операцию утверждения типа (type assertion) для конвертации ошибки в конкретный базовый тип.

if err := testFunc(0); err != nil {
    if v, ok := err.(TimeError); ok {
        fmt.Println(v.Time, v.Text)
    } else {
        fmt.Println(err)
    }
}

Если ошибки могут быть разных типов, логично использовать конструкцию выбора типа:

if err := testFunc(0); err != nil {
    switch v := err.(type) {
    case TimeError:
        fmt.Println(v.Time, v.Text)
    case *os.PathError:
        fmt.Println(v.Err)
    default:
        fmt.Println(err)
    }
} 

Но лучше применить функцию As пакета errors, так как она, в отличие от type assertion, работает с «обёрнутыми» ошибками, которые разберём ниже. As находит первую в цепочке ошибку err, устанавливает тип, равным этому значению ошибки, и возвращает true.

if err := testFunc(0); err != nil {
    var te TimeError
    if ok := errors.As(err, &te); ok { //  Сравниваем полученную и контрольную ошибки. Сравнение идёт по типу ошибки.
        fmt.Println(te.Time, te.Text)
    } else {
        fmt.Println(err)
    }
} 

Возвращение ошибки не всегда означает, что ситуация критическая. Ошибка может сообщать о статусе или состоянии какого-то действия или ресурса. Например, при проверке наличия файла нужно дополнительно проверить полученную ошибку функцией os.IsNotExist. Другой пример — чтение из источника должно продолжаться до получения ошибки io.EOF, которая сигнализирует о том, что все данные прочитаны.

if _, err := os.Stat(filename); err == nil {
    // файл существует
} else if os.IsNotExist(err) {
    // файл не существует
} else {
    // в этом случае непонятно, что случилось, и нужно смотреть текст ошибки
} 
func main() {
    if data, err := ReadTextFile(`myconfig.yaml`); err != nil {
        if os.IsNotExist(errors.Unwrap(err)) {
            fmt.Println(`Файл не существует!`)
        }
    } else {
        fmt.Println(data)
    }
} 

В данном примере можно использовать функцию Is(err, target error) bool из пакета errors, которая определяет, содержит ли цепочка ошибок конкретную ошибку.

func main() {
    data, err := ReadTextFile("myconfig.yaml")
    if errors.Is(err, os.ErrNotExist) {
        fmt.Println("Файл не найден")
        return
    }
    fmt.Println(data)
} 

📂 Go | Последнее изменение: 03.09.2024 12:47