Великая ересь, или Как использовать protobuf без контракта
- вторник, 14 июля 2026 г. в 00:00:31

Привет! На связи Влад, разработчик product-facade — сердца витрины Ozon и одного из самых высоконагруженных сервисов, который выдерживает до 2,2 млн RPS. В прошлой моей статье я рассказывал об одном из архитектурных вызовов, с которым мы столкнулись в процессе работы. В этот раз продолжим тему микросервисной архитектуры и поговорим о том, что происходит, когда привычная строгость контрактов начинает мешать.
Одна из ключевых причин использования gRPC для связи между сервисами — строгий protobuf-контракт. Он даёт типизацию, фиксирует схему данных и снижает риск случайно сломать интеграцию. Но иногда случаются ситуации, когда эти плюсы загоняют нас в рамки. Например, когда сервис просто передаёт данные дальше, но его всё равно приходится обновлять из-за изменений, которые нужны только конечному потребителю. В таких случаях полезно знать, какие механизмы protobuf позволяют работать с контрактом более гибко, не отказываясь от него полностью. В статье расскажу о трёх из них и приведу примеры, для чего они могут применяться.
Каждый следующий метод позволяет всё меньше и меньше полагаться на заранее известный контракт.

Всё описанное в этой статье относится к Go, все примеры кода на нём же, однако я уверен, что и другие языки предоставляют сходные инструменты.
Рассмотренные методы являются крайне мощными, но одновременно они довольно неочевидны и могут вызывать различные проблемы у того, кто недостаточно аккуратно решит ими воспользоваться. Они могут быть полезны в некоторых задачах, но не стоит использовать их просто потому, что они прикольные. Пожалуйста, перед внедрением убедитесь, что вы точно знаете, что делаете, какие у этого есть риски и каким образом вы их минимизируете.
Двигаться будем от самого безопасного варианта к самому гибкому:
Unknown Fields — когда сервис не знает часть полей, но может сохранить их и передать дальше.
google.protobuf.Any — когда нужно передавать сообщения разных типов через один контракт.
dynamicpb — когда нужно работать с protobuf-сообщением, имея только его runtime-дескриптор.
Самый простой и одновременно самый безопасный метод во всей статье. Он позволяет работать с сильно устаревшим контрактом. Звучит вроде круто и даже применимо в продуктовых микросервисах.
Основная идея в том, что если у вас сервис оперирует устаревшей версией контракта, то все поля с индексами, которых нет в контракте, попадают в служебное поле Unknown Fields. Можно ли как-то обработать эти поля? Можно, а зачем? Фактически самый простой способ с ними работать — это обновить-таки контракт. Зачем же я это всё тогда рассказываю? А вот тут и кроется главная неочевидная штука, которая делает это поле полезным. Мы можем замаршалить (преобразовать его в бинарный формат) структуру, содержащую неизвестные для нас поля, и передать тому, кто об этих полях знает, — и он сможет с ними работать. Это плохо, так как мы создаём слишком большую зависимость между тремя сервисами, но иногда может быть оправданно.
Представим, что мы разрабатываем систему показа персональных новостей, в которой есть три сервиса
1. user-service — владелец информации о пользователе, которую отдаёт в таком контракте:
message User { string user_id = 1; string display_name = 2; string language = 3; string avatar_url = 4; repeated string interests = 5; string subscription_tier = 6; int64 age = 7; // появится позже }
2. backend-for-frontend — использует display_name, language, avatar_url и рекомендации для того, чтобы собрать информацию для отрисовки ленты новостей.
3. recomendation-engine — на основании user_id, language, interests и subscription_tier строит список рекомендаций.
Для снижения нагрузки на user-service recomendation-engine получает информацию о пользователе в запросе от backend-for-frontend. Но в какой-то момент сервису рекомендаций становится нужно новое поле — age. Обычным и довольно хорошим подходом было бы просто обновить bff. Но Unknown Fields позволяют сделать изящнее, без бесполезных релизов bff: мы можем отправлять recomendation-engine данные в контракте user-service, и bff будет корректно их передавать.

Такой подход можно использовать только для транзитных сервисов, которые не пересобирают сообщение вручную и не завязывают бизнес-логику на неизвестные поля. Если сервис должен понимать данные, лучше явно обновить контракт.
С каждым днём мы всё дальше от ...контракта.
Итак, наш новый фаворит: google.protobuf.Any. Поприветствуем аплодисментами! Это специализированный тип, который представляет собой универсальный контейнер для любого сообщения. По сути, это простая структура с двумя значимыми полями: TypeUrl — полное имя типа и Value — массив байтов, который следует в этот тип анмаршалить.
type Any struct { TypeUrl string Value []byte // дальше идут служебные поля state protoimpl.MessageState unknownFields protoimpl.UnknownFields sizeCache protoimpl.SizeCache }
Один из реальных примеров, который я уже описывал в предыдущей статье, — внутренний сервис, который принимает события и рассылает их по принципу pub‑sub. Ему абсолютно не нужно знать внутреннюю структуру каждого события. Достаточно посмотреть на TypeUrl, определить, кто на этот тип подписан, и переслать событие дальше.
Тут же давайте рассмотрим упрощённую схему:

Конечно, это может быть реализовано, например, через Kafka, но это не всегда возможно (если есть сомнения, то прошу в предыдущую статью, там я подробно об этом рассказывал).
Any не отменяет контракт полностью, получатель всё равно должен понимать, какой тип лежит внутри, или иметь способ получить его дескриптор.
И наконец, magnum opus — dynamicpb (мне не слишком интересно писать о перекладывании сырых байтов, а вам вряд ли будет интересно об этом читать, так что ещё дальше от контрактов уходить не будем). С помощью dynamicpb мы можем полноценно работать с сообщением, имея только его дескриптор (рантайм-схему, содержащую всю информацию о нём).
Реальное применение, которое я видел своими глазами, — универсальная админка. Представьте: мы хотим отображать данные из десятка разных сервисов в одном месте. Вообще, идеально было бы, если бы информация была полной и при этом каждое изменение контрактов, одного из сервисов не вело к переборке админки. А если учесть, что на разных окружениях или даже на разных версиях контракты могут различаться, то этот метод идеален.
Базовый пример работы с dynamicpb:
func dynamicpbExample(msgDesc protoreflect.MessageDescriptor) { // создаём сообщение из дескриптора dm := dynamicpb.NewMessage(msgDesc) dm.ProtoReflect().Set(msgDesc.Fields().ByName("name"), protoreflect.ValueOfString("Alice")) dm.ProtoReflect().Set(msgDesc.Fields().ByName("age"), protoreflect.ValueOfInt32(30)) data, err := proto.Marshal(dm) if err != nil { panic(err) } // анмаршалим сообщение зная только дескриптор dm2 := dynamicpb.NewMessage(msgDesc) if err := proto.Unmarshal(data, dm2); err != nil { panic(err) } fmt.Printf("name=%s age=%d\n", dm2.ProtoReflect().Get(msgDesc.Fields().ByName("name")).String(), dm2.ProtoReflect().Get(msgDesc.Fields().ByName("age")).Int(), ) }
Наверняка именно этим вопросом вы сейчас задались. На самом деле способов много, но давайте остановимся на четырёх, которые я по авторскому произволу считаю самыми прикольными. Но учитывайте, что это всё довольно тяжёлые операции, не стоит их вызывать на каждый запрос.
Динамическая сборка
Ну начнём с самого очевидного: любую структуру можно заполнить вручную, использовав unsafe для очень нужных, но, к сожалению, приватных полей. Но у нас есть инструмент получше, а unsafe оставим для пет-проектов и прочих игр разума. protodesc.NewFile позволяет создавать дескрипторы на основе любых, даже самых сложных условий. Можно даже написать UI, в котором пользователи из блоков будут собирать нужный контракт, но за это они вас, скорее всего, не поблагодарят. (Нет, серьёзно, не делайте так.)
fileDesc, err := protodesc.NewFile(&descriptorpb.FileDescriptorProto{ Name: proto.String("person.proto"), Package: proto.String("example"), Syntax: proto.String("proto3"), MessageType: []*descriptorpb.DescriptorProto{{ Name: proto.String("Person"), Field: []*descriptorpb.FieldDescriptorProto{ {Name: proto.String("name"), Number: proto.Int32(1), Type: descriptorpb.FieldDescriptorProto_TYPE_STRING.Enum(), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum()}, {Name: proto.String("age"), Number: proto.Int32(2), Type: descriptorpb.FieldDescriptorProto_TYPE_INT32.Enum(), Label: descriptorpb.FieldDescriptorProto_LABEL_OPTIONAL.Enum()}, }, }}, }, protoregistry.GlobalFiles)
Собираем дескриптор из .proto
Ну если очень уж хочется получать контракт от пользователей, то дайте им возможность нормально загрузить контракт в proto и делайте с ним что хотите, тем более инструмент для этого уже есть, и называется он protocompile.
А если вы привыкли работать глубоко в бэкенде и о пользователях с UI слышали только от коллег, то вам ничего не мешает сделать ручку для загрузки proto другими сервисами или через swagger.
// мапа название файла - его содержимое // можно добавить несколько связанных файлов, при компиляции зависимости разрешатся files := map[string]string{ "user.proto": content, } resolver := &protocompile.SourceResolver{ Accessor: protocompile.SourceAccessorFromMap(files), } compiler := protocompile.Compiler{ Resolver: protocompile.WithStandardImports(resolver), // добавляем Well-Known Types } compiled, err := compiler.Compile(context.Background(), "user.proto") if err != nil { panic(err) } fd := compiled.FindFileByPath("user.proto") dynamicpbExample(fd.Messages().ByName("User"))
Получаем дескриптор через gRPC-рефлексию
Но можно пойти и дальше: не ждать, пока вам любезно предоставят новый контракт, а самому спросить его у сервиса-владельца, — но есть нюанс... Сервис-владелец должен поддерживать gRPC-рефлексию. Но поскольку это и так хороший тон, то вряд ли это станет прямо большой проблемой.
client := reflectionpb.NewServerReflectionClient(conn) stream, err := client.ServerReflectionInfo(ctx) if err != nil { panic(err) } req := &reflectionpb.ServerReflectionRequest{ MessageRequest: &reflectionpb.ServerReflectionRequest_FileContainingSymbol{ FileContainingSymbol: "example.User", }, } if err := stream.Send(req); err != nil { log.Fatalf("send: %v", err) } stream.CloseSend() var fdps []*descriptorpb.FileDescriptorProto for { resp, err := stream.Recv() if err != nil { break } fr := resp.GetFileDescriptorResponse() if fr == nil { continue } for _, b := range fr.FileDescriptorProto { fdp := &descriptorpb.FileDescriptorProto{} if err := proto.Unmarshal(b, fdp); err != nil { log.Fatalf("unmarshal: %v", err) } fdps = append(fdps, fdp) } } // Строим реестр из полученных дескрипторов files, err := protodesc.NewFiles(&descriptorpb.FileDescriptorSet{File: fdps}) if err != nil { log.Fatalf("protodesc.NewFiles: %v", err) } desc, err := files.FindFileByPath("proto/example.proto") if err != nil { log.Fatalf("FindFileByPath: %v", err) } dynamicpbExample(desc.Messages().ByName("User"))
Получаем дескриптор через gRPC вместе с запросом
Но ходить и кого-то опрашивать... Ну иногда можно, конечно, но как бы был прекрасен мир, если бы с задачей приносили и все необходимые данные для её выполнения...
Это всё, конечно, мечты и фантазии, но в данном случае это реально. Пусть у нас есть метод, который в ответе отдаёт дескриптор сообщения и само сообщение, замаршаленное в proto.Any
import "google/protobuf/any.proto"; import "google/protobuf/descriptor.proto"; service DescriptorService { rpc GetMessage (GetMessageRequest) returns (GetMessageResponse); } message GetMessageRequest {} message GetMessageResponse { google.protobuf.Any payload = 1; google.protobuf.FileDescriptorProto file_descriptor_proto = 2; }
Сервер, который владеет актуальным контрактом, отдаёт и само сообщение в Any, и его схему:
func (s *server) GetMessage(ctx context.Context, req *pb.GetMessageRequest) (*pb.GetMessageResponse, error) { user := &userpb.User{Name: "Alice", Age: 30} anyMsg, err := anypb.New(user) if err != nil { return nil, err } userFD := protodesc.ToFileDescriptorProto(userpb.File_proto_example_proto) // userpb.File_proto_example_proto это переменная с *descriptorpb.FileDescriptorProto (генерируется protoc) return &pb.GetMessageResponse{ Payload: anyMsg, FileDescriptorProto: userFD, }, nil }
Клиент, который понятия не имел о структуре User, получает дескриптор, динамически регистрирует его и распаковывает Any в dynamicpb.Message
resp, err := client.GetMessage(ctx, &pb.GetMessageRequest{}) if err != nil { log.Fatalf("could not get message: %v", err) } fileDesc, err := protodesc.NewFile(resp.GetFileDescriptorProto(), protoregistry.GlobalFiles) if err != nil { log.Fatalf("failed to build file descriptor: %v", err) } localFiles := new(protoregistry.Files) if err := localFiles.RegisterFile(fileDesc); err != nil { log.Fatalf("failed to register file: %v", err) } anyMsg := resp.GetPayload() // type_url имеет вид "type.googleapis.com/example.User" нам нужно только "example.User" msgDesc, err := localFiles.FindDescriptorByName(protoreflect.FullName(extractMessageName(anyMsg.GetTypeUrl()))) if err != nil { log.Fatalf("descriptor not found: %v", err) } md, ok := msgDesc.(protoreflect.MessageDescriptor) if !ok { log.Fatal("descriptor is not a message descriptor") } dm := dynamicpb.NewMessage(md) if err := anyMsg.UnmarshalTo(dm); err != nil { log.Fatalf("failed to unmarshal Any: %v", err) }
Как видите, любые ограничения можно обойти, но, пожалуйста, помните, что появились они не на пустом месте и их обход может принести ещё больше проблем и ограничений. Все эти методы находят применение не только во внутренних инструментах и дебаггерах, но и в реальной микросервисной архитектуре. Особенно там, где скорость эволюции схем опережает скорость перевыпуска сервисов.
А если вы всё-таки решили использовать эти техники — да пребудет с вами сила и огромная удача вы теперь знаете о gRPC и Protobuf чуть больше.
На этом у меня всё. Ниже список того, что я упоминал, со ссылками, а ваши истории, вопросы и критику — в комментарии.