Глава 10. Обработка ошибок

127 просмотров
0 лайков
0 в избранном

Введение

В большинстве языков, к которым ты привык, ошибки — это исключения: где-то в глубине кода что-то падает, вылетает throw, и стек раскручивается вверх, пока кто-нибудь его не поймает через try/catch. Go выбрал принципиально другой путь. Здесь ошибка — это просто значение, такое же, как число или строка. Функция, которая может завершиться неудачей, возвращает ошибку последним значением, а вызывающий код обязан на месте решить, что с ней делать. Никакой «магии» невидимого потока управления: ты всегда видишь, где ошибка может возникнуть и где её обрабатывают. Именно поэтому Go-код так «многословен» на проверки if err != nil — и именно поэтому его так легко читать и сопровождать спустя год. В этой главе мы разберём весь инструментарий работы с ошибками: от базового интерфейса error до обёртки ошибок и современных функций errors.Is, errors.As и errors.Join.

Ошибка — это обычное значение

В основе всего лежит крошечный встроенный интерфейс. Любой тип, у которого есть метод Error() string, считается ошибкой. Это всё, что нужно знать про «устройство» ошибок в Go.

// Так error объявлен в стандартной библиотеке (пакет builtin).
type error interface {
    Error() string
}

Из этого определения вытекает мощное следствие: раз ошибка — это интерфейс, её значением может быть что угодно, лишь бы был метод Error(). Простая текстовая ошибка, структура с кучей полей, целая цепочка вложенных ошибок — всё это одинаково укладывается в тип error. А ещё ошибку, как любое значение, можно сохранить в переменную, сравнить, передать в функцию и вернуть из неё. Отсутствие ошибки — это nil. Отсюда главная идиома языка.

Идиома «if err != nil»

Функция возвращает результат и ошибку, а вызывающий сразу же проверяет ошибку. Если она не nil — что-то пошло не так, и дальше по «счастливому пути» идти нельзя. Аналогия: представь, что каждый вызов функции — это дверь, а err — записка на ручке. Прежде чем войти, ты читаешь записку: если там «всё хорошо» (nil) — проходишь, если предупреждение — реагируешь и не открываешь дверь дальше.

package main

import (
    "errors"
    "log"
)

// Sqrt считает квадратный корень. Отрицательный вход — это ошибка,
// поэтому вторым значением функция возвращает error.
func Sqrt(f float64) (float64, error) {
    if f < 0 {
        return 0, errors.New("norgate math: корень из отрицательного числа")
    }
    // ... настоящая реализация ...
    return 42, nil
}

func main() {
    result, err := Sqrt(-10)
    if err != nil {
        // Ошибка есть — обрабатываем её и НЕ используем result.
        log.Fatalln(err)
    }
    log.Println("результат:", result)
}

Обрати внимание на порядок: сначала объявляем result, err, затем сразу проверяем err, и только внутри «счастливой ветки» имеем право доверять result. Когда ошибка не nil, второе значение (result) обычно бессмысленно — это нулевое значение типа. Поэтому никогда не используй результат до проверки ошибки.

Куда девать ошибку: печать, логи, завершение

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

// Три способа среагировать на ошибку, от мягкого к жёсткому.

// 1) Напечатать в stderr и продолжить работу.
if err != nil {
    fmt.Println("что-то пошло не так:", err)
}

// 2) Записать в лог (добавит дату/время) и продолжить.
if err != nil {
    log.Println("ошибка при открытии файла:", err)
}

// 3) Залогировать и НЕМЕДЛЕННО завершить процесс с кодом 1.
//    log.Fatalln = log.Println + os.Exit(1).
if err != nil {
    log.Fatalln(err)
}

Общее правило: log.Fatalln и panic уместны только на самом верхнем уровне программы (обычно в main) или в самых безнадёжных ситуациях. Внутри библиотечных функций так делать нельзя — ты отбираешь у вызывающего право решать, что делать с ошибкой. Библиотека возвращает ошибку, а не убивает процесс за спиной пользователя.

errors.New и fmt.Errorf

Создать ошибку из текста можно двумя способами. errors.New — когда сообщение статическое. fmt.Errorf — когда в текст нужно подставить значения (форматирование как у Printf).

// Статичный текст.
err1 := errors.New("файл не найден")

// Текст с подстановкой значений.
name := "config.yaml"
line := 42
err2 := fmt.Errorf("разбор %s: ошибка на строке %d", name, line)

Часто повторяющиеся, «опознаваемые» ошибки удобно объявлять один раз как переменные уровня пакета — их называют сигнальными ошибками (sentinel errors). Имя по соглашению начинается с Err. Такую ошибку можно потом сравнивать в вызывающем коде и точно понимать, что именно случилось.

// Сигнальные ошибки пакета — объявлены один раз, используются много где.
var (
    ErrNotFound   = errors.New("не найдено")
    ErrPermission = errors.New("доступ запрещён")
)

func findUser(id int) (*User, error) {
    u, ok := storage[id]
    if !ok {
        return nil, ErrNotFound // возвращаем именно эту переменную
    }
    return u, nil
}

Кастомные типы ошибок

Строка — это хорошо, но иногда ошибке нужны поля: код статуса, имя поля формы, координаты, вложенная причина. Тогда объявляют собственный тип и реализуют у него метод Error(). Такой тип несёт структурированные данные, которые потом можно программно прочитать.

package main

import (
    "fmt"
    "log"
)

// Собственный тип ошибки с дополнительными полями.
type NorgateMathError struct {
    lat, long string
    err       error
}

// Реализуем метод Error() — теперь *NorgateMathError является error.
func (n *NorgateMathError) Error() string {
    return fmt.Sprintf("ошибка norgate math: %v %v %v", n.lat, n.long, n.err)
}

func Sqrt(f float64) (float64, error) {
    if f < 0 {
        cause := fmt.Errorf("корень из отрицательного числа: %v", f)
        return 0, &NorgateMathError{"50.2289 N", "99.4656 W", cause}
    }
    return 42, nil
}

func main() {
    if _, err := Sqrt(-10.23); err != nil {
        log.Println(err)
    }
}

Метод объявлен на указателе (*NorgateMathError), поэтому и возвращаем мы &NorgateMathError{...} — со взятием адреса. Это частая практика: указательный получатель дешевле копировать и позволяет сравнивать ошибки по идентичности. Так же устроены и многие ошибки стандартной библиотеки — например, *os.PathError или *net.OpError хранят внутри путь, операцию и вложенную причину.

Обёртка ошибок: глагол %w (Go 1.13)

Реальная программа состоит из слоёв: обработчик вызывает сервис, сервис — репозиторий, репозиторий — драйвер БД. Ошибка рождается глубоко внизу, но осмысленной она станет, только если по пути наверх к ней добавят контекст: «где» и «при чём» это произошло. До Go 1.13 контекст добавляли через %v — но тогда исходная ошибка превращалась в строку, и связь с ней терялась. В Go 1.13 появился глагол %w (от wrap): он сохраняет исходную ошибку внутри новой, образуя цепочку.

package main

import (
    "errors"
    "fmt"
)

var ErrNotFound = errors.New("не найдено")

func findUser(id int) error {
    // %w оборачивает ErrNotFound, добавляя контекст, но НЕ теряя её.
    return fmt.Errorf("findUser(%d): %w", id, ErrNotFound)
}

func main() {
    err := findUser(42)
    fmt.Println("ошибка:", err) // findUser(42): не найдено

    // %w сохранил связь — errors.Is находит ErrNotFound в цепочке.
    fmt.Println("это ErrNotFound?", errors.Is(err, ErrNotFound)) // true

    // Для сравнения: %v связь теряет.
    errV := fmt.Errorf("findUser: %v", ErrNotFound)
    fmt.Println("через %v:", errors.Is(errV, ErrNotFound)) // false
}

Разница простая: %v — только текст, связь потеряна; %w — текст плюс сохранённая ссылка на исходную ошибку. Запомни правило: если ты хочешь, чтобы вызывающий код мог «докопаться» до первопричины — оборачивай через %w. Если исходная ошибка внутренняя и наружу её показывать не надо — используй %v.

errors.Is — ищем конкретную ошибку в цепочке

Раньше писали if err == ErrNotFound. Но как только ошибку обернули хотя бы раз, прямое сравнение ломается: снаружи уже другая ошибка. errors.Is разворачивает всю цепочку по %w и сравнивает на каждом уровне — поэтому работает сквозь любую глубину обёрток.

package main

import (
    "errors"
    "fmt"
)

var ErrPermission = errors.New("доступ запрещён")

func readFile() error { return ErrPermission }

func loadConfig() error {
    if err := readFile(); err != nil {
        return fmt.Errorf("loadConfig: %w", err) // 1-я обёртка
    }
    return nil
}

func startApp() error {
    if err := loadConfig(); err != nil {
        return fmt.Errorf("startApp: %w", err) // 2-я обёртка
    }
    return nil
}

func main() {
    err := startApp()
    fmt.Println("полная ошибка:", err) // startApp: loadConfig: доступ запрещён

    // Несмотря на две обёртки, Is находит ErrPermission в глубине.
    if errors.Is(err, ErrPermission) {
        fmt.Println("→ обнаружена проблема с правами доступа")
    }
}

errors.As — извлекаем ошибку нужного типа

errors.Is отвечает на вопрос «это та самая ошибка?», а errors.As — на вопрос «есть ли в цепочке ошибка такого типа, и если да — дай мне её», чтобы прочитать поля. Это современная замена старому приведению типа err.(*MyError), которое не умеет заглядывать под обёртки.

package main

import (
    "errors"
    "fmt"
)

// Структурированная ошибка с полями.
type ValidationError struct {
    Field string
    Msg   string
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("поле %q: %s", e.Field, e.Msg)
}

func validate() error {
    return &ValidationError{Field: "email", Msg: "неверный формат"}
}

func handleRequest() error {
    if err := validate(); err != nil {
        return fmt.Errorf("handleRequest: %w", err) // обернули
    }
    return nil
}

func main() {
    err := handleRequest()

    // Достаём *ValidationError из-под обёртки и читаем поля.
    var ve *ValidationError
    if errors.As(err, &ve) {
        fmt.Printf("→ поле=%s, причина=%s
", ve.Field, ve.Msg)
    }
}

Важная деталь: во второй аргумент errors.As передаётся адрес переменной нужного типа (&ve). Функция «заполнит» эту переменную найденной ошибкой и вернёт true.

errors.Unwrap — как устроена цепочка

Обе функции выше, Is и As, внутри просто вызывают errors.Unwrap в цикле, снимая по одному слою. Напрямую Unwrap нужен редко, но он показывает механику. Чтобы твой собственный тип стал частью цепочки, у него объявляют метод Unwrap() error, возвращающий вложенную ошибку.

package main

import (
    "errors"
    "fmt"
)

var ErrDB = errors.New("сбой базы данных")

type QueryError struct {
    Query string
    Err   error
}

func (e *QueryError) Error() string {
    return fmt.Sprintf("запрос %q: %v", e.Query, e.Err)
}

// Метод Unwrap делает QueryError частью цепочки для Is/As/Unwrap.
func (e *QueryError) Unwrap() error { return e.Err }

func main() {
    err := fmt.Errorf("сервис недоступен: %w",
        &QueryError{Query: "SELECT 1", Err: ErrDB})

    // Разворачиваем цепочку по одному слою.
    for err != nil {
        fmt.Printf("  %v
", err)
        err = errors.Unwrap(err)
    }
}

errors.Join — несколько ошибок сразу (Go 1.20)

Иногда нужно вернуть не одну ошибку, а сразу список — например, все нарушения валидации формы, а не только первое. До Go 1.20 для этого писали свои типы или склеивали строки. errors.Join делает это штатно: объединяет несколько ошибок в одну, игнорирует nil-ы, печатает каждую с новой строки, а errors.Is/errors.As находят любую из объединённых.

package main

import (
    "errors"
    "fmt"
)

var (
    ErrEmptyName = errors.New("имя не заполнено")
    ErrBadEmail  = errors.New("неверный email")
    ErrTooYoung  = errors.New("возраст меньше 18")
)

type Form struct {
    Name  string
    Email string
    Age   int
}

func (f Form) Validate() error {
    var errs []error // копим ВСЕ нарушения, а не первое

    if f.Name == "" {
        errs = append(errs, ErrEmptyName)
    }
    if f.Email == "" {
        errs = append(errs, ErrBadEmail)
    }
    if f.Age < 18 {
        errs = append(errs, ErrTooYoung)
    }
    // Join вернёт nil, если errs пуст — можно возвращать напрямую.
    return errors.Join(errs...)
}

func main() {
    err := Form{Name: "", Email: "", Age: 15}.Validate()
    fmt.Println(err)

    fmt.Println("пустое имя?", errors.Is(err, ErrEmptyName)) // true
    fmt.Println("молод?    ", errors.Is(err, ErrTooYoung))   // true
}

panic и recover — для исключительных ситуаций

У Go всё же есть механизм, похожий на исключения: panic раскручивает стек, а recover (вызванный внутри defer) его останавливает. Но пользоваться этим для обычной обработки ошибок нельзя. panic — это про «программист облажался» (обращение по nil-указателю, выход за границы среза) или про действительно неустранимые ситуации на старте программы. Всё, что может случиться в нормальной жизни (файл не найден, сеть отвалилась) — это ошибки, а не паники.

package main

import "fmt"

// safeDivide перехватывает панику деления на ноль и превращает её в ошибку.
func safeDivide(a, b int) (result int, err error) {
    defer func() {
        if r := recover(); r != nil {
            // Превращаем панику в обычную ошибку — граница «модуля».
            err = fmt.Errorf("восстановлено после паники: %v", r)
        }
    }()
    return a / b, nil // при b == 0 будет паника
}

func main() {
    if _, err := safeDivide(10, 0); err != nil {
        fmt.Println(err) // восстановлено после паники: integer divide by zero
    }
    fmt.Println("программа продолжает работать")
}

Типичное место для recover — на границе, где нельзя дать всей программе рухнуть из-за одной задачи: например, HTTP-сервер оборачивает каждый обработчик, чтобы паника в одном запросе не убила весь процесс. Именно так делает встроенный net/http.

Кейс из реального проекта

Рассмотрим типичное трёхслойное приложение: обработчик HTTP → сервис (бизнес- логика) → репозиторий (доступ к данным). Ошибка рождается в репозитории, а каждый слой добавляет свой контекст через %w. Наверху обработчик одной проверкой errors.Is решает, какой HTTP-код вернуть.

package main

import (
    "errors"
    "fmt"
)

// Сигнальная ошибка доменного уровня.
var ErrUserNotFound = errors.New("пользователь не найден")

// --- Слой репозитория: работает с «хранилищем» ---
func repoGetUser(id int) (string, error) {
    users := map[int]string{1: "Аня", 2: "Борис"}
    name, ok := users[id]
    if !ok {
        // Оборачиваем сигнальную ошибку, добавляя технический контекст.
        return "", fmt.Errorf("repo.GetUser id=%d: %w", id, ErrUserNotFound)
    }
    return name, nil
}

// --- Слой сервиса: бизнес-логика ---
func serviceProfile(id int) (string, error) {
    name, err := repoGetUser(id)
    if err != nil {
        // Ещё один слой контекста, исходная причина сохранена.
        return "", fmt.Errorf("service.Profile: %w", err)
    }
    return "Профиль: " + name, nil
}

// --- Слой обработчика: превращает ошибку в ответ ---
func handleProfile(id int) (status int, body string) {
    profile, err := serviceProfile(id)
    if err != nil {
        // Одна проверка Is сквозь ВСЕ слои обёрток.
        if errors.Is(err, ErrUserNotFound) {
            return 404, "нет такого пользователя"
        }
        return 500, "внутренняя ошибка"
    }
    return 200, profile
}

func main() {
    for _, id := range []int{1, 99} {
        status, body := handleProfile(id)
        fmt.Printf("id=%d → %d %q
", id, status, body)
    }
    // id=1  → 200 "Профиль: Аня"
    // id=99 → 404 "нет такого пользователя"
}

Ценность подхода: технический контекст (repo.GetUser id=99) для логов сохраняется на каждом слое, но принятие решения («это 404») остаётся в одном месте — обработчике, и не зависит от того, сколько обёрток по пути. Именно так строят обработку ошибок в продакшене на Go.

Типичные ошибки

Игнорировать ошибку через _

Самая опасная привычка — глушить ошибку: result, _ := doWork(). Компилятор промолчит, а баг проявится в проде в самый неподходящий момент. Если ошибка тебе действительно не важна — оставь короткий комментарий, почему её можно проигнорировать. Но в 99% случаев её нужно хотя бы залогировать. Линтер errcheck ловит такие места автоматически.

Оборачивать через %v вместо %w, когда нужна цепочка

Если написать fmt.Errorf("...: %v", err), исходная ошибка превратится в строку, и вызывающий код уже не сможет проверить её через errors.Is. Помни: %w — когда причина должна остаться доступной программно; %v — когда наружу её выносить не надо.

Сравнивать ошибки по тексту

Соблазн написать if strings.Contains(err.Error(), "not found") велик, но это хрупко: текст может измениться, локализоваться, обрасти контекстом. Сравнивай ошибки по идентичности (errors.Is) или по типу (errors.As), а не по подстроке.

Возвращать nil-указатель кастомной ошибки

Классическая ловушка: функция возвращает *MyError, внутри пишет var e *MyError; return e (то есть nil-указатель), а вызывающий проверяет if err != nil — и проверка срабатывает, хотя ошибки нет! Причина в том, что интерфейс error с nil-указателем внутри сам по себе не nil. Возвращай тип error и явный nil, когда ошибки нет.

Практика

  1. Напиши функцию ParsePort(s string) (int, error), которая преобразует строку в номер порта. Верни осмысленные ошибки для пустой строки, для нечислового ввода и для значения вне диапазона 1–65535. Используй fmt.Errorf с подстановкой значения.
  2. Объяви сигнальную ошибку ErrEmpty и функцию, которая оборачивает её через %w на двух уровнях вложенности. Убедись, что errors.Is находит её сквозь обе обёртки.
  3. Создай кастомный тип HTTPError с полями Code int и Message string, реализуй Error(). Напиши код, который через errors.As достаёт этот тип из-под обёртки и печатает Code.
  4. Реализуй валидатор структуры Order{Items int; Total float64}, который через errors.Join возвращает сразу все нарушения (пустой заказ, отрицательная сумма).
  5. Напиши функцию Must[...]-подобную обёртку, которая перехватывает panic через recover и превращает её в ошибку, и покажи на примере деления на ноль.

Итог

Ошибки в Go — это значения, а не исключения, и весь стиль работы с ними строится вокруг явной проверки if err != nil. Ты научился создавать ошибки (errors.New, fmt.Errorf), объявлять сигнальные ошибки и кастомные типы с методом Error(), а главное — работать с цепочками: оборачивать через %w, искать причину через errors.Is, извлекать типизированную ошибку через errors.As, разворачивать цепочку errors.Unwrap и собирать несколько ошибок в одну через errors.Join. panic/recover оставляем для действительно исключительных ситуаций и границ, где нельзя дать всей программе упасть. Такой подход делает поток ошибок видимым и предсказуемым — читая код, ты всегда знаешь, что и где может пойти не так.

Комментарии 0

Для добавления комментариев необходимо войти или зарегистрироваться.

Пока нет комментариев. Станьте первым!