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

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

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

Урок 8 из 25 ~18 мин +20 XP

With: предзаданные поля и логгер на запрос

Метод slog With решает задачу, которую в текстовых логах обычно закрывают копипастой: поля, которые обязаны быть в каждой строке — имя сервиса, окружение, номер запроса, — задаются один раз, а дальше логгер с полями go носит их сам. Разберём, как устроены потомки логгера, почему родителя With не меняет, как собрать логгер на один HTTP-запрос в middleware и где прячется ловушка дубликатов ключей.

Содержание урока · 9 разделов

Разбор структурных логов до этого момента молчал о главном практическом вопросе: куда девать поля, которые должны быть в каждой записи. Название сервиса и версия — чтобы понять, кто писал строку после того, как в кластере поднялись три релиза; окружение — чтобы отделить прод от стенда; номер запроса — чтобы склеить записи одного HTTP-вызова из разных слоёв. Писать их аргументами в каждом вызове Info — путь к опечаткам и пропущенным полям: рано или поздно один обработчик забудет номер запроса, и его строки выпадут из общей истории. В slog для этого есть метод With: он возвращает новый логгер, у которого перечисленные атрибуты уже вписаны в каждую будущую запись. Из этого простого метода вырастает вся дисциплина логгера на проекте: базовый логгер с полями сервиса, логгеры компонентов поверх него и логгер на один запрос в middleware — всё из одной точки конструирования.

slog With: логгер с полями go без копипасты#

Сигнатура метода минималистична: logger.With(attrs ...Attr) возвращает новый логгер, поэтому разбор slog with начинается с двух его свойств. Первое — накопление: вызовы With можно наслаивать, base.With("service", "billing").With("component", "db") даст логгер, в каждой записи которого оба поля; атрибуты, добавленные при записи, идут после предзаданных. Второе — совместимость с WithGroup из седьмого урока: поля и группы конструируются цепочкой в любом осмысленном порядке. Метод существует и у глобального логгера: slog.With(...) отталкивается от дефолтного и удобен на старте, пока своя точка сборки не заведена. Практический шаблон выглядит так: в main собирается базовый логгер с полями сервиса и окружения, из него выводятся логгеры компонентов с полем component, и всё это раздаётся зависимостям через конструкторы структур. В результате каждая строка сервиса отвечает на вопросы кто, где и на каком пути — без единого повторения ключей в вызывающем коде.

Базовый логгер и логгеры компонентов

GO
package main

import (
        "log/slog"
        "os"
)

func main() {
        base := slog.New(slog.NewJSONHandler(os.Stdout, nil)).
                With("service", "billing", "env", os.Getenv("APP_ENV"))

        api := base.With("component", "api")
        db := base.With("component", "db")

        api.Info("слушаю порт", "port", 8080)
        db.Info("миграции применены", "count", 3)
        base.Info("старт сервиса завершён")
}

Поля service и env предзаданы один раз и приедут во все три записи, поле component различит строки компонентов, а аргументы конкретного вызова идут после предзаданных. Дочерние логгеры бесплатны в прямом смысле слова: они делят с родителем один обработчик и отличаются только накопленной цепочкой атрибутов.

Иммутабельность: родителя With не меняет#

With возвращает нового потомка и оставляет родителя как был — на этом свойстве стоит весь стиль конструирования. base продолжает писать без component после того, как из него вывели api и db; функция, получившая логгер и добавившая себе поле через With, не изменит логгер, которым пользуется вызывающая сторона. Поэтому потомков безопасно раздавать по слоям и не нужно защищать мьютексом при конструировании: сами записи, как разберёт тринадцатый урок, конкурентно безопасны, а цепочка With на старте — однопоточная работа. Из иммутабельности следует и стиль хранения: логгер держат полем структуры, которое заполняет конструктор, а не глобальной переменной, которую переприсваивают по ходу дела. Цена потомка мала: логгер — тонкая обёртка над общим Handler, атрибуты накапливаются в цепочке без копирования существующих записей. Отсюда правило: создавать логгеры при старте компонентов и в middleware — нормально, на каждый элемент в горячем цикле — уже транжинство, о нём отдельный warn ниже.

Логгер на один HTTP-запрос#

Самое прибыльное применение With — логгер на один HTTP-запрос. В middleware на входе создаются поля, общие для всех записей этого вызова: request_id, метод, путь, иногда идентификатор пользователя после аутентификации. Логгер с этими полями кладётся в контекст запроса через context.WithValue с собственным типом-ключом — строковые ключи из урока про контекст здесь только риск коллизий. Дальше любой слой достаёт логгер из контекста и пишет обычные записи: каждое сообщение несёт номер запроса без единого лишнего аргумента в вызывающем коде, и записи одного вызова склеиваются фильтром по request_id за секунды. Обработчикам достаточно знать, что логгер в контексте есть; если он там отсутствует — вернётся дефолтный, и приложение продолжит работать, просто с меньшим контекстом в строках. Всю картину сквозных полей с генерацией и пробросом идентификаторов разбирает пятнадцатый урок, здесь — сама механика With и контекста. Для сравнения подходов в фреймворках полезно заглянуть в middleware из курса по gin: там та же идея решается средствами c.Set и c.Get.

Middleware: логгер в контекст запроса

GO
type loggerKey struct{}

func withLogger(base *slog.Logger, next http.Handler) http.Handler {
        return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
                id := newRequestID()
                logger := base.With("request_id", id, "method", r.Method)
                start := time.Now()

                ctx := context.WithValue(r.Context(), loggerKey{}, logger)
                next.ServeHTTP(w, r.WithContext(ctx))

                logger.Info("запрос завершён", "duration", time.Since(start))
        })
}

Логгер собирается один раз на входе и несёт номер запроса во все записи вызова — включая строку о завершении с длительностью. Ключ-структура loggerKey гарантирует уникальность значения в контексте, а срез контекста делается на копии запроса: r.WithContext не меняет входящий объект.

Дубликаты ключей: обе пары останутся в записи#

У накопления полей есть неприятная особенность, о которой стоит знать до первого инцидента: slog не проверяет уникальность ключей. Если логгер получил id через With, а в вызове записи передали тот же ключ с другим значением, обе пары попадут в запись: в JSON появятся два поля id, в текстовом выводе два вхождения id=... Штатные обработчики ничего не выбрасывают и не перезаписывают — что добавили, то и напечатано. Кто из дублей «правильный», решает уже потребитель: парсеры и коллекторы берут первое или последнее вхождение в зависимости от своей конфигурации, и одна и та же запись в двух системах может выглядеть по-разному. Практический вывод: ключи из With в вызовах записи не повторяют; если поле обязано измениться, это новое поле с новым именем вроде retried_of или итоговое значение вместо промежуточного. Проверить себя просто: строка с двумя одинаковыми ключами в JSON — уже повод поискать, где родился второй With.

Отдельный вопрос — как логгер ведёт себя в тестах и в коде без запросов. В тестах потомков создают свободно: каждый кейс строит логгер со своими полями и отдаёт его тестируемой функции, а захват вывода в память из двадцатого урока проверяет, что нужное поле дошло до записи. В фоновых задачах, где HTTP-контекста нет, логгер на задачу собирается тем же методом: With с полем job и идентификатором запуска — и записи ночного батча склеиваются фильтром так же легко, как записи запроса. Правило одно для всех мест: точка конструирования — вход в область видимости, будь то запрос, задача или компонент, а не глубина внутри неё. Если вы замечаете, что With вызывается по три раза в одной функции, поля стоит поднять на уровень выше — к точке, где эта область видимости началась.

Что кладут в With на каждом уровне#

УровеньПоляКто создаёт
базовый логгерservice, env, versionmain при старте
компонентcomponent: api, db, cacheконструктор структуры при старте
HTTP-запросrequest_id, method, pathmiddleware на входе вызова
пользовательuser_id после аутентификацииаутентификационный middleware

Правила With#

  • With возвращает нового потомка: родительский логгер продолжает писать без добавленных полей.
  • Поля, заданные через With, идут в записи раньше аргументов конкретного вызова.
  • Ключ из With не повторяют в вызовах записи: обе пары попадут в вывод, а разрешит их потребитель.
  • Базовый логгер с полями сервиса собирают в main, логгеры компонентов — в конструкторах структур.
  • Логгер на запрос собирается в middleware и передаётся через context.WithValue с типом-ключом.
  • Логгеры создают на старте компонентов и на входе запроса — не на каждый элемент в цикле.

Связи с другими уроками: седьмой урок про группы дополняет With префиксами-пространствами имён, а шестнадцатый урок про дефолтный логгер объясняет, от кого отталкивается slog.With без приёмника. Полную схему сквозных полей запроса собирает пятнадцатый урок про request ID, конкурентные записи общего логгера разбирает тринадцатый урок. Проверить логгер тестами поможет двадцатый урок, а сравнение с middleware фреймворков — курс по gin. Карта курса — на странице курса.

Проверка: иммутабельность

base := slog.Default().With("env", "prod"); child := base.With("component", "db"). Что будет в записи base.Info("готово")?

Проверка: дубликаты ключей

child := base.With("id", 1); child.Info("запись", "id", 2). Что окажется в выводе?

Какие поля будут в двух записях?

Прочитайте код и выберите, что он выведет:

base := slog.New(slog.NewJSONHandler(os.Stdout, nil)).
        With("service", "billing")
api := base.With("component", "api")

api.Info("готово")
base.Info("стартуем")

Соберите middleware

Допишите: предзадать поля через With, положить логгер в контекст и достать его в обработчике.

logger := base.("request_id", id, "method", r.Method) ctx := context.(r.Context(), loggerKey{}, logger) // в обработчике: l := slog.(ctx) l.Info("тело запроса принято", "bytes", n)

Middleware по шагам

Расставьте строки обработчика middleware по порядку: идентификатор, заголовок ответа, логгер, старт таймера, передача дальше, итоговая запись.

Строки

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

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

Карточки: With и логгер на запрос

Термин

Logger.With

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

Возвращает потомка с предзаданными атрибутами: они печатаются в каждой записи перед аргументами вызова.

1 / 6

Песочница урока

Редактируйте код и запускайте его прямо здесь — интерпретатор работает в браузере, ничего устанавливать не нужно. За каждый запуск — +2 XP.

Вывод

Запустите код, чтобы увидеть вывод.

Коротко: что запомнить

  • With возвращает потомка с предзаданными полями: они печатаются в каждой записи перед аргументами вызова.
  • Родителя With не меняет — из базового логгера выводят потомков на компоненты и раздают по слоям.
  • Логгер на HTTP-запрос собирается в middleware и передаётся через context.WithValue с типом-ключом.
  • Дубликаты ключей не фильтруются: обе пары попадут в запись, а выберет между ними парсер.
  • Поля сервиса кладут в With на старте: service, env, version — и каждая строка отвечает, кто её написал.
  • Создание потомка дёшево, но его место — старт компонента и вход запроса, а не горячий цикл.

Домашнее задание

Соберите middleware из урока локально и повесьте его на два маршрута: убедитесь, что записи разных запросов различаются по request_id, а записи одного запроса склеиваются фильтром. Затем добавьте поле component в логгер обработчика через With и проверьте в выводе порядок полей: предзаданные, потом аргументы вызова.

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

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

Создавать логгер через With на каждый запрос — это дорого?

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

Как передать логгер в глубину слоёв: аргументом или через контекст?

В HTTP-приложении логгер на запрос удобно нести в контексте: слои и так принимают ctx, отдельный параметр не появляется. В коде без запросов логгер передают аргументом или полем структуры — это явнее и проще для тестов из двадцатого урока.

Можно ли перезаписать поле, заданное через With?

Перезаписи нет: обе пары окажутся в записи, и потребитель решит, какую считать. Если поле меняется по ходу запроса, например после аутентификации появился user_id, добавляйте новое имя или стройте потомка аутентификационным middleware — старому логгеру поле не присваивают повторно.

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

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

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

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

PostgreSQL в Go: драйвер pgx с нуля до продакшена

Курс pgx для Go: подключение к PostgreSQL, pgxpool, сканирование структур, транзакции, Batch, COPY, JSONB, LISTEN/NOTIFY и тесты. 25 уроков.

  • Подключаться к PostgreSQL через pgx и pgxpool
  • Сканировать строки в структуры и справляться с NULL
  • Работать с типами Postgres: UUID, JSONB, массивы, timestamptz
  • Проводить транзакции и массовые вставки через Batch и COPY