Ошибки, defer и логирование в Go
В Go ошибки не прячутся в исключениях. Функция явно возвращает error, а вызывающий код явно решает, что делать дальше. На первых шагах это кажется многословным: после почти каждой операции появляется if err != nil. Но в этом есть сильная идея: программа постоянно показывает, где именно может случиться проблема.
Хороший Go-код не делает вид, что ошибок нет. Он проверяет ошибки, добавляет к ним контекст, освобождает ресурсы через defer и пишет логи так, чтобы через неделю можно было понять, что произошло.
Ошибка как обычное значение
В Go error - это интерфейс:
type error interface { Error() string }
То есть ошибка - обычное значение, у которого есть текстовое описание. Поэтому функция может вернуть результат и ошибку:
file, err := os.Open("data.txt") if err != nil { return err } defer file.Close()
Такой код читается сверху вниз. Сначала мы пытаемся открыть файл. Если не получилось, возвращаем ошибку. Если получилось, продолжаем работу.
Возвращать, логировать или завершать программу
Не каждая ошибка должна обрабатываться одинаково.
Если вы пишете функцию внутри библиотеки или сервиса, чаще всего ошибку нужно вернуть выше:
func readConfig(path string) ([]byte, error) { data, err := os.ReadFile(path) if err != nil { return nil, err } return data, nil }
Если вы находитесь в main маленькой CLI-программы и дальше работать бессмысленно, можно вывести ошибку и завершить процесс:
func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } }
Такой стиль удобен: вся программа возвращает ошибки наверх, а решение о завершении находится в одном месте.
Добавляйте контекст
Плохая ошибка:
return err
Она говорит только "что-то случилось", но не говорит, где и при каких данных.
Лучше:
return fmt.Errorf("open config %q: %w", path, err)
Теперь сообщение может выглядеть так:
open config "config.yaml": no such file or directory
Это уже полезно. Видно действие, путь к файлу и исходную причину.
%w важен: он оборачивает исходную ошибку так, что ее можно проверить позже:
if errors.Is(err, os.ErrNotExist) { fmt.Println("файл не найден") }
Без %w вы сохраните только текст и потеряете возможность надежно распознать тип ошибки.
Цепочка ошибок
Представьте, что ошибка идет наверх через несколько уровней:
start server: load config: open config "config.yaml": no such file or directory
Каждый уровень добавил свой контекст. Это похоже на маршрут доставки: если посылка потерялась, полезно знать не только конечный факт, но и на каком участке пути это произошло.
Пример:
func startServer() error { cfg, err := loadConfig("config.yaml") if err != nil { return fmt.Errorf("load config: %w", err) } return runHTTPServer(cfg) }
Не нужно писать длинные художественные сообщения. Хорошая ошибка короткая и отвечает на два вопроса: что пытались сделать и с какими данными.
defer
defer откладывает выполнение функции до выхода из текущей функции:
file, err := os.Open(path) if err != nil { return err } defer file.Close()
Это удобно для ресурсов, которые нужно закрыть независимо от того, где функция завершится:
- файл;
- HTTP response body;
- lock;
- транзакция базы данных;
- временный файл;
- сетевое соединение.
Пример с mutex:
mu.Lock() defer mu.Unlock() items = append(items, item)
Даже если позже в функции появится return, lock будет освобожден.
Когда defer выполняется
defer выполняется в обратном порядке:
defer fmt.Println("first") defer fmt.Println("second") defer fmt.Println("third")
Вывод:
third second first
Это похоже на стопку тарелок: что положили последним, то сняли первым. Обычно это удобно, потому что ресурсы освобождаются в обратном порядке их получения.
Ошибка при Close
Иногда Close тоже может вернуть ошибку. Для чтения файла этим часто можно пренебречь, но для записи важно убедиться, что данные действительно сброшены.
func writeFile(path string, data []byte) error { file, err := os.Create(path) if err != nil { return fmt.Errorf("create file: %w", err) } _, writeErr := file.Write(data) closeErr := file.Close() if writeErr != nil { return fmt.Errorf("write file: %w", writeErr) } if closeErr != nil { return fmt.Errorf("close file: %w", closeErr) } return nil }
Не нужно усложнять каждый учебный пример, но полезно знать: закрытие ресурса не всегда формальность.
panic и recover
panic - это аварийная остановка текущего пути выполнения. Она запускает отложенные defer, поднимается вверх по стеку и может завершить программу.
Для обычных ошибок используйте error, не panic.
Плохой стиль:
if err != nil { panic(err) }
Нормальный стиль:
if err != nil { return fmt.Errorf("read input: %w", err) }
panic уместна, когда программа попала в состояние, которое не должно быть возможно: например, нарушен внутренний инвариант или отсутствует обязательная dependency при старте приложения.
recover используют редко. Частый случай - граница HTTP-сервера, чтобы один запрос не уронил весь процесс:
func recoverMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { defer func() { if value := recover(); value != nil { slog.Error("panic recovered", "value", value) http.Error(w, "internal error", http.StatusInternalServerError) } }() next.ServeHTTP(w, r) }) }
Логирование
Логи - это память программы. Когда пользователь говорит "у меня не работает", часто у вас нет debugger, нет его компьютера и нет возможности повторить ситуацию. Есть только логи.
Для маленьких программ достаточно пакета log:
log.Printf("processed file=%s lines=%d", path, count)
Для сервисов удобнее структурированные логи через log/slog:
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) logger.Info("request handled", "method", r.Method, "path", r.URL.Path, "status", 200, "duration_ms", 34, )
Структурированные логи легко фильтровать: можно найти все запросы со status=500, все события конкретного пользователя или все ошибки за последние десять минут.
Что писать в лог
Полезный лог содержит:
- действие;
- важные входные данные;
- результат;
- длительность, если операция может быть медленной;
- идентификатор запроса или пользователя, если он есть;
- ошибку с контекстом.
Плохой лог:
error happened
Хороший лог:
slog.Error("failed to send invoice", "invoice_id", invoiceID, "user_id", userID, "err", err, )
Не логируйте пароли, токены, номера карт и другие секреты. Логи часто попадают в сторонние системы, где доступ шире, чем к production-базе.
Ошибки в CLI
CLI-инструменты должны писать результат в stdout, а ошибки - в stderr:
fmt.Fprintln(os.Stderr, "error: file not found") os.Exit(1)
Это позволяет использовать пайпы:
mytool input.txt > result.txt
Если ошибка попадет в stdout, она может случайно оказаться в файле результата.
Частые ошибки новичков
- Игнорировать ошибку через
_. - Логировать ошибку на каждом уровне и возвращать ее дальше.
- Терять контекст:
return errв глубоком коде. - Использовать
panicдля обычной валидации. - Забывать
defer resp.Body.Close()после HTTP-запроса. - Писать в лог секреты.
- Возвращать пользователю внутренний текст ошибки базы данных.
Чеклист
- Проверяйте каждую ошибку, которую возвращает функция.
- Добавляйте контекст через
fmt.Errorf. - Используйте
%w, если исходная ошибка важна. - Проверяйте ошибки через
errors.Isиerrors.As, а не через сравнение текста. - Освобождайте ресурсы через
defer. - Используйте
panicтолько для действительно невозможных состояний. - Для сервисов выбирайте структурированные логи.
- Не логируйте одну и ту же ошибку на каждом уровне.
- Пишите ошибки CLI в
stderr. - Не раскрывайте пользователю внутренние детали production-системы.