golang

Великая ересь, или Как использовать protobuf без контракта

  • вторник, 14 июля 2026 г. в 00:00:31
https://habr.com/ru/companies/ozontech/articles/1057468/

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

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

Каждый следующий метод позволяет всё меньше и меньше полагаться на заранее известный контракт.

Дилемма жёстких контрактов
Дилемма жёстких контрактов

Дисклеймер

Всё описанное в этой статье относится к Go, все примеры кода на нём же, однако я уверен, что и другие языки предоставляют сходные инструменты.

Рассмотренные методы являются крайне мощными, но одновременно они довольно неочевидны и могут вызывать различные проблемы у того, кто недостаточно аккуратно решит ими воспользоваться. Они могут быть полезны в некоторых задачах, но не стоит использовать их просто потому, что они прикольные. Пожалуйста, перед внедрением убедитесь, что вы точно знаете, что делаете, какие у этого есть риски и каким образом вы их минимизируете.

Двигаться будем от самого безопасного варианта к самому гибкому:

  1. Unknown Fields — когда сервис не знает часть полей, но может сохранить их и передать дальше.

  2. google.protobuf.Any — когда нужно передавать сообщения разных типов через один контракт.

  3. dynamicpb — когда нужно работать с protobuf-сообщением, имея только его runtime-дескриптор.

Unknown Fields — призрачные поля.

Самый простой и одновременно самый безопасный метод во всей статье. Он позволяет работать с сильно устаревшим контрактом. Звучит вроде круто и даже применимо в продуктовых микросервисах.

Основная идея в том, что если у вас сервис оперирует устаревшей версией контракта, то все поля с индексами, которых нет в контракте, попадают в служебное поле 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_namelanguageavatar_url и рекомендации для того, чтобы собрать информацию для отрисовки ленты новостей.

3. recomendation-engine — на основании user_idlanguageinterests и subscription_tier строит список рекомендаций.

Для снижения нагрузки на user-service recomendation-engine получает информацию о пользователе в запросе от backend-for-frontend. Но в какой-то момент сервису рекомендаций становится нужно новое поле —  age. Обычным и довольно хорошим подходом было бы просто обновить bff. Но Unknown Fields позволяют сделать изящнее, без бесполезных релизов bff: мы можем отправлять recomendation-engine данные в контракте user-service, и bff будет корректно их передавать.

Схема использования Unknown Fields
Схема использования Unknown Fields

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

google.protobuf.Any — динамическая типизация в статическом контракте

С каждым днём мы всё дальше от ...контракта.

Итак, наш новый фаворит: google.protobuf.Any. Поприветствуем аплодисментами! Это специализированный тип, который представляет собой универсальный контейнер для любого сообщения. По сути, это простая структура с двумя значимыми полями: TypeUrl — полное имя типа и Value — массив байтов, который следует в этот тип анмаршалить. 

Реализация google.protobuf.Any
type Any struct {
	TypeUrl       string 
	Value         []byte
    // дальше идут служебные поля
 	state         protoimpl.MessageState 
    unknownFields protoimpl.UnknownFields
	sizeCache     protoimpl.SizeCache
}

Где это может быть полезно

Один из реальных примеров, который я уже описывал в предыдущей статье, — внутренний сервис, который принимает события и рассылает их по принципу pub‑sub. Ему абсолютно не нужно знать внутреннюю структуру каждого события. Достаточно посмотреть на TypeUrl, определить, кто на этот тип подписан, и переслать событие дальше.

Тут же давайте рассмотрим упрощённую схему:

Схема Pub Sub
Схема Pub Sub

Конечно, это может быть реализовано, например, через Kafka, но это не всегда возможно (если есть сомнения, то прошу в предыдущую статью, там я подробно об этом рассказывал).

Any не отменяет контракт полностью, получатель всё равно должен понимать, какой тип лежит внутри, или иметь способ получить его дескриптор.

dynamicpb — создание сообщения из дескриптора

И наконец, magnum opus — dynamicpb (мне не слишком интересно писать о перекладывании сырых байтов, а вам вряд ли будет интересно об этом читать, так что ещё дальше от контрактов уходить не будем). С помощью 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

Код сборки дескриптора из photo
	// мапа название файла - его содержимое
	// можно добавить несколько связанных файлов, при компиляции зависимости разрешатся
	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-рефлексию. Но поскольку это и так хороший тон, то вряд ли это станет прямо большой проблемой.

Код для получения дескриптора через 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

Proto контракт
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 чуть больше.

На этом у меня всё. Ниже список того, что я упоминал, со ссылками, а ваши истории, вопросы и критику — в комментарии.

Для тех, кто хочет применить полученные знания

  1. Unknown Fields

  2. google/protobuf/any.proto

  3. dynamicpb

  4. protodesc

  5. protocompile

  6. grpc reflection