Содержание урока · 12 разделов
- zap.L() и zap.S(): что лежит в глобале
- Зачем глобальный логгер go-коду: три честных случая
- Опасность первая: порядок инициализации
- Опасность вторая: тесты
- Что будет, если ReplaceGlobals вызвать дважды
- Паттерн: настраиваем в main, отдаём явно
- Чек-лист: включать ли глобал
- restore: сигнатура и альтернативы
- Что дальше по курсу
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
История с одним и тем же началом: в большой проект подключена библиотека аналитики, из её глубины должны идти записи, но в системе сбора от неё пусто. Код библиотеки безупречен — он пишет через zap.L(), глобальный логгер пакета zap. Ни один тест не падает: вызовы проходят, записи просто никуда не попадают. Причина в том, что глобал по умолчанию — заглушка, а zap.ReplaceGlobals в main никто не вызвал, потому что о договорённости никто не знал. Глобальный логгер go-программы — скрытая зависимость уровня процесса: он либо настроен до первого вызова, либо молча съедает телеметрию. Разберём, как работает пара zap.L() и ReplaceGlobals, где глобал действительно нужен и почему зрелые проекты настраивают его один раз в main, а дальше передают логгер явно через конструкторы.
zap.L() и zap.S(): что лежит в глобале#
В пакете zap живут две глобальные переменные: L для zap.Logger и S для zap.SugaredLogger. До первого вызова ReplaceGlobals обе указывают на заглушки: записи уходят в никуда, Sync возвращает nil, паники нет — код работает и молчит. zap.ReplaceGlobals(logger) меняет обе переменные разом и возвращает функцию restore, которая восстанавливает прежние значения. Подмена безопасна для читателей: внутри глобала мьютекс, data race не возникнет — опасность в другом, в порядке вызовов, и об этом ниже. Читать глобал дёшево: zap.L() снимает блокировку на чтение и отдаёт текущее значение. Полезно понимать механику: zap.L() каждый раз возвращает актуального логгера, а вот переменная, в которую вы скопировали zap.L() однажды, остаётся старым логгером навсегда — на этом копировании спотыкаются чаще, чем на самой подмене.
Минимальная настройка в main
GOfunc main() {
logger, err := zap.NewProduction()
if err != nil {
panic(err)
}
defer logger.Sync()
restore := zap.ReplaceGlobals(logger)
defer restore()
srv := server.New(":8080", zap.L().Named("server"))
if err := srv.ListenAndServe(); err != nil {
zap.L().Fatal("server stopped", zap.Error(err))
}
}ReplaceGlobals вызывается сразу после сборки: с этого момента zap.L() и zap.S() во всех пакетах пишут в production-конфиг. restore вешается на defer — не столько для этого процесса, сколько для тестов и CLI-подкоманд, где глобал меняют и возвращают. Сервер при этом получает своего Named-потомка явно: глобал здесь страховка для аварийных путей, а не основной канал зависимостей. zap.L().Named("server") читает глобал в момент вызова: сервер фиксирует потомка раз и навсегда, последующие подмены на него не влияют — копия zap.L() в поле структуры замораживает значение, о чём говорит predict ниже.
Зачем глобальный логгер go-коду: три честных случая#
Случай первый — утилита в main-пакете: пять файлов, два флага, DI-цепочка длиной в одну функцию; глобал здесь дешевле контекста, и это нормально. Случай второй — граница с чужим кодом: библиотека пишет через zap.L(), и её сигнатуры вы поменять не можете; тогда пара zap ReplaceGlobals и zap.L() — единственный шов, куда записи библиотеки вообще попадут. Случай третий — аварийные пути, до которых контекст не доезжает: хендлер паники в горутине, обработчик сигналов, финализатор процесса. Во всех трёх глобал — запасной выход, а не архитектура. Признак здорового отношения к глобалу: основной поток приложения передаёт логгер явно — конструкторам, структурам, контексту, — а zap.L() остаётся страховкой, на которую смотрят при разборе падений.
Проверка: что в глобале до настройки
zap.ReplaceGlobals ещё не вызывался. Что вернёт zap.L() и что будет с записью Info?
Опасность первая: порядок инициализации#
Опасность глобала — не data race: подмена защищена мьютексом, и race detector ругаться не будет. Опасность — в порядке вызовов. init-функции пакетов выполняются до main, и если библиотека пишет в zap.L() из init, на тот момент глобал ещё заглушка. Если ReplaceGlobals зовут в init другого пакета, порядок определяет граф импортов — нечитаемая зависимость, которая меняется от одной перестановки импорта в файле. Горутины, стартовавшие до подмены — фоновая очистка, прогрев кеша, запущенный из инициализатора, — тоже пишут в заглушку, и их записи теряются без следа. Диагноз ставится по одному симптому: записи есть у одних пакетов и нет у других, а перезапуск не меняет ничего. Лечение всегда одно: глобал настраивается в main до запуска горутин, а не в глубине импортов.
Ловушка init-функции
GO// package analytics — библиотечный код
package analytics
import "go.uber.org/zap"
func init() {
// Выполнится ДО main: глобал ещё заглушка.
zap.L().Info("analytics initialized") // запись потеряна
}Инициализаторы исполняются до main в порядке зависимостей импортов, поэтому ReplaceGlobals из main ещё не был вызван. Запись уходит в no-op без ошибок и предупреждений. Правило для библиотек: не писать в глобал из init вовсе — телеметрия старта библиотека отдаёт при явном вызове конструктора, куда хост передаёт настроенный логгер.
No-op глобал не ломает ни один тест: вызовы дёшевы, паник нет, формат вывода не важен. Сломанная телеметрия обнаруживается через недели, когда лог нужен для разбора, а его нет. Если проект обязан использовать глобал, добавьте тест, который проверяет, что zap.L() уже боевой к моменту первого пакета, — одна проверка экономит час разбора.
Опасность вторая: тесты#
Глобал — состояние всего процесса, и тесты делят его между собой. Заменили глобал в одном тесте и не вернули — следующий тест пишет в чужого наблюдателя или молчит. С t.Parallel() картина грустнее: параллельные тесты дерутся за один глобал, и результат зависит от планировщика, то есть флакает. Дисциплина простая: zap.ReplaceGlobals возвращает restore, и defer restore() — обязательная пара к каждому вызову в тесте; а параллельный тест, которому нужен глобал, — повод переписать его на явную передачу логгера, как делает observer из урока про тестирование логов. Прогон таких тестов — обычный go test на локальном Go, никакой обвязки не нужно.
Тест с restore
GOfunc TestGlobalConfigured(t *testing.T) {
core, logs := observer.New(zapcore.DebugLevel)
restore := zap.ReplaceGlobals(zap.New(core))
defer restore()
doWork() // внутри — zap.L().Warn("degraded")
if logs.FilterMessageSnippet("degraded").Len() == 0 {
t.Fatal("expected a warning from doWork")
}
}Пара ReplaceGlobals и defer restore() в тесте нерушима: как только тест закончится, глобал вернётся к прежнему значению, и соседние тесты ничего не заметят. Проверки те же, что и для обычного логгера: уровень, сообщение, поля. Единственное отличие — код под тест берёт логгера из zap.L(), поэтому наблюдатель попадает внутрь через глобал.
Что будет, если ReplaceGlobals вызвать дважды#
Второй вызов не запрещён и не паникует: он подменяет текущее значение и возвращает restore, возвращающий уже его. Пары вызовов складываются в стек, откаты применяются в обратном порядке — это удобно в CLI с подкомандами: корень настроил глобал, подкоманда подменила логгера своим Named-потомком и вернула всё defer'ом. Две подмены без отката — уже запах дизайна: второй restore вернёт первого логгера, а не заглушку, и цепочка подмен растёт с каждым пакетом, решившим «подстроить глобал под себя». Правило из чек-листа ниже — один вызов на процесс — существует именно потому, что механика позволяет больше.
Что выведет каждая из двух записей?
Прочитайте код и выберите, что он выведет:
early := zap.L()
logger, _ := zap.NewProduction()
_ = zap.ReplaceGlobals(logger)
early.Info("one")
zap.L().Info("two")Паттерн: настраиваем в main, отдаём явно#
Здоровый проект выглядит так: main собирает конфиг и логгер — пресет или Build из урока про конфиг, — затем делает ReplaceGlobals один раз и дальше передаёт логгера по рукам: конструкторам сервера, репозиториев, посредников. Дети строятся через With и Named; иерархию имён разбирает урок про With и Named. Глобал при такой схеме остаётся для трёх случаев из начала урока. В тестах же логгер вообще не глобален: код под тест принимает *zap.Logger, и наблюдатель входит через конструктор. Спорное мнение в защиту глобала: в маленьких CLI он экономит больше, чем стоит, — я не люблю глобальные состояния, но жертвовать архитектурой ради пятисот строк утилиты не готов, и zap.L() с ReplaceGlobals в main — разумный компромисс с условием: как только файл растёт, зависимость уходит в конструкторы.
Чек-лист: включать ли глобал#
- main-пакет или CLI-утилита без слоёв — можно, настройка в начале main.
- Библиотека — никогда: принимает *zap.Logger или logr.Logger параметром.
- Один вызов ReplaceGlobals на процесс: больше — повод пересмотреть дизайн.
- Каждый вызов в тестах парный: ReplaceGlobals и defer restore().
- t.Parallel и глобал несовместимы: уберите параллельность или глобал.
- Горутины стартуют только после ReplaceGlobals — иначе их записи потеряны.
restore: сигнатура и альтернативы#
restore — это func() без аргументов и результата: она возвращает те глобальные значения, которые были до подмены, включая заглушку, если глобал ещё не настраивали. Подмена идёт парами — L и S меняются одновременно, рассинхрона не бывает. Если цель была не в подмене логгера, а в смене уровня, глобал трогать не нужно: атомарный уровень из урока про динамические уровни меняет порог на живом процессе без перевязки зависимостей. Подмена нужна там, где меняется само ядро: тест ставит наблюдателя, CI-скрипт перевключает файловый вывод. Правило разделения: уровень — через AtomicLevel, ядро — через ReplaceGlobals, и путать их не стоит.
Проверка: что возвращает ReplaceGlobals
Что возвращает zap.ReplaceGlobals(logger)?
Библиотечный код, который вызывает zap.ReplaceGlobals, ломает хост-приложение: подменяет чужой логгер, чужой конфиг, чужой формат. Если библиотеке нужен логгер, она принимает его параметром; если параметра нет и правка невозможна, остаётся задокументированный zap.L() — но подмену делает хост, не библиотека.
Соберите настройку глобала
Восстановите вызовы: подмена, возврат, чтение глобала.
Соберите порядок настройки
Расставьте строки main по порядку: от сборки логгера до старта сервера.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: глобальный логгер
Что дальше по курсу#
Соседняя проблема — код, который не должен зависеть даже от факта наличия zap, — решается интерфейсом: мост logr и zapr разбирается в следующем уроке, и там подмена в тестах становится ещё проще. Дисциплина тестов глобала опирается на observer из урока про zaptest и observer. Карта всего курса — на странице курса zap; дальше по плану — эксплуатация и итоговый проект, где глобал, наконец, заменяется явной передачей.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- До ReplaceGlobals zap.L() — no-op: вызовы безопасны, записи исчезают.
- ReplaceGlobals меняет L и S разом и возвращает restore для отката.
- Опасность — порядок, а не data race: init и горутины до подмены пишут в заглушку.
- В тестах ReplaceGlobals парный к defer restore(), с t.Parallel() глобал не дружит.
- Библиотека глобал не трогает: принимает логгер параметром.
- Паттерн зрелого проекта: настроили в main один раз, дальше передаём явно.
- Уровень меняет AtomicLevel, ядро — ReplaceGlobals: разные инструменты.
Домашнее задание
Добавьте в маленький проект глобальный логгер: сборка через пресет, один вызов ReplaceGlobals в main с defer restore(). Напишите тест с observer, который проверяет запись из zap.L(), и убедитесь, что без restore() соседний тест ломается. Затем переведите один пакет с zap.L() на явную передачу логгера через конструктор и сравните, где проверки стали проще. После этого соберите predict-сценарий урока в тест: переменная, снятая до подмены, и zap.L() после — обе пишут Info, и одна запись теряется.
Личные заметкиПусто
Вопросы и ответы по уроку
Насколько безопасна подмена глобала из нескольких горутин?
Внутри zap мьютекс — data race не будет, и это часто вводит в соблазн. Безопасна такая подмена по смыслу только на старте или в тестах: на живом трафике она меняет поведение всех пакетов разом, а это операция уровня main, не рантайма. Чужие горутины о подмене не уведомляются: кто успел снять zap.L() до неё, остался со старым значением.
Нужно ли вызывать restore() в main?
Практического эффекта нет: restore вернёт заглушку перед самым выходом из процесса. defer там — привычка и образец для тестов, где откат обязателен и без него ломаются соседние проверки.
Что делать с чужой библиотекой, которая пишет только в zap.L()?
Настроить глобал в main — это её единственный канал, и Named-потомков она не увидит: глобал один. Рабочие пути: договориться о настройке глобала в main, взять форк с параметром-логгером или вынести критичные события из библиотеки в свой код.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Сравните с альтернативами
Практический вопрос после первых уроков: а почему именно эта библиотека? Мы разобрали конкурентов по одним критериям, с кодом одной задачи рядом и персональным подбором под ваши приоритеты.
Изучайте дальше
Следующая ступень учебного пути
Colly: парсинг и веб-скрапинг на Go
Интерактивный курс Colly — фреймворка веб-скрапинга для Go: селекторы, пагинация, формы, прокси, лимиты, кэш, хранение данных. 25 уроков и итоговый проект.
- Писать скраперы на Colly от первого Visit до обхода целого сайта
- Вынимать данные через CSS-селекторы и складывать в структуры Go
- Настраивать темп: задержки, параллелизм, лимиты на домен
- Работать с капризными сайтами: заголовки, cookies, прокси