Идиоматичный 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 королём сетевых сервисов.