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

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

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

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

Эволюция схем сообщений

Сообщения Kafka живут в топике часами и неделями, а код продюсера и консьюмера выкатывается в разные моменты: старые события прочитает новый код, новые события — старый код, и оба должны выжить. Эволюция схем — дисциплина, при которой структура json меняется без остановки потока: только добавляйте поля, держите опциональные с дефолтами, носите версию в payload и мигрируйте в хендлере. Разберём безопасные и ломающие изменения, поведение Unmarshal на старых и новых бинарях и план вывода старой версии из обращения.

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

Продюсер нового кода выкатился, консьюмер — ещё нет: между ними в топике лежат сообщения, которые никто не согласовал. Kafka не конвертирует данные — она переносит байты, и единственная совместимость, которая существует, — та, что вы построили в формате сообщений. Отсюда главный принцип эволюции схем: продюсер и консьюмер деплоятся не одновременно, и каждый из них обязан читать сообщения, созданные любой версией, живущей в ретеншене. Старый код встречает новые сообщения, новый код — старые, и ретеншен из семнадцатого урока гарантирует, что разрыв во времени может растянуться на весь срок хранения данных. Одиннадцатый урок собрал json-конвейер, четырнадцатый — Reader, девятнадцатый показал, во что превращается поток, когда консьюмер падает на нечитаемом; здесь — как не создавать таких сообщений собственными руками: правила обратной совместимости для json, версионирование payload и распознавание изменений, которые нельзя вкатывать в текущую схему.

Обратная совместимость: только добавлять поля#

Безопасное изменение схемы одно, а всё остальное — вариации вокруг него: добавление нового поля. Старый консьюмер читает сообщение с незнакомым полем и молча его игнорирует, новый консьюмер встречает сообщение без этого поля и получает нулевое значение — обе стороны работают. Правило распространяется на всё: добавлять поля можно, удалять и переименовывать нельзя, потому что переименование для потребителя — одновременно удаление старого поля и добавление нового со старыми данными, а удаление отнимает у нового кода данные, которые старый код ещё продолжает отправлять. Отсюда же запрет на смену смысла при сохранении имени: поле amount, означавшее копейки, внезапно означающее рубли, — это ломающее изменение, замаскированное под совместимое, и код против него бессилен, потому что байты выглядели знакомыми. Практическая дисциплина: каждое изменение схемы прогоняется через вопрос «что увидит консьюмер противоположной версии, открыв это сообщение» — если ответ «ошибка или молчаливую ерунду», изменение ломающее, и путь ему не в текущий топик, а в новую версию payload.

Опциональные поля: указатели и omitempty#

Добавленное поле требует аккуратности с обеих сторон. На продюсере json-тег omitempty не записывает поле в сообщение, когда оно пустое: старые потребители получают компактный json без новых имен — с ними и нечего было читать. На консьюмере поле, которого в сообщении нет, распаковывается в нулевое значение, и тут кроется ловушка: ноль и «поле отсутствовало» — разные вещи, а нулевое значение типа их не различает. Для различения поле делают указателем: nil означает «сообщение старой схемы, поля не было», ненулевой указатель — «поле пришло», и пустая строка внутри — честные данные. Это и есть дефолтная политика совместимости: новое поле опционально у обеих сторон, консьюмер при nil применяет свой локальный дефолт, и только когда выкат завершён и минимальная версия потребителя подтянулась, поле объявляют обязательным. Поля-структуры ведут себя так же — указатель на вложенную структуру различает «отсутствует» и «пустая» без сюрпризов распаковки. Вторая половина правила — на стороне продюсера: он не имеет права требовать от старых потребителей поля, которого те никогда не читали, поэтому обязательность вводится лишь после того, как весь рой консьюмеров обновился.

Схема с версией и опциональным полем

GO
type PaymentCreated struct {
        Version    int    `json:"version"`
        OrderID    string `json:"order_id"`
        Amount     int64  `json:"amount"`
        ClientTier *int   `json:"client_tier,omitempty"`
}

func derefInt(p *int) int {
        if p == nil {
                return 0 // дефолт для старых сообщений
        }
        return *p
}

Version — обязательное поле любой эволюционирующей схемы: его пишут с первого дня, когда оно ещё не нужно. ClientTier — указатель с omitempty: nil различает «сообщение старой схемы» и «пустое значение», omitempty не записывает nil в json. derefInt локализует дефолт в одном месте — хендлер не разбрызгивает проверки на nil по всему коду.

Проверка: зачем указатель

Новое поле ClientTier сделано указателем *int с omitempty. Какую проблему это решает?

Версионное поле и миграция в хендлере#

Только добавляемые поля покрывают мирные изменения, но однажды понадобится поменять смысл данных — и для этого схема носит версию в самом payload. Поле version пишется продюсером с первого дня: даже когда схема одна, число уже в сообщении, и изменение становится операцией, а не катастрофой. Миграция живёт в хендлере консьюмера: switch по версии, ветка для текущей схемы, ветка для предыдущей, дефолт с ошибкой для незнакомых версий. Ветка старой версии — это не копия бизнес-логики, а адаптер: поднять значение до нужной структуры и вызвать общий путь обработки, чтобы бизнес-код не знал о существовании версий. Дефолтная ветка важнее, чем кажется: консьюмер, встретивший версию из будущего, должен ответить осмысленной ошибкой и уйти в ретраи-очередь, а не обработать сообщение по первому case, случайно совпавшему по структуре. Срок жизни ветки ограничен ретеншеном топика: когда все сообщения старой версии вышли из хранения, ветка удаляется — это план вывода, записанный рядом с кодом, а не в головах.

Хендлер с двумя ветками версии

GO
func handlePayment(raw []byte) error {
        var msg PaymentCreated
        if err := json.Unmarshal(raw, &msg); err != nil {
                return err
        }
        switch msg.Version {
        case 0, 1:
                return applyPayment(msg.OrderID, msg.Amount, 0)
        case 2:
                return applyPayment(msg.OrderID, msg.Amount, derefInt(msg.ClientTier))
        default:
                return fmt.Errorf("payment: версия %d не поддерживается", msg.Version)
        }
}

Unmarshal заполняет struct: новые поля старых сообщений остаются нулевыми, отсутствие старых полей в новых сообщениях — тоже. Ветка case 0, 1 — адаптер, подставляющий дефолт tier и зовущий общий applyPayment; бизнес-логика версий не знает. Default защищает от сообщений из будущего: ошибка уводит событие в разбор вместо молчаливой порчи данных.

Что сделает старый консьюмер с новым сообщением?

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

// Сообщение продюсера версии 2:
// {"version":2,"order_id":"a-1","amount":500,
//  "client_tier":3}
// Бинарь консьюмера версии 1: struct без
// ClientTier, json-теги те же.
err := json.Unmarshal(raw, &msg)

Анврап в struct: старое и новое место#

Механику стоит сформулировать точно, потому что она бесплатно даёт половину совместимости. Старый код, распаковывающий новое сообщение: незнакомые поля игнорируются — Unmarshal не возвращает ошибки на лишние имена, поля структуры заполняются тем, что нашлось. Новый код, распаковывающий старое сообщение: отсутствующие поля получают нулевые значения — ошибки тоже нет, и лишь указатель или отдельный флаг различает «не было» и «пусто». Оба поведения поддерживают правило «только добавлять»: добавленное поле на старой стороне прозрачно, добавленное поле на новой стороне даёт дефолт. Третий случай, про который забывают, — строгий режим: json.Decoder с опцией DisallowUnknownFields превращает незнакомое поле в ошибку. Для публичных контрактов это осознанный выбор «падать громко на изменениях схемы», но для потока на kafka он почти всегда вреден: одно добавленное поле останавливает группу с ядовитым эффектом из девятнадцатого урока. Поэтому дефолт json-go — терпимость на анврапе, а контроль схем делают не в рантайме, а в тестах из двадцатого урока: фикстуры сообщений каждой версии прогоняются через хендлер в CI, и ломающее изменение ловится до выката.

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

В схеме PaymentCreated поле Amount сменили с int64 на string. Что из этого верно?

Изменение → безопасно или нет#

ИзменениеВердиктЧто видит противоположная сторона
Добавить поле с omitemptyБезопасноСтарый код игнорирует, новый получает nil и ставит дефолт
Добавить обязательное поле сразуОпасноСтарые сообщения без поля проваливают валидацию нового кода
Удалить полеЛомающееНовый код теряет данные; откат не читает свежие сообщения
Переименовать полеЛомающееОдновременно удаление и добавление: старое имя пусто, новое незнакомо
Сменить тип поляЛомающееОшибка Unmarshal на всей истории сообщений со старым типом
Сменить смысл значенияЛомающее сильнее всехБайты совпадают, данные врут: ни одна ошибка не покажет место
Поднять version и ветку хендлераБезопасноКаждый бинарь обрабатывает свою ветку, чужая версия — осмысленная ошибка

Ломающие изменения: новый топик или новая версия#

Ломающее изменение всё-таки случилось — смена типа, переименование, новый смысл поля. Путь первый, чистый: новый топик. Продюсер пишет обе схемы параллельно, консьюмеры переезжают своим темпом, старый топик гасят после окончания ретеншена; это дорогая операция, зато версии физически не встречаются. Путь второй, обыденный: новая версия в том же payload — version поднимается, хендлер получает вторую ветку, а продюсер переключается на неё одним коммитом. Оба пути требуют плана вывода старой версии: срок жизни ветки равен ретеншену топика плюс время выката всех потребителей, и после этого срока ветка удаляется, а незнакомая версия в дефолтной ветке остаётся ошибкой. Отдельно — правило продюсера: он переключается на новую версию только после того, как все потребители её понимают, иначе новый код руками создаёт яд для старого кода. И правило честности в уроках: интерактивы здесь разыгрывают распаковку на программах в чистом Go без сети — json в руках стандартной библиотеки, никакого брокера не нужно.

Правила эволюции схем#

  • Version в payload пишется с первого дня: изменение схемы — операция с кодом, а не авария.
  • Только добавлять: новые поля опциональны у обеих сторон, обязательность — после выката всего роя.
  • Указатель и omitempty различают «поле отсутствовало» и «поле пустое»; дефолт ставит консьюмер.
  • Миграция в хендлере: switch по version, ветки-адаптеры зовут общий бизнес-путь, дефолт — ошибка.
  • Смена типа, переименование и смена смысла не вкатываются: новый топик или новая версия с планом вывода.
  • Срок жизни старой ветки равен ретеншену плюс время выката; фикстуры всех версий гоняются в тестах.

Допишите совместимую схему

Впишите имя версионного поля в json-тег и опцию тега, которая не записывает пустое поле в сообщение.

type PaymentCreated struct { Version int `json:""` OrderID string `json:"order_id"` Amount int64 `json:"amount"` ClientTier *int `json:"client_tier,"` }

Соберите порядок безопасного выката

Расставьте шаги добавления поля в живой поток: схема, консьюмер, продюсер, наблюдение, обязательность, версия.

Строки

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

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

Карточки: эволюция схем

Термин

Правило обратной совместимости

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

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

1 / 6

Итог части: эволюция схем — это контракты на три стороны — продюсер, консьюмер и хранилище, и все правила урока существуют ради момента, когда коды расходятся. Только добавляемые поля дают совместимость бесплатно, указатели различают отсутствие от пустоты, версия в payload превращает ломающее изменение в управляемую операцию с двумя ветками и планом вывода, а тесты с фикстурами всех версий держат всё это под замком в CI. Дальше по курсу: урок про json собрал конвейер, внутри которого живёт схема; урок про ошибки консьюмера показал яд-сообщения — цену ломкой эволюции, если её всё-таки вкатить; урок про Reader — то место, где сообщение приходит к вашему хендлеру; а главная курса — карта всего пути от первого продюсера до продакшена. Начните в песочнице: три фикстуры, один хендлер, отладчик — и вы увидите руками, что старый и новый код читают друг друга без единой строчки синхронного деплоя.

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

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

Вывод

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

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

  • Продюсер и консьюмер деплоятся не одновременно: совместимость строится в формате сообщений.
  • Безопасное изменение — добавление поля: старый код игнорирует незнакомое, новый ставит дефолт.
  • Указатель с omitempty различает «поле отсутствовало» и «поле пустое»; дефолт применяет консьюмер.
  • Version пишется в payload с первого дня и позволяет миграцию внутри хендлера.
  • Switch по version: ветки-адаптеры зовут общий бизнес-путь, незнакомая версия — осмысленная ошибка.
  • Удаление, переименование, смена типа и смена смысла — ломающие: новый топик или новая версия.
  • Срок жизни старой ветки — ретеншен плюс время выката; фикстуры всех версий проверяются тестами.
  • DisallowUnknownFields для kafka-потока почти всегда вреден: незнакомое поле уложит группу.

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

Соберите в песочнице три фикстуры PaymentCreated: версия 1 без client_tier, версия 2 с client_tier и «будущая» версия 3 с полем refund_from. Напишите хендлер из урока и прогоните все три: зафиксируйте, что старые и новые сообщения распаковываются без ошибок, а версия 3 уходит в default с осознанной ошибкой. Затем воспроизведите инцидент: переименуйте json-тег order_id в orderId и убедитесь, что Unmarshal молчит, а в обработку уезжает пустая строка, — и добавьте валидацию обязательных полей, которая ловит это до бизнес-логики. Финал: напишите миграцию, где поле Amount переезжает в AmountKopecks со сменой смысла, — оформите её как новую версию payload с двумя ветками и планом вывода старой, отметив в комментариях срок жизни ветки относительно ретеншена топика.

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

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

Нужна ли версия в payload, если схема меняется только добавлением полей?

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

Почему старый код игнорирует новые поля json, но ломается на смене типа?

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

Когда выбирать новый топик, а когда хватит версии в payload?

Версия в payload — путь по умолчанию: дёшево, атомарно и совпадает с механикой анврапа. Новый топик оправдан, когда схемы несовместимы структурно и по смыслу — например, событие переименовано в команду с другим набором полей, — когда старый поток должен продолжать жить для других потребителей или когда нужен разный ретеншен и компакция из семнадцатого урока. Ориентир по цене: версия — коммит и ветка в хендлере; новый топик — два выката, параллельная запись и план гашения старого. Если сомневаетесь — начните с версии, топик добавите, когда версия перестанет справляться.

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

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

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

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

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

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

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