В этой статье я опишу процесс создания сервера с gRPC и RESTful JSON API одновременно и Swagger документацию к нему.
Эта статья — продолжение разбора различных способов реализаций API-сервера на Golang с автогенерацией кода и документации. Там я обещал более подробно остановиться на этом подходе.
grpc-gateway — это плагин protoc. Он читает определение сервиса gRPC и генерирует обратный прокси-сервер, который переводит RESTful JSON API в gRPC. Этот сервер создается в соответствии с пользовательскими параметрами в вашем определении gRPC.
Это выглядит вот так:

Установка
Для начала нам нужно установить protoc.
И еще нам понадобятся 3 исполняемых библиотеки на Go protoc-gen-go, protoc-gen-swagger, protoc-gen-grpc-gateway.
Так как мы все уже давно перешли на модули, то давайте зафиксируем зависимости по этой инструкции.
Создадим файлик tools.go и положим его в наш модуль.
// +build tools package tools import ( _ "github.com/grpc-ecosystem/grpc-gateway/protoc-gen-grpc-gateway" _ "github.com/grpc-ecosystem/grpc-gateway/protoc-gen-swagger" _ "github.com/golang/protobuf/protoc-gen-go" )
Вызовем go mod tidy для загрузки нужных версий пакетов. И установим их:
$ go install \ github.com/grpc-ecosystem/grpc-gateway/protoc-gen-grpc-gateway \ github.com/grpc-ecosystem/grpc-gateway/protoc-gen-swagger \ github.com/golang/protobuf/protoc-gen-go
Теперь, когда у нас весь инструментарий готов, можно переходить непосредственно к процессу создания.
Описание интерфейса
Если вы уже писали интерфейсы для gRPC, то этот код вам может показаться знакомым. Если же нет, то документацию можно почитать здесь.
syntax = "proto3"; package api_pb; message AddressRequest { string address = 1; uint64 height = 2; } message AddressResponse { map<string, string> balance = 1; string transactions_count = 2; } service BlockchainService { rpc Address (AddressRequest) returns (AddressResponse); }
Добавим в него аннотации google.api.http.
syntax = "proto3"; package api_pb; import "google/api/annotations.proto"; message AddressRequest { string address = 1; uint64 height = 2; } message AddressResponse { map<string, string> balance = 1; string transactions_count = 2; } service BlockchainService { rpc Address (AddressRequest) returns (AddressResponse) { option (google.api.http) = { get: "/address/{address}" }; } }
Добавим так же web-socket endpoint.
syntax = "proto3"; package api_pb; import "google/api/annotations.proto"; import "google/protobuf/struct.proto"; message AddressRequest { string address = 1; uint64 height = 2; } message AddressResponse { map<string, string> balance = 1; string transactions_count = 2; } message SubscribeRequest { string query = 1; } message SubscribeResponse { string query = 1; google.protobuf.Struct data = 2; message Event { string key = 1; repeated string events = 2; } repeated Event events = 3; } service BlockchainService { rpc Address (AddressRequest) returns (AddressResponse) { option (google.api.http) = { get: "/address/{address}" }; } rpc Subscribe (SubscribeRequest) returns (stream SubscribeResponse) { option (google.api.http) = { get: "/subscribe" }; } }
Я предпочитаю создать Makefile файл для команд генерации и положить его в папку с проектом.
all: mkdir -p "api_pb" protoc -I/usr/local/include -I. \ -I${GOPATH}/src \ -I${GOPATH}/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \ -I${GOPATH}/src/github.com/grpc-ecosystem/grpc-gateway \ --grpc-gateway_out=logtostderr=true:./api_pb \ --swagger_out=allow_merge=true,merge_file_name=api:. \ --go_out=plugins=grpc:./api_pb ./*.proto
И вызывать их из gen.go файла.
//go:generate make package grpc_gateway_example
У нас появились 3 новых файла ./api_pb/api.go, ./api_pb/api.gw.go и ./api.swager.json.

А вместе с ними и интерфейс сервера, который нам надо реализовать:
// BlockchainServiceServer is the server API for BlockchainService service. type BlockchainServiceServer interface { Address(context.Context, *AddressRequest) (*AddressResponse, error) Subscribe(*SubscribeRequest, BlockchainService_SubscribeServer) error }
Встроенные типы
Я бы хотел остановиться более подробно на некоторых встроенных типах protobuf. Тип google.protobuf.Struct — это просто прототипное представление объекта JSON. Любое сообщение proto3 может быть механически преобразовано в JSON и встроено в поле этого типа. Это очень гибкий тип и дает преимущества динамической типизации для protobuf.
Тип google.protobuf.Any встраивает двоичный сериализованный protobuf вместе с информацией о типе в поле другого protobuf. Внутри это просто байтовый массив с сериализацией протокольного формата встроенного сообщения и строкой, содержащей тип URL. URL-адрес типа — это, по сути, строка, содержащая имя типа в форме type.googleapis.com/packagename.messagename.
Хотя эти типы похожи, они имеют некоторые различия.
Про более простые типы можно почитать здесь.
Инициализация сервера
package service import ( "bytes" "context" "encoding/json" "github.com/golang/protobuf/jsonpb" _struct "github.com/golang/protobuf/ptypes/struct" "github.com/klim0v/grpc-gateway-example/api_pb" "google.golang.org/grpc/codes" "google.golang.org/grpc/status" "time" ) type BlockchainServer struct { eventBus <-chan interface{} } func NewBlockchainServer(eventBus <-chan interface{}) *BlockchainServer { return &BlockchainServer{eventBus: eventBus} } func (b *BlockchainServer) Address(_ context.Context, req *api_pb.AddressRequest) (*api_pb.AddressResponse, error) { if req.Address != "Mxb9a117e772a965a3fddddf83398fd8d71bf57ff6" { return &api_pb.AddressResponse{}, status.Error(codes.FailedPrecondition, "wallet not found") } return &api_pb.AddressResponse{ Balance: map[string]string{ "BIP": "12345678987654321", }, TransactionsCount: "120", }, nil } func (b *BlockchainServer) Subscribe(req *api_pb.SubscribeRequest, stream api_pb.BlockchainService_SubscribeServer) error { for { select { case <-stream.Context().Done(): return stream.Context().Err() case event := <-b.eventBus: byteData, err := json.Marshal(event) if err != nil { return err } var bb bytes.Buffer bb.Write(byteData) data := &_struct.Struct{Fields: make(map[string]*_struct.Value)} if err := (&jsonpb.Unmarshaler{}).Unmarshal(&bb, data); err != nil { return err } if err := stream.Send(&api_pb.SubscribeResponse{ Query: req.Query, Data: data, Events: []*api_pb.SubscribeResponse_Event{ { Key: "tx.hash", Events: []string{"01EFD8EEF507A5BFC4A7D57ECA6F61B96B7CDFF559698639A6733D25E2553539"}, }, }, }); err != nil { return err } case <-time.After(5 * time.Second): return nil } } }
Про использование, форматирование и коды ошибок здесь, здесь и здесь.
package main import ( "context" "flag" "github.com/golang/glog" grpc_prometheus "github.com/grpc-ecosystem/go-grpc-prometheus" "github.com/grpc-ecosystem/grpc-gateway/runtime" gw "github.com/klim0v/grpc-gateway-example/api_pb" "github.com/klim0v/grpc-gateway-example/service" "github.com/tmc/grpc-websocket-proxy/wsproxy" "golang.org/x/sync/errgroup" "google.golang.org/grpc" "net" "net/http" "time" ) func run() error { ctx := context.Background() ctx, cancel := context.WithCancel(ctx) defer cancel() lis, err := net.Listen("tcp", ":8842") if err != nil { return err } grpcServer := grpc.NewServer( grpc.StreamInterceptor(grpc_prometheus.StreamServerInterceptor), grpc.UnaryInterceptor(grpc_prometheus.UnaryServerInterceptor), ) eventBus := make(chan interface{}) gw.RegisterBlockchainServiceServer(grpcServer, service.NewBlockchainServer(eventBus)) grpc_prometheus.Register(grpcServer) var group errgroup.Group group.Go(func() error { return grpcServer.Serve(lis) }) mux := runtime.NewServeMux(runtime.WithMarshalerOption(runtime.MIMEWildcard, &runtime.JSONPb{OrigName: true, EmitDefaults: true})) opts := []grpc.DialOption{ grpc.WithInsecure(), grpc.WithDefaultCallOptions(grpc.MaxCallRecvMsgSize(50000000)), } group.Go(func() error { return gw.RegisterBlockchainServiceHandlerFromEndpoint(ctx, mux, ":8842", opts) }) group.Go(func() error { return http.ListenAndServe(":8843", wsproxy.WebsocketProxy(mux)) }) group.Go(func() error { return http.ListenAndServe(":2662", promhttp.Handler()) }) group.Go(func() error { for i := 0; i < 100; i++ { eventBus <- struct { Type byte Coin string Value int TransactionCount int Timestamp time.Time }{ Type: 1, Coin: "BIP", TransactionCount: i, Timestamp: time.Now(), } } return nil }) return group.Wait() } func main() { flag.Parse() defer glog.Flush() if err := run(); err != nil { glog.Fatal(err) } }
Здесь я переопределил jsonpb.Marshaler с его полями:
EmitDefaults: true— для вывода значений поумолчанию, таких как0дляint,""дляstring;EnumsAsInts: true— для выводаenumзначений по их строковому именованию, а не индексу;OrigName: true— для вывода имен полей в json'е, по их именованию в.protoфайле.
Увеличил максимальный размер ответа сервера (если это нужно) grpc.MaxCallRecvMsgSize(50000000).
Чтобы обнаружить потоковые конечные точки web-sokets, создадим handler с помощью обработчика wsproxy.WebsocketProxy(mux). wsproxy использует json-кодирование с разделителями строк.
Для prometheus метрики и middleware есть отдельные репозитории с документацией на странице github.
Советы по написанию protobuf
Переопределить имена полей JSON можно в .proto файле.
Генератор Swagger документации grpc-gateway использует верблюжий регистр по умолчанию при генерации определений Swagger. Если вы хотите использовать вместо этого snake_case, вы можете установить опцию поля встроенного json_name в protobuf на желаемое имя свойства.
message AwesomeName { uint32 id = 1; string awesome_name = 2 [json_name = "awesome_name"]; }
Можно установить поля только для чтения. Есть определенные поля, такие как идентификатор ресурса, который не имеет смысла обновлять. Просто добавив комментарий // Output only. в поле сообщения protobuf пометит поле как доступное только для чтения.
message AwesomeName { // Output only. uint32 id = 1; string awesome_name = 2; }
Вставить значения пути URL в сообщение protobuf
Это полезно, если вы хотите, чтобы определения ваших прототипов сообщений были аккуратными. В типичной реализации REST для обновления ресурсов требуется, чтобы идентификатор ресурса был передан в URL.
service AwesomeService { rpc UpdateAppointment (UpdateAwesomeNameRequest) returns (AwesomeName) { option (google.api.http) = { put: "/v1/awesome-name/{awesome_name.id}" body: "awesome_name" }; }; } message UpdateAwesomeNameRequest { AwesomeName awesome_name = 1; }
Фильтр значений для обновления. Поскольку будет трудно определить, является ли значение пустым или не определено в запросе, grpc-gateway сам может решать эту проблему. Для этого сообщение запроса protobuf должно иметь поле update_mask с типом, а запрос должен быть запросом PATCH. Подробнее здесь, здесь и здесь.
message UpdateAwesomeNameRequest { AwesomeName awesome_name = 1; google.protobuf.FieldMask update_mask = 2; // This field will be automatically populated by grpc-gateway. }
Можно определить несколько HTTP-методов для одного RPC, используя опцию additional_bindings. Этот и другие примеры построения endpoint'ов можно посмотреть здесь
Что бы описать дополнительные параметры Swagger в отдельном файле опишем option (grpc.gateway.protoc_gen_swagger.options.openapiv2_swagger).
syntax = "proto3"; import "protoc-gen-swagger/options/annotations.proto"; package awesome.service; option (grpc.gateway.protoc_gen_swagger.options.openapiv2_swagger) = { info: { title: "My Habr Example Service" version: "1.0" contact: { name: "Klimov Sergey" url: "https://github.com/klim0v" email: "klim0v-sergey@yandex.ru" }; }; schemes: [HTTP,HTTPS] consumes: "application/json" produces: "application/json" responses: { key: "404" value: { description: "Returned when the resource does not exist." schema: { json_schema: { type: STRING }; }; }; }; };
Есть поддержка ответа от сервера c пользовательскими заголовками. Например это полезно для выгрузки файлов.
import "google/api/httpbody.proto"; import "google/api/annotations.proto"; import "google/protobuf/empty.proto"; service HttpBodyExampleService { rpc HelloWorld(google.protobuf.Empty) returns (google.api.HttpBody) { option (google.api.http) = { get: "/helloworld" }; } }
func (*HttpBodyExampleService) Helloworld(ctx context.Context, in *empty.Empty) (*httpbody.HttpBody, error) { return &httpbody.HttpBody{ ContentType: "text/html", Data: []byte("Hello World"), }, nil }
Больше деталей и особенностей вы найдете в разделе Features
Вывод
Возможность grpc-gateway генерировать обратный HTTP прокси к gRPC и файлы swagger документации — это замечательная функция, которая помогает быстро создать сервер и красивую документацию, для тестирования вашего приложения.
Прочитав этот пост, я надеюсь, что вам будет удобнее пользоваться библиотекой. Если у вас есть комментарии или предложения, пишите в разделе комментариев.
P.S. Все файлы из статьи можно найти в репозитории github и OpenAPI спецификацию на github-pages
