JavaRush /Курсы /Go SELF /`log/slog`: уровни, `Logger`, `Handler`

`log/slog`: уровни, `Logger`, `Handler`

Go SELF
52 уровень , 1 лекция
Открыта

1. Зачем нужен slog

Когда вы только начинаете писать программы, кажется, что log.Println("сюда дошли") — это уже вершина инженерной мысли. И правда: пока проект маленький, всё работает. Но как только программа начинает делать больше одного действия, а ошибок становится больше одной, строки в логах превращаются в «детектив без улик».

Проблема не в том, что строки плохие. Проблема в том, что строки плохо ищутся и плохо фильтруются. Вы хотите быстро ответить на вопросы: «какая операция упала?», «сколько заняла по времени?», «по какому id?», «сколько было элементов?», «это ошибка или просто предупреждение?». Если лог — это одна строка текста, вам приходится надеяться, что формат этой строки всегда одинаковый (а он почти никогда не одинаковый).

log/slog решает это через две идеи. Первая — уровни логов (Debug/Info/Warn/Error), чтобы управлять «шумом». Вторая — структура: лог — это не только текст, но и набор полей (attributes), которые можно стабильно добавлять и потом анализировать.

В этой лекции мы сфокусируемся на фундаменте: что такое Logger и Handler, и как работают уровни. Про поля поговорим позже — иначе мозг начнёт делать panic: stack overflow прямо у вас в голове.

2. Модель slog: Logger и Handler

Когда люди впервые видят slog, они часто пытаются запомнить «волшебную строчку инициализации» и дальше копировать её из проекта в проект. Это работает… до первой попытки что-то поменять. Поэтому лучше понять модель.

В slog есть два ключевых объекта.

Handler — это «куда и как писать». Он отвечает за формат (например, текстовый или JSON) и за вывод (обычно os.Stderr), а также за фильтрацию по уровню.

Logger — это «что писать». Он создаёт записи (Info, Error, …) и передаёт их в Handler.

Можно представлять это так:

flowchart LR
    A["Ваш код: logger.Info(...)"] --> B["slog.Logger"]
    B --> C["slog.Handler<br/>(TextHandler/JSONHandler)"]
    C --> D["io.Writer<br/>(обычно os.Stderr)"]

Заметьте, насколько это похоже на здравый смысл: вы в коде «просите залогировать», а где-то ниже решается, в каком формате и куда именно это попадёт.

3. Быстрый старт: Logger с TextHandler

Когда вы впервые подключаете slog, вам нужна минимальная «точка старта». В идеале она занимает несколько строк и не требует шаманства.

Вот самый простой вариант: текстовый handler, вывод в stderr, и логгер поверх него.

package main

import (
	"log/slog"
	"os"
)

func main() {
	logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
	logger.Info("app started")
}

Обратите внимание на две вещи. Во-первых, мы явно используем os.Stderr, чтобы не смешивать диагностику с результатом команды. Во-вторых, вторым параметром в NewTextHandler мы передали nil: это значит «опции по умолчанию».

Если запустить такую программу, вы увидите запись примерно в стиле time=... level=INFO msg="app started" (точный вид зависит от handler’а и версии).

4. Уровни логов и порог в HandlerOptions

С уровнями у новичков часто возникает путаница. Кажется, что это просто четыре варианта сказать «что-то произошло», но на практике уровни — это ваш главный инструмент против лог-хаоса.

Идея простая: у логов есть «важность». Когда всё работает, вам не хочется читать 10 тысяч строк отладки. Когда что-то сломалось, вам наоборот хочется больше деталей. Поэтому уровни должны позволять вам включать и выключать детали одним «краном».

В slog есть стандартные уровни: Debug, Info, Warn, Error. Обычно их читают так: Debug — подробности для разработчика, Info — нормальные события жизни приложения, Warn — что-то подозрительное, но не фатальное, Error — операция провалилась.

Самое важное: видимость логов зависит от порога уровня, который задаётся в HandlerOptions.

Посмотрим на пример, где Debug не выводится, потому что порог выставлен на INFO.

package main

import (
	"log/slog"
	"os"
)

func main() {
	opts := &slog.HandlerOptions{Level: slog.LevelInfo}
	logger := slog.New(slog.NewTextHandler(os.Stderr, opts))

	logger.Debug("debug is hidden")
	logger.Info("info is visible")
}

Это почти идеальный «антишум» по умолчанию: в обычном запуске пользователю и вам (как оператору/разработчику) достаточно Info и выше. А когда нужно расследование, вы поднимаете детализацию до Debug.

Теперь включим Debug:

package main

import (
	"log/slog"
	"os"
)

func main() {
	opts := &slog.HandlerOptions{Level: slog.LevelDebug}
	logger := slog.New(slog.NewTextHandler(os.Stderr, opts))

	logger.Debug("now debug is visible")
}

Заметьте: мы не меняли код в местах логирования. Мы поменяли только настройки handler’а. Это и есть смысл уровней: управление детализацией должно быть централизованным.

5. Конфигурация handler’а: уровень и форматы

Почему уровень задаётся на Handler, а не на Logger

Вопрос, который звучит странно, пока вы не наступили на грабли: почему порог уровня задаётся опциями handler’а?

Потому что фильтрация — это часть «куда и как писать». Сегодня вы пишете в stderr текстом и хотите показывать только Info+. Завтра вы пишете JSON в файл и хотите сохранять всё, включая Debug, потому что это окружение отладки. Послезавтра вы пишете в два места разными handler’ами (это уже более продвинуто, но сама идея логична).

Если уровень живёт в handler’е, вы можете переиспользовать один и тот же Logger-вызов (например, logger.Debug("...")) и просто менять конфигурацию вывода, не переписывая приложение.

TextHandler и JSONHandler: один смысл, два формата

В slog есть два самых популярных «готовых» handler’а: TextHandler и JSONHandler. И тут новичкам важно не попасть в ловушку «JSON всегда лучше, потому что современно».

TextHandler хорош, когда вы читаете логи глазами: локальная разработка, учебные проекты, быстрый запуск в терминале. Он обычно даёт компактный и читаемый формат.

JSONHandler хорош, когда логи будет читать не человек, а система: сборщик логов, поиск, фильтрация, агрегация. Даже если вы пока не используете такие системы, полезно понимать, зачем этот формат существует.

Вот минимальный пример JSON-логов:

package main

import (
	"log/slog"
	"os"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stderr, nil))
	logger.Info("app started")
}

Вывод будет в виде JSON-объекта на строку (формат «json lines»). Опять же: точная форма зависит от реализации, но идея всегда одна — машинам проще с этим жить.

6. Логгер по умолчанию: slog.SetDefault

Когда проект маленький, можно держать logger в переменной и прокидывать его руками. Но в реальности вы быстро увидите, что часть кода зовёт пакетные функции slog.Info("..."), а часть использует logger.Info("..."). Если эти два мира не согласованы, у вас получится смешение форматов и настроек.

В slog есть механизм «логгер по умолчанию»: вы создаёте логгер и назначаете его дефолтным. Тогда пакетные вызовы slog.Info используют вашу настройку.

package main

import (
	"log/slog"
	"os"
)

func main() {
	logger := slog.New(slog.NewTextHandler(os.Stderr, nil))
	slog.SetDefault(logger)

	slog.Info("started via default logger")
}

Важно относиться к этому как к глобальной настройке процесса: вы сделали это один раз при старте — и дальше весь код, который использует slog.*, пишет в одном стиле.

Паранойя-разработчика подсказывает: «глобальное — зло». Иногда да, но здесь это скорее централизованный конфиг. В следующих лекциях мы обсудим, где логировать и как не плодить хаос, а пока нам важно просто научиться уверенно собирать «рабочий» логгер.

7. Встраивание в CLI: stdout отдельно, логи отдельно

Сейчас сделаем маленький шаг в сторону нашего учебного CLI-приложения (условно назовём его todo). Задача: оставить результат команды в stdout, а диагностику — в stderr через slog.

Представим, что у нас есть run() — функция, которая делает полезную работу и возвращает error. Мы хотим залогировать старт/финиш команды, но не смешать это с выводом результата.

Минимальная «обвязка» в main может выглядеть так:

package main

import (
	"log/slog"
	"os"
)

func main() {
	logger := slog.New(slog.NewTextHandler(os.Stderr, nil))

	if err := run(logger); err != nil {
		logger.Error("command failed")
		os.Exit(1)
	}
}

Здесь мы сделали две вещи. Мы создали логгер, а потом передали его в run(logger). Да, это чуть больше кода, чем «просто глобальный логгер», но зато вы уже сейчас чувствуете границу: main собирает зависимости (включая логгер), а логика выполнения живёт внутри run.

Теперь давайте сделаем run, которая печатает результат в stdout (через fmt.Println) и параллельно пишет диагностику в stderr (через logger.Info). Это отличный пример разделения потоков.

package main

import (
	"fmt"
	"log/slog"
)

func run(logger *slog.Logger) error {
	logger.Info("listing tasks")
	fmt.Println("0 tasks") // 0 tasks
	return nil
}

Если вы запустите программу, вы увидите «0 tasks» в stdout (это контракт результата), а лог «listing tasks» — в stderr. В терминале они могут визуально смешиваться, но для скриптов, пайпов и тестов это два разных мира.

8. Dev и prod: включаем Debug без переписывания кода

Очень частый сценарий: вы хотите, чтобы в разработке было больше деталей, а в обычном запуске — меньше шума. Мы пока не вводим env-конфиг как полноценную тему (она у вас будет в отдельном дне), но чисто технически можно показать переключение уровней на константе.

package main

import (
	"log/slog"
	"os"
)

func main() {
	level := slog.LevelInfo // поменяйте на slog.LevelDebug для разработки

	opts := &slog.HandlerOptions{Level: level}
	logger := slog.New(slog.NewTextHandler(os.Stderr, opts))

	logger.Debug("debug details") // в LevelInfo это не появится
	logger.Info("start")
}

Ключевая мысль: вы не должны переписывать десятки мест в коде, чтобы «включить больше логов». Вы должны менять конфигурацию handler’а.

9. stdout и stderr: схема для CLI

Эта идея уже звучала раньше, но сейчас она особенно важна, потому что slog легко делает «правильный» вывод по умолчанию.

flowchart TB
    A["CLI-команда"] --> B["Бизнес-логика"]
    B --> C["stdout<br/>результат (таблица/JSON/сообщение)"]
    B --> D["slog -> Handler -> stderr<br/>диагностика (уровни, контекст)"]

Когда вы начнёте писать тесты для CLI, вы внезапно оцените эту дисциплину: stdout можно сравнивать как контракт результата, а stderr можно либо игнорировать, либо анализировать отдельно. Если же всё смешать, тесты превращаются в борьбу с мусором.

10. Типичные ошибки при работе с log/slog

Ошибка №1: «Я залогировал Debug, но ничего не выводится — slog сломан».
Обычно тут не баг, а настройка: порог уровня в HandlerOptions выставлен выше, чем Debug. Это нормальное поведение: Debug часто специально скрыт по умолчанию, чтобы логи не превращались в роман на 700 страниц.

Ошибка №2: создают новый логгер в каждой функции, а потом удивляются разному формату и разным уровням.
Технически slog.New(...) — недорогая операция, но организационно это почти всегда ошибка. Логгер должен быть собран один раз (или хотя бы централизованно), иначе вы получаете зоопарк настроек: тут JSON, там Text, тут Debug, там Info.

Ошибка №3: пишут логи в stdout «потому что так проще посмотреть».
Это ломает идею «stdout как контракт результата». Пока вы в одиночку запускаете программу руками, кажется, что всё нормально. Но как только появятся пайпы, скрипты или автопроверка, эти «удобные» логи станут проблемой. Лучше приучиться: результат — stdout, логи — stderr.

Ошибка №4: путают роли Logger и Handler и пытаются «поставить уровень на логгер».
В slog фильтр уровня живёт в handler’е. Это не случайность, а дизайн: один и тот же логгер можно подключать к разным обработчикам (форматы/выводы/пороги). Если помнить эту модель, настройка становится логичной.

Ошибка №5: выбирают JSON «потому что модно», а потом читают его глазами и страдают.
JSON-логи прекрасны, когда их обрабатывает система. Когда вы читаете логи глазами в терминале, текстовый формат часто удобнее. Выбор формата — это не религия, а «кому читать: человеку или машине».

1
Задача
Go SELF, 52 уровень, 1 лекция
Недоступна
Первый Slog
Первый Slog
1
Задача
Go SELF, 52 уровень, 1 лекция
Недоступна
Фильтр по уровню
Фильтр по уровню
1
Задача
Go SELF, 52 уровень, 1 лекция
Недоступна
Дефолтный логгер
Дефолтный логгер
1
Задача
Go SELF, 52 уровень, 1 лекция
Недоступна
Конструктор логгера
Конструктор логгера
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ