ハトネコエ Web がくしゅうちょう

プログラミングやサーバー関連、チームマネジメントなど得た技術のまとめ

gRPC チュートリアルで入門しようぜ!

Rails や Node.js で API サーバ作るよりは、
gRPC めっちゃ簡単ですよ!! って同僚に勧められて、「やらねば!」と思ったのでチュートリアルです。

https://grpc.io

f:id:nekonenene:20190119225843p:plain

「GET STARTED」のボタンから各言語の Quick Start Guide に簡単に飛べます。
今回は私が最近 Go を勉強中なので Go にします。 https://grpc.io/docs/quickstart/go.html

余談ですが、Ruby 2.6.0 で Quick Start Guide をおこなう場合には
ここのissueに書かれている通り、 gem install google-protobuf --platform ruby が必要な点にご注意ください。

そうでないと cannot load such file -- google/2.6/protobuf_c (LoadError) でつまずきます。(つまずきました)

以下のチュートリアルは、Go言語用の Quick Start Guide をベースにしつつ、より深く掘り下げられるよう一部変更・追加しています

では、やっていきましょう。

1. grpc ライブラリのダウンロード

go get -u google.golang.org/grpc

まずは grpc ライブラリをダウンロード。
なお、GoDocはここ

2. さっそく試してみる

次に example の実行。

cd $GOPATH/src/google.golang.org/grpc/examples/helloworld

で examples ディレクトリ下の helloworld ディレクトリに移動。

チュートリアルにはそう書いてありますけど、私みたいに export GOPATH="$HOME/.go/third-party:$HOME/.go/my-projects" と GOPATH を複数定義している場合には
このコマンドだと上手くいかないので注意ですね!

cd $(echo "$GOPATH" | awk -F '[:]' '{print $1}')/src/google.golang.org/grpc/examples/helloworld

こちらの方が確実ですね。

で、ここのディレクトリで

go run greeter_server/main.go

を実行します。これで API サーバが起動します。
何も出力がなくて不安になりますが、いったん忘れてターミナルのタブをもう一つ開き、以下を実行します。

go run greeter_client/main.go

先ほど立ち上げた API サーバにアクセスし、結果をコンソールに出力します。
2019/01/19 22:54:32 Greeting: Hello world といったメッセージが返ってきます。
また、API サーバの方では 2019/01/19 22:54:32 Received: world と出力されています。

3. grpcurl を使ってみる

greeter_client/main.go を使わずに API サーバにアクセスする手法にも触れておきます。

「え、 curl で行けるんじゃないの?」と思うかもしれませんが、
今回の example の場合、通常のHTTPクライアントで正常なレスポンスを得るのは難しいです。

ここ自信ないんですが、期待するバイナリの値が渡されない場合に、
サーバ側がエラー処理して通信を切断するんだと思います。 *1

さて、 grpcurl について。

これは本当によく出来ていて、エンドポイント, メソッド, JSON形式のリクエストを渡すと
いい感じに gRPC 用にパースしてリクエストしてくれて、
レスポンスも JSON にパースして見せてくれます。

実際にやってみましょう。

3-1. grpcurl のインストール

grpcurlリポジトリ のREADMEにある通り、以下のコマンドでインストールできます。

go get github.com/fullstorydev/grpcurl
go install github.com/fullstorydev/grpcurl/cmd/grpcurl

これで grpcurl コマンドが使えるようになります。

3-2. service list の確認

======2019/02/10 追記======

最新版だと、 greeter_server/main.go から reflect が消されていました。
これだと grpcurl を実行しても Failed to list services: server does not support the reflection API とエラーが出てしまいますので、

cd $(echo "$GOPATH" | awk -F '[:]' '{print $1}')/src/google.golang.org/grpc/examples/helloworld
git checkout tags/v1.9.2

と、筆者実行時のバージョンに移動した上で、再度 go run greeter_server/main.go でサーバーを起動してみてください。

======2019/02/10 追記ここまで======

grpcurl -plaintext localhost:50051 describe

これを実行すると以下のような出力がなされます。

grpc.reflection.v1alpha.ServerReflection is a service:
service ServerReflection {
  rpc ServerReflectionInfo ( stream .grpc.reflection.v1alpha.ServerReflectionRequest ) returns ( stream .grpc.reflection.v1alpha.ServerReflectionResponse );
}

helloworld.Greeter is a service:
service Greeter {
  rpc SayHello ( .helloworld.HelloRequest ) returns ( .helloworld.HelloReply );
}

grpc.reflection.v1alpha.ServerReflection という service に ServerReflectionInfo という RPC。
helloworld.Greeter という service に SayHello という RPC があることがわかります。

3-3. grpcurl を用いたリクエス

SayHello へのリクエストは以下のようにおこないます。

grpcurl -plaintext -d '{"name": "Cat"}' localhost:50051 helloworld.Greeter/SayHello

すると、

{
  "message": "Hello Cat"
}

このようにJSON形式のレスポンスを出力してくれます。
ServerReflectionInfo の方も同様です。

grpcurl -plaintext -d '{"list_services": ""}' localhost:50051 grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo

{
  "originalRequest": {
    "listServices": ""
  },
  "listServicesResponse": {
    "service": [
      {
        "name": "grpc.reflection.v1alpha.ServerReflection"
      },
      {
        "name": "helloworld.Greeter"
      }
    ]
  }
}
grpcurl -plaintext -d '{"file_containing_symbol": "helloworld.Greeter"}' localhost:50051 grpc.reflection.v1alpha.ServerReflection/ServerReflectionInfo

{
  "originalRequest": {
    "fileContainingSymbol": "helloworld.Greeter"
  },
  "fileDescriptorResponse": {
    "fileDescriptorProto": [
      "ChBoZWxsb3dvcmxkLnByb3RvEgpoZWxsb3dvcmxkIiIKDEhlbGxvUmVxdWVzdBISCgRuYW1lGAEgASgJUgRuYW1lIiYKCkhlbGxvUmVwbHkSGAoHbWVzc2FnZRgBIAEoCVIHbWVzc2FnZTJJCgdHcmVldGVyEj4KCFNheUhlbGxvEhguaGVsbG93b3JsZC5IZWxsb1JlcXVlc3QaFi5oZWxsb3dvcmxkLkhlbGxvUmVwbHkiAEIwChtpby5ncnBjLmV4YW1wbGVzLmhlbGxvd29ybGRCD0hlbGxvV29ybGRQcm90b1ABYgZwcm90bzM="
    ]
  }
}

ServerReflectionInfo については https://github.com/grpc/grpc/blob/7897ae9/src/proto/grpc/reflection/v1alpha/reflection.proto#L29-L61 をご参照ください。

3-4. grpcurl のリクエストに関するメモ

大まかな流れとしては、
https://github.com/fullstorydev/grpcurl/blob/1c6532c/cmd/grpcurl/grpcurl.go#L497 から
https://github.com/fullstorydev/grpcurl/blob/1c6532c/invoke.go#L87 を経由して
https://github.com/fullstorydev/grpcurl/blob/1c6532c/invoke.go#L165 が呼ばれる。

grpcdynamic.Stub の InvokeRpc メソッドは、別リポジトリ
https://github.com/jhump/protoreflect/blob/d0f5a64/dynamic/grpcdynamic/stub.go#L53-L65 で定義されていて、
s.channel.Invoke の箇所で実質的に grpc ライブラリの https://godoc.org/google.golang.org/grpc#ClientConn.Invoke が呼び出され、
リクエストを送信しレスポンスを得ているっぽい。

4. Protocol Buffers 定義の更新

4-1. proto ファイルの更新

gRPC では通常 Protocol Buffers というバイナリ形式のシリアライズ手法を用いるのですが、
どのような値が渡されるかの一覧表(インターフェイス)を定義しておかねばなりません。
(その一覧表を見て、サーバーはエンコードし、クライアントはデコードします)

一覧表にあたるのが proto ファイルです。

なお、現在は ver. 3 である proto3 が主流になってきているようです。
proto2 では存在した required, optional などは無くなっているので、古いサイトを参考にする場合はご注意ください。
(どうやら、定義を簡単に追加・削除できる思想と required の制限は相反する、という考えのもと、ver. 3 では無くなったらしい*2

変更は簡単で、リクエストに年齢を示す age を追加したいな〜と思ったとしたら一行追加するだけ。

 message HelloRequest {
   string name = 1;
+  int32 age = 2;
 }

4-2. proto ファイルのビルド

ビルドするためには protoc コンパイラ と、
Go言語用の protoc プラグイン両方が必要です。

macOS であれば、以下のコマンドで両者のインストールが可能です。

brew install protobuf
go get -u github.com/golang/protobuf/protoc-gen-go

そして、以下のコマンドでコンパイルします。(proto ファイルの場所に応じてコマンドは適宜修正してください)

protoc helloworld/helloworld.proto --go_out plugins=grpc:./

plugins=grpc を指定しつつ、helloworld.proto と同じディレクトリ( ./ )にgoファイルを生成するコマンドです。
proto ファイルを元に、Go 言語から扱えるようにした helloworld.pb.go が生成(更新)されます。

(メモ: protoc helloworld/helloworld.proto --go_out ./ でもコンパイルは出来るのだが、 GreeterClient インターフェイスなどが消えてしまう)

このファイルの上部を見ると package helloworld の一行があり、
2. で扱った greeter_server/main.go および greeter_client/main.go のコードを読むと、
pb "google.golang.org/grpc/examples/helloworld/helloworld" として import され、使われていることがわかります。

4-3. greeter_server/main.go の更新

最後に server 側を更新します。

--- a/examples/helloworld/greeter_server/main.go
+++ b/examples/helloworld/greeter_server/main.go
@@ -22,6 +22,7 @@ package main
 
 import (
        "context"
+       "fmt"
        "log"
        "net"
 
@@ -39,8 +40,8 @@ type server struct{}
 
 // SayHello implements helloworld.GreeterServer
 func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
-       log.Printf("Received: %v", in.Name)
-       return &pb.HelloReply{Message: "Hello " + in.Name}, nil
+       log.Printf("Received: %v, %v", in.Name, in.Age)
+       return &pb.HelloReply{Message: fmt.Sprintf("%s is %d years old.", in.Name, in.Age)}, nil
 }

こんな感じで、HelloReply の Message の値を変更しています。

2. の手順でAPIサーバーを立ち上げたままの場合は、
いったん Ctrl + C で API サーバーを落としたのち、以下コマンドで再び起動させます。

go run greeter_server/main.go

サーバーを起動させたら、grpcurl を用いて確認してみましょう。

grpcurl -plaintext -d '{"name": "Cathy", "age": 12}' localhost:50051 helloworld.Greeter/SayHello

{
  "message": "Cathy is 12 years old."
}

設定通りの値で返ってきてくれましたね。

5. おわりに

以上、Quick Start Guide をベースにしたチュートリアルでした。

gRPC、なにが便利なの? と思った方もいるでしょうけれども、
Go言語などの型厳密のプログラムではそうとう便利です

APIのレスポンスってプログラムからすると何が返ってくるかわからないのでバグの温床になりやすいのですが、
あらかじめ定義ファイルがあることで、レスポンスの型がわかっていますし、
フィールド名の誤りも起こりにくくなります。
proto ファイルを見ればいいので、社内向けAPIドキュメントをせっせと作る必要もなくなります。

ただ、protoファイルから各言語用ファイルを作る手間は増えますので、
業務で使う場合は、どうすれば運用工数が減るか考えるのが大事になりますね。
サーバー側とクライアント側(Web, iOSアプリ, Androidアプリ等)で同じ proto ファイルを参照することになるでしょうから。

RPC life cycle の部分がまだわかってないので、次はここを理解しないとなぁ、と思いました。
あとは、一般公開向けのREST APIの作成を考慮し grpc-gateway についても軽く触れておきたいな〜と思ったり。

Unary が「単項」の意味、「単項演算子」は unary operator と知った今日でした。
binary って言葉から考えるとたしかにですね。

Web API: The Good Parts

Web API: The Good Parts

gRPC 記事の続き ↓

nekonenene.hatenablog.com

*1:なので、期待されるバイナリの形式に直せばいける? と echo 'name: "hoge"' | protoc --encode "helloworld.HelloRequest" ./helloworld/helloworld.proto | curl -sS --http2 -X POST --data-binary @- "localhost:50051" で投げてみたりしましたが上手く行かず……。要検証

*2:https://stackoverflow.com/questions/31801257/why-required-and-optional-is-removed-in-protocol-buffers-3