Содержание урока · 10 разделов
- Обратная совместимость: только добавлять поля
- Опциональные поля: указатели и omitempty
- Версионное поле и миграция в хендлере
- Анврап в struct: старое и новое место
- Изменение → безопасно или нет
- Ломающие изменения: новый топик или новая версия
- Правила эволюции схем
- Песочница урока
- Коротко: что запомнить
- Вопросы и ответы
Продюсер нового кода выкатился, консьюмер — ещё нет: между ними в топике лежат сообщения, которые никто не согласовал. Kafka не конвертирует данные — она переносит байты, и единственная совместимость, которая существует, — та, что вы построили в формате сообщений. Отсюда главный принцип эволюции схем: продюсер и консьюмер деплоятся не одновременно, и каждый из них обязан читать сообщения, созданные любой версией, живущей в ретеншене. Старый код встречает новые сообщения, новый код — старые, и ретеншен из семнадцатого урока гарантирует, что разрыв во времени может растянуться на весь срок хранения данных. Одиннадцатый урок собрал json-конвейер, четырнадцатый — Reader, девятнадцатый показал, во что превращается поток, когда консьюмер падает на нечитаемом; здесь — как не создавать таких сообщений собственными руками: правила обратной совместимости для json, версионирование payload и распознавание изменений, которые нельзя вкатывать в текущую схему.
Обратная совместимость: только добавлять поля#
Безопасное изменение схемы одно, а всё остальное — вариации вокруг него: добавление нового поля. Старый консьюмер читает сообщение с незнакомым полем и молча его игнорирует, новый консьюмер встречает сообщение без этого поля и получает нулевое значение — обе стороны работают. Правило распространяется на всё: добавлять поля можно, удалять и переименовывать нельзя, потому что переименование для потребителя — одновременно удаление старого поля и добавление нового со старыми данными, а удаление отнимает у нового кода данные, которые старый код ещё продолжает отправлять. Отсюда же запрет на смену смысла при сохранении имени: поле amount, означавшее копейки, внезапно означающее рубли, — это ломающее изменение, замаскированное под совместимое, и код против него бессилен, потому что байты выглядели знакомыми. Практическая дисциплина: каждое изменение схемы прогоняется через вопрос «что увидит консьюмер противоположной версии, открыв это сообщение» — если ответ «ошибка или молчаливую ерунду», изменение ломающее, и путь ему не в текущий топик, а в новую версию payload.
Опциональные поля: указатели и omitempty#
Добавленное поле требует аккуратности с обеих сторон. На продюсере json-тег omitempty не записывает поле в сообщение, когда оно пустое: старые потребители получают компактный json без новых имен — с ними и нечего было читать. На консьюмере поле, которого в сообщении нет, распаковывается в нулевое значение, и тут кроется ловушка: ноль и «поле отсутствовало» — разные вещи, а нулевое значение типа их не различает. Для различения поле делают указателем: nil означает «сообщение старой схемы, поля не было», ненулевой указатель — «поле пришло», и пустая строка внутри — честные данные. Это и есть дефолтная политика совместимости: новое поле опционально у обеих сторон, консьюмер при nil применяет свой локальный дефолт, и только когда выкат завершён и минимальная версия потребителя подтянулась, поле объявляют обязательным. Поля-структуры ведут себя так же — указатель на вложенную структуру различает «отсутствует» и «пустая» без сюрпризов распаковки. Вторая половина правила — на стороне продюсера: он не имеет права требовать от старых потребителей поля, которого те никогда не читали, поэтому обязательность вводится лишь после того, как весь рой консьюмеров обновился.
Схема с версией и опциональным полем
GOtype 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, случайно совпавшему по структуре. Срок жизни ветки ограничен ретеншеном топика: когда все сообщения старой версии вышли из хранения, ветка удаляется — это план вывода, записанный рядом с кодом, а не в головах.
Хендлер с двумя ветками версии
GOfunc 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 защищает от сообщений из будущего: ошибка уводит событие в разбор вместо молчаливой порчи данных.
Инцидент: разработчик «для чистоты» переименовал order_id в orderId в структуре продюсера без json-тега в старом коде. Консьюмеры одной версии не упали: Unmarshal пропустил незнакомое поле, а знакомое order_id получил нулевой строкой — платежи стали обрабатываться с пустым идентификатором заказа, и никто не увидел ни одной ошибки до отчётности. Мораль двойная: переименование — это удаление плюс добавление, ломающее с обеих сторон; и нулевые значения после распаковки не сигналят — они просто едут дальше в бизнес-логику. Валидация обязательных полей на входе хендлера — страховка, которая в этом инциденте сработала бы в первый день: пустой order_id отклоняется с ошибкой, а не доезжает до базы.
Что сделает старый консьюмер с новым сообщением?
Прочитайте код и выберите, что он выведет:
// Сообщение продюсера версии 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-тег и опцию тега, которая не записывает пустое поле в сообщение.
Соберите порядок безопасного выката
Расставьте шаги добавления поля в живой поток: схема, консьюмер, продюсер, наблюдение, обязательность, версия.
Строки
Ваша программа
Нажимайте на строки слева, чтобы собрать программу
Вся механика урока — стандартная библиотека encoding/json, и она полностью живёт локально: сохраните три json-фикстуры версии 1, 2 и будущей, анврапьте их в struct из урока и посмотрите в отладчике, какие поля заполнились, где nil и что вернёт ветка default. Песочница здесь — просто файлы фикстур и отладчик. Брокер не нужен вовсе: совместимость — свойство формата, а не транспорта. В песочнице полезнее всего воспроизвести инцидент с переименованием: смените тег order_id на orderId в одной структуре и увидьте пустую строку там, где ждал заказ. А продюсер и Reader против настоящего Docker-брокера из третьего урока подключают локально, когда схема уже устойчива и хочется проверить поток целиком.
Карточки: эволюция схем
Итог части: эволюция схем — это контракты на три стороны — продюсер, консьюмер и хранилище, и все правила урока существуют ради момента, когда коды расходятся. Только добавляемые поля дают совместимость бесплатно, указатели различают отсутствие от пустоты, версия в 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 с двумя ветками и планом вывода старой, отметив в комментариях срок жизни ветки относительно ретеншена топика.
Личные заметкиПусто
Вопросы и ответы по уроку
Нужна ли версия в payload, если схема меняется только добавлением полей?
Да, и именно потому, что «только добавление» когда-нибудь кончится. Пока все изменения обратимо-добавочные, версию никто не читает, но поле уже в каждом сообщении, и день ломающей замены превращается в коммит, а не в миграцию кластера. Второй аргумент — диагностика: сообщение с version отвечает на вопрос «кто его создал и по какой схеме», а без версии даже идеальный анврап оставляет вас гадать по составу полей. Стоимость поля — несколько байт; стоимость его отсутствия — весь этот урок.
Почему старый код игнорирует новые поля json, но ломается на смене типа?
Потому что оба случая обрабатываются по имени поля: незнакомое имя пропускается без ошибок, знакомое — распаковывается в заявленный тип, и несовпадение типов становится ошибкой Unmarshal. Добавление поля не трогает ни одно существующее имя, поэтому обе стороны живут; смена типа оставляет имя прежним, но меняет правило распаковки, и вся история сообщений со старым типом превращается в поток ошибок — яд-сообщения из девятнадцатого урока. Отсюда граница эволюции: имена стабильны, изменения уходят в новые имена и новые версии.
Когда выбирать новый топик, а когда хватит версии в payload?
Версия в payload — путь по умолчанию: дёшево, атомарно и совпадает с механикой анврапа. Новый топик оправдан, когда схемы несовместимы структурно и по смыслу — например, событие переименовано в команду с другим набором полей, — когда старый поток должен продолжать жить для других потребителей или когда нужен разный ретеншен и компакция из семнадцатого урока. Ориентир по цене: версия — коммит и ветка в хендлере; новый топик — два выката, параллельная запись и план гашения старого. Если сомневаетесь — начните с версии, топик добавите, когда версия перестанет справляться.
Термины урока
Ключевые понятия из этого урока — короткие определения в глоссарии. Наведите на термин, чтобы вспомнить, откройте карточку для подробностей и ссылки на практику.
Изучайте дальше
Следующая ступень учебного пути
Ebitengine: 2D-игры на Go
Курс Ebitengine для Go: игровой цикл Update/Draw, спрайты и анимации, ввод, коллизии, тайлмапы, камера, звук, сцены и сборка на платформы. 25 уроков.
- Понимать игровой цикл Update/Draw и Layout
- Рисовать спрайты, атласы и примитивы
- Обрабатывать ввод: клавиатура, мышь, тач
- Строить движение, коллизии и анимации