Go Идиоматичный Go: обработка ошибок, паттерны и стиль
0%

Идиоматичный Go: обработка ошибок, паттерны и стиль

Идиоматичный Go и работа с ошибками

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

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

В Go нет исключений. Есть panic, но это не механизм обработки ожидаемых ошибок (о нём — в конце). Обычные сбои — файл не найден, сеть отвалилась, валидация не прошла — выражаются через возврат значения типа error.

error — это встроенный интерфейс с единственным методом:

type error interface {
	Error() string
}

Идиома проверки — самая узнаваемая конструкция Go:

data, err := os.ReadFile("config.yaml")
if err != nil {
	return fmt.Errorf("чтение конфига: %w", err)
}
// здесь err гарантированно nil, работаем с data

Да, этих if err != nil будет много. Это осознанный компромисс: поток обработки ошибок виден глазами прямо в коде, а не спрятан в невидимых точках выброса исключений. Вы физически не можете «случайно» проигнорировать сбой — компилятор и линтер errcheck этого не позволят. Рефлексию на тему «почему так» отлично изложил Роб Пайк в Errors are values.

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

import "errors"

// Простая ошибка-строка
err := errors.New("соединение закрыто")

// С форматированием
err := fmt.Errorf("пользователь %d не найден", id)

// Сентинел — заранее объявленная ошибка для сравнения
var ErrNotFound = errors.New("не найдено")

// Собственный тип ошибки — когда нужны структурированные данные
type ValidationError struct {
	Field string
	Msg   string
}

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

Три инструмента на три ситуации: errors.New/fmt.Errorf для разовых ошибок, сентинелы (var ErrX = errors.New(...)) когда вызывающий должен уметь распознать конкретный случай, и типы ошибок когда нужно передать данные (какое поле невалидно, какой код HTTP).

Wrapping: главный навык работы с ошибками

Ошибка «файл не найден» без контекста бесполезна: какой файл, в какой операции? Поэтому по мере всплытия ошибки вверх по стеку её оборачивают, добавляя контекст. Глагол %w в fmt.Errorf создаёт цепочку:

func loadConfig(path string) (*Config, error) {
	data, err := os.ReadFile(path)
	if err != nil {
		return nil, fmt.Errorf("loadConfig: чтение %s: %w", path, err)
	}
	var cfg Config
	if err := yaml.Unmarshal(data, &cfg); err != nil {
		return nil, fmt.Errorf("loadConfig: парсинг %s: %w", path, err)
	}
	return &cfg, nil
}

%w (в отличие от %v) сохраняет исходную ошибку внутри новой, образуя цепочку. В итоге вызывающий видит осмысленное сообщение вроде loadConfig: чтение /etc/app.yaml: open /etc/app.yaml: no such file or directory — и при этом может программно добраться до корневой причины.

errors.Is и errors.As — разбор цепочки

Раз ошибки обёрнуты, сравнивать их через == нельзя — сентинел спрятан внутри. Для этого есть две функции:

// errors.Is — проверка, есть ли в цепочке конкретный сентинел
if errors.Is(err, ErrNotFound) {
	// где-то в глубине была ErrNotFound, даже если её обернули 5 раз
	return http.StatusNotFound
}

// errors.As — извлечь из цепочки ошибку нужного ТИПА
var valErr *ValidationError
if errors.As(err, &valErr) {
	// добрались до *ValidationError, доступны valErr.Field и valErr.Msg
	log.Printf("невалидное поле: %s", valErr.Field)
}

Золотое правило: никогда не сравнивайте ошибки через err == ErrX и не проверяйте err.Error() == "текст" — это ломается при первом же оборачивании. Всегда errors.Is / errors.As. Линтер errorlint следит за этим.

Когда оборачивать, а когда нет

  • Оборачивайте с %w, когда вызывающий может захотеть распознать причину (Is/As).
  • Используйте %v вместо %w, если хотите намеренно скрыть внутреннюю ошибку от вызывающего (не делать её частью контракта).
  • Не оборачивайте одну и ту же ошибку дважды на соседних уровнях — получите шум вроде a: b: c: d: real error. Добавляйте контекст там, где он реально новый.
  • Не начинайте текст ошибки с заглавной буквы и не ставьте точку/перенос строки (golint/revive следят): ошибки склеиваются в цепочку, Failed to X. посреди строки выглядит уродливо.

Паттерн functional options

В Go нет аргументов по умолчанию и перегрузки. Когда у конструктора много необязательных параметров, идиома — функциональные опции. Её популяризировал Дэйв Чейни в статье Functional options for friendly APIs.

type Server struct {
	addr    string
	timeout time.Duration
	tls     bool
}

type Option func(*Server)

func WithTimeout(d time.Duration) Option {
	return func(s *Server) { s.timeout = d }
}

func WithTLS() Option {
	return func(s *Server) { s.tls = true }
}

func NewServer(addr string, opts ...Option) *Server {
	s := &Server{addr: addr, timeout: 30 * time.Second} // разумные умолчания
	for _, opt := range opts {
		opt(s)
	}
	return s
}

// Использование — читаемо и расширяемо без ломки сигнатуры
srv := NewServer("0.0.0.0:8080", WithTimeout(5*time.Second), WithTLS())

Плюс: добавление новой опции не ломает существующие вызовы. Не тащите этот паттерн всюду — для двух-трёх обязательных полей достаточно обычного конструктора или структуры-конфига.

defer, panic и recover

defer откладывает вызов до выхода из функции (LIFO-порядок). Основное применение — освобождение ресурсов рядом с их захватом:

mu.Lock()
defer mu.Unlock()   // разблокируется при любом выходе

f, _ := os.Open(path)
defer f.Close()

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

recover перехватывает панику внутри defer. Легитимных применений мало: не дать одному упавшему HTTP-запросу уронить весь сервер (middleware-recover), либо на границе горутины. Восстанавливаться и «глотать» панику как обычную ошибку — плохо.

func safeHandler(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 error", http.StatusInternalServerError)
			}
		}()
		next.ServeHTTP(w, r)
	})
}

Мнемоника от команды Go: panic для программиста, error для пользователя.

Тонкость с defer и возвращаемыми значениями

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

func writeFile(path string, data []byte) (err error) {  // именованный err
	f, err := os.Create(path)
	if err != nil {
		return err
	}
	defer func() {
		// ошибка Close важна для записи — не теряем её
		if cerr := f.Close(); cerr != nil && err == nil {
			err = cerr
		}
	}()
	_, err = f.Write(data)
	return err
}

Ещё одна ловушка: аргументы defer вычисляются в момент объявления, а не выполнения. defer fmt.Println(i) захватит текущее i, а не финальное. Это частый источник сюрпризов в циклах.

Идиомы, которые отличают опытного гофера

  • Ранний возврат вместо вложенности. Проверили ошибку — вышли. Счастливый путь остаётся слева, без «лесенки» else.
    // Так — идиоматично
    if err != nil {
    	return err
    }
    doHappyPath()
    
  • Accept interfaces, return structs. Принимайте io.Reader, а не *os.File; возвращайте конкретный тип.
  • Интерфейсы объявляет потребитель, а не поставщик. Определяйте интерфейс там, где его используете, минимальным (один-два метода). Не создавайте «интерфейс на каждую структуру заранее».
  • Нулевое значение должно быть полезным. Спроектируйте типы так, чтобы var b bytes.Buffer уже работал.
  • context.Context — первый аргумент функций, делающих I/O: func Do(ctx context.Context, ...).
  • Не паникуйте в библиотеках. Возвращайте ошибку.
  • Обрабатывайте ошибку один раз. Либо логируете, либо возвращаете наверх — но не то и другое (иначе одна ошибка залогируется пять раз на разных уровнях).

Антипаттерны, за которые бьют на ревью

  • Игнорирование ошибок через _:
    data, _ := os.ReadFile(path) // почти всегда баг; errcheck это ловит
    
  • Пустой интерфейс interface{} (any) как замена типизации. Иногда нужен, но чаще это лень. Дженерики или конкретные типы лучше.
  • Типизированный nil в error (разбирали в основах): возвращайте буквальный nil, а не var e *MyErr; return e.
  • Пакеты utils, common, helpers, base — свалка без смысла. Имя пакета должно говорить, что внутри (http, parser, retry).
  • Слишком крупные интерфейсы. io.Reader с одним методом — эталон; интерфейс на 15 методов — код-смелл.
  • Геттеры вида GetName(). В Go принято Name() (без Get), а сеттер — SetName().
  • Стрингли-типизированные ошибки: if err.Error() == "not found". Только errors.Is/As.

Code style: где записаны правила

  • Effective Go — базовый свод идиом от авторов языка.
  • Go Code Review Comments — официальный чек-лист того, что придирают на ревью в самом проекте Go. Читается за 20 минут, экономит годы.
  • Uber Go Style Guide — практичнейший корпоративный гайд с примерами «плохо/хорошо». Многие команды берут его за основу.
  • Блог Dave Cheney — глубокие разборы про ошибки, API-дизайн, производительность.
  • Книга «100 Go Mistakes and How to Avoid Them» (Teiva Harsanyi) — систематический каталог ловушек.

Главное про стиль: gofmt и golangci-lint уже автоматизируют бо́льшую часть. Ваша задача — правильно называть вещи, держать функции короткими, а обработку ошибок — честной.

Что дальше

Мы научились писать чистый последовательный код. Теперь — то, ради чего многие выбирают Go: конкурентность. Горутины, каналы и паттерны, которые делают Go королём сетевых сервисов.

04. Конкурентность

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов