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

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

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

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

Структура JSON: ключи и вложенность

Строка JSON из zerolog читается системой сбора, а не человеком: порядок ключей, имена level, time и message, глубина вложенности — всё это определит, что станет колонкой и фильтром в Loki, а что придётся доставать регуляркой. Это урок про форму записи: что именно окажется в строке, кто задаёт имена ключей и почему плоская структура дешевле глубокой. Разберём скелет записи, константы имён из globals.go, вложенность без рефлексии через Dict и Array, интерфейс Interface с тегами encoding/json и правила сбора для системы.

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

Анатомия записи фиксирована. Событие рождается с ключом level — порогом, который перед записью проходит через LevelFieldMarshalFunc. Затем ложится байтовый контекст логгера: то, что собрано через With — service, request_id, timestamp. Потом едут ваши поля события в порядке вызовов. Последним, при финализации, дописывается message — и только если оно непустое: Send с пустым сообщением даёт запись без ключа message вовсе. Порядок подтверждается исходниками: newEvent сначала пишет уровень, затем добавляет контекст логгера, поля цепочки следом. Парсеру порядок безразличен — JSON-объект неупорядочен по спецификации, — но людям и diff-ам порядок важен: стабильные первые ключи дают визуальные якоря при просмотре ленты, а одинаковый порядок полей между релизами делает дифы логов читаемыми. Фиксированный скелет — не косметика: первые ключи парсеры пайплайна разбирают с минимальными настройками, а хвост ленты глазами просматривает дежурный при tail — когда level и service всегда на своих местах, аномалия видна на первой сотне строк. Отсюда простое правило: контекст — для того, что есть у каждого события модуля или запроса, поля — для факта конкретной записи; это разделение из урока про поля события прямо диктует и структуру JSON: два источника, два участка строки, message всегда в хвосте.

Скелет записи целиком

GO
package main

import (
        "os"

        "github.com/rs/zerolog"
)

func main() {
        logger := zerolog.New(os.Stdout).With().
                Str("service", "orders").Timestamp().Logger()

        logger.Info().Str("op", "create").Int("items", 3).Msg("order created")
}

// {"level":"info","service":"orders","time":"2024-04-06T12:00:00+03:00",
//  "op":"create","items":3,"message":"order created"}

Порядок ключей здесь виден целиком: level дописан при создании события, затем контекст — service и time в том порядке, как вызывались в With, затем поля события и message последним. Контекст лежит в логгере байтами, поэтому на каждое событие не тратится ни одной пересборки — строка склеивается из готовых кусков.

Имена ключей: переменные zerolog#

Имена скелетных ключей — переменные пакета, а не константы: TimestampFieldName равен time, LevelFieldName — level, MessageFieldName — message, ErrorFieldName — error, CallerFieldName — caller. Их можно переопределить при старте: чужая система сбора, которая ждёт ts вместо time, — одна строка до создания логгера. Это обычные переменные без защиты: присваивайте до запуска горутин, чтобы не устроить гонку на горячем пути. Переопределение живёт в инициализации пакета логирования — первая строка main или func init, до первого события и до старта воркеров: переменные читаются на каждой записи, и менять их в рантайме — значит гонять присваивание наперегонки с записью, безо всякой синхронизации вокруг них. Значение уровня тоже настраивается — LevelFieldMarshalFunc превращает info в INFO, если формат вашей ленты требует верхний регистр. Время — TimeFieldFormat: RFC3339 по умолчанию, а TimeFormatUnix и TimeFormatUnixMs дают целые числа, что экономит байты и упрощает сортировку. Что важно понимать честно: API для массового переименования собственных полей в zerolog нет — ключ из Str("user_id") навсегда ваш строковый литерал. Имена фиксируются константами вашего пакета логирования, и их смена — осознанная миграция дашбордов, а не правка одной строки. Поле error из Err — часть того же скелетного словаря: алерты и дежурные ищут именно его, поэтому переопределять ErrorFieldName без крайней необходимости не стоит.

Dict и Array: вложенность без рефлексии#

Два способа собрать вложенное. Dict: zerolog.Dict() создаёт безымянное событие, вы наполняете его обычными методами полей, а e.Dict(key, dict) закрывает его фигурной скобкой и вклеивает под вашим ключом; тот же метод есть у Context для контекста. Array: zerolog.Arr() создаёт массив с тем же словарём методов — Str, Int, Dur, Time, Object, Dict; e.Array(key, arr) записывает его значением ключа. Оба пути — типизированные методы, дописывающие байты: рефлексии нет, аллокаций почти нет, вложенность стоит примерно столько же, сколько плоские поля. Практический ориентир — группы, у которых есть смысл как целое: http с method, path и status, client с ip и версией приложения. Поля, которые фильтруются по отдельности, вложенности не просят: level, request_id и op живут наверху. Для структур с поведением есть Object: тип реализует LogObjectMarshaler с методом MarshalZerologObject(e *Event) и сам раскладывает себя по полям события; EmbedObject вклеивает те же поля без имени-обёртки. Третий путь — Interface: если значение не умеет MarshalZerologObject, оно уезжает через encoding/json с выключенным HTML-экранированием, и имена ключей определяют теги json структуры. Именно поэтому Object и Dict — инструменты горячего пути, а Interface — редких дампов: цена и предсказуемость различаются, экономику этого выбора разбирает урок про производительность.

Объект, словарь и массив в одной записи

GO
package main

import (
        "github.com/rs/zerolog"
        "github.com/rs/zerolog/log"
)

type request struct {
        Method string
        Path   string
}

func (r request) MarshalZerologObject(e *zerolog.Event) {
        e.Str("method", r.Method).Str("path", r.Path)
}

func main() {
        log.Info().
                Object("request", request{"POST", "/api/orders"}).
                Dict("client", zerolog.Dict().Str("ip", "203.0.113.7")).
                Array("ids", zerolog.Arr().Int(101).Int(102)).
                Msg("batch accepted")
}

// {"level":"info","time":"...","request":{"method":"POST","path":"/api/orders"},
//  "client":{"ip":"203.0.113.7"},"ids":[101,102],"message":"batch accepted"}

Object зовёт MarshalZerologObject и вклеивает объект под ключом request: метод сам решает, какие поля и в каком порядке уедут. Dict собирает подобъект на месте из методов полей, Array пишет массив чисел. Все три пути обходятся без рефлексии: байты дописываются в буфер теми же Append-методами, что и плоские поля события.

Вложенность чаще всего нужна в двух местах. Первое — контекст: поля, общие для всех событий запроса, собираются в With и могут быть группой — Context.Dict вклеивает объект прямо в байтовый контекст логгера, и каждое событие уносит его без повторных вызовов. Второе — событие-отчёт: дамп конфигурации при старте, сводка пакета задач, результат батча. Промежуточные варианты — Dict в каждой записи горячей цепочки «на всякий случай» — это байты, которые платят все события ради одного, кому они были нужны; правило из урока про условные поля применимо и здесь: собирайте вложенность там, где она объясняет запись, а не украшает её. Отдельный случай — слайс своих структур: вместо ручного перебора с Dict на каждый элемент определите тип-маршалер. У LogArrayMarshaler есть метод MarshalZerologArray(a *Array) — типизированный путь, который пишет массив целиком через те же методы, и e.Array принимает такой тип напрямую, без ручной сборки. Маршалер переиспользуется: один и тот же тип годится и для Object, и для EmbedObject, и для контекста, и для события — раскладка по полям описана один раз, рядом с самим типом.

Что окажется в JSON#

МетодФорма в JSONКогда брать
Dict(key, zerolog.Dict())объект под ключомгруппировка родственных полей без своих типов
Array(key, zerolog.Arr())массив значенийсписки идентификаторов и тегов
Object(key, obj)объект из MarshalZerologObjectсвои типы на горячем пути
EmbedObject(obj)поля объекта прямо в событииплоская запись без имени-обёртки
Interface / Anyто, что выдаст encoding/jsonредкие дампы структур с тегами json
RawJSON(key, b)готовые байты как естьзначение уже сериализовано, например из кэша

Плоская структура для Loki#

Loki собирает ленту JSON-объектов: каждая строка — самостоятельный объект, и запросы LogQL разбирают его ключи. Практические следствия для zerolog. Первое: ключи верхнего уровня — ваши главные фильтры; json-парсер LogQL достаёт их и даёт фильтровать ленту по значению, поэтому service, op, level и request_id должны лежать наверху, а не внутри объектов. Второе: вложенные объекты парсер разворачивает в плоские имена с подчёркиваниями — method внутри объекта http станет http_method; один уровень вложенности переживается спокойно, но каждый следующий — место, где имена перестают быть очевидными. Третье: message остаётся текстом для полнотекстового поиска, а level — стандартным ключом для распределения по уровням; оба имени заданы скелетом zerolog, и это совпадает с ожиданиями сборщика. Четвёртое — время: если пайплайн не настроен взять time как метку события, запись получит время приёма, и при задержках доставки графики будут врать. TimeFieldFormat в виде Unix-миллисекунд упрощает разбор: число парсится однозначно, а RFC3339 со смещением требует явного формата. Типовой разбор глазами дежурного: селектор по service, затем json-парсер, затем фильтр по level — все три слова это ключи вашей записи, и ни одно не появилось бы там само. Плоская запись делает каждую строку самодостаточной для такого разбора: логи разных сервисов склеиваются в одну ленту, и фильтр отделяет чужое. Итог: глубина максимум один уровень, ключи наверху, message — факт, level — стандартный, время — машиночитаемое.

Правила структуры одним списком#

  • скелет записи: level, контекст из With, поля события в порядке вызовов, message последним
  • имена скелета — переменные zerolog: time, level, message, error; менять до старта горутин
  • собственные ключи — константы вашего пакета: переименование поля это миграция дашбордов
  • вложенность — Dict, Array и Object: методы полей, без рефлексии и без сюрпризов
  • Interface — для редких дампов: форму записи диктуют теги json структуры
  • для Loki: ключи верхнего уровня, глубина не больше одного уровня, level и message стандартные
  • time — машиночитаемым: Unix-миллисекунды парсятся пайплайном однозначнее, чем RFC3339
  • каждый уровень вложенности — плюс скобки и байты, минус очевидность имён в фильтрах

Цена вложенности и ключ error#

Вложенность не бесплатна до бесконечности. Каждая скобка — байты, каждый подобъект — вызовы методов и ветвления; на тысячах событий в секунду разница между плоским событием из шести полей и трёхуровневой конструкцией заметна в профиле — та же экономика, что и в производительности. Отдельно про значения-объекты в Interface: encoding/json упакует и слайсы, и вложенные структуры, но заплачено будет рефлексией на каждой записи; если такой дамп нужен раз в сутки при старте — цена незаметна, а если он переехал в обработчик запроса, профиль покажет рост аллокаций раньше, чем вы успеете удивиться. Читаемость страдает раньше скорости: вложенный объект занимает строку на каждый уровень и в консоли, и в дампе, и поиск глазами по ленте замедляется. Договоритесь в команде на одном правиле: два-три имени объектов максимум — например, http для запроса и client для источника, — внутри всё плоско. Err стоит отдельно: его ключ задаётся ErrorFieldName и по умолчанию равен error, значение — текст ошибки или пропуск при nil, как разобрано в уроке про поля. Этот ключ тоже часть словаря сборщика: алерты строятся по error, и переопределять имя стоит только вместе с ними. Поле Errs для слайса ошибок и AnErr для ошибки-объекта завершают семейство — оно целиком разобрано в уроке про логирование ошибок.

Проверка: хвост записи

Какой ключ в JSON-записи zerolog дописывается последним?

Проверка: Dict против Interface

Чем Dict выгоднее Interface для вложенного объекта?

Что окажется в JSON?

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

log.Info().
        Dict("http", zerolog.Dict().
                Str("method", "GET").
                Int("status", 200)).
        Msg("done")

Собрать вложенное событие

Восстановите вызовы: объект под ключом, сам объект и список идентификаторов.

log.Info(). ("client", zerolog.().Str("ip", ip)). ("ids", zerolog.Arr().Int(101).Int(102)). Msg("batch accepted")

Событие с вложенностью

Расставьте строки в правильном порядке: импорты, функция с вложенным событием из словаря и массива.

Строки

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

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

Карточки: структура JSON

Термин

TimestampFieldName

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

Переменная пакета со значением time. Переопределяется при старте до горутин; формат значения задаёт TimeFieldFormat — RFC3339 или Unix-миллисекунды.

1 / 6

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

Следующий урок — тесты логов: bytes.Buffer вместо writer-а, разбор JSON в тесте и фиксация структуры из этого урока как контракта. После — логи и метрики Prometheus: ключи записи становятся колонками дашбордов, а счётчик рядом с событием превращает ленту в алерт. Структура, которую вы сегодня зафиксировали, — это и есть контракт, который будут защищать тесты.

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

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

Вывод

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

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

  • Скелет записи: level, контекст из With, поля в порядке вызовов, message последним.
  • Имена скелета — переменные пакета: time, level, message, error, caller; менять до старта горутин.
  • Dict и Array собирают вложенность методами полей — без рефлексии и аллокаций.
  • Object и EmbedObject — свои типы через MarshalZerologObject; Interface — encoding/json и теги.
  • Форму Interface-поля меняют теги json: фиксируйте структуру тестом.
  • Для Loki: ключи наверху, глубина до одного уровня, message — факт, level — стандарт.
  • Время — машиночитаемое: TimeFormatUnixMs парсится пайплайном однозначнее RFC3339.
  • RawJSON пишет готовые байты без проверок и экранирования — ответственность на вызывающем.

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

Соберите событие с тремя видами структуры: Dict для клиента, Array для идентификаторов и Object на собственном типе с MarshalZerologObject — и рассмотрите JSON: проверьте порядок ключей и то, что message в хвосте. Переопределите TimestampFieldName на ts при старте и убедитесь, что лента сменила имя ключа. Затем добавьте структуру с тегами json и передайте её через Interface: переименуйте поле, добавьте omitempty и посмотрите, как форма записи поменялась без правки вызова. Напишите тест, который пишет это событие в буфер и сверяет набор ключей верхнего уровня — он станет стражем контракта. Напоследок посчитайте байты: плоский вариант против варианта с тремя уровнями вложенности — разницу увидите на первой тысяче событий.

Личные заметкиПусто
Хранятся только в вашем браузере (zerolog · Структура JSON: ключи и вложенность)

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

Какие имена ключей использует zerolog в JSON?

Скелетные имена — переменные пакета: TimestampFieldName равен time, LevelFieldName — level, MessageFieldName — message, ErrorFieldName — error, CallerFieldName — caller. Их можно переопределить при старте программы до запуска горутин — это обычные переменные без защиты от гонок. Собственные поля именуете вы: API массового переименования в библиотеке нет, ключи Str — ваши строковые литералы.

Как вложить объект или массив в запись zerolog?

Dict: zerolog.Dict() создаёт безымянное событие, методы полей наполняют его, а e.Dict(key, dict) вклеивает объект под ключом. Array: zerolog.Arr() с тем же словарём методов и e.Array(key, arr). Для своих типов — Object с методом MarshalZerologObject. Все пути пишут байты напрямую; Interface остаётся для значений, которые сериализует encoding/json по тегам.

Какая структура JSON нужна Loki?

Плоская: json-парсер LogQL берёт ключи верхнего уровня как поля фильтров, а вложенные объекты разворачивает в имена с подчёркиваниями. Держите service, level, request_id и op наверху и ограничьте глубину одним уровнем. Настройте пайплайн на разбор time как метки события, иначе записи получат время приёма, и графики соврут при задержках доставки.

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

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

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

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

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

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

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