Обработка ошибок как философия

Обработка ошибок как философия

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

Основы error

Интерфейс error

error - это обычный интерфейс, а не магия:

type error interface {
    Error() string
}

// Использование
func divide(a, b float64) (float64, error) {
    if b == 0 {
        return 0, errors.New("деление на ноль")
    }
    return a / b, nil
}

// Проверка
result, err := divide(10, 0)
if err != nil {
    log.Fatal(err) // обработка ошибки
}
Error всегда последнее возвращаемое значение. Если функция может fail, она должна возвращать error.

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

errors.New

import "errors"

var ErrNotFound = errors.New("not found")
var ErrInvalidInput = errors.New("invalid input")

func findUser(id int) (*User, error) {
    if id <= 0 {
        return nil, ErrInvalidInput
    }
    // поиск пользователя...
    return nil, ErrNotFound
}

fmt.Errorf

func validateAge(age int) error {
    if age < 0 {
        return fmt.Errorf("возраст не может быть отрицательным: %d", age)
    }
    if age > 150 {
        return fmt.Errorf("невероятный возраст: %d", age)
    }
    return nil
}

Обертывание ошибок (Go 1.13+)

func readConfig(filename string) (*Config, error) {
    data, err := os.ReadFile(filename)
    if err != nil {
        return nil, fmt.Errorf("не удалось прочитать конфиг %s: %w", filename, err)
    }

    var config Config
    if err := json.Unmarshal(data, &config); err != nil {
        return nil, fmt.Errorf("не удалось распарсить конфиг: %w", err)
    }

    return &config, nil
}

// Проверка обернутых ошибок
if err := readConfig("app.json"); err != nil {
    if errors.Is(err, os.ErrNotExist) {
        log.Println("Конфиг не найден, используем дефолтный")
    } else {
        log.Fatal(err)
    }
}
Используйте %w в fmt.Errorf для обертывания ошибок. Это сохраняет цепочку ошибок для errors.Is и errors.As.

Цепочка обёрнутых ошибок: errors.Is проходит её через Unwrap до sentinel

errors.Is проходит цепочку слева направо через Unwrap() - от верхнего слоя до причины. errors.As находит в цепи первое значение нужного типа и присваивает его.

Проверка ошибок

errors.Is

// Проверка на конкретную ошибку
if errors.Is(err, os.ErrNotExist) {
    // файл не существует
}

// Работает через всю цепочку обернутых ошибок
err1 := errors.New("original")
err2 := fmt.Errorf("wrapped: %w", err1)
err3 := fmt.Errorf("double wrapped: %w", err2)

fmt.Println(errors.Is(err3, err1)) // true

errors.As

// Своя ошибка с дополнительными данными
type ValidationError struct {
    Field string
    Value interface{}
}

func (e *ValidationError) Error() string {
    return fmt.Sprintf("validation failed for %s: %v", e.Field, e.Value)
}

// Проверка типа ошибки
var validErr *ValidationError
if errors.As(err, &validErr) {
    fmt.Printf("Поле %s невалидно\n", validErr.Field)
}

Паттерны обработки

Early return

В примере ниже - связка early-return и defer (отложенное закрытие ресурса):

func processFile(filename string) error {
    file, err := os.Open(filename)
    if err != nil {
        return fmt.Errorf("открытие файла: %w", err)
    }
    defer file.Close()

    data, err := io.ReadAll(file)
    if err != nil {
        return fmt.Errorf("чтение файла: %w", err)
    }

    if err := processData(data); err != nil {
        return fmt.Errorf("обработка данных: %w", err)
    }

    return nil
}

Группировка проверок

// Когда много похожих проверок
func createUser(name, email string, age int) (*User, error) {
    if err := validateName(name); err != nil {
        return nil, err
    }

    if err := validateEmail(email); err != nil {
        return nil, err
    }

    if err := validateAge(age); err != nil {
        return nil, err
    }

    return &User{name, email, age}, nil
}

Helper для fatal ошибок

func must(err error) {
    if err != nil {
        log.Fatal(err)
    }
}

// Использование в main или init
func main() {
    config, err := loadConfig()
    must(err)

    db, err := connectDB(config.DatabaseURL)
    must(err)
    defer db.Close()
}

Создание своих типов ошибок

С дополнительным контекстом

type HTTPError struct {
    Code    int
    Message string
    Details map[string]interface{}
}

func (e *HTTPError) Error() string {
    return fmt.Sprintf("HTTP %d: %s", e.Code, e.Message)
}

func (e *HTTPError) StatusCode() int {
    return e.Code
}

// Использование
func handleRequest(w http.ResponseWriter, r *http.Request) {
    user, err := getUser(r.Context(), userID)
    if err != nil {
        var httpErr *HTTPError
        if errors.As(err, &httpErr) {
            http.Error(w, httpErr.Error(), httpErr.StatusCode())
        } else {
            http.Error(w, "Internal Server Error", 500)
        }
        return
    }
}

Множественные ошибки

type MultiError struct {
    errors []error
}

func (m *MultiError) Error() string {
    var msgs []string
    for _, err := range m.errors {
        msgs = append(msgs, err.Error())
    }
    return strings.Join(msgs, "; ")
}

func (m *MultiError) Add(err error) {
    if err != nil {
        m.errors = append(m.errors, err)
    }
}

func (m *MultiError) HasErrors() bool {
    return len(m.errors) > 0
}

Sentinel errors

// Определяем глобальные ошибки для сравнения
var (
    ErrUserNotFound    = errors.New("user not found")
    ErrInvalidPassword = errors.New("invalid password")
    ErrTokenExpired    = errors.New("token expired")
)

func authenticate(username, password string) error {
    user, err := findUser(username)
    if err != nil {
        return err
    }

    if !checkPassword(user, password) {
        return ErrInvalidPassword
    }

    return nil
}

// Использование
if err := authenticate("alice", "wrongpass"); err != nil {
    switch {
    case errors.Is(err, ErrUserNotFound):
        // обработка
    case errors.Is(err, ErrInvalidPassword):
        // обработка
    default:
        // неизвестная ошибка
    }
}

Best practices

<ComparisonTable data={{ headers: ["Практика", "Плохо", "Хорошо"], rows: [ ["Игнорирование", "result, _ := doSomething()", "result, err := doSomething()\nif err != nil { ... }"], ["Контекст", "return err", "return fmt.Errorf("processing user %d: %w", userID, err)"], ["Sentinel errors", "errors.New("not found") каждый раз", "var ErrNotFound = errors.New("not found")"], ["Паника", "panic(err) // в библиотеке", "return err // паника только для невосстановимых"], ["Логирование", "log.Println(err); return err", "return err // логируем на верхнем уровне"] ] }} />

Когда использовать panic

// Panic уместен для невосстановимых ошибок программирования
func MustCompile(pattern string) *Regexp {
    re, err := Compile(pattern)
    if err != nil {
        panic(err) // программист передал невалидный паттерн
    }
    return re
}

// В init функциях
func init() {
    template = template.Must(template.ParseFiles("template.html"))
}

// Но НЕ для обычных runtime ошибок!

Обработка panic

func safeFunction() (err error) {
    defer func() {
        if r := recover(); r != nil {
            err = fmt.Errorf("panic recovered: %v", r)
        }
    }()

    // код, который может паниковать
    riskyOperation()

    return nil
}

Практический пример: API клиент

type APIError struct {
    StatusCode int
    Message    string
    RequestID  string
}

func (e *APIError) Error() string {
    return fmt.Sprintf("API error %d: %s (request: %s)",
        e.StatusCode, e.Message, e.RequestID)
}

func (e *APIError) Temporary() bool {
    return e.StatusCode == 429 || e.StatusCode >= 500
}

type Client struct {
    baseURL    string
    httpClient *http.Client
}

func (c *Client) GetUser(ctx context.Context, id string) (*User, error) {
    url := fmt.Sprintf("%s/users/%s", c.baseURL, id)

    req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
    if err != nil {
        return nil, fmt.Errorf("создание запроса: %w", err)
    }

    resp, err := c.httpClient.Do(req)
    if err != nil {
        return nil, fmt.Errorf("выполнение запроса: %w", err)
    }
    defer resp.Body.Close()

    if resp.StatusCode != http.StatusOK {
        return nil, &APIError{
            StatusCode: resp.StatusCode,
            Message:    resp.Status,
            RequestID:  resp.Header.Get("X-Request-ID"),
        }
    }

    var user User
    if err := json.NewDecoder(resp.Body).Decode(&user); err != nil {
        return nil, fmt.Errorf("декодирование ответа: %w", err)
    }

    return &user, nil
}

Итоги

  • Ошибки в Go явные, не исключения
  • Всегда обрабатывайте ошибки
  • Добавляйте контекст при обертывании
  • Используйте errors.Is и errors.As
  • panic только для невосстановимых ситуаций

В следующем уроке погрузимся в конкурентность - горутины!

Типичная ошибка

Игнорировать ошибку через _ - «и так работает». Работает... пока не прод. Всегда проверяй err != nil.

Зарегистрируйтесь бесплатно, чтобы пройти квиз, решить задание с автопроверкой и вести прогресс.