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

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

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

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

Сериализация: JSON в сообщениях

В связке kafka json golang всё держится на двух функциях стандартной библиотеки: json.Marshal у продюсера и json.Unmarshal у консьюмера, потому что Value в kafka.Message — это просто срез байтов. Разберём сериализацию сообщений как контракт: теги struct, общий пакет, поведение при расхождении схем и цену формата.

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

Продюсер из четвёртого урока отправлял то, что ему положили: []byte("charge-100") — и этого хватало, пока сообщений было немного и форма была очевидна. Как только событие обрастает полями — идентификатор заказа, аккаунт, сумма, время, — возникает вопрос, на который Kafka сама не отвечает: в каком виде байты лежат в партиции. Брокеру всё равно, для него Value — непрозрачный срез; соглашение о форме — дело продюсера и консьюмера, и это соглашение называется сериализацией сообщений. В Go-стеке самый частый ответ — JSON из стандартной библиотеки: json.Marshal собирает структуру в байты перед отправкой, json.Unmarshal читает их на другом конце. Формат выбирается один раз и живёт годами: смена формы Value — это миграция всех читателей топика, а не правка одного поля. Поэтому разберём не «как вызвать Marshal» — это одна строка, — а то, что вокруг неё: теги, общий пакет контрактов, поведение при расхождении схем и точки, где JSON начинает вредить.

kafka json golang: Value в kafka.Message это []byte#

Вернёмся к анатомии сообщения из второго урока с новой стороны. kafka.Message несёт несколько полей, и для сериализации значимы три: Key — маршрут по партициям, Value — полезная нагрузка, Headers — пары ключ-значение для метаданных. Поле Value объявлено как []byte, и это не упрощение kafka-go, а честная позиция брокера: Kafka не знает про ваши домены и не обязана знать. Связка «kafka json golang» устроена поэтому тривиально: json.Marshal(order) возвращает байты, они кладутся в Value, консьюмер вызывает json.Unmarshal(m.Value, &event) — и событие снова структура. Сериализационного слоя в kafka-go нет, и это плюс: библиотека не тянет ни protobuf-рантайм, ни avro-схемы, а решение о формате остаётся за вами и меняется без вопросов к клиенту. Заметим и цену: JSON избыточен — имена полей повторяются в каждом сообщении, числа без схемы гадают типы, а деньги во float64 живут с погрешностью. Для сервисных потоков это допустимо, для больших объёмов — нет; альтернативы разберём ниже, а пока зафиксируем правило: то, что вы положили в Value, интерпретирует только ваш же код, и чем раньше оба конца согласуют форму, тем дешевле каждое следующее изменение.

Общий пакет контрактов: форма в одном месте

GO
// events/events.go — пакет импортируют продюсер и консьюмер
package events

import "encoding/json"

type OrderPaid struct {
        OrderID     string `json:"order_id"`
        AccountID   string `json:"account_id"`
        AmountCents int64  `json:"amount_cents"`
}

func (e OrderPaid) Marshal() ([]byte, error) {
        return json.Marshal(e)
}

func ParseOrderPaid(value []byte) (OrderPaid, error) {
        var e OrderPaid
        err := json.Unmarshal(value, &e)
        return e, err
}

Пакет events — единственное место, где живёт форма события: структура, теги и две обёртки над Marshal и Unmarshal. Продюсер вызывает OrderPaid.Marshal и кладёт байты в Value, консьюмер — ParseOrderPaid на выходе ReadMessage. Деньги сразу в целых копейках: amount_cents int64 не страдает погрешностью float64. Теги задают нижний регистр с подчёркиваниями — форму, которую прочитает и соседний сервис на Python, и отладочный скрипт.

Теги json: поведение, о котором легко забыть#

Тег json — это спецификация формы, и у него есть поведение, которое лучше знать до инцидента. Без тега encoding/json использует имя поля как есть: OrderID превратится в «OrderID» с заглавной буквы, а консьюмер на другом языке ждёт order_id — поэтому теги пишут на обеих сторонах, даже если кажется, что и так совпадёт. omitempty пропускает пустые значения при маршаллинге, но ноль — легитимное значение для суммы, скидки или счётчика: если ноль нужно отличать от отсутствия, берите указатели, а не флаг. Неэкспортированные поля не сериализуются вовсе: маленькая буква в имени поля — и поле исчезло из сообщения без предупреждений. При декодировании неизвестные поля игнорируются — это фундамент обратной совместимости: продюсер добавляет поля, и старый консьюмер не падает. Но игнорирование работает в обе стороны: опечатка в теге не вызовет ошибки ни у кого, события просто потеряют поле. Самый дешёвый тест контракта — unit-тест, который маршалит и тут же декодирует структуру и сверяет все поля: он же документирует событие лучше любого комментария.

Проверка: новое поле и старый консьюмер

Продюсер добавил в событие новое поле, консьюмер декодирует сообщение старой структурой. Что произойдёт?

Сериализация сообщений как релизный процесс#

Идея общего пакета проста, а экономия огромна: структуры событий живут в одном пакете — events, contracts, как ни назовите, — и оба сервиса импортируют его. Один процесс маршалит, второй декодирует, форма существует в единственном экземпляре, и правка проходит одним коммитом через оба сервиса, если они в одном репозитории. Внутри монорепозитория это самая дешёвая схема из возможных: компилятор сам проверяет использование полей, а rename-рефакторинг переименовывает и продюсер, и консьюмер за один заход. С отдельными репозиториями честнее сразу признать: контракт — внешний интерфейс, его изменение — релиз с миграцией, а не правка. Вот где встаёт поле Headers из kafka.Message: тип события и версия схемы едут в заголовках, Value декодируется по ним, и два формата спокойно сосуществуют в одном топике. Полноценный schema registry kafka-go не предоставляет — при потребности подключают внешний реестр или protobuf; но для большинства сервисных потоков общий пакет закрывает задачу целиком, а заголовки дают место для развилки на время перехода.

Продюсер и консьюмер на одном пакете

GO
// продюсер: структура в байты одним вызовом
event := events.OrderPaid{OrderID: order.ID, AccountID: acc.ID, AmountCents: amount}
value, err := event.Marshal()
if err != nil {
        log.Printf("marshal order %s: %v", order.ID, err)
        return
}
err = writer.WriteMessages(ctx, kafka.Message{
        Key:   []byte(order.ID),
        Value: value,
})

// консьюмер: байты обратно в структуру
m, err := reader.ReadMessage(ctx)
if err != nil {
        log.Printf("read: %v", err)
        break
}
paid, err := events.ParseOrderPaid(m.Value)
if err != nil {
        log.Printf("broken message %s/%d/%d: %v", m.Topic, m.Partition, m.Offset, err)
        continue
}
process(paid)

Обе стороны используют один пакет events: продюсер маршалит структуру и кладёт байты в Value рядом с ключом заказа, консьюмер декодирует Value той же формой. Ошибка маршаллинга не убивает поток — событие логируется и пропускается. Ошибка декодирования у консьюмера — признак битого или чужого сообщения: адрес топик-партиция-оффсет в логе позволяет найти виновника вручную, а обработка продолжается со следующего события.

Правила сериализации одним списком#

  • Value — срез байтов: форма содержимого — договорённость продюсера и консьюмера, брокер её не знает.
  • Форма события живёт в одном пакете: Marshal и Unmarshal обеих сторон собираются из одной структуры.
  • Теги пишутся всегда: без тега поле уезжает с заглавной буквы и не читается снаружи.
  • Ноль и отсутствие — разные вещи: нужны оба — берите указатели, а не omitempty.
  • Неизвестные поля при декодировании игнорируются: добавление поля безопасно, переименование — миграция.
  • Деньги — в целых копейках int64: float64 рано или поздно даст 19.990000000000002.
  • Тип и версия события — в Headers: Value декодируется по заголовку, а не по угадыванию.

Проверка: обрезанное сообщение

В Value оказалось обрезанное сообщение — сеть порезала байты посреди JSON. Что вернёт json.Unmarshal?

Что вернёт Unmarshal при несовпадении типов?

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

type Produced struct {
        OrderID string  `json:"order_id"`
        Amount  float64 `json:"amount"`
}

type Consumed struct {
        OrderID string `json:"order_id"`
        Amount  int     `json:"amount"`
}

// в Value лежит: {"order_id":"o-1","amount":19.99}
var consumed Consumed
err := json.Unmarshal(value, &consumed)
fmt.Println(consumed.OrderID, consumed.Amount, err != nil)

Когда JSON — не тот выбор#

JSON остаётся дефолтом, потому что читается человеком, дебажится консольными утилитами и не тянет зависимостей, — но у него есть пределы, и лучше узнать о них до внедрения. Первый — размер: имена полей повторяются в каждом сообщении, и на потоках в десятки тысяч событий в секунду вы платите трафиком и диском за повторение order_id миллион раз. Второй — скорость: рефлексия encoding/json не бесплатна, а Writer копит батчи по байтовому лимиту из десятого урока — чем меньше сообщение, тем больше событий уезжает в одном produce-запросе. Третий — типы: у JSON нет десятичных дробей, время живёт строкой или числом, и каждая такая вещь требует конвенции. Альтернативы старые как сам Kafka: protobuf даёт компактность и строгие типы за счёт генерации кода из схемы, avro — эволюцию схем с реестром, msgpack — компромисс по размеру без отдельной схемы. kafka-go честно равнодушен: Value останется []byte при любом выборе, менять придётся только места Marshal и Unmarshal — ещё один аргумент за общий пакет из прошлого раздела. Начинайте с JSON, переходите тогда, когда цифры, а не мода, скажут, что формат стал узким местом.

Форматы Value одним столом#

ФорматЧем сериализуетсяКогда брать
JSON, encoding/jsonСтандартная библиотека: Marshal и UnmarshalСервисные потоки, отладка, старт проекта
protobufГенерация кода из .proto, строгие типыБольшие объёмы и межъязыковые контракты
avroСхемы с реестром и эволюцией версийДолгоживущие потоки с изменяемой схемой
[]byte без форматаСвои кодеки поверх среза байтовВнутренние потоки с готовым бинарным payload

Яд-сообщения: когда сериализация ломает чтение#

Худший сценарий сериализации происходит не на продюсере, а на консьюмере: одно сообщение, которое не декодируется, — и группа стоит. Механика проста: ReadMessage возвращает событие, Unmarshal падает, код логирует ошибку и делает continue — но оффсет ещё не закоммичен, и после рестарта группа начнёт с того же места. Топик зацикливается на битом сообщении, лаг растёт, дежурный смотрит в лог с тысячей одинаковых строк. Отсюда правило: ветка ошибки декодирования — это решение о судьбе события, а не строчка лога. Варианты — пропустить с ручным коммитом оффсета, отправить в карантинный топик или ждать фикса с паузой; развёрнутый разбор с DLQ-паттерном — в девятнадцатом уроке. json.Valid позволяет отсечь грубую порчу до Unmarshal, а залогированные топик, партиция и оффсет — найти сообщение консольной утилитой и понять, что в нём лежит. В песочнице это не воспроизвести: нужен локальный брокер из Docker-стенда третьего урока — там и накатайте сценарий с битым Value.

Соберите обёртки общего пакета

Допишите тип возврата маршаллинга, имя ключа в теге и функцию декодирования.

type OrderPaid struct { OrderID string `json:""` } func Marshal(e OrderPaid) (, error) { return json.Marshal(e) } func Parse(value []byte) (OrderPaid, error) { var e OrderPaid err := json.(value, &e) return e, err }

Соберите круговорот события

Расставьте строки полного цикла: продюсер маршалит и отправляет, консьюмер читает, декодирует и обрабатывает — с ветками ошибок на своих местах.

Строки

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

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

Карточки: сериализация сообщений

Термин

kafka.Message.Value

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

Срез []byte: брокер не знает его форму; JSON, protobuf или свой формат — выбор продюсера и консьюмера.

1 / 6

Форма Value теперь под контролем: структуры в общем пакете, теги выписаны, декодирование с веткой ошибки, а формат выбран осознанно, а не по привычке. Дальше по курсу: шестой урок про первого консьюмера напомнит, как устроен цикл чтения, в который встаёт ParseOrderPaid; девятнадцатый урок про ошибки консьюмера доведёт яд-сообщения до карантинного топика; двадцать второй урок про эволюцию схем покажет, как менять контракт без остановки потока. Формат честности прежний: код урока запускается локально против брокера из Docker, а интерактивы ниже тренируют механику Marshal, Unmarshal и тегов на программах в чистом Go — прямо в песочнице, без сети и брокера.

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

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

Вывод

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

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

  • Value в kafka.Message это []byte: форма содержимого — контракт продюсера и консьюмера, а не забота брокера.
  • json.Marshal перед отправкой и json.Unmarshal при чтении — минимальная сериализация сообщений в Go.
  • Форма события живёт в одном пакете: структуры, теги и обёртки — единственный источник правды.
  • Теги задают имена полей: без тега поле уезжает с заглавной буквы и не читается снаружи.
  • Неизвестные поля при декодировании игнорируются: добавление поля обратно совместимо, переименование — миграция.
  • Деньги — в целых копейках int64: float64 накапливает погрешность на каждом сообщении.
  • Ветка ошибки Unmarshal — решение о судьбе события: пропустить с коммитом, отправить в карантин или ждать фикс.

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

Вынесите структуры событий платёжного сервиса в пакет events: OrderPaid и OrderRefunded с тегами в snake_case и суммой в копейках. Напишите unit-тест контракта: Marshal свежим продюсером, Unmarshal свежим консьюмером, сверка всех полей — плюс тест на сообщение с лишним неизвестным полем, оно должно декодироваться без ошибки. Против брокера из третьего урока отправьте десять событий и убедитесь консьюмером, что формы совпали. Финал: сломайте тег на консьюмерной стороне (order_id в orderid) и опишите по логам, что вы увидите и на каком этапе это поймается.

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

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

Нужно ли сжимать JSON перед отправкой?

kafka-go умеет сжатие на уровне продюсера через CompressionCodec в WriterConfig, и JSON сжимается хорошо: повторяющиеся имена полей — его слабость и лучший друг gzip. Кодеки — константы kafka.Gzip, kafka.Snappy, kafka.Lz4 и kafka.Zstd, и какой из них выиграет — предмет замера на вашем payload: форматы выигрывают на разных типах данных. Но сжатие — тема производительности, а не контракта: включайте его, когда измерение покажет, что трафик стоит денег. Для консьюмера разница невидима — брокер отдаёт уже разжатые байты, и формат Value не меняется.

Почему не взять gob или binary.Marshal вместо JSON?

gob привязывает оба конца к Go: утилиты консоли, дашборды и скрипты на других языках останутся за бортом, а однажды появится консьюмер на другом языке — и придётся переписывать. JSON из стандартной библиотеки честнее как общий язык экосистемы, а для скорости и компактности существуют protobuf и avro. Смена формата позже — миграция всех читателей топика, поэтому выбирайте сразу с мыслью о внешних потребителях, даже если их пока нет.

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

Тип и версия — в Headers: kafka.Message.Headers, срез пар строка-байты, читается без декодирования тела. По заголовку выбираете структуру и вызываете нужный Parse. Второй путь — поле-дискриминатор внутри самого JSON, но оно требует декодировать Value хотя бы в json.RawMessage, чтобы заглянуть. Оба пути работают; заголовки чище, потому что не трогают тело события.

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

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

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

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

Ebitengine: 2D-игры на Go

Курс Ebitengine для Go: игровой цикл Update/Draw, спрайты и анимации, ввод, коллизии, тайлмапы, камера, звук, сцены и сборка на платформы. 25 уроков.

  • Понимать игровой цикл Update/Draw и Layout
  • Рисовать спрайты, атласы и примитивы
  • Обрабатывать ввод: клавиатура, мышь, тач
  • Строить движение, коллизии и анимации