В этой статье я опишу процесс создания сервера с 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 пользовательскими заголовками, например это полезно для выгрузки файлов. Больше деталей и особенностей вы найдете в разделе Features
Вывод
Возможность grpc-gateway генерировать обратный HTTP прокси к gRPC и файлы swagger документации — это замечательная функция, которая помогает быстро создать сервер и красивую документацию, для тестирования вашего приложения.
Прочитав этот пост, я надеюсь, что вам будет удобнее пользоваться библиотекой. Если у вас есть комментарии или предложения, пишите в разделе комментариев.
P.S. Все файлы из статьи можно найти в репозитории https://github.com/klim0v/grpc-gateway-example
Автор: Сергей Климов