gRPC, рефлексия и Postman: баг, которого нет

Всем привет! Меня зовут Олег, я Go-разработчик в компании ТехВилл.
Мою первую статью о нетипизированных константах можно прочитать тут (https://habr.com/ru/companies/automacon/articles/874144/).
Но если та статья была теоретической, то сегодня — более практическая, связанная с болью, глупостью и, все же, со счастливым концом.
Это моя вторая статья, так что сильно камнями не кидайтесь. И если есть какая-то важная информация по статье – буду благодарен, если вы напишите ее в комментариях.
Немного вступительной теории
В нашей компании многое построено на микросервисной архитектуре. Для взаимодействия между сервисами мы активно используем gRPC. Думаю, большинство знакомо с этим инструментом, но всё же коротко напомню:
gRPC — это современный протокол удалённого вызова процедур (RPC), основанный на HTTP/2 и использующий бинарную сериализацию через Protocol Buffers (protobuf). Он позволяет эффективно обмениваться данными между сервисами и создавать строго типизированные API.
Один из ключевых элементов в gRPC — это proto-файлы. Это текстовые файлы, описывающие интерфейс системы: какие есть сервисы, методы, какие типы сообщений используются и какие у них поля. Они пишутся на специальном языке proto3 и выглядят просто, но при этом очень информативно.
На основе proto-файлов автоматически генерируется код клиента и сервера — будь то Go, Java, Python или любой другой поддерживаемый язык. Это избавляет от рутины, устраняет ошибки при сериализации/десериализации и гарантирует строгую типизацию на всех этапах обмена.
Например, если у нас есть сервис с методом GetUser, в proto-файле описывается, какие параметры он принимает и что возвращает. После генерации в Go вы получаете интерфейс с этим методом и строго заданными структурами — клиенту остаётся только вызывать метод и передавать данные.
Как я тестирую gRPC-сервисы
Перед тем как отдавать код на проверку тестировщикам, каждый разработчик, конечно, должен сначала сам убедиться, что всё работает как надо. Я для этого использую Postman. В принципе — ничего сложного: загружаешь proto-файлы, указываешь пути к ним, и вперёд — можно дергать свои свеженаписанные методы сколько угодно.
Затем всё это передаётся тестировщику — вместе с архивом, содержащим proto-файлы. И тут начинается боль: если вдруг в структуре что-то незначительно поменялось (а такое происходит часто) — ты отправляешь новый архив. Потом ещё один. И ещё. Это утомляет.
Так мы и пришли к мысли: а что, если включить рефлексию и забыть об перекидывании архивов?
Что такое рефлексия в контексте gRPC?
Рефлексия (reflection) — это механизм, который позволяет клиенту динамически получить описание API на gRPC-сервере. Без proto-файлов, прямо на лету. Можно узнать:
какие сервисы доступны,
какие методы у каждого сервиса,
какие сообщения ожидаются на вход и возвращаются на выходе,
какие поля есть у сообщений и какого они типа.
Это особенно удобно, когда у клиента (например, у нашего тестировщика) нет доступа к proto-файлу, но он всё равно хочет протестировать метод.
Как подключить рефлексию в Go
На самом деле — всё элементарно. Нужно:
Импортировать пакет:
import "google.golang.org/grpc/reflection"
После регистрации всех gRPC-сервисов — вызвать reflection.Register(server):
// Создаём gRPC-сервер
server := grpc.NewServer()
// Регистрируем свой сервис
RegisterMyServiceServer(server, &myService{})
// Подключаем рефлексию
reflection.Register(server)
Теперь в Postman можно просто включить "Use Server Reflection", и сервисы автоматически подтянутся.

Если ваш сервер работает через TLS, не забудьте нажать на замочек слева от адреса в Postman — это включает безопасное соединение, иначе рефлексия не сработает.
Не всё так просто: проблемы с рефлексией
Но, как говорится, «не долго музыка играла». На одних микросервисах всё работало отлично, а на других — рефлексия в Postman переставала функционировать (точнее, даже не начинала функционировать). Без всяких логов и подробностей — просто «Unknown error» при попытке получить методы сервиса.

При этом, если вызвать команду:
grpcurl -plaintext localhost:50051 list
— видно, что gRPC-сервисы успешно зарегистрированы и доступны. Значит, сервер работает.

Ну да ладно, не Postman'ом единым. Есть ещё Insomnia — отличный инструмент для тех же задач. Но и он оказался бессилен. Ошибка сохранялась.

Диагностика по методу «разницы»
Я решил попробовать старый добрый метод «найди отличие»: взять один микросервис, где всё работает, и другой — где нет, и просто сравнить код сервера.
Почти всё совпадало, за исключением одной важной детали: в проблемном сервисе было зарегистрировано две версии gRPC-сервисов, примерно это выглядело вот так:
server := grpc.NewServer()
v1.RegisterMyServiceServerV1(server, &myServiceV1{})
v2.RegisterMyServiceServerV2(server, &myServiceV2{})
reflection.Register(server)
И v1, и v2 — это разные версии одних и тех же методов. Для каждой — свои proto-файлы, свой сгенерированный код. Причем, v2 была сгенерирована с помощью такого инструмента как Buf.
Первая гипотеза
Гугление дало немного. Но я наткнулся на комментарий к issue на GitHub (https://github.com/postmanlabs/postman-app-support/issues/10844#issuecomment-1343313616
), из которого сделал вывод (неверный), что:
«Если сервис на одной конечной точке содержит несколько зарегистрированных gRPC-интерфейсов, Postman может не справиться с их перечислением».
Я решил проверить. Закомментировал v1, оставил только v2, перезапустил сервер — рефлексия заработала. Отлично. Подозрений никаких не возникло. А потом я увидел, что у нас есть другие микросервисы с v1 и v2 и там рефлексия работает. Значит не туда воюем.
Чтобы уточнить, я отключил v2 и поднял сервер только с v1. И — та-да! — рефлексия снова не работает.
Круг сужается. Значит, проблема не в количестве сервисов, а в содержимом v1.
Вперёд — к .proto-файлам
Я перешёл к сравнению proto-структур. Вот репозиторий на GitHub, который я специально создал для воспроизведения проблемы https://github.com/DoktorGhost/reflection
Если заглядывать в репу нет желания – то вот так все там выглядит:

Вложенность чумовая. Суть в том, что в директории api/grpc/protobuf/data/v1 лежали proto-файлы с описанием разных message, а в api/grpc/protobuf/service_1 – proto-файл, в котором было описание сервисов, использующих те самые message из директории data. Рассчитывалось, что message будут переиспользоваться для разных сервисов. Следовательно, service_1.proto содержал импорты на другие proto-файлы и в go-коде сервера у нас появлялись импорты на пакеты с типами данных и пакеты с методами.

Можно воспроизвести проблему на Go с помощью main файла внутри репозитория:
package main
import (
"context"
v1 "github.com/DoktorGhost/reflection/src/go/pkg/grpc/data/v1"
"github.com/DoktorGhost/reflection/src/go/pkg/grpc/service_1/api/grpc/protobuf/service_1"
"log"
"net"
"google.golang.org/grpc/reflection"
)
type oneServiceServer struct {
service_1.UnimplementedOneServiceServer
}
type twoServiceServer struct {
service_1.UnimplementedTwoServiceServer
}
type threeServiceServer struct {
service_1.UnimplementedThreeServiceServer
}
func (s oneServiceServer) GetOther(_ context.Context, _ v1.DataOneRequest) (*v1.DataOneResponse, error) {
return &v1.DataOneResponse{}, nil
}
func (s twoServiceServer) GetOther(_ context.Context, _ v1.DataTwoRequest) (*v1.DataTwoResponse, error) {
return &v1.DataTwoResponse{}, nil
}
func (s threeServiceServer) GetOther(_ context.Context, _ v1.DataThreeRequest) (*v1.DataThreeResponse, error) {
return &v1.DataThreeResponse{}, nil
}
func main() {
lis, err := net.Listen("tcp", ":50051")
if err != nil {
log.Fatalf("Failed to listen: %v", err)
}
grpcServer := grpc.NewServer()
service_1.RegisterOneServiceServer(grpcServer, &oneServiceServer{})
service_1.RegisterTwoServiceServer(grpcServer, &twoServiceServer{})
service_1.RegisterThreeServiceServer(grpcServer, &threeServiceServer{})
reflection.Register(grpcServer)
log.Println("gRPC server listening on :50051")
if err := grpcServer.Serve(lis); err != nil {
log.Fatalf("Failed to serve: %v", err)
}
}
v2
В версии v2 структура была проще:

Меньше вложенности.
Нет импортов других proto-файлов
Код генерировался с помощью buf.
Я сделал вывод: если v2 работает, а v1 — нет, и ключевое отличие между ними в наличии вложенных импортов, стоит попробовать объединить всё в один proto-файл, без внешних зависимостей.
Сделал — работает.
Поддержка Postman отвечает
Но всё же хотелось понять, в чём конкретно проблема. Как будто бы, не может быть такого, что нельзя использовать импорты на другие proto-файлы. Документация Postman ничего об этом не говорила. Я написал в поддержку, подробно описал ситуацию, приложил скриншоты.
Ответ пришёл быстро — уже через час мне ответил некий Guillermo Buendia:

То есть, по сути:
«Postman пока не умеет обрабатывать вложенные импорты proto-файлов при использовании рефлексии.»
При этом они поощряют пользователей отправлять предложения и баги в их github (https://support.postman.com/hc/en-us/articles/115003270949-How-to-submit-a-feature-request) чем я и занялся.
Я отправил свой issue в их репозиторий
https://github.com/postmanlabs/postman-app-support/issues/13646 .
Другой инженер Postman (Appurva Murawat) уже в комментариях к issue попытался воспроизвести проблему, создал вложенность proto-файлов, но у него всё заработало.

Получается, что Guillermo Buendia был не прав, Postman прекрасно справляется со вложенными импортами, и товарищ Appurva Murawat это продемонстрировал.
Как я (не)починил рефлексию
Дальше последовала череда экспериментов. Я по-разному размещал proto-файлы в директориях, пробовал убирать и добавлять импорты, и постепенно начали проявляться проблески решения. Чтобы не возиться с длинными путями и названиями, я сделал минимальный тестовый проект с иерархией вида 1/2/3, упростил имена и начал на нем тестировать генерацию. На этом этапе стало понятно, что мой старый Makefile, который до этого генерировал Go-код, требует перемен. До всех рефакторингов он выглядел так.
generate-go-data-v1-api:
mkdir -p src/go/pkg/grpc/data/v1
GOBIN=$(LOCAL_BIN) protoc \
--proto_path api/grpc/protobuf/data/v1 \
--go_out=src/go/pkg/grpc/data/v1 \
--go_opt=paths=source_relative \
--go-grpc_out=src/go/pkg/grpc/data/v1 \
--go-grpc_opt=paths=source_relative \
--plugin=protoc-gen-go=bin/protoc-gen-go \
--plugin=protoc-gen-go-grpc=bin/protoc-gen-go-grpc \
--plugin=protoc-gen-doc=bin/protoc-gen-doc \
api/grpc/protobuf/data/v1/*.proto
А вот код генерации самих сервисов:
generate-go-service-api:
mkdir -p src/go/pkg/grpc/service_1
GOBIN=$(LOCAL_BIN) protoc \
--proto_path . \
--proto_path api/grpc/protobuf/service_1 \
--go_out=src/go/pkg/grpc/service_1 \
--go_opt=paths=source_relative \
--go-grpc_out=src/go/pkg/grpc/service_1 \
--go-grpc_opt=paths=source_relative \
--plugin=protoc-gen-go=bin/protoc-gen-go \
--plugin=protoc-gen-go-grpc=bin/protoc-gen-go-grpc \
--plugin=protoc-gen-doc=bin/protoc-gen-doc \
api/grpc/protobuf/service_1/*.proto
Самые внимательные читатели уже заметили, что есть некоторые отличия в строке --proto_path=. А собственно, за что вообще она отвечает?
--proto_path указывает на корень импорта. Например, в моем репозитории есть proto-файл, импортирующий структуры из других proto-файлов, выглядит это так:
import "api/grpc/protobuf/data/v1/data_3.proto";
Но, если мы отбросим вот эту часть «api/grpc/protobuf/» IDEA не будет ругаться, проблемы возникнут на этапе генерации кода, если мы не укажем в --proto_path отброшенную в импорте часть пути «api/grpc/protobuf»
Получается, для proto-файла со структурами (в нашем случае все в папке data/v1) --proto_path как бы и не нужен, там нет никаких импортов, код сгенерируется.
В доке то собственно прекрасно описано как нужно делать (https://protobuf.dev/programming-guides/proto3/#:~:text=The%20protocol%20compiler,for%20all%20imports.), но Makefile написан уже давно кем-то другимс, gRPC работает, а значит не надо туда лезть.
“Если флаг не указан, поиск выполняется в каталоге, из которого был вызван компилятор. Как правило, следует установить --proto_path флаг на корневой каталог проекта и использовать полные имена для всех импортируемых файлов.”
Для proto-файла, импортирующего структуры из другого proto-файла, эта строка нужна только в том случае, если мы сократили строку импорта.

Довольно полезная информация бывает в доке.
Так как у меня импорты прописаны полностью, Makefile запускается из корня проекта – удаляем строку с --proto_path и там и там. Генерируем, запускаем сервер – здравствуйте рефлексия, давно не виделись.

Конечно, немного нарушилась иерархия, добавилась вложенность, за это действие отвечает
--go_opt=paths=source_relative
Я не стал разбираться, как настроить генерацию с нужной мне вложенностью, а просто отредактировал в proto-файлах option go_package и заново сгенерировал код, чтобы в go-файлах были правильные импорты (это можно посмотреть в ветке succ моего репозитория).
Почему так?

Оказывается, что собака была зарыта в процессе генерации, а я откидывал этот вариант, потому что сервисы работали (кроме рефлексии), и на продакшене все работало (кроме рефлексии) и в целом по жизни все было отлично (кроме, конечно же, рефлексии).
Флаг --proto_path говорит protoc, где искать proto-файлы, особенно те, что импортируются. Но ещё важнее — он влияет на то, как protoc привязывает пути к файлам в дескрипторах, которые потом используются.
Дескрипторы в Protobuf — это модели, описывающие структуру данных, определённых в Protobuf-файлах, они нужны для генерации кода, рефлексии, динамических сообщений и rpc (подробнее можно прочитать тут https://buf.build/docs/reference/descriptors/)
Нас же интересует FileDescriptorProto, потому что в нем protoc сохраняет имена proto-файлов относительно --proto_path. Эти имена потом используются в рефлексии — т.е. если они не совпадают с импортами в других proto, дескрипторы не свяжутся между собой
Допустим в proto-файле, вот такой импорт:
import "data/v1/data_1.proto";
И мы компилируем service_1.proto, находящийся в:
api/grpc/protobuf/service_1/service_1.proto
Если не указать --proto_path=api/grpc/protobuf, protoc не сможет найти путь data/v1/..., потому что он ищет его относительно текущей директории.
А если мы добавим --proto_path=api/grpc/protobuf в одной генерации, но при этом другие зависимости были сгенерированы без --proto_path, то в дескрипторах возникнет такая ситуация:
Допустим в генерации сервисов мы прописали --proto_path=api/grpc/protobuf, service_1 видит data/v1/data_1.proto, но файл data_1.proto в дескрипторе у data-сборки, где мы не прописывали --proto_path значится как api/grpc/protobuf/data/v1/data_1.proto
Они не совпадают, и рефлексия не работает, потому что дескрипторы не связываются.
Если заглянуть в сгенерированный go-файл и найти переменную типа protoreflect.FileDescriptor, можно увидеть, как называется файл — именно этим путём gRPC будет искать зависимости:

А в файле proto импорты на полный путь

Вот из-за этого несовпадения и не работает рефлексия.
Если же мы в обоих генераторах укажем путь:
--proto_path=api/grpc/protobuf
И используем укороченные импорты:
import "data/v1/data_1.proto";
Тогда protoc и для типов, и для сервисов видит все одинаково: data/v1/data_1.proto. И все дескрипторы становятся совместимыми, ServerReflection на сервере может найти зависимости, типы, сервисы и отразить их полностью.


Тут рефлексия будет работать.
Теперь немного про grpccurl, он то возвращал нам список имен сервисов при нерабочей рефлексии:

Но в том то и дело, что это просто список имен зарегистрированных сервисов.
Если мы выполним такую команду, для получения описания конкретного сервиса:
grpcurl -plaintext localhost:50051 describe combo.OneService
то мы получим ошибку

Разберем по шагам:
grpcurl спрашивает у сервера дескриптор proto-файла, в котором определен combo.OneService.
Сервер находит proto-файл, в котором объявлен combo.OneService (service_1.proto), и пытается вернуть весь его FileDescriptorProto, включая все импорты.
gRPC-сервер собирает FileDescriptorSet, чтобы отправить его клиенту, но не может найти зависимый файл api/grpc/protobuf/data/v1/data_1.proto, следовательно, он не может сериализовать FileDescriptorProto, потому что один из импортов неразрешим.
А вот такую информацию о сервисе мы получим, если все правильно сделано:

Некоторые выводы
gRPC Reflection — удобный и мощный инструмент, который значительно упрощает работу с сервисами, особенно для тестировщиков, которым мы забыли предоставить доступ к proto-файлам;
Не нужно мудрить со структурой и иерархией proto-файлов, это чревато последствиями и длинными строчками импортов;
Если всё же намудрили со структурой — не ошибитесь с параметрами при генерации;
Команда поддержки Postman довольно быстро отвечает на глупые сообщения;
Ответы от команды поддержки postman могут быть такими же глупыми, как и вопросы;
Не ленитесь заглядывать в документацию, она действительно поможет найти решение.
А мы, тем временем, порешали все проблемы с proto-файлами и генерацией кода, и с удовольствием пользуемся рефлексией.
Спасибо за внимание! Если статья оказалась интересной, а может быть даже полезной (или же наоборот) - дайте знать в комментариях.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.