Полный набор gRPC, RESTful JSON API и Swagger из одного .proto файла. От введения до нюансов и тонкостей grpc-gateway

от автора

В этой статье я опишу процесс создания сервера с 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.

Хотя эти типы похожи, они имеют некоторые различия.

Про более простые типы можно почитать здесь.

Инициализация сервера

Пример реализации интерфейса BlockchainServiceServer

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         }     } }

Про использование, форматирование и коды ошибок здесь, здесь и здесь.

Пример main.go

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                 };             };         };     }; };

Больше деталей и особенностей вы найдете в разделе Features

Вывод

Возможность grpc-gateway генерировать обратный HTTP прокси к gRPC и файлы swagger документации — это замечательная функция, которая помогает быстро создать сервер и красивую документацию, для тестирования вашего приложения.

Прочитав этот пост, я надеюсь, что вам будет удобнее пользоваться библиотекой. Если у вас есть комментарии или предложения, пишите в разделе комментариев.

P.S. Все файлы из статьи можно найти в репозитории https://github.com/klim0v/grpc-gateway-example

ссылка на оригинал статьи https://habr.com/ru/post/496574/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *