3. Логирование в Go

От стандартного пакета log до log/slog, Zap и Zerolog

«Не комментируйте плохой код — перепишите его. И следите за тем, чтобы логи не утопили в себе истину».
— Брайан Керниган

В предыдущей статье мы изучили иерархию уровней логирования и узнали, как отсекать информационный шум. Теперь перейдем к конкретному инструментарию в экосистеме языка Go.

История логирования в Go драматична и поучительна. На протяжении более чем десяти лет стандартная библиотека предлагала лишь аскетичный пакет log. Индустрия ответила на это созданием сотен сторонних решений, разделившись на непримиримые лагеря поклонников logrus, zap и zerolog. Ситуация кардинально изменилась с выходом Go 1.21, когда в стандартную библиотеку вошел долгожданный пакет log/slog.

Для Senior-инженера критически важно понимать архитектуру каждого инструмента: логирование часто становится скрытым узким местом (bottleneck) в высоконагруженных сервисах, сжигая процессорные циклы на системные вызовы write и провоцируя лишние сборки мусора в куче.


Эволюция логирования в Go

Эволюцию инструментов логирования в Go можно представить как движение от простоты к предельной производительности и стандартизации:

  1. 2009–2014: Пакет log — плоский текст, единый глобальный мьютекс, нулевая структурированность.
  2. 2014–2017: Эпоха sirupsen/logrus — структурированный JSON, уровни, но чудовищное количество аллокаций из-за рефлексии.
  3. 2017–2023: Эпоха uber-go/zap и rs/zerolog — бескомпромиссная битва за zero-allocation, пулы буферов sync.Pool, строгая типизация полей.
  4. 2023+ (Go 1.21+): Пакет log/slog — официальный синтез лучших идей индустрии прямо в стандартной библиотеке.

1. Пакет log (Стандартная библиотека)

Изначально Go предлагал только пакет log.

  • Плюсы: Предельная простота, отсутствие внешних зависимостей, встроен в стандартную библиотеку, потокобезопасен (защищен внутренним sync.Mutex).
  • Минусы: Только текстовый формат, нет структурирования, нет уровней (Debug/Info/Error), нет контекста, глобальная блокировка на каждый вызов.
  • Вердикт: Используется только в утилитах командной строки (CLI) или коротких скриптах. В современном бэкенде он не применяется.

2. log/slog — Новый стандарт (Go 1.21+)

Начиная с Go 1.21, стандартная библиотека пополнилась пакетом log/slog. Это попытка команды Google стандартизировать структурированное логирование, чтобы избавиться от зоопарка сторонних библиотек.

Архитектура slog

slog построен на паттерне Handler (Стратегия). Это позволяет отделить интерфейс (как мы пишем лог) от реализации (куда и в каком формате он идет).

flowchart LR
    A["Ваш код"] -->|"slog.Info"| B["Logger"]
    B -->|"Record"| C["Handler Interface"]

    subgraph D ["Реализации Handler"]
        C --> E["TextHandler"]
        C --> F["JSONHandler"]
        C --> G["Custom Handler<br>Zap/Tint"]
    end

    E --> H["io.Writer<br>stdout/file"]
    F --> H
    G --> H

    style A fill:#1e293b,stroke:#38bdf8,stroke-width:2px,color:#fff
    style B fill:#312e81,stroke:#818cf8,stroke-width:2px,color:#fff
    style C fill:#065f46,stroke:#10b981,color:#fff
    style H fill:#0f172a,stroke:#475569,color:#fff
  

Когда вы вызываете slog.Info("msg", "key", "val"), логгер формирует внутреннюю структуру slog.Record на стеке и передает её в Handler.Handle(ctx, record). Хендлер форматирует запись и направляет байты в io.Writer.

Ключевые особенности:

  1. Производительность: slog спроектирован так, чтобы не аллоцировать память, если лог не записывается (уровень фильтрации).
  2. Контекст: Методы принимают context.Context, что позволяет связывать логи с трейсингом (автоматически подставлять trace_id).
  3. Ленивое вычисление (LogValue): Вы можете передавать объекты, которые вычисляются только в момент записи.

Пример конфигурации (Production-ready)

Go
package main

import (
    "log/slog"
    "os"
)

func main() {
    // Создаем хендлер, пишущий в JSON в stdout
    // В реальном приложении настройки (Level, AddSource) берутся из конфига
    opts := &slog.HandlerOptions{
        Level:     slog.LevelInfo, // Минимальный уровень
        AddSource: true,           // Добавлять файл:строка (дорого, только для debug/prod)
    }

    logger := slog.New(slog.NewJSONHandler(os.Stdout, opts))

    // Устанавливаем глобальным (для использования через slog.Info)
    slog.SetDefault(logger)

    // Пример логирования с контекстом
    slog.Info("User logged in",
        "user_id", 123,
        "ip", "192.168.1.1",
    )
}

3. High-Performance Alternatives: Zap и Zerolog

До появления slog, королями производительности были (и остаются) библиотеки Zap (Uber) и Zerolog.

Почему они быстрее slog?

  • Zap: Использует объекты Encoder, которые переиспользуют буферы памяти через sync.Pool. Это минимизирует нагрузку на Garbage Collector.
  • Zerolog: Использует принцип "Zero-Allocation", избегая рефлексии и интерфейсов, работая напрямую с байтовыми слайсами.

Когда использовать сторонние библиотеки?

Если slog теперь в стандартной библиотеке, зачем нужны Zap/Zerolog?

  1. Миграция: Если проект уже глубоко завязан на Zap, переход на slog может не окупиться.
  2. Экосистема: Zap имеет огромное количество готовых интеграций (ZapGrpc, ZapSugar), которых у slog пока меньше.
  3. Экстремальная производительность: В бенчмарках Zerolog все еще часто выигрывает у slog (хотя разница сходит на нет).

Паттерны проектирования (Best Practices)

1. Dependency Injection (DI)

Никогда не используйте глобальный логгер внутри библиотек.

Go
// ПЛОХО: Библиотека использует глобальный slog
func Process() {
    slog.Info("processing") // Куда запишется? В stdout? В файл?
}

// ХОРОШО: Структура принимает интерфейс логгера
type Service struct {
    log *slog.Logger
}

func NewService(log *slog.Logger) *Service {
    return &Service{log: log}
}

Это позволяет в тестах подменять логгер на заглушку (discard), а в разных микросервисах направлять логи в разные места.

2. Асинхронное логирование

Запись в файл или сокет — это системный вызов write(2). В высоконагруженном Go-сервисе (например, 100k RPS) синхронная запись лога на каждый запрос может стать узким местом (bottleneck), вызывая блокировки потоков ОС.

Решение: Использовать bufio.Writer (для файлов) или реализовать Middleware с каналом (Channel), который потребляет горутина-писатель. В slog для этого можно написать кастомный AsyncHandler. Фоновая горутина сбрасывает пачки записей по таймеру или заполнению буфера, полностью изолируя сетевые запросы от задержек дискового ввода-вывода.

3. Обработка ошибок

В Go error — это значение. При логировании ошибки важно не потерять её контекст.

Go
if err := db.Connect(); err != nil {
    // Используем slog.Error, чтобы стек вызовов попал в лог (если нужно)
    slog.Error("Failed to connect to DB", 
        "error", err, 
        "dsn", dsnWithoutPassword, // Безопасность: не логать пароли!
    )
}

Никогда не логируйте чувствительные данные: токены аутентификации, пароли к базе данных, номера кредитных карт клиентов.


Итог

  1. log/slog — современный стандарт Go. Используйте его для новых проектов.
  2. Zap/Zerolog — актуальны для систем с экстремальными требованиями к перформансу или legacy-проектов.
  3. Избегайте глобального состояния (глобальных переменных логгера), передавайте логгер через конструкторы.
  4. Помните, что логирование — это I/O. В высоконагруженных системах используйте буферизацию.

В следующей статье мы обсудим, куда девать все эти логи и как собрать их из сотен микросервисов в одно место: 4. Централизованные логи.