Содержание урока · 10 разделов
Три часа ночи, на графике 5xx, и вы открываете логи сервиса, который писали год. Логи — это вывод fmt.Println, оставшийся со времён прототипа: строки без времени, без уровня, без единого поля. Что было до ошибки, что после, когда именно случилось — не понять, потому что datetime в выводе нет вовсе: Println пишет то, что ему дали, и в том порядке, в котором вызвали. Классическая эволюция этой боли: сначала человек добавляет время руками через fmt.Println(time.Now(), "..."), потом вспоминает про стандартный пакет log, где время есть, а уровней и полей нет, потом ставит logrus — и вдруг сервис на пике тратит проценты CPU на собственные логи. Каждый шаг лечит одну рану и открывает следующую, потому что все эти инструменты писали строку для человека, а искать по ней придётся машине. Хуже всего, что такая лента врёт задним числом: без полей вы не отличите повторную попытку от первичной, а две одинаковые строки подряд — это два события или один дубль после ретрая? На ревью три вызова Println выглядят безобидно, в проде каждая из этих строк становится шумом, который дежурный вычитывает глазами, — и так на каждом инциденте.
Идея zerolog: событие вместо строки#
Знакомство с zerolog начинается с другой модели: лог — это не строка, а событие. Каждая запись — JSON-объект с фиксированными ключами level, time, message и любым числом ваших полей: user_id, order_id, elapsed. Поля типизированные: число пишется числом, строка строкой, длительность числом миллисекунд — для системы сбора это данные, по которым строятся фильтры и графики, а не текст, из которого регуляркой надо выковыривать значения. Zerolog появился в 2017 году — Оливье Пуатре, автор библиотеки, в то же время, когда Uber собирал zap, — и с самого начала был спроектирован вокруг одной цели: сериализация JSON без рефлексии, без промежуточных структур и без аллокаций на горячем пути. При этом API остался компактнее, чем у конкурентов: одна запись собирается цепочкой методов в одну строку, и читать её можно почти как предложение. Поля — это ещё и контракт со следующей версией вашего сервиса: ключ, однажды попавший в записи, обрастает дашбордами и алертами, и убрать его без последствий уже не выйдет. Поэтому события проектируют как схему данных: немного стабильных ключей, понятные типы, никаких одноразовых полей на каждый чих.
Первый взгляд: цепочечный API
GOpackage main
import (
"github.com/rs/zerolog/log"
)
func main() {
log.Info().
Str("user", "ann").
Int("age", 30).
Bool("admin", false).
Msg("user logged in")
}
// Output (stderr):
// {"level":"info","time":"2024-04-06T12:00:00+03:00","user":"ann","age":30,"admin":false,"message":"user logged in"}log — пакет github.com/rs/zerolog/log с готовым глобальным логгером. Info() начинает событие уровня info, дальше цепочка типизированных полей: Str для строки, Int для числа, Bool для флага. Msg завершает сборку и пишет запись. Обратите внимание на JSON: age и admin пришли числом и булевым значением, а не строками в кавычках, — это и есть структурное событие, которое система сбора разберёт без единой регулярки.
Ноль аллокаций: как zerolog устроен внутри#
Главное инженерное решение zerolog спрятано в слове «без аллокаций». Пока вы собираете событие, zerolog держит его не в структуре с полями, а в байтовом буфере: каждый метод цепочки дописывает в буфер готовые JSON-байты — ключ, кавычки, значение. Никакой рефлексии, никаких промежуточных слайсов полей, как в zap, и никаких map. Буфер берётся из sync.Pool: после вызова Msg он уходит в writer и возвращается в пул для следующего события, поэтому steady-state не создаёт мусора для сборщика. А если уровень события отключён — например, глобальный уровень Info, а вы вызвали Debug, — zerolog возвращает nil-событие, и вся цепочка превращается в no-op: методы проверяют receiver на nil и сразу возвращаются. Для сравнения: подход «собери структуру и отдай json.Marshal» платит рефлексией на каждом поле и аллоцирует результат на каждую запись — на тысячах RPS это уже график. Почему аллокации важны сами по себе: каждая из них — работа для сборщика мусора, а GC в Go стартует чаще, чем растёт куча. Логгер с десятками аллокаций на запись при десятках тысяч RPS стабильно подогревает кучу, и сборщик отбирает процессор у бизнес-кода — не паузами, которые видно на отдельном графике, а общим временем ответа. Ноль аллокаций — не трюк для бенчмарков, а способ вынести логирование из уравнения производительности целиком. Отключённая запись стоит единицы наносекунд, и это меняет привычку: отладочные вызовы можно не вычищать из горячего пути. Разбор с бенчмарками по этой теме — в уроке про производительность.
Логгеры Go: где место zerolog#
| Инструмент | Формат | Аллокации на запись | API | Ниша |
|---|---|---|---|---|
| fmt.Println | текст без времени и уровней | несколько на строку | Printf-шаблоны | скрипты и отладка, не прод |
| log (стандартный) | текст с временем | 2 на запись | Print-семейство | утилиты, простой вывод |
| logrus | текст или JSON | десятки на запись | пары ключ-значение | старые проекты |
| zap | JSON или console | ноль-две | конструкторы полей | высоконагруженный прод |
| slog | текст или JSON | ноль-две | Attrs | стандарт по умолчанию |
| zerolog | только JSON | ноль | цепочки вызовов | горячий путь, CLI, embedded |
Запись об ошибке: Err в действии
GOpackage main
import (
"errors"
"github.com/rs/zerolog/log"
)
func main() {
err := errors.New("connection refused")
log.Error().Err(err).Str("op", "dial").Msg("connect failed")
}
// Output (stderr):
// {"level":"error","time":"2024-04-06T12:00:00+03:00","error":"connection refused","op":"dial","message":"connect failed"}Error начинает событие третьего уровня, Err кладёт ключ error с текстом ошибки — а при nil не добавил бы ключ вовсе. Str добавляет контекст операции. Запись уходит в stderr глобального логгера, потому что время здесь из коробки. По полю error система сбора строит частоты провалов — ошибки как данные, а не как кусок текста.
Бенчмарки: что реально показывает README#
Собственные бенчмарки zerolog из README дают такой порядок величин: пустое событие — около 19 наносекунд, Info с сообщением — около 42, событие с полями — около 184 наносекунд, и во всех случаях ноль аллокаций. В бенчмарке самого zap на записи статичной строки zerolog выигрывает у zap в разы (50 против 236 наносекунд), а у logrus — на порядок (1244 наносекунды и 27 аллокаций). На событии с десятью полями разрыв с zap сжимается до процентов: 767 против 848 наносекунд — здесь у zerolog появляются единичные аллокации из-за упаковки значений в interface{}, которую требует сам бенчмарк. Числа — синтетические, зависят от машины и версии, и на вашем железе они будут другими; прогоните бенчмарки из репозитория сами, если решение стоит денег. Практический вывод не в цифрах, а в форме кривой: zerolog предсказуем на горячем пути — одинаковая цена на каждую запись без пиков на GC. Отдельно читайте колонку аллокаций, а не только время: logrus в том же прогоне отдаёт сборщику 27 объектов на каждую запись, zerolog — ноль. На коротком прогоне разница невидима, на длинном именно аллокации решают, как сервис ведёт себя под затяжной нагрузкой: куча не растёт, GC срабатывает реже, хвосты латентности не плывут.
Цепочечный API zerolog против zap и slog#
Чем цепочечный API отличается от соседей — на этом держится половина впечатления от библиотеки. В zap то же событие выглядит как logger.Info("user logged in", zap.String("user", "ann"), zap.Int("age", 30)): конструкторы полей перечисляются аргументами. В slog — атрибуты: slog.Info("user logged in", "user", "ann", "age", 30). В zerolog поля вплетены в цепочку: log.Info().Str("user", "ann").Int("age", 30).Msg("user logged in"). Мне цепочка читается легче: ключ и значение стоят рядом, тип виден в имени метода, и длинная запись не превращается в простыню аргументов. У этого вкуса есть и цена, о которой честно предупреждают в уроке про первый лог: цепочку надо чем-то завершить — вызовом Msg, — иначе запись не случится вовсе. Есть и практический бонус: порядок ключей в JSON совпадает с порядком вызовов, так что при ревью видно, как запись будет выглядеть в системе сбора, а автодополнение в редакторе предлагает ровно те типы полей, которые валидны для значения. Если после zap вам удобнее перечисление полей аргументами, это нормально; выбор логгера — командное решение, а не религиозное, и умение читать оба API окупается при миграциях.
Когда zerolog — удачный выбор#
- HTTP-сервис с высокими RPS, где логирование живёт на горячем пути каждого запроса
- CLI-утилиты: JSON-вывод парсится пайплайнами, а скорость сборки радует в мелких командах
- агенты и демоны на машинах с малым ресурсом — ноль мусора для GC на постоянной записи
- проекты, где уже выбран zerolog, — миграция на другой логгер дороже его особенностей
- команда хочет один формат записи (JSON) и не возится с плагинами и форматтерами
- неудачный момент: нужен экзотический формат или энкодер — zap с zapcore гибче
Интерпретатор в браузере исполняет только чистый Go без сторонних модулей, поэтому zerolog-код на этом сайте не запускается — runner-ов в курсе не будет. Песочница урока открывает тематическую программу на чистом Go, которая собирает JSON-событие вручную, — идею полей она показывает. Все примеры курса запускайте локально: go mod init demo, затем go get github.com/rs/zerolog/log.
Событие zerolog пишет только финализатор: Msg, Msgf или Send. Пока вы их не вызвали, запись не уедет в writer никуда — событие молча исчезнет, а буфер не вернётся в пул. Классика первых дней: построил длинную цепочку, убежал дописывать сообщение — и забыл. Правило для ревью: каждая цепочка заканчивается вызовом Msg. Если хочется страховки — включите в чек-лист ревью поиск цепочек без финализатора: IDE эту ошибку не подсвечивает, компилятор тем более, а запись исчезает молча.
Проверка: формат записи
Что пишет zerolog на каждый вызов Info().Msg()?
Проверка: цена отключённого уровня
Сколько стоит цепочка log.Debug().Str("k", "v").Msg("x"), если глобальный уровень — Info?
Какой JSON выйдет из цепочки?
Прочитайте код и выберите, что он выведет:
log.Info().Str("foo", "bar").Msg("hello")Что напечатает отключённый Debug?
Прочитайте код и выберите, что он выведет:
zerolog.SetGlobalLevel(zerolog.InfoLevel)
log.Debug().Str("key", "user:42").Msg("cache lookup")Восстановите первую запись
Соберите цепочку события: пакет, импорт глобального логгера, начало события, строковое поле, числовое и финализатор.
Соберите программу целиком
Расставьте строки в правильном порядке: от объявления пакета до записи с полем.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: словарь zerolog
Переезд с zap на zerolog короче, чем кажется, потому что идеи совпадают: уровни, типизированные поля, JSON-вывод, контекстные поля. Меняется форма: zap.NewProduction() превращается в zerolog.New(os.Stdout).With().Timestamp().Logger(), zap.Error(err) — в .Err(err), а logger.Sync() исчезает вовсе — в zerolog записи уходят в writer сразу, буферизовать нечего. С подробной картой перехода можно сверяться с курсом по zap, а если вы логировали через slog, остаётся знакомой сама структура записи. Разница, о которой стоит знать заранее: у zap есть готовые пресеты NewProduction и NewDevelopment, у zerolog пресетов нет — соберите два логгера сами при старте и выбирайте по окружению, это десять строк в main. Зато исчезает целый класс проблем: нечему терять хвост записей, нечего сбрасывать при выходе, а формат записи не зависит от пресета.
Что дальше по курсу#
Во втором уроке — первый лог в zerolog: zerolog.New(os.Stdout), ключ time, глобальный логгер из zerolog/log и честный ответ на вопрос, почему здесь нет Sync. Третий — уровни логов: от Trace до Fatal и SetGlobalLevel. Четвёртый — поля события во всей красе: Str, Int, Err и цена Any. Дальше ConsoleWriter, контекстные поля и вся библиотека до итогового проекта. Пока — установите zerolog локально и запустите пример из этого урока: первая JSON-строка в терминале объясняет идею библиотеки лучше любого текста.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Zerolog пишет логи событиями: одна запись — JSON с level, time, message и типизированными полями.
- Событие собирается цепочкой: log.Info().Str("user", "ann").Int("age", 30).Msg("...").
- Ноль аллокаций: поля дописываются байтами в буфер из sync.Pool, без рефлексии и промежуточных структур.
- Отключённый уровень возвращает nil-событие — цепочка становится no-op стоимостью в единицы наносекунд.
- По бенчмаркам README zerolog обгоняет logrus на порядок и стабильно быстрее zap на коротких записях.
- Цепочка без финализатора Msg ничего не пишет — проверяйте её в ревью.
- Пресетов у zerolog нет: writer и время настраиваются при старте руками — зато формат записи одинаков везде.
Домашнее задание
Установите zerolog локально: go mod init demo, затем go get github.com/rs/zerolog/log. Запустите пример из урока и рассмотрите JSON по ключам. Потом допишите событию ещё два поля разных типов — Str и Float64 — и проверьте по выводу, что каждое сериализовалось своим JSON-типом. Наконец, закомментируйте вызов Msg и убедитесь, что в терминале не появится ничего. Заодно загляните в go.mod: зависимостей у zerolog почти нет — ради этого его любят в утилитах и CLI.
Личные заметкиПусто
Вопросы и ответы по уроку
Что такое zerolog простыми словами?
Библиотека логирования для Go: каждый лог — это JSON-объект с уровнем, временем, сообщением и полями. Особенность — сборка записи без аллокаций, поэтому zerolog один из самых быстрых логгеров в экосистеме.
Zerolog или zap — что выбрать?
Оба быстрые и оба пишут JSON. Zerolog компактнее в API и чуть быстрее на типовых записях, zap гибче в конфигурации через zapcore и умеет готовые пресеты. Для нового сервиса с горячим путём zerolog — самый экономный выбор, а переезд между ними внутри приложения — день работы: идеи совпадают, меняется форма вызовов.
Почему zerolog быстрее logrus и fmt.Println-подхода?
Logrus на каждой записи проходит по полям рефлексией и собирает строку с аллокациями. Zerolog дописывает JSON-байты в переиспользуемый буфер: без рефлексии, без map, без мусора для сборщика — на высоких RPS это читается на графике CPU. Бонусом идёт компактная зависимость: zerolog не тянет цепочки пакетов, как это делают фреймворки.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Валидация в Go: go-playground/validator
Курс validator для Go: теги required/min/max, email, dive, вложенные структуры, свои валидаторы, Gin и Echo, русские сообщения, тесты. 25 уроков.
- Валидировать структуры тегами binding и validate
- Понимать ValidationErrors и писать понятные сообщения
- Работать с вложенностью, срезами и картами через dive
- Писать свои функции-валидаторы и StructLevel-правила