Перейти к содержимому
ГоМастер

Поиск по урокам

Найдите урок, курс, термин или раздел

Урок 25 из 25 ~24 мин +50 XP

Итоговый проект: логгер для CLI-сервиса

Итоговый zerolog проект собирает логгер сервиса из кирпичей, разобранных за двадцать четыре урока: конфигурация по окружению, порог, формат вывода, ротация, сублоггеры, тесты. Нового здесь мало, и это честно: на финале проверяется умение складывать знакомое в работающий сервис, а не знание свежих функций. Проект — логгер для CLI-сервиса: воркер или демон, который стартует из консоли, читает APP_ENV и LOG_LEVEL, в деве рисует человекочитаемые строки, в проде пишет JSON в stdout и в файл с ротацией, а при SIGINT аккуратно закрывает всё, что открыл. Суть проекта совпадает с планом курса: конфиг, уровни, файл плюс stdout, тесты и сборка. Сборка умещается в три куска: конструктор NewLogger(cfg), main с сигналами и тест конструктора с буфером. Каждое решение помечено уроком-источником, так что непонятный шов открывается заново за минуту, а полный код переносится в свой проект почти без правок и доращивается по чек-листу в конце урока.

Содержание урока · 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-а

GO
package 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.

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

GO
package 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: уровень, поля и порог

GO
package 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, SetGlobalLevel16 — конфигурацияпорог из окружения, дефолт Info, мусор валит старт
ConsoleWriter с Out и TimeFormat05 — консольный выводчеловекочитаемый формат в деве
lumberjack.Logger09 — ротацияMaxSize, MaxBackups, MaxAge, Compress — жизнь журнала
MultiLevelWriter08 — несколько writer-овstdout и файл одновременно
With().Str(module, ...)10 — сублоггерыкопия на модуль с полем во всех записях
буфер и json.Unmarshal20 — тесты логовконтракт события: уровень, поля, число строк

Чек-лист сборки перед выкаткой#

  • дефолт 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.

lvl, err := zerolog.(cfg.LogLevel) if err != nil { return zerolog.Nop(), nil, err } zerolog.(lvl) lumber := &lumberjack.Logger{ Filename: cfg.LogFile, : 10, MaxBackups: 5, : 30, Compress: true, } lg := zerolog.New(zerolog.(out, lumber)). With().Timestamp().Logger()

Порядок в main

Расставьте строки main: конфиг, конструктор, воркер, сигнал, выключение.

Строки

Ваша программа

Нажимайте на строки слева, чтобы собрать программу

Карточки: проект целиком

Термин

NewLogger(cfg)

нажмите, чтобы перевернуть
Определение

Конструктор проекта: ParseLevel, порог, writer по окружению. Возвращает логгер и функцию done: владелец writer-ов закрывает их сам.

1 / 6

Что дальше по курсу#

Каркас готов к переносу: скопируйте 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-поля.

Личные заметкиПусто
Хранятся только в вашем браузере (zerolog · Итоговый проект: логгер для CLI-сервиса)

Вопросы и ответы по уроку

Почему 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, но пусть логгер продолжает читать окружение: две системы конфигурации для двух полей — лишний шов.

Термины урока

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

Следующая ступень учебного пути

Validator25 уроков~11 чпрактика25 уроков уже доступны

Валидация в Go: go-playground/validator

Курс validator для Go: теги required/min/max, email, dive, вложенные структуры, свои валидаторы, Gin и Echo, русские сообщения, тесты. 25 уроков.

  • Валидировать структуры тегами binding и validate
  • Понимать ValidationErrors и писать понятные сообщения
  • Работать с вложенностью, срезами и картами через dive
  • Писать свои функции-валидаторы и StructLevel-правила