Содержание урока · 10 разделов
Три файла проекта держатся на одном правиле: решение о судьбе writer-ов живёт в одном месте. Конструктор NewLogger принимает структуру Config, возвращает zerolog.Logger и функцию done — она сбросит файловый буфер и закроет журнал при выходе. main не знает, как устроен вывод: он получает логгер зависимостью и раздаёт его компонентам. Тест подставляет в Config.Stdout буфер и проверяет контракт события без глобальных подмен. По дороге встретятся два живых места: stdout, который читает кто-то ещё, и дисциплина закрытия, которую zerolog не берёт на себя. Порядок такой: конфигурация, writer по окружению, сублоггеры, сигналы и Sync, тест, чек-лист сборки.
Конфигурация: LOG_LEVEL, дефолт и ParseLevel#
Конфигурация начинается со структуры Config: поля Env, LogLevel, LogFile, Stdout и три числа для ротации. Правило из урока про окружение одно на все окружения: чтение, дефолт, парсинг, применение, подтверждение. Дефолт Info ставится до ParseLevel: os.Getenv возвращает пустую строку и для неустановленной переменной, и для пустой, а ParseLevel на пустой строке отвечает NoLevel без ошибки — это порог, который глушит Info, Warn и Error молча. Мусорная строка ведёт себя иначе: опечатка ifno не совпадёт ни с одним именем уровня и вернёт ошибку, и на старте она обязана валить процесс, а не проглатываться. В проекте конструктор не зовёт Fatal сам: NewLogger возвращает ошибку, и решение о судьбе процесса принимает main — библиотечный код, который сам выходит из процесса, отнимает у вызывающего выбор. Подтверждающая запись с принятым порогом закрывает утренние споры о том, какой уровень реально вступил в силу.
NewLogger: конструктор с выбором writer-а
GOpackage logger
import (
"fmt"
"io"
"os"
"github.com/rs/zerolog"
"gopkg.in/natefinch/lumberjack.v2"
)
type Config struct {
Env string // development | production
LogLevel string // LOG_LEVEL: info, debug, warn...
LogFile string // путь к журналу
Stdout io.Writer // nil — os.Stdout; тесты подставляют буфер
MaxSize int // мегабайт до ротации
MaxBackups int
MaxAge int // дней
}
func NewLogger(cfg Config) (zerolog.Logger, func(), error) {
out := cfg.Stdout
if out == nil {
out = os.Stdout
}
lvl, err := zerolog.ParseLevel(cfg.LogLevel)
if err != nil {
return zerolog.Nop(), nil, err
}
zerolog.SetGlobalLevel(lvl)
if cfg.Env == "development" {
lg := zerolog.New(zerolog.ConsoleWriter{
Out: out,
TimeFormat: "15:04:05",
}).With().Timestamp().Logger()
return lg, func() { syncFile(out) }, nil
}
lumber := &lumberjack.Logger{
Filename: cfg.LogFile,
MaxSize: cfg.MaxSize,
MaxBackups: cfg.MaxBackups,
MaxAge: cfg.MaxAge,
Compress: true,
}
lg := zerolog.New(zerolog.MultiLevelWriter(out, lumber)).
With().Timestamp().Logger()
return lg, func() {
syncFile(out)
if err := lumber.Close(); err != nil {
fmt.Fprintln(os.Stderr, "close log file:", err)
}
}, nil
}
func syncFile(w io.Writer) {
if f, ok := w.(*os.File); ok {
f.Sync() // у буфера Sync нет: assertion отфильтрует
}
}Конструктор возвращает логгер вместе с функцией done: владелец writer-ов — NewLogger, поэтому закрывает их он сам. В деве — ConsoleWriter с TimeFormat, в проде — MultiLevelWriter, дублирующий каждое событие в stdout и в lumberjack. Порог применяется SetGlobalLevel до ветки формата. Метода Sync у zerolog.Logger в 1.34.0 не существует — файловый буфер сбрасывает os.File.Sync через type assertion, а закрытие файла делает lumber.Close.
Сервис-воркер печатал прогресс fmt.Println-ом в stdout и туда же писал JSON-логи: в терминале разработчика всё выглядело живым, а скрипт дежурного, собиравший stdout в таблицу, падал на строке баннера и на JSON-записи разом. Если ваш CLI-сервис — утилита, чей stdout является интерфейсом программы, то логам в этом потоке не место: уводите их в stderr или в файл через Config.Stdout. Если сервис фоновый и ничего человеческого не печатает, то пара stdout плюс файл из MultiLevelWriter — легальный tee для сборщика. Правило одно: заранее назовите, кто читает каждый из двух потоков, и не смешивайте.
Writer по окружению: консоль в деве, tee в проде#
Формат и порог меняются вместе — правило из урока про окружение, и NewLogger держит их в одной функции. Дев-ветка отдаёт ConsoleWriter: цветные строки key=value, время в формате 15:04:05, полный поток на Debug. Прод-ветка строит пару writer-ов: JSON едет в stdout, откуда его забирает сборщик, и одновременно в файл через lumberjack — ротация из урока про lumberjack. Числа ротации задаются явно и не остаются нулями: MaxSize — мегабайты до деления, MaxBackups — сколько кусков хранить, MaxAge — предельный возраст бэкапов в днях, Compress — gzip на переименованных файлах. Нулевые дефолты lumberjack означают «хранить всё вечно», и на долгоживущем сервисе это тихая дорога к полному диску; бюджет места считается заранее — примерно MaxSize, умноженный на MaxBackups плюс один. MultiLevelWriter принимает обычные io.Writer и заворачивает их в LevelWriterAdapter, так что писать можно в оба потока, не задумываясь, кто из них умеет WriteLevel.
Сублоггеры на модули и глобальный порог#
Логгер сервиса один, а модулей несколько: worker, scheduler, api. Копия с предзаданным полем module собирается из базового логгера одним With — механика сублоггеров: lg.With().Str(module, worker).Logger(). Копия дешёвая, собирается один раз при старте и передаётся компоненте зависимостью: воркер получает логгер в своём конструкторе, а не достаёт глобальный из пакета log — так тест без глобальной подмены добирается до сервиса, а поле module подписывает все записи модуля. Порог при этом один на процесс: SetGlobalLevel атомарен и применяется конструктором. Точечный порог на копию даёт метод Level, но два порога в одном сервисе путают дежурного — заводите второй только под осознанный сценарий вроде тихого healthcheck, где события сами по себе шумнее записей сервиса. Имя module выбирайте из числа каталогов проекта, а не по вкусу: фильтр module равно worker должен найти в ленте всё, что написал воркер, от старта до выключения, и это тот же контракт имён, что у полей access-лога. Подтверждающая запись при старте пишет zerolog.GlobalLevel(): принятый порог виден в первой строке ленты, и спор «а какой уровень реально встал» закрывается без дебаггера.
main: сигналы, выключение и Sync
GOpackage main
import (
"context"
"fmt"
"os"
"os/signal"
"syscall"
"example.com/myservice/internal/logger"
)
func main() {
cfg := logger.Config{
Env: envOr("APP_ENV", "development"),
LogLevel: envOr("LOG_LEVEL", "info"),
LogFile: "logs/service.log",
MaxSize: 10, // мегабайт
MaxBackups: 5,
MaxAge: 30, // дней
}
lg, done, err := logger.NewLogger(cfg)
if err != nil {
fmt.Fprintln(os.Stderr, "log config:", err)
os.Exit(1) // мусор в LOG_LEVEL: старт не имеет смысла
}
defer done()
lg.Info().Str("level", cfg.LogLevel).Msg("logger ready")
worker := NewWorker(lg.With().Str("module", "worker").Logger())
go worker.Run()
ctx, stop := signal.NotifyContext(context.Background(),
syscall.SIGINT, syscall.SIGTERM)
defer stop()
<-ctx.Done()
lg.Warn().Str("module", "main").Msg("SIGINT: выключаемся")
worker.Stop()
}
func envOr(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}main не знает, как устроен вывод: он получает логгер и функцию done зависимостью. Ошибка конфигурации уходит в stderr с os.Exit(1) — до этой точки ни один файл не открыт, так что закрывать нечего, и пропуск defer-а безвреден. signal.NotifyContext превращает SIGINT и SIGTERM в отмену контекста: разблокированный канал ctx.Done() запускает выключение, worker.Stop останавливает работу, и на выходе defer done синхронизирует stdout и закрывает журнал.
Слова про закрытие здесь честные, потому что у zerolog.Logger в 1.34.0 нет ни Sync, ни Close: среди публичных методов в log.go — Output, With, Level, Sample, Hook и события, а единственный Sync в поставке — SyncWriter из writer.go, который синхронизирует вызовы Write мьютексом и к fsync отношения не имеет. Сброс и закрытие делает владелец writer-а: os.File.Sync() руками и lumber.Close() внутри done. Нюанс, который легко пропустить: у MultiLevelWriter есть собственный Close, закрывающий все подписчики, реализующие io.Closer, по порядку, — но при первой ошибке остальные не закрываются вовсе, и файл журнала остаётся открытым до конца процесса. Поэтому проект закрывает каждого подписчика руками и не полагается на общий Close. Цена вопроса измеряется хвостом ленты: без Sync последние строки могут остаться в буфере libc и исчезнуть при обрыве питания, а без Close дескриптор журнала остаётся у процесса до самого конца — на файле это почти безвредно, но дисциплина едина и для сетевого writer-а, где забытый сокет дороже. В тестах эта дисциплина ничего не стоит: done подставляется в defer и выполняется на выходе из функции автоматически.
Тест конструктора: буфер и порог#
Тест логгера повторяет приём из урока про тесты логов: writer подменяется буфером, событие разбирается в карту, проверки идут по ключам. Здесь подмена даже проще, чем в двадцатом уроке: поле Config.Stdout принимает io.Writer, и тест кладёт туда буфер, не трогая os.Stdout глобальной подменой. Порог проверяется тем же прогоном: конструктор поднял глобальный уровень до Info, событие Debug отсекается в should() ещё до создания записи, и в буфере обязана остаться ровно одна строка — так тест ловит сломанный порог, а не только поля. Числа приходят из json.Unmarshal во float64: сравнение с float64(8080) — не педантизм, а условие зелёного теста. Тест получает логгер конструктором, а не собирает его сам, поэтому проверяется и вся ветка прод-конфига: порог, tee из двух writer-ов, разбор записи из первого потока. Журнальная часть конфига тоже накрыта: LogFile указывает в t.TempDir(), и lumberjack создаёт журнал во временном каталоге, не разбрасывая файлы по системе.
TestNewLogger: уровень, поля и порог
GOpackage logger
import (
"bytes"
"encoding/json"
"path/filepath"
"testing"
)
func TestNewLoggerProd(t *testing.T) {
var buf bytes.Buffer
cfg := Config{
Env: "production",
LogLevel: "info",
LogFile: filepath.Join(t.TempDir(), "service.log"),
Stdout: &buf, // события в буфер, а не в stdout теста
}
lg, done, err := NewLogger(cfg)
if err != nil {
t.Fatal(err)
}
defer done()
lg.Warn().Str("module", "api").Int("port", 8080).Msg("started")
lg.Debug().Msg("hidden by threshold")
var m map[string]any
if err := json.Unmarshal(buf.Bytes(), &m); err != nil {
t.Fatal(err)
}
if m["level"] != "warn" {
t.Fatalf("level: %v", m["level"])
}
if m["module"] != "api" || m["port"] != float64(8080) {
t.Fatalf("fields: %v / %v", m["module"], m["port"])
}
if lines := bytes.Count(buf.Bytes(), []byte("\n")); lines != 1 {
t.Fatalf("events: %d, want 1", lines)
}
}Буфер подставлен в Config.Stdout — конструктор тестируется как обычная функция, без глобальной подмены log.Logger. Warn-событие уезжает в буфер одной JSON-строкой, Debug отсечён порогом Info, который конструктор установил SetGlobalLevel: счётчик строк ловит лишние записи. Разбор идёт json.Unmarshal в карту: порт приходит float64, уровень и модуль — строками. LogFile указывает в t.TempDir(), поэтому lumberjack пишет журнал во временный каталог, который тест удалит сам.
Кирпичи проекта и их уроки#
| Кирпич | Урок курса | Роль в проекте |
|---|---|---|
| Config, ParseLevel, SetGlobalLevel | 16 — конфигурация | порог из окружения, дефолт Info, мусор валит старт |
| ConsoleWriter с Out и TimeFormat | 05 — консольный вывод | человекочитаемый формат в деве |
| lumberjack.Logger | 09 — ротация | MaxSize, MaxBackups, MaxAge, Compress — жизнь журнала |
| MultiLevelWriter | 08 — несколько writer-ов | stdout и файл одновременно |
| With().Str(module, ...) | 10 — сублоггеры | копия на модуль с полем во всех записях |
| буфер и json.Unmarshal | 20 — тесты логов | контракт события: уровень, поля, число строк |
Чек-лист сборки перед выкаткой#
- дефолт Info подставлен до ParseLevel: пустая строка окружения — не NoLevel
- ошибка ParseLevel валид старт: сообщение в stderr и os.Exit, без тишины
- writer и порог выбираются в одной ветке: консоль в деве, tee в проде
- числа lumberjack заданы явно: нулевые MaxBackups и MaxAge хранят архив вечно
- сублоггеры собираются один раз и передаются зависимостью, не глобалью
- выключение: ctx.Done от сигнала, затем done — Sync файла и lumber.Close
- тест: буфер в Stdout, карта по ключам, float64 у чисел, счётчик строк на порог
- go build собирает бинарник без единого CLI-флага: APP_ENV и LOG_LEVEL накрывают конфиг
Проверка: сброс файлового буфера
Как в этом проекте гарантировать, что запись попала на диск при выключении?
Проверка: куда уедет событие
Прод-конфиг: zerolog.New(zerolog.MultiLevelWriter(os.Stdout, lumber)). Куда попадёт Info-запись?
Что напечатает сборка конфига с опечаткой?
Прочитайте код и выберите, что он выведет:
cfg := logger.Config{
Env: "production",
LogLevel: "ifno", // опечатка вместо info
}
lg, done, err := logger.NewLogger(cfg)
fmt.Println(err != nil, done == nil)Соберите прод-ветку NewLogger
Восстановите пять пропусков: парсинг, порог, числа ротации и tee.
Порядок в main
Расставьте строки main: конфиг, конструктор, воркер, сигнал, выключение.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Карточки: проект целиком
Проект из трёх файлов с сигналами и внешними пакетами в песочнице сайта не собирается: os/signal и lumberjack за её границей. Песочница покажет программу на чистом Go, которая печатает пару записей в формате key=value, — разбирайте построчно. Полный прогон локален: go build, APP_ENV=production LOG_LEVEL=info, Ctrl-C — и лента выключения в терминале.
Что дальше по курсу#
Каркас готов к переносу: скопируйте logger-пакет в свой проект, подставьте свои поля конфига и держите конструктор единственным местом, где решается судьба writer-ов. Направления доращивания лежат в соседних уроках: метрика рядом с событием — из урока про Prometheus, корреляция записей через trace_id — из урока про трейсы, доступ-лог — из урока про HTTP-middleware, когда у сервиса появится HTTP-слой. Флаги командной строки сознательно не подключены: переменные окружения накрывают конфиг логгера целиком, а когда флагов станет больше пяти, возьмите cobra — но это история про парсинг аргументов, а не про логи. Последний совет финала: прежде чем добавлять новый кирпич, прогоните тест конструктора — зелёная карта с уровнем и полями стоит минуты, а ловит поломку контракта раньше дежурного.
Песочница урока
Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.
Запустите код, чтобы увидеть вывод.
Коротко: что запомнить
- Итоговый zerolog проект — конструктор NewLogger(cfg): конфиг, порог и writer в одном месте.
- Дефолт Info ставится до ParseLevel; мусорная строка валит старт, пустая — не ошибка, а «не настроено».
- Дев — ConsoleWriter в stdout; прод — MultiLevelWriter: JSON в stdout и в файл с lumberjack-ротацией.
- Числа ротации задаются явно: MaxSize в мегабайтах, MaxBackups и MaxAge — против вечного архива.
- У Logger в 1.34.0 нет Sync и Close: os.File.Sync и lumber.Close делает владелец в done.
- Сублоггеры с полем module передаются зависимостью; порог один на процесс через SetGlobalLevel.
- Тест: буфер в Config.Stdout, карта по ключам, float64 у чисел, счётчик строк против лишних событий.
- SIGINT через signal.NotifyContext: ctx.Done() запускает выключение, defer done закрывает журнал.
Домашнее задание
Соберите проект: logger-пакет с NewLogger и main с сигналом, прогоните оба окружения — в деве посмотрите на TimeFormat консоли, в проде убедитесь, что запись легла и в stdout, и в logs/service.log. Пошлите SIGINT и проверьте, что done выполнился: добавьте в него подтверждающую запись и найдите её последней строкой ленты. Сломайте контракт трижды: уберите дефолт LogLevel и запустите с пустой переменной, подставьте LogLevel ifno, верните в тесте Stdout без буфера — каждый раз тест обязан падать с внятным сообщением, а не молча. Выставьте MaxSize: 1 и залейте журнал до ротации, затем посчитайте бэкапы против MaxBackups. Напишите второй тест на дев-ветку: ConsoleWriter пишет не JSON, поэтому проверяйте в буфере подстроку уровня и отсутствие ключа message в виде JSON-поля.
Личные заметкиПусто
Вопросы и ответы по уроку
Почему NewLogger возвращает ошибку, а не зовёт log.Fatal на плохом LOG_LEVEL?
Fatal внутри библиотеки отнимает у вызывающего решение о судьбе процесса: конструктор не знает, чем является мусорный уровень — ошибкой конфигурации или поводом для фолбэка. Проект возвращает логгер, done и ошибку, а main решает: напечатать в stderr и выйти. Плюс defer-семантика: os.Exit пропускает defer-ы, и хорошо, что до точки ошибки ни один writer ещё не открыт.
Когда fmt.Println лучше логгера?
Когда stdout — интерфейс программы: CLI-утилита печатает результат человеку или другому процессу, и каждая JSON-запись в этом потоке ломает парсер. Для короткоживущей утилиты с парой сообщений логгер вообще не нужен. Как только появляется диагностический поток — ошибки, повторы, длительности, — структурный логгер в stderr или файл выигрывает: уровни, поля и фильтры дешевле, чем grep по произвольным строкам.
Нужны ли флаги командной строки для настройки логгера?
В проекте они сознательно не подключены: APP_ENV и LOG_LEVEL накрывают две оси конфигурации — окружение и порог, — и читаются одинаково в systemd-юните, docker-compose и CI. Флаги оправданы, когда одной командой запускаются разные сценарии вроде разового прогона. Для парсинга флагов возьмите cobra, но пусть логгер продолжает читать окружение: две системы конфигурации для двух полей — лишний шов.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Валидация в Go: go-playground/validator
Курс validator для Go: теги required/min/max, email, dive, вложенные структуры, свои валидаторы, Gin и Echo, русские сообщения, тесты. 25 уроков.
- Валидировать структуры тегами binding и validate
- Понимать ValidationErrors и писать понятные сообщения
- Работать с вложенностью, срезами и картами через dive
- Писать свои функции-валидаторы и StructLevel-правила