Содержание урока · 11 разделов
- Контекстный логгер go: Named
- With: поля, которые повторять не хочется
- Стек контекста: слои от сервиса до операции
- Named, With, InitialFields: что выбрать
- Дочерние логгеры дёшевы
- Конвенция: один логгер на модуль
- Логгер через структуру: DI против глобального
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Алерт приводит вас в логи в 2 часа ночи. Записи двух видов: «ошибка списания» и «ошибка списания» — поле error разное, всё остальное одинаковое. Grep по тексту выдаёт триста строк за сутки из billing, payments и отчётного воркера, и отличить их друг от друга нечем: ни имени модуля, ни контекста. Корень проблемы старый: логгер один на всё приложение, а модулей много, и каждый пихает в записи то, что вспомнил. Лечение — контекст: логгеру сообщают, кто он и что у него вокруг, один раз при создании, и дальше каждая запись уносит это с собой. В zap для этого два метода: With(fields...) добавляет поля, Named(name) добавляет имя. Оба возвращают новый логгер, оба копеечны по ресурсам, и оба меняют стиль проекта сильнее, чем кажется: пропадают повторяющиеся поля, появляются модули, а передача логгера в зависимости становится осмысленной. Дальше в уроке: механика обоих методов, цена потомков, конвенция одного логгера на модуль и вопрос «глобальный логгер или поле структуры», который в каждом втором ревью вызывает спор.
Контекстный логгер go: Named#
logger.Named("billing") возвращает логгер, у которого имя «billing» попадает в каждую запись — в JSON его пишет ключ из EncoderConfig: NameKey, у production-энкодера это «logger». Имя — это иерархия через точку: Named у именованного логгера добавляет подскоп, billing.Named("repo") даст «billing.repo». Если логгер ещё не имел имени, первый Named становится корнем; два Named подряд складываются, не перезаписывают. Связка zap With Named — это то, что в англоязычных источниках зовут child loggers: контекст накапливается слоями, и каждый слой добавляет только своё. Зачем имя, если есть поле? Фильтрация в системах сбора: в Loki и Kibana выборка по имени логгера — операция первого класса, а поле service вам всё равно придётся ставить отдельным With, потому что имя логгера и имя сервиса — вещи разные: сервис один, модулей внутри много. На практике работают так: корневой логгер собирается при старте из конфига, и каждый пакет при инициализации создаёт себе именованного потомка — auth, billing, api, worker. В записи это выглядит как поле «logger» со значением «billing.repo» — и ночные расследования перестают быть археологией.
Named: логгер с именем модуля
GOlogger, _ := zap.NewProduction()
defer logger.Sync()
base := logger.Named("billing")
repo := base.Named("repo")
base.Info("charge started", zap.Int("amount", 990))
repo.Warn("slow query", zap.Duration("took", 480*time.Millisecond))Две записи с полем logger: первая — «billing», вторая — «billing.repo». Создание потомков ничего не стоит: имя хранится строкой в контексте логгера. В консольном формате дев-энкодера имя выводится колонкой, в JSON — ключом NameKey; фильтруйте в системе сбора по нему как по обычному полю. Запуск примера — локально, как и весь курс.
With: поля, которые повторять не хочется#
logger.With(fields...) — вторая половина контекста: возвращает логгер, который на каждую запись приклеивает переданные поля. Экономия видна сразу: вместо logger.Info("charge started", zap.String("service", "billing"), zap.String("module", "repo")) в каждом вызове — один раз base := logger.With(zap.String("service", "billing"), zap.String("module", "repo")) и дальше короткие Info и Warn. Но главное не удобство, а корректность: повторяющиеся поля, набитые руками в каждом вызове, рано или поздно расходятся — в одном месте забыли version, в другом опечатались в ключе, и одинаковые события перестали группироваться в системе сбора. With решает это структурно: контекст задаётся в одном месте. Иммутабельность при этом полная: With не меняет исходный логгер, а возвращает нового потомка; поля потомка не просачиваются к родителю и наоборот. Наследование накопительное: потомок получает всё от родителя плюс своё — с этим мы уже сталкивались в уроке про SugaredLogger, у которого есть собственные With и Named с парами ключ-значение вместо конструкторов полей.
With: контекст запроса без повторов
GOtype Handler struct {
log *zap.Logger
db *sql.DB
}
func (h *Handler) handleOrder(order *Order) {
log := h.log.With(
zap.String("order_id", order.ID),
zap.String("user_id", order.UserID),
)
log.Info("order accepted")
if err := h.db.Save(order); err != nil {
log.Error("order save failed", zap.Error(err))
return
}
log.Info("order saved")
}Логгер контекстуализируется один раз в начале обработки — обе записи и ошибка несут order_id и user_id без повторов в каждом вызове. zap.Error внутри if добавляет ошибку поверх контекста: поля складываются в порядке добавления. Именно этот приём станет основой per-request логгера в уроке про request ID.
Стек контекста: слои от сервиса до операции#
Контекст накапливается слоями, и полезно видеть типовую схему целиком. Первый слой — приложение: метки app, version, env из InitialFields или опции zap.Fields живут в каждой записи с первого байта. Второй — модуль: Named-потомок при инициализации пакета, он же корень для всего его кода. Третий — операция: With-потомок в обработчике запроса, в обработчике сообщения из очереди, в вызове одного отчёта; у него общий идентификатор операции и, возможно, идентификатор пользователя. Четвёртый — точечные поля в самих вызовах Info: то, что относится к конкретной строке. Слои не мешают друг другу, потому что складываются: имя от Named сохранится через все With, поля операции дополнят метки приложения, а точечные поля лягут последними. Ошибка начинается тогда, когда слой пропускают: операцию логируют от корневого логгера — и записи теряются в общем потоке; или, наоборот, приложение пихает в каждый вызов поля второго слоя — и код обрастает повторами. Дисциплина слоёв — и есть та выгода, ради которой дешёвые потомки вообще придуманы.
Named, With, InitialFields: что выбрать#
| Инструмент | Что добавляет | Когда уместен |
|---|---|---|
| logger.Named("m") | имя логгера в поле logger | границы модулей и пакетов |
| logger.With(fields...) | типизированные поля каждой записи | общий контекст: сервис, запрос, пользователь |
| cfg.InitialFields | поля карты any всем записям | метки уровня приложения: app, version, env |
| zap.Fields опцией | поля при сборке логгера | то же, но без карты any |
Дочерние логгеры дёшевы#
На этом месте у бдительного читателя вспыхивает рефлекс: создание объекта на каждый запрос — дорого? В zap нет: With и Named не открывают файлов, не берут замков и не копируют энкодеры. Потомок — это копия маленькой структуры логгера: указатель на общее ядро плюс слайс полей контекста, который растёт на ваши несколько полей. Всё тяжёлое — ядро, энкодер, приёмники, сэмплер — общее и создаётся один раз. Поэтому здесь разрешено то, что в других библиотеках запрещено: заводить логгера на каждый HTTP-запрос, на каждую горутину-обработчик, на каждое сообщение из очереди. Что действительно дорого — создавать ЛОГГЕРОВ заново: zap.NewProduction или Build открывают приёмники и строят ядро; в двух местах программы — два набора открытых файлов и рассинхрон настроек. Правило простое: ядро и корневой логгер — один раз при старте, всё дальше — With и Named от него. С этим согласуется и Sync: вызов у потомка пробрасывается в общее ядро, поэтому дожидаться закрытия каждого дочернего логгера не нужно — достаточно корневого.
Проверка: что станет с исходным логгером
base := logger.With(zap.String("module", "billing")). Что произойдёт с logger после этого?
Конвенция: один логгер на модуль#
Дешевизна потомков подсказывает организацию, которая работает в проектах любого размера. Корневой логгер создаётся один раз при старте — из конфига, с метками приложения из урока про zap.Config. Каждый модуль при инициализации берёт себе именованного потомка и хранит его: пакет billing живёт с logger.Named("billing"), внутренние слои уточняют — Named("billing").Named("repo"). Дальше два варианта, и оба честные. Пакетная переменная: var log = базовый.Named(...) — удобно, но глобально: в тестах подменить труднее, а порядок инициализации пакетов становится незримой зависимостью. Структурный подход: логгер — поле структуры, которое прокладывается при конструировании, — стоит дороже в набивке кода, но продаёт себя в тестах и многомодульных сервисах. Третьего не дано: создавать логгер внутри каждой функции — потеря контекста и путаница с конфигурацией. Спорное мнение, к которому я пришёл: пакетная переменная допустима в прикладном коде конечного приложения, но библиотеку, которую читают чужие проекты, логгер обязан принимать параметром — навязывать свой logger чужому приложению невежливо.
Логгер через структуру: DI против глобального#
Передача логгера через поле структуры — это dependency injection в его простейшей форме, и у неё три практических плюса. Первый — явность: Handler с полем log *zap.Logger видно в сигнатуре конструктора, зависимости не прячутся в глобальных переменных. Второй — тесты: в конструктор подсовывается наблюдатель из zaptest, и тест читает записи обработчика, не трогая stdout; тестируемость логов — тема отдельного урока курса. Третий — изоляция конфигурации: структура хранит контекстуализированного потомка, и никто внутри не знает про глобальные состояния. Глобальный zap.L — законная альтернатива для кода, куда логгер не протянешь: глубокие легаси-слои, сторонние обёртки, отладочный вывод в библиотечных хелперах; ReplaceGlobals делает его безопаснее, но глобальность остаётся глобальностью: тесты, параллельность и скрытые зависимости никуда не деваются. Мой рабочий баланс: прикладной код — поле структуры, межпакетные границы — Named-потомки, глобальный логгер — аварийный люк, а не архитектура.
Прокладка логгера через конструктор
GOfunc NewBillingService(root *zap.Logger, db *sql.DB) *BillingService {
return &BillingService{
log: root.Named("billing"),
db: db,
}
}
func (s *BillingService) ProcessOrder(o *Order) {
log := s.log.With(zap.String("order_id", o.ID))
log.Info("processing")
// ...
}Корневой логгер приходит в конструктор извне, сервис берёт себе именованного потомка и хранит его в поле. Внутри методы контекстуализируют его под конкретную операцию — With на order_id живёт только в рамках ProcessOrder. Тест подставит вместо root своего наблюдателя и прочитает записи сервиса.
Какое имя окажется в записи?
Прочитайте код и выберите, что он выведет:
base := logger.Named("api")
auth := base.Named("auth")
log := auth.With(zap.String("user", "u42"))
log.Info("token refreshed")Проверка: логгер модуля
Корневой логгер собран при старте. Как правильно получить логгер модуля billing внутри пакета?
sugar.With("service", "billing") прицепляет пары ключ-значение без конструкторов полей, sugar.Named("billing") даёт имя. Обе операции возвращают нового потомка и не мутируют обёртку. Если модуль живёт на сахаре из урока про SugaredLogger — контекстуйте его, не десугаря ради одного With.
Соберите контекстный логгер
Восстановите цепочку: модуль, контекст запроса, запись.
Соберите работу с дочерними логгерами
Расставьте строки: от корневого логгера до записей запроса.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: контекст логгера
Что дальше по курсу#
Следующий уровень контекста — сквозной: request ID в логах порождает дочерний логгер на каждый HTTP-запрос, и приём With из этого урока превращается в middleware по образцу middleware Gin. Ошибки внутри модулей стоит прикладывать целиком, со стеком при необходимости, — этому посвящён урок про zap.Error и стек-трейсы. Карта курса — на странице курса.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- With прицепляет поля, Named даёт имя: оба возвращают нового потомка, не трогая исходный логгер.
- Имена склеиваются через точку: billing.repo; имя пишется в поле из NameKey — у прода это logger.
- Дочерний логгер — копия структуры с общим ядром: дешевле заводить на каждый запрос, чем повторять поля.
- Конвенция: корневой логгер один при старте, у модуля — свой Named-потомок, у операции — свой With.
- Логгер прокладывается полем структуры: явные зависимости, подмена в тестах; глобальный zap.L — аварийный люк.
- Повторяющиеся поля в каждом вызове — источник расползания ключей: контекст задаётся один раз.
- SugaredLogger имеет собственные With и Named — сахар и контекст не конфликтуют.
Домашнее задание
Соберите локально приложение из трёх структур — Service, Repository, Notifier — с полем log *zap.Logger у каждой, прокладываемым из корневого логгера через Named с именами billing, billing.repo и billing.notifier. Добавьте метод-обработчик, который создаёт With-потомка с полем request_id и пишет две записи. Проверьте по JSON-выводу, что поле logger принимает три значения, а request_id есть только в записях обработчика.
Личные заметкиПусто
Вопросы и ответы по уроку
With или Named — что использовать?
Это разные оси: Named отвечает на вопрос «кто пишет» и задаётся раз на модуль, With отвечает на вопрос «в каком контексте» и задаётся на операцию или запрос. В реальном коде они соседствуют: потомок billing.Named(...).With(...).
Действительно ли можно создавать логгер на каждый запрос?
Да: With и Named копируют маленькую структуру и добавляют слайс полей, ни файлов, ни замков, ни энкодеров не трогая. Дорогое — создание нового логгера через zap.New или Build: оно открывает приёмники и строит ядро, его делают один раз.
Почему не глобальный zap.L везде?
Глобальность скрывает зависимость, мешает параллельным тестам и фиксирует конфигурацию до старта тестов. Для кода, куда логгер не протянуть, zap.L с ReplaceGlobals существует, но в прикладном коде поле структуры и Named-потомки дают тот же результат с явной передачей.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Colly: парсинг и веб-скрапинг на Go
Интерактивный курс Colly — фреймворка веб-скрапинга для Go: селекторы, пагинация, формы, прокси, лимиты, кэш, хранение данных. 25 уроков и итоговый проект.
- Писать скраперы на Colly от первого Visit до обхода целого сайта
- Вынимать данные через CSS-селекторы и складывать в структуры Go
- Настраивать темп: задержки, параллелизм, лимиты на домен
- Работать с капризными сайтами: заголовки, cookies, прокси