Go не признаёт сложных развесистых фреймворков — и это правильно. Любой инструмент должен быть настолько очевидным, чтобы разработчики сразу видели пользу от его применения.
И если вы сталкивались на больших legacy-проектах с хаосом в обработке и логировании ошибок — когда одна и та же ошибка дублируется в логах на каждом уровне, когда непонятно где обрабатывать ошибку, а где передавать дальше, когда тесты превращаются в кромешный ад из-за необходимости мокать каждый возможный путь ошибки — тогда возможно пригодится то, что я хочу предложить.
Проблема error-hell
Go славится своей прямолинейностью в обработке ошибок (при этом отпугивая новичков). Но в реальных приложениях такая простота оборачивается избыточностью, которая часто засоряет код и лишь усложняет сопровождение.
Представьте: вы пишете обработку списка элементов в цикле. Один или несколько элементов не удалось обработать — что делать? По канонам Go нужно накопить ошибки в цикле и вернуть их вверх. Но зачем, если вы хотите всего лишь продолжить обработку остальных элементов? Ошибка не влияет на логику программы, но заставляет писать лишний код передачи ошибок и дублировать логирование на каждом уровне.
Или горутины: как элегантно обработать ошибку в фоновой задаче? Канал ошибок? Контекст? А если ошибка не критична и нужна только для отладки?
А ещё игра в испорченный телефон, как следствие от повторных обработок ошибок: return fmt.Errorf("level1: %w", err)
, return fmt.Errorf("level2: %w", err)
, return fmt.Errorf("level3: %w", err)
. В итоге получаем многословные цепочки, которые дублируют путь ошибки вверх по стеку.
И главное — возврат ошибок служит двум целям: управление ветвлением программы и обеспечение тестируемости. Но что если эти цели можно разделить? Структурированное логирование с инструментами вроде comerc/spylog решает проблему тестирования без необходимости возвращать ошибки вверх по стеку.
Решение: минимальная надстройка в соглашениях, которая сохраняет дух Go — "явное лучше скрытого" и "обрабатывай ошибки по месту", но убирает механическую избыточность.
Принципы
- Обрабатывай ошибку по месту — если ошибка не влияет на ветвление программы
- Передавай ошибку вверх — только если нужно изменить ход выполнения программы
- Не дублируй логи — либо логируй ошибку, либо передавай её вверх
Паттерны
✅ Обработка по месту (не влияет на ветвление)
// В циклах
for _, item := range items {
if err := processItem(item); err != nil {
log.Error("failed to process item", "item", item.ID, "error", err)
continue // продолжаем работу
}
}
// В горутинах
go func() {
if err := backgroundTask(); err != nil {
log.Error("background task failed", "error", err)
// не возвращаем ошибку - обработали по месту
}
}()
✅ Передача вверх (влияет на ветвление)
func validateUser(id string) error {
user, err := db.GetUser(id)
if err != nil {
return err // передаём как есть, без обёртки
}
if user.Status != "active" {
return ErrUserInactive // возвращаем типизированную ошибку
}
if user.Info == nil {
return fmt.Errorf("user.Info is nil: %w", &ErrUserInfo{data: "data"})
// уточняем ошибку - добавляем data
}
return nil
}
// Использование
func handleRequest(userID string) {
if err := validateUser(userID); err != nil {
if errors.Is(err, ErrUserInactive) {
// специальная обработка
}
var userInfoErr *ErrUserInfo
if errors.As(err, &userInfoErr) {
// обработка для ErrUserInfo
}
log.Error("validation failed", "error", err)
// логируем ошибку, только когда не передаём выше по стеку
return
}
}
❌ Избегаем обёртывания без цели
// Плохо - создаём информационный шум
func getConfig() (*Config, error) {
data, err := os.ReadFile("config.json")
if err != nil {
return nil, fmt.Errorf("failed to read config: %w", err) // лишняя обёртка
}
// ...
}
// Хорошо - передаём как есть
func getConfig() (*Config, error) {
data, err := os.ReadFile("config.json")
if err != nil {
return nil, err // передаём оригинальную ошибку
}
// ...
}
Типизированные ошибки для ветвления
var (
ErrUserNotFound = errors.New("user not found")
ErrUserInactive = errors.New("user inactive")
ErrInvalidInput = errors.New("invalid input")
)
// Проверка типа ошибки
if errors.Is(err, ErrUserNotFound) {
// специальная обработка
}
Структурированные логи вместо возврата ошибок
// Вместо возврата ошибки из горутины
func processInBackground(items []Item) {
for _, item := range items {
if err := process(item); err != nil {
log.Error("item processing failed",
"item_id", item.ID,
"error", err,
"retry_count", item.RetryCount)
// продолжаем обработку других элементов
}
}
}
Правило принятия решения
Спроси себя: "Изменит ли эта ошибка ход выполнения программы?"
- Да → передай ошибку вверх, типизируя по необходимости
- Нет → залогируй и продолжай выполнение
Это соглашение сохраняет простоту Go и убирает избыточность без введения сложных абстракций.
Тестирование с применением структурированных логов
Зачем spylog?
Когда мы обрабатываем ошибки по месту через логирование, возникает вопрос: как тестировать такой код? Вместо проверки возвращённой ошибки нам нужен способ убедиться, что логирование действительно произошло с правильными данными.
Библиотека comerc/spylog перехватывает записи в лог для конкретного модуля в рамках теста, позволяя проверить:
- Случилось ли логирование
- Правильное ли сообщение
- Корректные ли атрибуты (включая тип ошибки)
Пример с spylog
// Код модуля
type UserService struct {
log *slog.Logger
}
func NewUserService() *UserService {
return &UserService{
log: slog.With("module", "user_service"),
}
}
func (s *UserService) ProcessUsers(users []User) {
for _, user := range users {
if err := s.validateUser(user); err != nil {
// Логируем ошибку по месту, не возвращаем вверх
s.log.Error("user validation failed",
"user_id", user.ID,
"email", user.Email,
"error", err,
)
continue // продолжаем обработку других пользователей
}
// обрабатываем валидного пользователя
}
}
// Тест
func TestProcessUsers(t *testing.T) {
var service *UserService
logHandler := spylog.GetModuleLogHandler("user_service", t.Name(), func() {
service = NewUserService()
})
users := []User{
{ID: "1", Email: "valid@example.com"},
{ID: "2", Email: "invalid-email"}, // невалидный
}
service.ProcessUsers(users)
// Проверяем что залогирована одна ошибка
require.Len(t, logHandler.Records, 1)
record := logHandler.Records[0]
assert.Equal(t, "user validation failed", record.Message)
assert.Equal(t, "2", spylog.GetAttrValue(record, "user_id"))
assert.Equal(t, "invalid-email", spylog.GetAttrValue(record, "email"))
assert.Equal(t, ErrInvalidEmail, errors.Is(spylog.GetAttrValue(record, "error"))
}
Преимущества подхода
- Разделение ответственности: ошибки для ветвления vs ошибки для отладки
- Простота тестирования: проверяем логи вместо возвращаемых ошибок
- Меньше шаблонного кода: не нужно передавать ошибки вверх по стеку
P.S. проблема тестирования асинхронного кода так же решается с применением этого подхода через другую надстройку — synctest-experiment.
Комментарии (4)
RunSoFun
26.05.2025 19:35func validateUser(id string) error { ... userInfo, err := db.GetUserInfo(id) if err != nil { return errors.As(err, &ErrUserInfo) // уточняем ошибку } ... }
Ошибка будет, так как
errors.As(err, &ErrUserInfo)
возвращаетtrue|false
.
blind_oracle
Жалко что пропозал с улучшением обработки ошибок не прошёл.
Механизм хотя бы как в Расте уже был бы большим шагом вперёд...
comerc Автор
Любитесь как хотите с тем что есть. Если я правильно понимаю дзен Go.