Skip to content

Latest commit

 

History

History
308 lines (222 loc) · 23.3 KB

File metadata and controls

308 lines (222 loc) · 23.3 KB

Обработка ошибок

В .NET сбой — это исключение: вы throw-аете, и оно «всплывает» по стеку, пока кто-нибудь его не поймает в catch. Поток ошибок невидим в сигнатуре метода: по int Parse(string s) нельзя сказать, что внутри может вылететь FormatException. В Go всё ровно наоборот. Ошибка — это обычное значение, которое функция возвращает наряду с результатом. Обрабатывать его или нет — решает вызывающий, прямо в коде, явной проверкой. Никакого скрытого всплытия.

Это первая крупная перенастройка мышления для .NET-разработчика в теме ошибок: перестать «бросать и ловить» и начать «возвращать и проверять». Эта глава ставит правильную модель и показывает весь инструментарий пакета errors.

Философия: ошибки — это значения

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

func ReadConfig(path string) (Config, error) {
    data, err := os.ReadFile(path)
    if err != nil {
        return Config{}, err // пробрасываем ошибку наверх как значение
    }
    // ... разбор data ...
    return cfg, nil // nil-ошибка означает «успех»
}

Вызывающий обязан на месте решить, что делать с err. Отсюда вездесущая идиома Go:

cfg, err := ReadConfig("app.yaml")
if err != nil {
    return err // или залогировать, обернуть, вернуть дефолт — но решаем здесь
}
// сюда мы попадаем, только если err == nil
useConfig(cfg)

error — это всего лишь интерфейс из стандартной библиотеки, и он предельно мал:

type error interface {
    Error() string
}

Любой тип с методом Error() string является ошибкой. nil означает «ошибки нет». Это не специальный синтаксис языка — это обычный интерфейс (см. Раздел 2: Интерфейсы и Duck Typing), и error ведёт себя как любой другой интерфейс: его можно хранить, сравнивать с nil, оборачивать, приводить к конкретному типу.

Почему явный возврат, а не try/catch

Многословность if err != nil — частый объект критики, но за ней стоит сознательный выбор:

  • Поток управления виден. Каждая точка, где может произойти сбой, помечена в коде. Нет «невидимых» выходов из функции в произвольном месте, как при исключении.
  • Ошибка — часть контракта. Сигнатура func(...) (T, error) честно говорит: «я могу не справиться». В C# проверяемых исключений нет, и о возможном throw вы узнаёте из документации или падения в проде.
  • Обработка — обычный код. Ошибку можно положить в переменную, передать в функцию, накопить в слайсе, обернуть — это значение, а не управляющая конструкция.
  • Дёшево. Возврат значения не разворачивает стек и не аллоцирует объект исключения со стектрейсом. В Go нет стоимости «исключение в горячем пути».

Платой за это служит дисциплина: ошибку легко молча проигнорировать (val, _ := ...), и линтеры (errcheck, встроенный go vet) специально следят за необработанными ошибками.

Параллель с .NET: идиома if err != nil { return ..., err } — это то, что в C# вы делаете неявно через распространение исключения вверх по стеку. Разница в видимости: в .NET точка выброса и точка перехвата разнесены и не видны друг другу, в Go каждый «прыжок» ошибки наверх написан буквой. Ближе всего по духу — стиль с Result<T>/Either из функциональных библиотек (LanguageExt) или TryParse(out ...)-паттерн, но в Go это единственный штатный способ, а не альтернатива.

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

Два базовых конструктора живут в стандартной библиотеке.

errors.New — для простой текстовой ошибки:

import "errors"

err := errors.New("соединение разорвано")

fmt.Errorf — когда в текст нужно подставить данные:

import "fmt"

err := fmt.Errorf("не удалось открыть файл %q: код %d", name, code)

Текст ошибки по конвенции пишут со строчной буквы и без точки в конце — потому что ошибки часто склеиваются в цепочку («внешний контекст: внутренняя ошибка»), и заглавные буквы/точки посреди такой строки выглядят неряшливо. go vet это проверяет.

Оборачивание: глагол %w и цепочка ошибок

Ключевая возможность пакета errors (с Go 1.13) — оборачивание. Когда ошибка проходит через несколько слоёв, каждый слой может добавить контекст, не теряя исходную ошибку. Делает это глагол %w в fmt.Errorf:

func loadUser(id int) (*User, error) {
    row, err := db.Query(id)
    if err != nil {
        // %w оборачивает err, сохраняя его внутри новой ошибки
        return nil, fmt.Errorf("loadUser(%d): %w", id, err)
    }
    // ...
}

В отличие от %v (который просто вставит текст ошибки и «забудет» оригинал), %w создаёт обёртку, помнящую исходную ошибку. Так формируется цепочка: внешняя ошибка → её обёрнутая причина → её причина и т.д. Это прямой аналог InnerException в .NET, только цепочка строится явно в момент возврата.

flowchart LR
    OUT["fmt.Errorf(\"handler: %w\")"] -->|Unwrap| MID["fmt.Errorf(\"loadUser: %w\")"]
    MID -->|Unwrap| SENT["sql.ErrNoRows<br/>(sentinel в основании цепочки)"]
    OUT -. "errors.Is(err, sql.ErrNoRows)" .-> SENT
    OUT -. "errors.As(err, &pgErr)" .-> MID
Loading

Параллель с .NET: fmt.Errorf("...: %w", err)new MyException("контекст", inner) — вы заворачиваете нижнюю ошибку в верхнюю, сохраняя причину. Обход цепочки через errors.Unwrap ≈ обход ex.InnerException. Разница: в .NET обёртывание — это конструктор исключения, в Go — форматная строка с %w.

Распаковка цепочки: Is, As, Unwrap, Join

Раз ошибки складываются в цепочку, нужен способ её разбирать. Пакет errors даёт четыре функции.

errors.Is — сравнение с sentinel

errors.Is(err, target) проходит всю цепочку обёрток и проверяет, совпадает ли какое-либо её звено с target. Это правильный способ сравнения с заранее объявленной ошибкой-маркером (sentinel) — вместо err == target, который сломается, как только ошибку обернут:

data, err := loadUser(42)
if errors.Is(err, sql.ErrNoRows) { // ищет sql.ErrNoRows в любом звене цепочки
    return defaultUser, nil // штатная ситуация «не найдено»
}
if err != nil {
    return nil, err // любая другая ошибка
}

if err == sql.ErrNoRows сработал бы только для «голой» ошибки; после fmt.Errorf("...: %w", err) прямое сравнение даст false, а errors.Is — по-прежнему true.

errors.As — извлечение типа

errors.As(err, &target) ищет в цепочке звено конкретного типа и, если находит, записывает его в target — давая доступ к полям. Это аналог catch (SpecificException e):

var pathErr *fs.PathError
if errors.As(err, &pathErr) {
    // нашли *fs.PathError в цепочке — можем читать его поля
    fmt.Println("сбойная операция:", pathErr.Op, "путь:", pathErr.Path)
}

Второй аргумент — всегда указатель на переменную нужного типа (&pathErr), куда As положит найденное звено.

errors.Unwrap — шаг по цепочке

errors.Unwrap(err) возвращает следующую (обёрнутую) ошибку или nil, если её нет. На практике вы редко вызываете его вручную — за вас это делают Is/As, обходя цепочку. Кастомный тип ошибки может реализовать Unwrap() error, чтобы встроиться в эту механику.

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

errors.Join(errs...) объединяет несколько ошибок в одну. Это нужно, когда сбоев может быть несколько и важны все — например, при валидации формы или при закрытии нескольких ресурсов:

func validate(u User) error {
    var errs []error
    if u.Name == "" {
        errs = append(errs, errors.New("имя обязательно"))
    }
    if u.Age < 0 {
        errs = append(errs, errors.New("возраст не может быть отрицательным"))
    }
    return errors.Join(errs...) // вернёт nil, если errs пуст
}

Результат Join дружит с errors.Is/errors.As — они проверят каждую вложенную ошибку. Join игнорирует nil-элементы и возвращает nil, если все аргументы nil, — поэтому его удобно вызывать безусловно.

Параллель с .NET: errors.Is ≈ проверка на конкретный экземпляр/тип исключения, но по всей цепочке InnerException. errors.Ascatch (SpecificException e) — поймать определённый тип и получить доступ к его полям, опять же сквозь всю цепочку. errors.JoinAggregateException, который агрегирует несколько исключений (как в Task.WhenAll или Parallel.ForEach); разница в том, что Join — это обычная функция над значениями, а не специальный тип, который надо «разворачивать» через InnerExceptions/Flatten().

Sentinel-ошибки против кастомных типов

Есть два способа сделать ошибку, которую вызывающий сможет распознать. Выбор между ними — частый вопрос на ревью.

Sentinel-ошибки

Sentinel — это заранее объявленная переменная-маркер уровня пакета. Вызывающий сравнивает с ней через errors.Is:

package store

import "errors"

var ErrNotFound = errors.New("запись не найдена")

func (s *Store) Get(id int) (Item, error) {
    item, ok := s.data[id]
    if !ok {
        return Item{}, ErrNotFound // отдаём маркер
    }
    return item, nil
}
// у вызывающего
item, err := store.Get(id)
if errors.Is(err, store.ErrNotFound) {
    // конкретная штатная ветка
}

Sentinel подходит, когда ошибка — это факт без дополнительных данных («не найдено», «доступ запрещён», «достигнут конец»). Канонические примеры из stdlib: io.EOF, sql.ErrNoRows, os.ErrNotExist.

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

Когда ошибка должна нести данные (код, поле, значение), создают тип-структуру с методом Error():

type ValidationError struct {
    Field string
    Value any
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("поле %q: недопустимое значение %v", e.Field, e.Value)
}

Вызывающий извлекает его через errors.As и читает поля:

var vErr *ValidationError
if errors.As(err, &vErr) {
    fmt.Println("проблемное поле:", vErr.Field)
}

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

Критерий Sentinel (var ErrX = errors.New) Кастомный тип (struct + Error())
Несёт ли данные нет, только факт да, любые поля
Как проверяют errors.Is(err, ErrX) errors.As(err, &target)
Когда выбрать ошибка без контекста нужны код/поле/детали для обработки
Аналог в .NET конкретный экземпляр/маркерное исключение свой класс-наследник Exception с полями

Параллель с .NET: кастомный тип ошибки ≈ собственный класс-наследник Exception со своими свойствами, который вы ловите через catch (ValidationException e) и читаете e.Field. Sentinel-ошибка ближе к проверке кода/маркера (как HttpRequestException с конкретным StatusCode, или сравнение с известным экземпляром) — данных нет, важен сам факт «эта ситуация».

panic и recover: не замена ошибкам

В Go есть механизм, внешне похожий на исключения, — panic/recover. Но это не инструмент штатной обработки ошибок, и путать их с try/catch — типичная ошибка новичка из .NET.

panic разворачивает стек, выполняя по пути все отложенные defer, и, если его не перехватить, роняет всю программу. Он предназначен для действительно исключительных, невосстановимых ситуаций: нарушенный инвариант, программная ошибка, повреждённое состояние. Сам рантайм паникует при разыменовании nil, выходе за границы слайса, делении на ноль.

func mustParseTemplate(s string) *template.Template {
    t, err := template.New("").Parse(s)
    if err != nil {
        // шаблон зашит в код: если он не парсится — это баг, программу нет смысла продолжать
        panic(fmt.Sprintf("невалидный встроенный шаблон: %v", err))
    }
    return t
}

recover перехватывает панику — но только внутри defer. Он останавливает разворачивание стека и возвращает значение, переданное в panic. Использовать его уместно на границах: например, в HTTP-middleware, чтобы паника в одном обработчике не уронила весь сервер, а превратилась в ответ 500.

func recoverMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        defer func() {
            if rec := recover(); rec != nil {
                log.Printf("паника в обработчике: %v", rec)
                http.Error(w, "internal server error", http.StatusInternalServerError)
            }
        }()
        next.ServeHTTP(w, r) // если здесь случится panic — recover превратит её в 500
    })
}

Правило простое:

  • panic — для багов и невосстановимых нарушений инвариантов; для Must*-хелперов на этапе инициализации.
  • recover — на границах (middleware, корень горутины), чтобы локализовать сбой.
  • panic/recover как замена возврату error для ожидаемых сбоев (файл не найден, невалидный ввод, сетевой таймаут) — это анти-паттерн.

Паника не пересекает границу горутины: recover в одной горутине не поймает panic из другой. Поэтому каждая долгоживущая фоновая горутина, способная паниковать, должна иметь свой defer recover(), иначе её паника уронит процесс.

defer для очистки

defer откладывает вызов до выхода из функции — и выполняется в любом случае: при обычном return, при возврате ошибки и при panic. Это идиоматичный способ освобождать ресурсы рядом с местом их захвата:

func processFile(name string) error {
    f, err := os.Open(name)
    if err != nil {
        return err
    }
    defer f.Close() // выполнится при любом выходе из функции

    // ... работаем с f; даже при panic или раннем return файл закроется ...
    return nil
}

Несколько defer выполняются в порядке LIFO (последний отложенный — первым). defer также позволяет менять именованный возвращаемый error уже после return — частый приём для обёртывания или для конвертации recover обратно в ошибку.

Параллель с .NET: defer — это гибрид finally и using. Как finally, он гарантированно выполняется при выходе из функции, включая «всплытие» паники; как using/IDisposable, он привязывает освобождение ресурса к месту его получения (defer f.Close()using var f = ...). А пара panic/recover — это «аварийный» аналог throw/catch, но зарезервированный именно для исключительных ситуаций: повседневные ошибки в Go не бросают, а возвращают.

Итог

  • В Go ошибки — это значения: функция возвращает error последним, а вызывающий явно проверяет его через if err != nil. Никакого скрытого всплытия по стеку.
  • error — это интерфейс с единственным методом Error() string; nil означает «ошибки нет».
  • Создание: errors.New (текст) и fmt.Errorf (с данными). Оборачивание глаголом %w сохраняет исходную ошибку и строит цепочку — аналог InnerException.
  • Распаковка: errors.Is (сравнение с sentinel сквозь всю цепочку), errors.As (извлечение конкретного типа и его полей), errors.Unwrap (шаг по цепочке), errors.Join (несколько ошибок в одной, Go 1.20).
  • Sentinel (var ErrX = errors.New) — когда важен лишь факт ошибки; кастомный тип (struct + Error()) — когда ошибка несёт данные.
  • panic/recoverне замена ошибкам, а механизм для невосстановимых ситуаций и границ (recover в middleware). defer гарантирует очистку при любом выходе, включая панику.

Дальше — встроенный инструмент тестирования Go: пакет testing, идиома table-driven тестов, бенчмарки, Example-тесты и фаззинг.


⌂ Главная · ↑ Раздел · ← Предыдущий: Раздел 14 · → Следующий: defer, panic и recover