Protobuf: схема, сообщения и совместимость

Protocol Buffers - это язык описания данных и бинарный формат сериализации. В gRPC protobuf обычно играет роль контракта: в .proto файле описываются сервисы, методы, request/response сообщения и правила совместимости.

Если в REST контракт часто живёт в OpenAPI, README и договорённостях, то в gRPC .proto становится исходником, из которого генерируются Go-типы, клиент и серверный интерфейс.

В production проблема не в том, чтобы "сериализовать быстрее JSON". Проблема в том, что сервисы деплоятся независимо. Сегодня новый сервер может получить запрос от старого клиента, завтра новый клиент может читать ответ от старого сервера, а часть трафика может идти через разные версии одновременно. Хорошая protobuf-схема помогает пережить такой rollout без массовых Internal, silent data loss и ручных договорённостей в чатах.

Наивный .proto ломается не сразу. Он ломается позже:

  • когда номер удалённого поля заняли новым смыслом, и старые клиенты начали отправлять "валидные" байты не туда;
  • когда string status разъехался между сервисами: один пишет published, другой ждёт PUBLISHED;
  • когда отсутствие поля нельзя отличить от нулевого значения;
  • когда в схему попали internal/debug поля, PII или transport metadata, которые потом нельзя безопасно удалить;
  • когда контракт повторяет domain entity один в один и каждое внутреннее изменение домена становится breaking change для клиентов.

Первый .proto

proto
syntax = "proto3"; package calculator.v1; option go_package = "example.com/grpc-demo/gen/calculator/v1;calculatorv1"; service CalculatorService { rpc Add(AddRequest) returns (AddResponse); rpc Divide(DivideRequest) returns (DivideResponse); } message AddRequest { int64 a = 1; int64 b = 2; } message AddResponse { int64 result = 1; } message DivideRequest { int64 dividend = 1; int64 divisor = 2; } message DivideResponse { double result = 1; }

Разберём элементы.

syntax

proto
syntax = "proto3";

Указывает версию языка protobuf. В новом Go-коде почти всегда используют proto3.

package

proto
package calculator.v1;

Это protobuf namespace. Он защищает от конфликтов имён между схемами. Версия в package (v1) помогает явно развивать контракт.

option go_package

proto
option go_package = "example.com/grpc-demo/gen/calculator/v1;calculatorv1";

Это Go-specific настройка генерации. До ; указывается import path, после ; - имя Go package. Без go_package генератору сложнее корректно разложить Go-код.


message

message похож на DTO:

proto
message Lesson { string slug = 1; string title = 2; string body_markdown = 3; int32 estimated_minutes = 4; repeated string tags = 5; }

После генерации в Go появится структура с getters:

go
lesson.GetSlug() lesson.GetTitle() lesson.GetTags()

В protobuf важны не только имена, но и номера полей.

proto
string slug = 1; string title = 2;

Номер поля - часть бинарного контракта. Если клиент отправляет поле 1, сервер должен понимать, что это всё ещё slug. Переименовать поле в .proto иногда можно без поломки wire-формата, а вот переиспользовать номер под другой смысл нельзя.


Базовые типы

Частые типы:

ProtobufGo
stringstring
boolbool
int32int32
int64int64
doublefloat64
bytes[]byte
repeated T[]T
map<K, V>map[K]V

Тип в .proto - это формат передачи, а не гарантия бизнес-валидности. int64 amount = 1 не говорит, что сумма положительная. string currency = 2 не говорит, что это ISO 4217. google.protobuf.Timestamp не говорит, что время не из будущего. Эти правила должны проверяться в transport/application слое, а не предполагаться из самого protobuf.

Для финансовых значений не используйте double: бинарная floating-point арифметика плохо подходит для точных денег, комиссий и курсов, которые потом сравниваются, округляются и аудируются. Обычно выбирают один из вариантов:

  • minor units: int64 amount_minor = 1, например копейки или центы;
  • decimal как строка с явным форматом: string rate = 1;
  • отдельный message с units/nanos, если команда готова поддерживать такой формат.

Главное - описать scale, rounding policy и допустимый диапазон рядом с полем или в документации контракта. Иначе два корректных protobuf-клиента могут считать одну и ту же сумму по-разному.

Для времени обычно используют well-known type:

proto
import "google/protobuf/timestamp.proto"; message ProgressEvent { string lesson_slug = 1; google.protobuf.Timestamp happened_at = 2; }

В Go это будет *timestamppb.Timestamp, который можно конвертировать из time.Time.

Presence и zero value в proto3

В proto3 scalar-поля по умолчанию имеют zero value: пустая строка, 0, false. Это удобно для простых DTO, но опасно, когда бизнесу важно различать "клиент не прислал значение" и "клиент явно прислал ноль".

Например:

proto
message UpdateLessonRequest { string slug = 1; int32 estimated_minutes = 2; }

В Go req.GetEstimatedMinutes() вернёт 0 и для отсутствующего поля, и для явно переданного 0. Для update/patch-методов это часто ломает смысл операции.

Есть несколько вариантов:

  • сделать отдельный RPC с явным действием вместо универсального patch;
  • использовать optional для scalar-поля, если нужно presence;
  • использовать wrapper/well-known types там, где это принято в проекте;
  • вынести изменяемые поля в отдельный message и явно договориться о semantics.

Выбор зависит от контракта. Важно не притворяться, что zero value всегда означает валидное бизнес-значение.


service и rpc

proto
service CourseService { rpc GetLesson(GetLessonRequest) returns (GetLessonResponse); rpc ListLessons(ListLessonsRequest) returns (stream Lesson); }

service описывает группу удалённых методов. rpc описывает конкретный метод. В generated Go-коде появятся:

  • client interface;
  • server interface;
  • функция регистрации сервера;
  • типы stream-клиентов и stream-серверов.

Пример unary метода:

proto
rpc GetLesson(GetLessonRequest) returns (GetLessonResponse);

Пример server-side streaming:

proto
rpc ListLessons(ListLessonsRequest) returns (stream Lesson);

Слово stream меняет сигнатуру generated Go-кода: вместо обычного response вы получаете stream с методами Send или Recv.


Совместимость схем

Главное правило: старый клиент и новый сервер должны уметь пережить постепенный rollout.

Можно:

  • добавлять новое поле с новым номером;
  • перестать использовать поле, но оставить его номер занятым;
  • добавлять новый RPC метод;
  • добавлять новое значение enum аккуратно, если клиенты готовы к неизвестному значению.

Нельзя:

  • переиспользовать номер удалённого поля;
  • менять смысл существующего поля;
  • менять тип поля на несовместимый;
  • удалять RPC, которым ещё пользуются клиенты;
  • считать, что порядок полей в .proto важнее их номеров.

Совместимость бывает не только бинарной. Wire-формат может не сломаться, но поведение всё равно станет несовместимым:

  • поле осталось string, но формат поменялся с RUB на 643;
  • поле amount_minor осталось int64, но scale поменялся с копеек на рубли;
  • enum получил новое значение, а старый клиент считает default успешным состоянием;
  • сервер начал требовать поле, которое старые клиенты не отправляют;
  • response перестал заполнять поле, на которое завязана логика клиента.

Поэтому "не поменяли номер поля" - это только минимальная техническая защита. Для production-контракта нужно описывать смысл, единицы измерения, формат строк, диапазоны и политику миграции.

Если поле удалили, номер и имя лучше зарезервировать:

proto
message Lesson { reserved 4; reserved "legacy_duration"; string slug = 1; string title = 2; string body_markdown = 3; int32 estimated_minutes = 5; }

Так следующий разработчик не займёт номер 4 новым смыслом.

Unknown fields и rollout

Protobuf-клиент может получить поле, которого нет в его локальной версии схемы. Это нормальная ситуация при постепенном rollout: новый сервер уже пишет поле, старый клиент ещё не знает его имени.

Практический вывод:

  • добавляйте поля так, чтобы старые клиенты могли их игнорировать;
  • не делайте новое поле обязательным для корректной обработки старого request;
  • не переносите критичное бизнес-решение только в новое поле без migration window;
  • не полагайтесь на то, что промежуточный сервис сохранит unknown fields при read-modify-write. В разных языках, версиях runtime и способах преобразования в JSON это может вести себя по-разному.

Если изменение должно пройти через несколько сервисов, планируйте expand/contract: сначала все участники учатся читать новое поле, потом кто-то начинает писать, и только после этого старый путь можно убирать.


Версионирование

Обычно версию кладут в package и путь:

proto
package course.v1; option go_package = "example.com/mentor/gen/course/v1;coursev1";

Если изменения обратно несовместимы, лучше создать course.v2, а не ломать v1.

text
proto/course/v1/course.proto proto/course/v2/course.proto

Версия в package честно говорит клиентам: это другой контракт.

Версионирование не отменяет совместимость внутри v1. Если каждое неудобное изменение сразу уносить в v2, у команды появится несколько живых API, несколько наборов generated-кода и дорогая миграция клиентов. Обычно v2 нужен, когда меняется модель данных или semantics метода так, что старые клиенты невозможно поддержать честно.

Хороший признак для v2: старый и новый клиент не могут одинаково понять один и тот же request/response даже при аккуратных optional fields и migration window.


Enum и zero value

Если поле имеет ограниченный набор состояний, лучше использовать enum, а не свободную строку:

proto
enum LessonStatus { LESSON_STATUS_UNSPECIFIED = 0; LESSON_STATUS_DRAFT = 1; LESSON_STATUS_PUBLISHED = 2; LESSON_STATUS_ARCHIVED = 3; } message Lesson { string slug = 1; string title = 2; LessonStatus status = 3; }

В proto3 первое значение enum должно быть нулевым. Практический стиль - делать его *_UNSPECIFIED или *_UNKNOWN, чтобы нулевое значение не означало реальное бизнес-состояние. Иначе пустое/неинициализированное поле может случайно стать, например, PUBLISHED.

Ещё один плюс enum: если клиент получил неизвестное новое значение от более свежего сервера, он хотя бы понимает, что поле относится к фиксированному набору состояний, а не парсит произвольную строку.

oneof, map и repeated поля

oneof полезен, когда у сообщения есть несколько взаимоисключающих вариантов:

proto
message RateLookupRequest { oneof query { string pair = 1; string rate_id = 2; } }

Так контракт явно говорит: клиент должен выбрать один способ поиска. Без oneof легко получить request, где заполнены оба поля, и серверу придётся угадывать приоритет.

Но oneof тоже надо эволюционировать осторожно. Нельзя менять смысл существующего варианта, а удалённые номера лучше резервировать. Если старый клиент получает новый вариант oneof, он может не знать, как с ним работать, поэтому у сервера должен быть безопасный fallback или понятная ошибка.

map удобен для небольших словарей, но не стоит делать из него универсальный escape hatch:

proto
map<string, string> metadata = 10;

Такое поле быстро превращается в невалидируемый JSON внутри protobuf: непонятные ключи, разные форматы значений, высокая кардинальность в логах и метриках. Для бизнес-данных лучше явные поля. map оставляйте для действительно открытого набора атрибутов и заранее определите ограничения на ключи, размер и чувствительные значения.

Для repeated полей отдельно договоритесь:

  • важен ли порядок элементов;
  • допускаются ли дубликаты;
  • есть ли лимит размера;
  • что делает сервер с пустым списком.

Без этих правил клиенты могут отправить корректный protobuf, который создаст лишнюю нагрузку или неоднозначное бизнес-поведение.


Валидация на границе

Generated Go-типы не заменяют validation. Они гарантируют, что bytes можно распаковать в структуру, но не гарантируют, что request допустим для вашего usecase.

Пример плохой надежды:

go
func (s *Server) GetLesson(ctx context.Context, req *coursev1.GetLessonRequest) (*coursev1.GetLessonResponse, error) { lesson, err := s.usecase.GetLesson(ctx, req.GetSlug()) // ... }

Если slug пустой, слишком длинный или содержит неожиданные символы, это должно быть остановлено на transport/application границе и превращено в понятную ошибку, обычно InvalidArgument. Сам protobuf этого не сделает.

Что стоит проверять:

  • required-by-business поля: slug, id, currency, amount;
  • диапазоны чисел и лимиты размеров списков;
  • формат строк: id, currency, locale, version;
  • корректность timestamp: timezone semantics, будущие/прошлые значения;
  • enum: *_UNSPECIFIED в request часто должен быть ошибкой, а не "авто-режимом";
  • взаимные ограничения полей, особенно без oneof.

В больших проектах validation часто описывают рядом со схемой через линтеры или validation-плагины, а затем всё равно проверяют важные инварианты в application layer. Не переносите бизнес-правила полностью в .proto: схема должна помогать контракту, но домен не должен зависеть от generated DTO.


Наблюдаемость и безопасность контракта

Protobuf бинарный, но это не шифрование. Любой сервис, прокси, логгер или debug-инструмент с доступом к payload может прочитать сообщение по схеме. Поэтому не кладите в обычные сообщения секреты, raw tokens, пароли и лишние PII "на всякий случай".

Для production-контракта полезны правила:

  • request id, trace id и auth обычно передают через gRPC metadata/interceptors, а не копируют в каждое бизнес-сообщение;
  • поля с PII должны быть явно названы и попадать под masking/redaction в логах;
  • не логируйте весь protobuf request целиком на error path;
  • не добавляйте debug-only поля в публичный/межсервисный контракт, если их нельзя поддерживать годами;
  • следите за cardinality: свободные строки из protobuf не должны становиться label values в метриках.

Метрики и traces обычно строятся вокруг RPC method, status code, latency и размера сообщений. Содержимое protobuf лучше использовать в логах точечно и после фильтрации. Иначе одна удобная строка slog.Info("request", "req", req) может превратить контракт в источник утечек и дорогих индексов.


Что проверяет senior reviewer

При ревью .proto смотрят не только на синтаксис:

  • есть ли версия в package и корректный go_package;
  • не переиспользуются ли field numbers и reserved names;
  • понятны ли единицы измерения, scale, rounding и формат строк;
  • можно ли добавить поле без поломки старых клиентов;
  • нет ли double для денег и точных значений;
  • не протекают ли domain/internal/debug поля наружу;
  • не стал ли map<string, string> способом спрятать неописанный контракт;
  • есть ли limits для repeated и bytes;
  • различаются ли "не прислали поле" и "прислали zero value", если это важно;
  • где будут validation, error mapping, metrics и redaction.

Если команда использует Buf или другой contract workflow, в CI обычно добавляют lint и breaking-change check. Это не заменяет ревью смысла полей, но ловит самые дорогие механические ошибки: переиспользование номеров, неправильные имена, проблемы package/layout.


Практика: описать CalculatorService

Создайте protobuf-контракт:

proto
syntax = "proto3"; package calculator.v1; option go_package = "example.com/grpc-demo/gen/calculator/v1;calculatorv1"; service CalculatorService { rpc Add(AddRequest) returns (AddResponse); rpc Divide(DivideRequest) returns (DivideResponse); } message AddRequest { int64 a = 1; int64 b = 2; } message AddResponse { int64 result = 1; } message DivideRequest { int64 dividend = 1; int64 divisor = 2; } message DivideResponse { double result = 1; }

Проверьте себя:

  • почему divisor нельзя потом заменить на lesson_slug, оставив номер 2;
  • где лучше держать trace_id: в message или в gRPC metadata, и почему;
  • что лучше сделать, если поле удалили;
  • почему go_package не то же самое, что package.

Типичные ошибки

  • Использовать номера полей как "красивый порядок", а не как контракт.
  • Удалить поле и потом занять его номер новым смыслом.
  • Забыть go_package, а потом бороться с import path generated-кода.
  • Тащить доменную модель один в один в protobuf. Proto messages - это transport DTO, а не обязательно ваша domain entity.
  • Делать один гигантский CommonMessage на все методы.
  • Добавлять string status = 1, хотя лучше enum с явными значениями и нулевым *_UNSPECIFIED.

Источники


Интерактивная практика

Quiz+10 XP

Что нельзя делать с номером поля protobuf после удаления поля?

  • Оставить комментарий рядом с message
  • Добавить поле с новым именем и новым номером
  • Переиспользовать старый номер под новый смысл
  • Зарезервировать старый номер через reserved
Predict+15 XP

Что выведет этот код?

go
package main import "fmt" func changeKind(oldNumber, newNumber int, sameMeaning bool) string { if oldNumber == newNumber && !sameMeaning { return "breaking" } return "safe" } func main() { fmt.Println(changeKind(2, 3, false)) fmt.Println(changeKind(2, 2, false)) }
Задача+20 XP

Реализуй FieldChangeReview: если номер тот же, но смысл изменился, верни "bad", иначе "ok".