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

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