Урок 5: Обработка ошибок — продвинутые паттерны

Урок 5. Обработка ошибок — продвинутые паттерны

🔄 Node.js → Go (ключевые отличия):

📋 Что изучаем

📦 Инициализация проекта

mkdir go-errors && cd go-errors
go mod init go-errors

💻 Код программы

package main

import (
    "context"
    "database/sql"
    "errors"
    "fmt"
    "io"
    "os"
    "time"
)

// ╔══════════════════════════════════════════════════════════╗
// ║  1. SENTINEL ERRORS (сторожевые ошибки)                ║
// ╚══════════════════════════════════════════════════════════╝

// Sentinel errors — предопределённые ошибки уровня пакета.
// Их можно сравнивать через errors.Is().
// По соглашению: ErrXxx или errXxx (если не экспортируются).
var (
    ErrNotFound       = errors.New("resource not found")
    ErrValidation     = errors.New("validation failed")
    ErrTimeout        = errors.New("operation timed out")
    ErrUnauthorized   = errors.New("unauthorized")
    ErrInternalServer = errors.New("internal server error")
)

// ╔══════════════════════════════════════════════════════════╗
// ║  2. КАСТОМНЫЕ ТИПЫ ОШИБОК                              ║
// ╚══════════════════════════════════════════════════════════╝

// ValidationError — ошибка валидации с контекстом.
// Реализует интерфейс error (метод Error() string).
type ValidationError struct {
    Field   string // Какое поле не прошло валидацию
    Value   any    // Какое значение получено
    Message string // Человекочитаемое сообщение
}

// Error() — реализация интерфейса error.
func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation error on field %q (got %v): %s",
        e.Field, e.Value, e.Message)
}

// Дополнительный метод для извлечения поля (через errors.As)
func (e *ValidationError) FieldName() string {
    return e.Field
}

// QueryError — ошибка с кодом (пример для БД/API)
type QueryError struct {
    Query  string
    Code   int
    Cause  error // исходная ошибка
}

func (e *QueryError) Error() string {
    return fmt.Sprintf("query %q failed with code %d: %v",
        e.Query, e.Code, e.Cause)
}

// Unwrap — метод для цепочки ошибок. Без него errors.Is/As не видят Cause.
func (e *QueryError) Unwrap() error {
    return e.Cause
}

// TemporaryError — временная ошибка (можно повторить)
type TemporaryError struct {
    Operation string
    RetryAfter time.Duration
}

func (e *TemporaryError) Error() string {
    return fmt.Sprintf("temporary error in %s, retry after %s",
        e.Operation, e.RetryAfter)
}

// Temporary() bool — кастомный метод для проверки (можно использовать с errors.As)
func (e *TemporaryError) Temporary() bool {
    return true
}

// ╔══════════════════════════════════════════════════════════╗
// ║  3. СЕРВИСНЫЙ СЛОЙ (демонстрация оборачивания)         ║
// ╚══════════════════════════════════════════════════════════╝

// Repository — имитация слоя данных
type Repository struct{}

func (r *Repository) FindUser(ctx context.Context, id string) (string, error) {
    switch id {
    case "":
        return "", &ValidationError{
            Field: "id", Value: id, Message: "must not be empty",
        }
    case "404":
        // Возвращаем sentinel error
        return "", ErrNotFound
    case "timeout":
        // Оборачиваем sentinel error с дополнительным контекстом
        return "", fmt.Errorf("FindUser timeout for id=%s: %w", id, ErrTimeout)
    case "db-error":
        // Имитация ошибки БД
        return "", &QueryError{
            Query: "SELECT * FROM users WHERE id = $1",
            Code:  500,
            Cause: sql.ErrNoRows, // оборачиваем реальную ошибку
        }
    case "temp":
        return "", &TemporaryError{
            Operation: "FindUser", RetryAfter: 5 * time.Second,
        }
    default:
        return "user_" + id, nil
    }
}

// Service — бизнес-логика (оборачивает ошибки репозитория)
type Service struct {
    repo *Repository
}

func (s *Service) GetUser(ctx context.Context, id string) (string, error) {
    user, err := s.repo.FindUser(ctx, id)
    if err != nil {
        // Добавляем контекст к ошибке, НЕ теряя исходную (через %w)
        return "", fmt.Errorf("GetUser(id=%s): %w", id, err)
    }
    return user, nil
}

// ╔══════════════════════════════════════════════════════════╗
// ║  4. ОБРАБОТЧИК ОШИБОК (error handler)                  ║
// ╚══════════════════════════════════════════════════════════╝

// HandleError — демонстрирует ВСЕ способы проверки ошибок
func HandleError(err error) {
    if err == nil {
        fmt.Println("✅ Успех!")
        return
    }

    fmt.Printf("❌ Ошибка: %v\n", err)
    fmt.Printf("   Подробно: %+v\n", err)

    // === 4.1 errors.Is — проверка sentinel errors и цепочки ===
    // Проходит по всей цепочке Unwrap(), ищет target.
    if errors.Is(err, ErrNotFound) {
        fmt.Println("   → Ресурс не найден (sentinel)")
    }
    if errors.Is(err, ErrTimeout) {
        fmt.Println("   → Таймаут (sentinel, даже через обёртку)")
    }
    if errors.Is(err, ErrValidation) {
        fmt.Println("   → Ошибка валидации (sentinel)")
    }
    if errors.Is(err, sql.ErrNoRows) {
        fmt.Println("   → sql.ErrNoRows (даже через QueryError!)")
    }

    // === 4.2 errors.As — извлечение кастомного типа ===
    // Извлекает ПЕРВЫЙ подходящий тип из цепочки.
    var valErr *ValidationError
    if errors.As(err, &valErr) {
        fmt.Printf("   → ValidationError: поле=%q значение=%v\n",
            valErr.FieldName(), valErr.Value)
    }

    var queryErr *QueryError
    if errors.As(err, &queryErr) {
        fmt.Printf("   → QueryError: запрос=%q код=%d\n",
            queryErr.Query, queryErr.Code)
    }

    var tempErr *TemporaryError
    if errors.As(err, &tempErr) {
        fmt.Printf("   → TemporaryError: операция=%s повтор через=%s\n",
            tempErr.Operation, tempErr.RetryAfter)
    }

    fmt.Println()
}

// ╔══════════════════════════════════════════════════════════╗
// ║  5. ERRORS.JOIN — объединение ошибок (Go 1.20+)        ║
// ╚══════════════════════════════════════════════════════════╝

func validateMultiple(fields map[string]string) error {
    var errs []error // слайс ошибок

    for field, value := range fields {
        if value == "" {
            errs = append(errs, &ValidationError{
                Field: field, Value: value, Message: "must not be empty",
            })
        }
    }

    if len(errs) == 0 {
        return nil
    }

    // errors.Join объединяет несколько ошибок в одну.
    // errors.Is проверяет КАЖДУЮ.
    return errors.Join(errs...)
}

// ╔══════════════════════════════════════════════════════════╗
// ║  6. DEFER + RECOVER — ловля паник                       ║
// ╚══════════════════════════════════════════════════════════╝

// ВАЖНО: Паника ≠ Ошибка. Паника — это критический сбой (как throw в JS).
// Не используй panic/recover для обычной обработки ошибок!

func riskyOperation(willPanic bool) (err error) {
    // defer + recover — аналог try-catch, но ТОЛЬКО для паник
    defer func() {
        if r := recover(); r != nil {
            // Преобразуем панику в ошибку
            err = fmt.Errorf("recovered from panic: %v", r)
        }
    }()

    if willPanic {
        panic("что-то пошло не так!") // аналог throw
    }

    return nil
}

// ╔══════════════════════════════════════════════════════════╗
// ║  7. BEST PRACTICES: ОБОРАЧИВАНИЕ ОШИБОК                ║
// ╚══════════════════════════════════════════════════════════╝

// WrapWithContext — паттерн: добавляем контекст, сохраняя исходную ошибку
func WrapWithContext(op string, err error) error {
    return fmt.Errorf("%s: %w", op, err)
}

// IsTemporary — хелпер для проверки временных ошибок
func IsTemporary(err error) bool {
    var tempErr *TemporaryError
    return errors.As(err, &tempErr)
}

// ╔══════════════════════════════════════════════════════════╗
// ║  MAIN                                                    ║
// ╚══════════════════════════════════════════════════════════╝

func main() {
    fmt.Println("╔══════════════════════════════════════════╗")
    fmt.Println("║   ОБРАБОТКА ОШИБОК — ПРОДВИНУТЫЕ ПАТТЕРНЫ ║")
    fmt.Println("╚══════════════════════════════════════════╝")

    repo := &Repository{}
    svc := &Service{repo: repo}
    ctx := context.Background()

    // ==========================================
    // 1. Тестируем разные сценарии ошибок
    // ==========================================
    fmt.Println("── 1. СЦЕНАРИИ ОШИБОК ──\n")

    testCases := []struct {
        name string
        id   string
    }{
        {"Успешный запрос", "123"},
        {"Пустой ID (ValidationError)", ""},
        {"Не найден (sentinel)", "404"},
        {"Таймаут (wrapped sentinel)", "timeout"},
        {"Ошибка БД (QueryError)", "db-error"},
        {"Временная ошибка (TemporaryError)", "temp"},
    }

    for _, tc := range testCases {
        fmt.Printf(">>> %s (id=%q)\n", tc.name, tc.id)
        user, err := svc.GetUser(ctx, tc.id)
        if err == nil {
            fmt.Printf("   Пользователь: %s\n\n", user)
        } else {
            HandleError(err)
        }
    }

    // ==========================================
    // 2. errors.Join
    // ==========================================
    fmt.Println("── 2. ERRORS.JOIN ──")

    fields := map[string]string{
        "name":  "",
        "email": "",
        "age":   "25",
    }
    err := validateMultiple(fields)
    if err != nil {
        fmt.Printf("Объединённая ошибка:\n%v\n", err)
        // Проверка: содержит ли объединённая ошибка ValidationError?
        var valErr *ValidationError
        if errors.As(err, &valErr) {
            fmt.Printf("  Первая ValidationError: поле=%q\n", valErr.FieldName())
        }
        // Сколько всего ошибок?
        if unwrapped := errors.Unwrap(err); unwrapped != nil {
            fmt.Printf("  Unwrap первой: %v\n", unwrapped)
        }
    }
    fmt.Println()

    // ==========================================
    // 3. Panic + Recover
    // ==========================================
    fmt.Println("── 3. PANIC + RECOVER ──")

    err = riskyOperation(false)
    fmt.Printf("Без паники: err=%v\n", err)

    err = riskyOperation(true)
    fmt.Printf("С паникой: err=%v\n", err)

    // ==========================================
    // 4. Паттерны оборачивания
    // ==========================================
    fmt.Println("\n── 4. ОБОРАЧИВАНИЕ ──")

    // Создаём цепочку ошибок
    baseErr := io.EOF
    wrapped1 := fmt.Errorf("reading config: %w", baseErr)
    wrapped2 := fmt.Errorf("initialization: %w", wrapped1)
    wrapped3 := fmt.Errorf("server startup: %w", wrapped2)

    fmt.Printf("Цепочка: %v\n", wrapped3)
    fmt.Printf("  errors.Is(baseErr, io.EOF) = %v\n", errors.Is(wrapped3, io.EOF))
    fmt.Printf("  errors.Is(baseErr, ErrNotFound) = %v\n", errors.Is(wrapped3, ErrNotFound))

    // ==========================================
    // 5. ПРОВЕРКА ВРЕМЕННОЙ ОШИБКИ
    // ==========================================
    fmt.Println("\n── 5. ВРЕМЕННЫЕ ОШИБКИ ──")

    tempErr := &TemporaryError{Operation: "connect", RetryAfter: time.Second}
    fmt.Printf("tempErr: %v\n", tempErr)
    fmt.Printf("IsTemporary(tempErr) = %v\n", IsTemporary(tempErr))
    fmt.Printf("IsTemporary(ErrNotFound) = %v\n", IsTemporary(ErrNotFound))
}

🧠 Поток обработки ошибок (production-паттерн)

Handler (HTTP/gRPC) │ ├─ Ошибка? → логируем, маппим на статус-код │ Service (бизнес-логика) │ ├─ Ошибка из Repository → оборачиваем: fmt.Errorf(“GetUser: %w”, err) │ Repository (доступ к данным) │ ├─ Ошибка драйвера → оборачиваем: fmt.Errorf(“FindUser: %w”, err) │ Driver (pgx, mongo, etc.) │ └─ Исходная ошибка

КЛИЕНТ (HTTP-ответ) │ ├─ errors.Is(err, ErrNotFound) → 404 ├─ errors.Is(err, ErrValidation) → 400 ├─ errors.Is(err, ErrTimeout) → 504 ├─ errors.As(err, &valErr) → 422 с деталями └─ остальное → 500

📊 Сравнение методов проверки ошибок

МетодНазначениеПример
err == ErrXxxПрямое сравнение (НЕ для обёрнутых!)if err == ErrNotFound {}
errors.Is(err, target)Проверка по цепочке Unwrap()errors.Is(err, io.EOF)
errors.As(err, &target)Извлечение типа из цепочкиerrors.As(err, &valErr)
errors.Unwrap(err)Извлечение вложенной ошибкиcause := errors.Unwrap(err)
errors.Join(errs...)Объединение нескольких ошибокerrors.Join(err1, err2)

🚀 Запуск программы

# Запуск
go run main.go

# Сборка
go build -o errors-demo main.go
./errors-demo
⚠️ Типичные ошибки:
💡 Практический совет:

💡 Best practices от сеньоров:

🔑 Ключевые концепции

Что нужно запомнить из этого урока:

💡 Для Node.js разработчика:

← Предыдущий урок Следующий урок →