Версионирование контрактов: эволюция добавлением, reserved для мёртвых тегов и buf breaking на страже
Protobuf эволюционирует добавлением: новые поля с новыми тегами деплоятся в любом порядке — неизвестные поля пропускаются, отсутствующие читаются как дефолты. Смена или реюз тега тихо портит данные: reserved хоронит теги, buf breaking стоит в CI, пакет v2 форкает остальное.
Через шесть месяцев после того, как команда прайсинга удалила int64 discount_cents = 5; из сообщения Order, инженер из антифрода добавил int64 risk_score = 5;. Компилятор был доволен, все тесты прошли, деплой зелёный. Потом ночной батч прогнал архив заказов прошлого квартала через новую схему, и фрод-модель начала видеть risk score 1499 и 250 — это были скидки $14.99 и $2.50, декодированные не в то поле. Ошибки не возникло, потому что ошибке неоткуда взяться: декодер сопоставил тег 5, нашёл varint там, где ждал varint, и идеально сделал свою работу. Лояльных клиентов с большими скидками пометили как высокорисковых; три недели тихо отравленных фрод-решений прошли, прежде чем эскалация из поддержки заставила кого-то открыть историю proto-файла. Однострочный фикс, который превратил бы сентябрьское изменение в ошибку компиляции вместо порчи данных: reserved 5;.
Добавление — единственный безопасный глагол
Если вам когда-нибудь было интересно, почему protobuf-схему можно обновлять без синхронизации всех команд — ответ в двух правилах декодера, которые делают большинство изменений невидимыми для старых читателей.
Прошлый урок установил: теги — идентичность на проводе. Версионирование вытекает из двух правил декодера. Неизвестные поля пропускаются, а не отвергаются: старый читатель, встретив незнакомый тег, перешагивает его по wire type (а современный proto3 ещё и сохраняет эти байты при пересериализации, так что прокси не теряют данные). Отсутствующие поля читаются как дефолты: новый читатель старого сообщения видит ноль, пустую строку, false. Вместе это значит: добавить поле со свежим тегом безопасно в обе стороны, и продюсер с консьюмером деплоятся в любом порядке — свойство, которое вообще делает возможным независимый деплой сервисов.
message Order {
string order_id = 1;
int64 total_cents = 2;
string promo_code = 7; // новое поле, новый тег — безопасно в обе стороны
optional int32 rating = 8; // явное присутствие: «не задано» отличимо от 0
}Строка с optional делает настоящую работу. У обычных скаляров proto3 нет присутствия (presence — явное отслеживание того, было ли поле задано): rating равный 0 и никогда не заданный rating сериализуются одинаково — это яд для различия «пользователь поставил ноль звёзд» и «пользователь не оценивал». Ключевое слово optional возвращает явное присутствие — в Go сгенерированное поле становится указателем, и появляется has-проверка. Это же — ответ на вопрос, куда делся required: proto3 убрал его, потому что обязательное поле — контракт, который нельзя раскрутить назад. Стоит одному required-полю уехать в прод — ни один писатель не может его опустить и ни одна схема не может его убрать: каждый старый читатель жёстко отвергает сообщение. Обязательность — это валидация, а валидация живёт в коде приложения, который может эволюционировать, а не в wire-формате, который не может.
Продюсер добавляет string promo_code = 7 и деплоится раньше, чем обновится хоть один консьюмер. Что старые консьюмеры сделают с новым полем?
Запрещённые ходы — и как поле уходит на пенсию
Контракт ломают три изменения, по возрастанию коварства:
- Смена типа — иногда громко, иногда тихо.
stringвint64меняет wire type: старые данные не парсятся или приходят мусором. Тихий вариант:int32иint64делят wire type, ошибок нет — но старыйint32-читатель значения больше 2³¹ молча его усекает. - Смена тега — поле становится на проводе другим полем. Каждое уже записанное сообщение и каждый ещё не задеплоенный писатель теперь с вами не согласны.
- Переиспользование удалённого тега — крючок этого урока. Старые данные и отстающие писатели всё ещё шлют тег со старым смыслом; если типы wire-совместимы, декодер даже не способен заметить. Это единственная порча здесь, невидимая и компилятору, и рантайму.
Все три сбоя объединяет паттерн: они создают раздвоенную реальность, где старые и новые данные несут один и тот же тег с разными смыслами. Когда вы видите аномалию, коррелирующую со сменой схемы, но без единой ошибки в рантайме — одно из трёх почти наверняка и есть причина.
Раз удаление создаёт риск переиспользования, удаление — процесс, а не правка. Пометьте поле [deprecated = true], чтобы сгенерированный код предупреждал потребителей. Мигрируйте писателей, пока поле не перестанет записываться. Только потом удаляйте — и тем же коммитом хороните идентичность навсегда:
message Order {
reserved 5; // тег больше не переназначить — ошибка компиляции
reserved "discount_cents"; // и имя тоже: защищает JSON-маппинг от реюза
string order_id = 1;
int64 total_cents = 2;
}С reserved 5; в файле сентябрьский risk_score = 5 — ошибка protoc прямо за столом фрод-инженера, а не три недели отравленных решений.
▸Почему это работает
Почему декодер не носит версию схемы и просто не отвергает несовпадающие данные? Потому что сообщения живут дольше сервисов. Архивные события, задачи в очередях и строки в хранилищах записаны всеми версиями схемы, которые вы когда-либо выкатывали, и будут прочитаны версиями, которые вы ещё не написали. Проверка версии делала бы старые данные нечитаемыми на каждом релизе. Вместо этого protobuf делает каждое поле достаточно самоописывающим, чтобы его перешагнуть, — и переносит всю тяжесть безопасности на одно правило, которое обязаны держать люди: тег, однажды уехавший в прод, постоянен.
Форкать или эволюционировать — и гейт, который реально всё это держит
У аддитивной эволюции есть потолок: когда меняется смысл данных — суммы переезжают из центов в decimal-тип, одно сообщение распадается на три, ресурс перемоделируется, — латание полей по одному оставляет обе половины команды в недоумении, что вообще значит сообщение. Для этого и существуют версии пакетов: package checkout.v1; и package checkout.v2; — разные типы и разные сервисы, которые один бинарь может обслуживать бок о бок. Потребители мигрируют в своём темпе; v1 получает окно депрекации и дату отключения. Форкайте, когда меняется смысл; эволюционируйте, когда только добавляете. v2 — дорого: вы будете гонять оба месяцами, поэтому команды, форкающие из-за переименованного поля, платят за церемонию, а команды, «эволюционирующие» семантическое изменение, выкатывают крючок этого урока с лишними шагами.
Ничто из этого не переживает торопливый пятничный merge, если не проверяется машиной. Для этого buf breaking в CI:
# .github/workflows/proto.yml — собственно гейт
- uses: bufbuild/buf-action@v1
with:
breaking_against: "https://github.com/acme/protos.git#branch=main"buf breaking диффает ваш proto против живого контракта и валит PR на переиспользовании тега, смене типа, удалении поля без reserved — весь запретный список, на уровне правил WIRE или более строгом FILE, защищающем ещё и совместимость сгенерированного кода. Человеческое ревью пропускает переиспользованный тег — дифф выглядит как чистое добавление. Машина — нет. Та же логика говорит относиться к proto как к разделяемому артефакту первого класса: один репозиторий или schema registry (Buf Schema Registry — hosted-вариант), где команды-потребители — обязательные ревьюеры изменений контракта. Схема — единственный кусок кода, чей радиус поражения — все сервисы, которые на ней говорят.
Почему proto3 выбросил ключевое слово required, которое было в proto2?
- 01Разбери, почему переиспользование удалённого тега портит данные без единой ошибки, и полный процесс выведения поля, который это предотвращает.
- 02Когда форкать пакет v2 вместо эволюции v1 и что на практике принуждает к этой разнице?
Версионирование protobuf держится на двух поведениях декодера: неизвестные теги перешагиваются по самоописывающему wire type (и в современном proto3 переживают пересериализацию), а отсутствующие поля читаются как дефолты типа. Вместе они делают добавление со свежим тегом единственным всегда-безопасным изменением — продюсеры и консьюмеры деплоятся в любом порядке, и именно на это свойство молча опираются микросервисы. Всё остальное — запрещённые ходы с характерными отказами: смена типа либо громко падает между wire type, либо тихо усекает внутри одного (int32 читает int64); смена тега превращает поле в другое поле; а реюз тега после удаления хуже всех, потому что невидим — старые данные декодируются в новое поле без ошибки, когда wire type совпадают, как показал инцидент «скидка становится risk score». Поэтому удаление — процесс: депрекация, чтобы codegen предупреждал; осушение писателей; затем удаление с reserved для тега и имени в одном коммите, превращающее будущий реюз в ошибку компиляции. proto3 выбросил required, потому что обязательное поле замораживает контракт навсегда — обязательность есть валидация и живёт в коде, который может меняться; optional возвращает явное присутствие там, где «не задано» отличается от нуля. Когда меняется сам смысл — хватит эволюционировать: форкайте пакет v2, обслуживайте оба, гасите v1 осознанно — и принимайте месяцы двойной работы как честную цену семантического разрыва. Принуждение, переживающее торопливые merge, механическое: buf breaking в CI против живого контракта и команды-потребители в ревьюерах схемных PR — ведь радиус поражения схемы — все сервисы, которые на ней говорят. Теперь, когда вы ревьюите proto-PR, выглядящий как чистое добавление, первый вопрос — не используется ли снова тег удалённого поля: именно это дифф не покажет без reserved.
Практика
Начни сверху. Задачи идут от простого к сложному: вспомнить факт, применить к случаю, затем senior-уровень. Открой, попробуй, потом открой ответ.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.