В эпоху становления асинхронного программирования JavaScript-разработчики столкнулись с явлением, получившим название "callback-hell" — бесконечной вложенностью функций обратного вызова. Хотя с точки зрения функционального программирования функции являются полноправными гражданами первого класса, принцип "всё хорошо в меру" никто не отменял. Появление Promise и механизма async/await стало спасительным решением этой проблемы.


В мире Go у нас есть более элегантные инструменты — каналы и горутины. Однако совершенству нет предела, и здесь нас поджидает другая ловушка — "error-hell". Новички в Golang часто приходят в недоумение от того, что идиомы языка требуют обработки ошибок буквально на каждом шагу.


Двойственность


У такой педантичности есть неоспоримые преимущества для библиотек общего назначения:


  1. Локальность обработки — ошибку проще обработать в месте её возникновения
  2. Тестируемость — покрытие тестами становится более удобным и предсказуемым

Однако в прикладных программах мы получаем существенное зашумление кода. Передача ошибок вверх по стеку вызовов превращается в "чемодан без ручки" — и тащить тяжело, и выбросить жалко.


Как следствие, в больших проектах на каждом уровне обработки ошибки, по принципу разделения ответственности, может быть добавлена новая запись в лог. В довесок получаем замусоривание логов.


Альтернатива


Что если пересмотреть эту практику? Представим себе мир, где мы логируем ошибки в месте их первоначального появления, а передаём наверх только тогда, когда это действительно необходимо для ветвления логики программы.


Но тут возникает закономерный вопрос: как тестировать такой код? Вместо проверки возвращённой ошибки нам нужен способ убедиться, что логирование действительно произошло.


Структурированное логирование


Благодаря механизму структурированного логирования в Go с помощью Slog, записи в лог приобретают формализованную структуру. Это позволяет задавать и выполнять проверки необходимых значений в тестах.


Библиотека comerc/spylog и её аналоги элегантно решают задачу перехвата вывода в лог для целей тестирования.


Практическое применение


import (
    "log/slog"
    "testing"

    "github.com/stretchr/testify/assert"
    "github.com/stretchr/testify/require"
)

type SomeObject struct {
    log *slog.Logger // определяем инстанс лога для модуля
    val any
}

func NewSomeObject(val any) *SomeObject {
    return &SomeObject{
        log: slog.With("module", "module_name"), // задаём название модуля для логирования
        val: val,
    }
}

func (o *SomeObject) SomeMethod() {
    // при возникновении ошибки, записываем данные в лог 
    o.log.Error("test message from some method", 
    "attr_key1", "attr_val1",
    "attr_key2", "attr_val2",
  ) 
}

func TestSomeMethod(t *testing.T) {
    var o *SomeObject
    logHandler := GetModuleLogHandler("module_name", t.Name(), func() {
        o = NewSomeObject("val") // вызываем функцию-конструктор в обёртке logHandler
    })
    o.SomeMethod() // вызываем тестируемый метод

    slog.Error("test message from default") // другие записи в лог не перехватываются

    require.True(t, len(logHandler.Records) == 1)
    r0 := logHandler.Records[0]

    assert.Equal(t, "test message from some method", r0.Message)
    assert.Equal(t, "attr_val1", GetAttrValue(r0, "attr_key1"))
    assert.Equal(t, "attr_val2", GetAttrValue(r0, "attr_key2"))
}

Заключение


Данный подход освобождает нас от необходимости совершать грех "shadowed error" или явно игнорировать ошибки. Обработка ошибок по месту их возникновения может значительно облегчить разработку на Go, если руководствоваться здравым смыслом и проводить аналогии с решением callback-hell в JavaScript.


Однако важно помнить, что этот подход требует осознанного применения и может не подходить для всех сценариев использования. Ключ к успеху — в разумном балансе между упрощением кода и сохранением контроля над потоком выполнения программы.




Анализ от Claude


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


Сильные стороны


  • Убираем шаблонный if err != nil и необходимость передачи ошибок вверх по стеку
  • Логирование в месте возникновения ошибки может быть очень полезным для отладки
  • Элегантное решение для тестирования через перехват логов

Потенциальные риски


  • Потеря семантики — вызывающий код теряет информацию о том, что операция завершилась неудачно
  • Нарушение контракта — функция может "молча" провалиться, что противоречит принципу явности в Go
  • Неопределенность состояния — функция может завершиться "успешно", но с внутренними ошибками
  • Сложность композиции — труднее строить сложную логику, когда неясно, какие операции успешны
  • Сложность отладки — без явного возврата ошибок труднее отследить цепочку проблем и путь их распространения

Рекомендации по применению


  • Подходит для утилитарных функций, где ошибка не влияет на основной поток
  • Хорошо работает в сценариях "best effort" (например, метрики, аналитика)
  • Не рекомендуется для критически важных операций (работа с БД, файлами, сетью)

Комментарии (11)


  1. bekhruz
    23.05.2025 09:31

    Глазам больно. Выглядит как попытка решить проблему, которой нет.


    1. SergeyEgorov
      23.05.2025 09:31

      Для тех, кто знал и умел Javascript никогда не было проблемой положить комплект функций в массив и выполнить их по одной, одна за другой. "callback-hell"-ом оно казалось новичкам.


  1. Dhwtj
    23.05.2025 09:31

    Идиома в го:

    Проброс ошибки наверх без обработки

    v, err := foo()
    if err != nil { return err}

    Обработка на месте

    if err != nil //{return something (err)}
    return nil, fmt.Errorf("имя функции(%d): %w", параметры функции, err)

    На самом верхнем уровне (main, HTTP-handler, worker-loop) единожды пишем в лог и решаем судьбу процесса.

    if err := run(); err != nil {
       log.Fatalf("fatal: %v", err)
    }

    А у вас вызывающий код не узнает об ошибке; придётся парсить лог (как в тесте). 


  1. ainu
    23.05.2025 09:31

    Лайк за анализ от клауди, кмк должно стать новой нормой.

    По существу: передача ошибок вверх не спасает от зашумления кода, error hell никуда не делся, даже если делать банальные паники на каждую ошибку (что не всегда правильно но читается глазами лучше чем сложная передача вверх), все равно это hell, так что пока не избавились


    1. Nuark
      23.05.2025 09:31

      Лайк за анализ от клауди, кмк должно стать новой нормой.

      Не должно, звучит как бред
      Люди пишут для людей, если кому-то надо - сам пускай проводит анализ статьи/решения
      А так - это такой же мусор, как и ссылка на телегу


  1. panter_dsd
    23.05.2025 09:31

    Какая-то ерунда. Подходит только для бизнеслогики, где всегда есть одно и только одно действие. А если у меня два действия и второе я не могу сделать, если зафейлилось первое? А еще для лога нужно будет всю метаинформацию пробрасывать в самый низ...


  1. NeoCode
    23.05.2025 09:31

    В современных языках, таких как Rust и Zig, обработка ошибок сделана достаточно легковесной и грамотной. Непонятно что мешает добавить её в Go - ИМХО это ни коим образом не нарушит идеологию "максимально простого и прозрачного" языка.

    Специальные алгебраические типы Option и Result (которые можно завернуть в компактный синтаксис опционалов, со знаком вопроса и т.п.), и компактный оператор распаковки - который возвращает значение опционала если ошибки не было, и "пробрасывает" код возврата в вызывающую функцию через штатный return - если ошибка была. Никаких наворотов с классическими исключениями C++/C#/Java... Хотя от этих наворотов кажется всё равно никуда не деться - во всех языках есть "паника" с размоткой стека, есть деление на ноль и прочее, что не укладывается в легковесный механизм.


    1. Dhwtj
      23.05.2025 09:31

      Ну да

      Если без обработки

      • Rust: оператор '?' на каждом уровне

      • C#: throw и ловить где-то на самом верху

      • Go: if err != nil { return err} на каждом уровне

      Это нормально©

      А для деления на нуль в kotlin есть отметка throwable или что-то похожее


      1. NeoCode
        23.05.2025 09:31

        Писать оператор "?" на каждом уровне проще чем явно проверять коды возврата в Go. Но если хочется совсем уж неявно - то можно придумать такой синтаксис: если функция возвращает Option или Result, и мы в ней разыменовываем объект Option/Result в котором невалидные данные - делать неявный return этого объекта. Явность теряется (никаких знаков вопроса и т.п.), но зато и никакой лишней писанины.


    1. NickNill
      23.05.2025 09:31

      Там на гитхабе до сих пор обсуждают обработку ошибок, были неплохие идеи. Может когда-то добавят. Go тоже современный язык. А вообще нет никакой проблемы. Есть проблема что я не знаю какие ошибки обработать


      1. allishappy
        23.05.2025 09:31

        Последний обсуждаемый issue был как раз про ? - https://github.com/golang/go/issues/71203

        После долгих и бурных обсуждений issue ожидаемо закрыли с пометкой «not planned»