Breaking changes в Protobuf: что выживает на проводе
5 мин чтения
REST API меняется, когда меняется форма JSON, и большая часть этой формы видна в ответе, который
можно прочитать в браузере. gRPC API меняется, когда меняется файл .proto, а бинарный формат
провода Protocol Buffers имеет собственные правила о том, что может стерпеть клиент, не имеющие
никакого отношения к тому, что говорят имена полей. Два изменения, выглядящие одинаково мелкими в
диффе, перенумерация поля против добавления нового, попадают по разные стороны черты, которую
breaking changes проводит в общем: одно невидимо для каждого
существующего клиента, другое ломает их все разом.
Отличить breaking changes Protobuf от безопасных значит читать собственные правила формата
провода, а не гадать по тому, как изменение выглядит в диффе .proto.
Почему нумерация полей важнее имени поля в Protobuf?
Потому что формат провода кодирует поля по номеру, а не по имени. Сгенерированный код на каждом
языке читает и пишет эти номера; имя поля email в вашем файле .proto — удобство для людей,
которое никогда не касается двоичных байтов, отправляемых по сети. Переименование поля, email в
email_address, безопасно в двоичном формате провода, пока номер остаётся тем же, что удивляет
инженерок, привыкших к REST, где переименованный JSON-ключ — именно тот тип изменения, что ломает
клиента. Исключение составляет тот же случай, что и в REST:
форматы ProtoJSON и text сериализуют имя, поэтому
переименование ломает JSON-транскодирование (например, grpc-gateway), файлы в text-формате и field
mask. Перенумерация того же поля, с сохранением имени но изменением 1 на 7, — ровно
наоборот: невидимо в code review, показывающем только имена, и портит каждое сообщение, которое
клиент отправляет или получает с этого момента.
| Изменение | Безопасно на проводе | Почему |
|---|---|---|
| Переименовать поле, сохранить номер | В двоичном да, в JSON и text нет | Двоичное кодирование использует номер; ProtoJSON и text-формат используют имя |
| Изменить номер поля | Нет | Каждое существующее сообщение теперь читается как неверное поле |
| Добавить новое поле с новым номером | Да | Старые клиенты игнорируют незнакомые им поля |
| Удалить поле, повторно использовать его старый номер для чего-то другого | Нет | Старые данные декодируются в неверное новое поле |
Несовместимо изменить тип поля (напр. int32 в string) | Нет | Кодирование провода отличается по типам |
Что делает удаление поля другим, чем то же в ответе REST JSON?
Номер становится радиоактивным. Собственное руководство Protobuf рекомендует помечать номер
удалённого поля как reserved, а не позволять его повторное использование, потому что именно в
повторном использовании и происходит настоящий ущерб: клиент, всё ещё работающий на
сгенерированном коде месячной давности, отправляет сообщение, используя старый номер поля для
старого значения, а сервер, теперь ожидающий, что этот номер означает что-то другое, молча
неверно интерпретирует данные вместо того, чтобы прямо их отклонить. У REST нет эквивалентной
ловушки, потому что удалённый JSON-ключ просто перестаёт появляться; нет способа, чтобы запрос
старого клиента был тихо переинтерпретирован как что-то другое. Файл .proto с reserved 4, 9, 12; в начале сообщения — это постоянный шрам, и в этом весь смысл: он не даёт номеру достаться
новому полю от того, кто не знал его истории.
message Invoice {
reserved 4; // было `legacy_customer_id`, удалено 2026-06-01
reserved "legacy_customer_id"; // и имя тоже, для JSON/text
string customer_id = 5;
string status = 6;
}
Требует ли добавление поля вообще записи в changelog?
Обычно не запись о breaking change, но часто обычную, потому что «безопасно на проводе» и «невидимо для читательницы, которой это важно» — два разных утверждения. Добавление поля в сообщение ответа структурно ничего не стоит, старые клиенты декодируют сообщение и автоматически игнорируют новое поле. Но у того, кто строит новую интеграцию против этого сервиса, нет способа узнать, что поле существует, если кто-то ему не скажет, потому что ничто в успешной сборке или пройденном тесте не делает новое опциональное поле видимым. Changelog API разбирает в общем, чем аддитивная запись обязана читателям; специфичная для gRPC причина всё же её написать в том, что нет эквивалента просмотру ответа REST в отладчике, чтобы заметить появление нового ключа.
Чем это отличается от того, с чем сталкиваются вызывающие GraphQL?
Правила для добавлений совпадают, но степень видимости разная. Депрекация схемы GraphQL разбирает модель, где клиент получает только те поля, которые явно запрашивает, что делает аддитивные изменения по сути безрисковыми, а удаления — единственной реальной опасностью. Клиенты gRPC, напротив, получают всё, что отправляет сервер, и декодируют всё против собственной скомпилированной копии схемы; экспозиция клиента ограничена не тем, что он запросил, а только тем, что умеет читать его сгенерированный код. Эта разница важна для написания changelog: запись GraphQL может разумно предполагать, что клиенты защищены от полей, которые они не запрашивали, а запись gRPC не может предполагать это вовсе.
Работает ли версионирование сервиса gRPC так же, как /v1/, /v2/ в REST?
Механизм отличается, даже когда намерение то же. Что такое v1 и v2 в REST
API разбирает версионирование как параллельные пути
URL, обслуживающие разные контракты; сервисы gRPC обычно версионируются через имя пакета в самом
файле .proto, payments.v1.InvoiceService становится payments.v2.InvoiceService, что меняет
полностью квалифицированное имя сервиса, которое набирает клиент, вместо сегмента URL, который он
запрашивает. Оба подхода решают одну и ту же проблему, позволяя старому контракту продолжать
работать, пока существует новый, но команда с бэкграундом REST часто ищет номер версии не в том
месте и упускает, что эту работу выполняет объявление пакета.
Что на самом деле должна называть запись changelog gRPC?
Сообщение, номер поля, и является ли изменение аддитивным или удалением, требующим миграции, в
этом порядке важности для читательницы, решающей, действовать ли. «Добавлено shipping_address
(поле 8) в Order» говорит интегратору всё необходимое, чтобы обновить сгенерированный код и
начать его использовать. «Зарезервировано поле 4 в Invoice, legacy_customer_id исчезло»
говорит ей проверить, не читает ли что-то в её кодовой базе всё ещё это поле, что заметка в стиле
REST «удалено поле из ответа» не сообщает с той же срочностью, потому что удаления REST просто
возвращают меньше данных, а повторное использование полей Protobuf активно их портит.
FAQ
Может ли тип поля быть когда-либо изменён без поломки формата провода?
Только в рамках конкретных совместимых групп, которые документирует Protobuf, вроде расширения
int32 до int64 в некоторых случаях. Относитесь к любому изменению типа как к breaking, если не
проверили его против собственной таблицы совместимости Protobuf; предположение совместимости по
аналогии с системой типов языка — как это идёт не так.
Работает ли депрекация поля в Protobuf как директива @deprecated в GraphQL?
Похожим образом: Protobuf поддерживает опцию поля [deprecated = true], которую могут показывать
инструменты. Ни то, ни другое не принуждается: сервер GraphQL всё равно отвечает на запрос
депрекированного поля, а клиент protobuf всё равно его кодирует. Оба механизма рекомендательные и
требуют той же поддержки changelog.
Безопасна ли перенумерация, если вы контролируете каждый клиент? В полностью закрытой системе, в принципе, но это устраняет всё свойство безопасности, ради которого существуют номера полей, а «мы контролируем каждый клиент» — утверждение, которое перестаёт быть верным в момент, когда сборка кешируется, деплой откладывается, или добавляется клиент, о котором никто не помнил. Резервируйте номер вместо повторного использования, даже внутри компании.
Нужна ли сервисам gRPC страница changelog, как публичному REST API?
Только если внешние команды потребляют их, не читая диффы .proto напрямую, тот же тест «кто на
другой стороне», который в общем применяют changelog внутренних API.
Сервис gRPC, потребляемый только другими сервисами той же команды, часто может обойтись без
формального changelog в пользу истории коммитов, потому что у любого, кто его читает, схема уже
открыта.
Технические утверждения в этой статье не проходили независимую проверку. Если здесь что-то не так, сообщите нам, и мы исправим.