golang

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

  • вторник, 3 ноября 2026 г. в 00:00:14
https://habr.com/ru/companies/vkusvill/articles/1102762/

Всем привет! Меня зовут Олег, я 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"

"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-файлами и генерацией кода, и с удовольствием пользуемся рефлексией.

Спасибо за внимание! Если статья оказалась интересной, а может быть даже полезной (или же наоборот) - дайте знать в комментариях.