
Всем привет! Меня зовут Олег, я Go‑разработчик в компании ТехВилл. Мою первую статью о нетипизированных константах можно прочитать тут.
Но если та статья была теоретической, то сегодня — более практическая, связанная с болью, глупостью и, все же, со счастливым концом.
Это моя вторая статья, так что сильно камнями не кидайтесь. И если есть какая‑то важная информация по статье — буду благодарен, если вы напишите ее в комментариях.
Немного вступительной теории
В нашей компании многое построено на микросервисной архитектуре. Для взаимодействия между сервисами мы активно используем 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‑сервисов, примерно это выглядело вот так:
И v1, и v2 — это разные версии одних и тех же методов. Для каждой — свои proto‑файлы, свой сгенерированный код. Причем, v2 была сгенерирована с помощью такого инструмента как Buf.
Первая гипотеза
Гугление дало немного. Но я наткнулся на комментарий к issue на GitHub (ссылка), из которого сделал вывод (неверный), что:
«Если сервис на одной конечной точке содержит несколько зарегистрированных gRPC‑интерфейсов, Postman может не справиться с их перечислением».
Я решил проверить. Закомментировал v1, оставил только v2, перезапустил сервер — рефлексия заработала. Отлично. Подозрений никаких не возникло. А потом я увидел, что у нас есть другие микросервисы с v1 и v2 и там рефлексия работает. Значит не туда воюем.
Чтобы уточнить, я отключил v2 и поднял сервер только с v1. И — та‑да! — рефлексия снова не работает.
Круг сужается. Значит, проблема не в количестве сервисов, а в содержимом v1.
Вперёд — к.proto‑файлам
Я перешёл к сравнению proto‑структур. Вот репозиторий на GitHub, который я специально создал для воспроизведения проблемы (ссылка).
Если заглядывать в репу нет желания — то вот так все там выглядит:

Вложенность чумовая. Суть в том, что в директории 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" "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 как бы и не нужен, там нет никаких импортов, код сгенерируется.
В доке то собственно прекрасно описано как нужно делать (ссылка), но 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‑файлами и генерацией кода, и с удовольствием пользуемся рефлексией.
Спасибо за внимание! Если статья оказалась интересной, а может быть даже полезной (или же наоборот) — дайте знать в комментариях.
