フラミナル

考え方や調べたことを書き殴ります。IT技術系記事多め

【新規ツール探し】gRPC を RESTful API で提供できる grpc-gateway

  • 記事作成日:2022/12/07

情報

名前 URL
Github https://github.com/grpc-ecosystem/grpc-gateway
公式サイト https://grpc-ecosystem.github.io/grpc-gateway/
デモサイト
開発母体 CNCF
version 2.14.0
言語 Go
価格 無料
ライセンス BSD 3-Clause

何ができるもの?

gPRC のサービスに対して、RESTful API の口を用意するためのリバースプロキシ。

以下の図に書かれているように、protoファイルに付与した google.api.http のアノテーションからリバースプロキシの設定と、gRPC サービスを生成する。

利用シーン

  • gRPC をメインで使っているが、RESTful API でも提供したいとき
  • gRPC で独自の Marshal の仕組みを入れてる時の緩衝レイヤーとして

登場背景

by DeepL

gRPCは素晴らしい - 多くのプログラミング言語でAPIクライアントとサーバスタブを生成し、高速で使いやすく、帯域幅効率に優れ、そのデザインはGoogleによって戦闘的に証明されています。しかし、従来のRESTful APIも提供したいと思うかもしれない。その理由は、後方互換性の維持、gRPCで十分にサポートされていない言語やクライアントのサポート、単にRESTfulアーキテクチャに関わる美観やツールの維持など、多岐にわたります。

このプロジェクトは、あなたの gRPC サービスに HTTP+JSON インタフェースを提供することを目的としています。このライブラリを用いてリバースプロキシーを生成するために必要なのは、HTTPセマンティクスをアタッチするためのサービスにおけるわずかな設定だけです。

所感

  • 既存の proto に annotation をつけるだけでコードが生成でき、それを http サーバに組み込むだけなので非常に簡単
  • Go にしか対応していないので、Go 以外の言語で gRPC を使っている場合にはそれ+Go でサーバを書かないといけないのが面倒
  • 調べると encording/json足を引っ張るケースがある ので、他のシリアライズライブラリに差し替えるのが良いという話があった
  • マニュアルを読むとエッジケースは対応しておらず、「80%ぐらいのユースケースに対応します」 と書いてあったのでガッツリ使う場合は事前に調査が必要
    • ただしこれは自動のマッピングの話なので、grpc-gateway を使ったリバースプロキシに手を入れればなんとかなるハズ
    • FAQ | gRPC-Gateway

gRPC-Gatewayは、包括的だが複雑なアノテーションを書くことを強いることなく、80%のユースケースをカバーすることを意図しています。そのため、ゲートウェイ自体が、デザイン上、必ずしもすべてのユースケースをカバーするわけではありません。言い換えれば、ゲートウェイはgRPCとHTTP/1通信間の典型的な退屈な定型マッピングを自動化しますが、任意に複雑なカスタムマッピングを代行するわけではありません。

一方、runtime.ServeMuxをラップするミドルウェアとして、好きなものを追加することは可能です。runtime.ServeMuxは標準的なhttp.Handlerなので、Goの既存のサードパーティライブラリ(例:gateway main.go program.ServeMux)を利用して、簡単にruntime.ServeMuxのカスタムラッパーを記述することができます。

使い方

$ go get github.com/grpc-ecosystem/grpc-gateway/v2/protoc-gen-grpc-gateway
$ go get google.golang.org/protobuf/cmd/protoc-gen-go
$ go get google.golang.org/grpc/cmd/protoc-gen-go-grpc

proto の生成

cat << EOF > proto/helloworld/hello_world.proto
syntax = "proto3";

package helloworld;
option go_package = "github.com/myuser/myrepo";

// The greeting service definition
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name
message HelloRequest {
  string name = 1;
}

// The response message containing the greetings
message HelloReply {
  string message = 1;
}
EOF
cat << EOF > buf.yaml
version: v1
name: buf.build/myuser/myrepo
EOF

❯ cat << EOF > buf.gen.yaml
heredoc> version: v1
plugins:
  - name: go
    out: .
    opt: paths=source_relative
  - name: go-grpc
    out: .
    opt: paths=source_relative

heredoc> EOF

❯ buf generate
Failure: plugin go-grpc: could not find protoc plugin for name go-grpc

エラーが出たので go install しておく。

❯ go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2
go: downloading google.golang.org/grpc v1.2.1
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.2  3.

生成

❯ buf generate
❯ tree

.
├── buf.gen.yaml
├── go.mod
├── go.sum
└── proto
   ├── buf.yaml
   └── helloworld
      ├── hello_world.pb.go
      ├── hello_world.proto
      └── hello_world_grpc.pb.go

main.goの生成

package main

import (
    "context"
    "log"
    "net"

    "google.golang.org/grpc"

    helloworldpb "github.com/myuser/myrepo/proto/helloworld"
)

type server struct{
    helloworldpb.UnimplementedGreeterServer
}

func NewServer() *server {
    return &server{}
}

func (s *server) SayHello(ctx context.Context, in *helloworldpb.HelloRequest) (*helloworldpb.HelloReply, error) {
    return &helloworldpb.HelloReply{Message: in.Name + " world"}, nil
}

func main() {
    // Create a listener on TCP port
    lis, err := net.Listen("tcp", ":8081")
    if err != nil {
        log.Fatalln("Failed to listen:", err)
    }

    // Create a gRPC server object
    s := grpc.NewServer()
    // Attach the Greeter service to the server
    helloworldpb.RegisterGreeterServer(s, &server{})
    // Serve gRPC Server
    log.Println("Serving gRPC on 0.0.0.0:8081")
    log.Fatal(s.Serve(lis))
}

起動

❯ go mod tidy
❯ go run main.go
2022/12/07 10:38:06 Serving gRPC on 0.0.0.0:8081

# 接続テスト
❯ grpcurl -protoset <(buf build -o -) -d '{"name": "Jane"}' -plaintext 0.0.0.0:8081 helloworld.Greeter/SayHello
{
  "message": "Jane world"
}

grpc-gateway で RESTful API を提供する

/v1/example/echo でアクセスできるように annotation をつけます。

syntax = "proto3";

package helloworld;
option go_package = "github.com/myuser/myrepo";

+ import "google/api/annotations.proto";

+ // Here is the overall greeting service definition where we define all our endpoints
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {
+     option (google.api.http) = {
+       post: "/v1/example/echo"
+       body: "*"
+     };
  }
}

// The request message containing the user's name
message HelloRequest {
  string name = 1;
}

// The response message containing the greetings
message HelloReply {
  string message = 1;
}
version: v1
plugins:
  - name: go
    out: .
    opt: paths=source_relative
  - name: go-grpc
    out: .
    opt: paths=source_relative
+  - name: grpc-gateway
+    out: .
+    opt: paths=source_relative
version: v1
name: buf.build/myuser/myrepo
+deps:
+  - buf.build/googleapis/googleapis

buf mod update する必要があった。

❯ buf generate
proto/helloworld/hello_world.proto:6:8:google/api/annotations.proto: does not exist

❯ buf mod update

# buf.lock が作成された
❯ ls
README.md    buf.lock     go.mod       main.go
buf.gen.yaml buf.yaml     go.sum       proto

❯ buf generate
❯ tree

proto
└── helloworld
   ├── hello_world.pb.go
   ├── hello_world.pb.gw.go
   ├── hello_world.proto
   └── hello_world_grpc.pb.go
package main

import (
    "context"
    "log"
    "net"
    "net/http"

    "github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
    "google.golang.org/grpc"
    "google.golang.org/grpc/credentials/insecure"

    helloworldpb "github.com/myuser/myrepo/proto/helloworld"
)

type server struct{
    helloworldpb.UnimplementedGreeterServer
}

func NewServer() *server {
    return &server{}
}

func (s *server) SayHello(ctx context.Context, in *helloworldpb.HelloRequest) (*helloworldpb.HelloReply, error) {
    return &helloworldpb.HelloReply{Message: in.Name + " world"}, nil
}

func main() {
    // Create a listener on TCP port
    lis, err := net.Listen("tcp", ":8081")
    if err != nil {
        log.Fatalln("Failed to listen:", err)
    }

    // Create a gRPC server object
    s := grpc.NewServer()
    // Attach the Greeter service to the server
    helloworldpb.RegisterGreeterServer(s, &server{})
    // Serve gRPC server
    log.Println("Serving gRPC on 0.0.0.0:8081")
    go func() {
        log.Fatalln(s.Serve(lis))
    }()

    // Create a client connection to the gRPC server we just started
    // This is where the gRPC-Gateway proxies the requests
    conn, err := grpc.DialContext(
        context.Background(),
        "0.0.0.0:8081",
        grpc.WithBlock(),
        grpc.WithTransportCredentials(insecure.NewCredentials()),
    )
    if err != nil {
        log.Fatalln("Failed to dial server:", err)
    }

    gwmux := runtime.NewServeMux()
    // Register Greeter
    err = helloworldpb.RegisterGreeterHandler(context.Background(), gwmux, conn)
    if err != nil {
        log.Fatalln("Failed to register gateway:", err)
    }

    gwServer := &http.Server{
        Addr:    ":8090",
        Handler: gwmux,
    }

    log.Println("Serving gRPC-Gateway on http://0.0.0.0:8090")
    log.Fatalln(gwServer.ListenAndServe())
}

起動

❯ go mod tidy
❯ go run main.go
2022/12/07 10:38:06 Serving gRPC on 0.0.0.0:8081

# 接続テスト
# by grpcurl
❯ grpcurl -protoset <(buf build -o -) -d '{"name": "Jane"}' -plaintext 0.0.0.0:8081 helloworld.Greeter/SayHello
{
  "message": "Jane world"
}

# by curl
❯ curl -X POST -k http://localhost:8090/v1/example/echo -d '{"name": " Jane"}'
{"message":" Jane world"}

Interceptor を足したらどうなるのか?

+ func testInterceptor() grpc.UnaryServerInterceptor {
+   return func(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) {
+       log.Println("testInterceptor")
+       return handler(ctx, req)
+   }
+ }

func main() {
    // Create a listener on TCP port
    lis, err := net.Listen("tcp", ":8081")
    if err != nil {
        log.Fatalln("Failed to listen:", err)
    }

    // add interceptor
+   opt := grpc.ServerOption(
+       grpc.UnaryInterceptor(testInterceptor()),
+   )

    // Create a gRPC server object
-  s := grpc.NewServer()
+ s := grpc.NewServer(opt)

この状態で gRPC / HTTP の両方を飛ばしたが、ちゃんと Interceptor のログが出力されていることを確認できた。

RegisterGreeterHandlerClient は、Greeter サービス用の http ハンドラを "mux" に登録します。ハンドラは与えられた "GreeterClient" の実装を介して grpc エンドポイントにリクエストを転送します。

注意: gRPC フレームワークは、gRPC ハンドラ内でインターセプターを実行します。渡された "GreeterClient" が通常の gRPC フロー (gRPC クライアントの作成など) を通らない場合、渡された "GreeterClient" が正しいインターセプターを呼び出すことになります。

と書かれていたので、HTTP -> gRPC の場合は内部でちゃんと Interceptor を通るようになってる。

おまけ

gRPC-Web との違い

利用方法

gRPC-Gatewayでは、プロトファイルのアノテーションからリバースプロキシーを生成します。フロントエンドでは、REST APIを直接呼び出します。OpenAPI v2の仕様を生成し、protoc-gen-openapiv2を使ってフロントエンドクライアントを生成することができる。

gRPC-webでは、クライアントコードはprotoファイルから直接生成され、フロントエンドで使用することができます。

性能

gRPC-Gateway は、gRPC サーバに送信する前に、JSON を protobuf バイナリフォーマットにパースします。その後、protobuf バイナリフォーマットから JSON への応答を再びパースする必要があります。パースのオーバーヘッドは性能にマイナスの影響を及ぼします。

gRPC-webでは、メッセージはすでにprotobufバイナリ形式で送信されるので、プロキシ側で追加のパースコストは発生しません。

メンテナンス

gRPC-Gatewayでは、protobufが変更された場合、ゲートウェイのリバースプロキシコードを再生成する必要があります。HTTP/JSON インタフェースを使用している場合、おそらくフロントエンドも変更する必要があり、これは 2 箇所の変更を意味します。

gRPC-web では、プロトファイルからファイルを再生成すると、自動的にフロントエンドのクライアントが更新されます。

他のツール

名前 できること
grpc-dynamic-gateway Node で書かれた grpc-gateway の動的に設定される代替品です。
rest2grpc Nodeで書かれたgrpc-gatewayの静的な代替設定。
The Envoy proxy gRPC-JSON transcoder 受信した JSON リクエストを gRPC に変換して返す Envoy プロキシフィルタです。
Google Cloud Platform HTTP/JSON gRPC transcoding grpc-gatewayのような動作をするGCP製品です。

Related projects | gRPC-Gateway