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
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
syntax = "proto3";
Указывает версию языка protobuf. В новом Go-коде почти всегда используют proto3.
package
package calculator.v1;
Это protobuf namespace. Он защищает от конфликтов имён между схемами. Версия в package (v1) помогает явно развивать контракт.
option go_package
option go_package = "example.com/grpc-demo/gen/calculator/v1;calculatorv1";
Это Go-specific настройка генерации. До ; указывается import path, после ; - имя Go package. Без go_package генератору сложнее корректно разложить Go-код.
message
message похож на DTO:
message Lesson {
string slug = 1;
string title = 2;
string body_markdown = 3;
int32 estimated_minutes = 4;
repeated string tags = 5;
}
После генерации в Go появится структура с getters:
lesson.GetSlug()
lesson.GetTitle()
lesson.GetTags()
В protobuf важны не только имена, но и номера полей.
string slug = 1;
string title = 2;
Номер поля - часть бинарного контракта. Если клиент отправляет поле 1, сервер должен понимать, что это всё ещё slug. Переименовать поле в .proto иногда можно без поломки wire-формата, а вот переиспользовать номер под другой смысл нельзя.
Базовые типы
Частые типы:
| Protobuf | Go |
|---|---|
string | string |
bool | bool |
int32 | int32 |
int64 | int64 |
double | float64 |
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:
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, но опасно, когда бизнесу важно различать "клиент не прислал значение" и "клиент явно прислал ноль".
Например:
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
service CourseService {
rpc GetLesson(GetLessonRequest) returns (GetLessonResponse);
rpc ListLessons(ListLessonsRequest) returns (stream Lesson);
}
service описывает группу удалённых методов. rpc описывает конкретный метод. В generated Go-коде появятся:
- client interface;
- server interface;
- функция регистрации сервера;
- типы stream-клиентов и stream-серверов.
Пример unary метода:
rpc GetLesson(GetLessonRequest) returns (GetLessonResponse);
Пример server-side streaming:
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-контракта нужно описывать смысл, единицы измерения, формат строк, диапазоны и политику миграции.
Если поле удалили, номер и имя лучше зарезервировать:
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 и путь:
package course.v1;
option go_package = "example.com/mentor/gen/course/v1;coursev1";
Если изменения обратно несовместимы, лучше создать course.v2, а не ломать v1.
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, а не свободную строку:
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 полезен, когда у сообщения есть несколько взаимоисключающих вариантов:
message RateLookupRequest {
oneof query {
string pair = 1;
string rate_id = 2;
}
}
Так контракт явно говорит: клиент должен выбрать один способ поиска. Без oneof легко получить request, где заполнены оба поля, и серверу придётся угадывать приоритет.
Но oneof тоже надо эволюционировать осторожно. Нельзя менять смысл существующего варианта, а удалённые номера лучше резервировать. Если старый клиент получает новый вариант oneof, он может не знать, как с ним работать, поэтому у сервера должен быть безопасный fallback или понятная ошибка.
map удобен для небольших словарей, но не стоит делать из него универсальный escape hatch:
map<string, string> metadata = 10;
Такое поле быстро превращается в невалидируемый JSON внутри protobuf: непонятные ключи, разные форматы значений, высокая кардинальность в логах и метриках. Для бизнес-данных лучше явные поля. map оставляйте для действительно открытого набора атрибутов и заранее определите ограничения на ключи, размер и чувствительные значения.
Для repeated полей отдельно договоритесь:
- важен ли порядок элементов;
- допускаются ли дубликаты;
- есть ли лимит размера;
- что делает сервер с пустым списком.
Без этих правил клиенты могут отправить корректный protobuf, который создаст лишнюю нагрузку или неоднозначное бизнес-поведение.
Валидация на границе
Generated Go-типы не заменяют validation. Они гарантируют, что bytes можно распаковать в структуру, но не гарантируют, что request допустим для вашего usecase.
Пример плохой надежды:
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-контракт:
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.
Источники
- Protocol Buffers Proto3 Specification
- Protocol Buffer Basics: Go
- Go Generated Code Guide
- gRPC Go Generated Code Reference
Интерактивная практика
Что нельзя делать с номером поля protobuf после удаления поля?
Что выведет этот код?
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))
}
Реализуй FieldChangeReview: если номер тот же, но смысл изменился, верни "bad", иначе "ok".