ぽよメモ

レガシーシステム考古学専攻

Protocol BuffersのGolang用API v2を使ってgRPCでHello Worldする

v2とは???

以下の記事にまとまっています。

qiita.com

blog.golang.org

Protocol BufferのGo言語用APIの新しい実装です。v1.20から始まるという奇怪なバージョニングになっています。しかも、後方互換性のない変更でありAPIv2とまで呼ばれているのに、モジュールの方はメジャー番号が1というカオスな状況です*1

github.com

2020年3月に出たばかりであり、まだまだ情報が少ないです。特に上の記事にもあるように新しいprotoc-gen-goはgRPC用のスタブを生成しなくなっており、そもそもこの新しいモジュールを使ってHello worldするのに試行錯誤したため、ここにメモを残しておきます。

ちなみに色々調べた後gRPCの公式ドキュメントを見たら普通に書いていました。最初から一次情報に当たれ、という話でした……

grpc.io

環境

  • macOS Catalina 10.15.6
  • Go 1.15.2
  • protoc v3.13.0
  • google.golang.org/protobuf/cmd/protoc-gen-go v1.23.0
  • google.golang.org/grpc/cmd/protoc-gen-go-grpc v1.0.1

Hello World

プロジェクトの準備

適当にgo mod initしてプロジェクトを作ります。

$ mkdir go-protobuf-v2-sample && cd go-protobuf-v2-sample
$ go mod init github.com/pddg/go-protobuf-v2-sample

ツール類をインストールします。protocのインストール方法は何でもよいです。GitHub Releasesからダウンロードして展開するのが楽でしょう。

$ PROTOC_VERSION=3.13.0
$ PROTOC_PKG=protoc-${PROTOC_VERSION}-osx-x86_64.zip
$ wget -q -O tmp/ https://github.com/protocolbuffers/protobuf/releases/download/v${PROTOC_VERSION}/${PROTOC_PKG}
$ unzip -q -o tmp/${PROTOC_PKG}
$ ./bin/protoc --version
libprotoc 3.13.0

protocのプラグインをインストールします。僕はGoのプロジェクトでGo製のCLIツールが必要な時はgexを使っています。

github.com

$ gex --add google.golang.org/protobuf/cmd/protoc-gen-go
$ gex --add google.golang.org/grpc/cmd/protoc-gen-go-grpc

サービスを定義する

proto/hello.proto に名前を与えると Hello ○○ と返してくれるだけのサービスを定義します。

syntax = "proto3";

option go_package = "github.com/pddg/go-protobuf-v2-sample/hello/pb";


message HelloRequest { string name = 1; }

message HelloResponse { string message = 1; }

service HelloService { rpc Hello(HelloRequest) returns(HelloResponse); }

gRPC用のコードを自動生成する

v1まではgithub.com/golang/protobuf/protoc-gen-goを使い、 --go_out=plugins=grpc:パス といった形式で指定することでコードを自動生成していました。
v2からは2つのプラグインgoogle.golang.org/protobuf/cmd/protoc-gen-go とgoogle.golang.org/grpc/cmd/protoc-gen-go-grpc)を使い、それぞれ役割の違うコードを2つ自動生成します。

今回は ./hello/pb 以下に自動生成したファイルを置くことにします。

$ mkdir -p hello/pb
$ ./bin/protoc \
        -I proto/ \
        --plugin protoc-gen-go=bin/protoc-gen-go \
        --plugin protoc-gen-go-grpc=bin/protoc-gen-go-grpc \
        --go-grpc_opt paths=source_relative \
        --go-grpc_out hello/pb/ \
        --go_opt paths=source_relative \
        --go_out hello/pb/ \
        proto/hello.proto

これで hello/pb/hello.pb.gohello/pb/hello_grpc.pb.go という2つのファイルが生成されます。
hello.pb.go は、gRPCを使わずGoでProtocol Buffersを利用するために必要なコードが生成されるようです。HelloServiceの定義はここにはありません。
hello_grpc.pb.goには、gRPCを利用するための定義が記述されています。サーバ側が満たすべきインターフェースと、クライアントの実装が含まれます。

サーバを実装する

github.com/pddg/go-protobuf-v2-sample/hello/pb にある HelloServiceServer というインターフェースを満たすようにサーバを実装します。 hello/hello.go として記述します。 以下のサンプルを参考に実装しました。

github.com

package hello

import (
    "context"

    "github.com/pddg/go-protobuf-v2-sample/hello/pb"
)

// HelloServiceの実際の実装。
type helloServer struct{
    pb.UnimplementedHelloServiceServer
}

// Hello ○○するハンドラ
func (hs helloServer) Hello(ctx context.Context, request *pb.HelloRequest) (*pb.HelloResponse, error) {
    return &pb.HelloResponse{Message: "Hello " + request.Name}, nil
}

// 実装したサーバを初期化する関数
func NewHelloServiceServer() pb.HelloServiceServer {
    return &HelloServer{}
}

cmd/server/main.go を作成し、サーバを起動するコマンドを実装します。オプションとしてリッスンするportとホスト名を取れるようにしました。
簡単のため、http2ではなくtcpでリッスンします。

package main

import (
    "flag"
    "fmt"
    "log"
    "net"

    "google.golang.org/grpc"
    "google.golang.org/grpc/reflection"

    "github.com/pddg/go-protobuf-v2-sample/hello"
    "github.com/pddg/go-protobuf-v2-sample/hello/pb"
)

var (
    port int
    host string
)

func main() {
    flag.IntVar(&port, "port", 8080, "Port number")
    flag.StringVar(&host, "host", "0.0.0.0", "Listen host name")
    flag.Parse()

    listenAddr, err := net.Listen("tcp", fmt.Sprintf("%s:%d", host, port))
    if err != nil {
        log.Fatalf("Failed to listen '%s:%d'\n", host, port)
    }
    log.Printf("HelloServiceServer is listening on tcp://%s:%d\n", host, port)

    s := grpc.NewServer()
    helloServer := hello.NewHelloServiceServer()
    pb.RegisterHelloServiceServer(s, helloServer)

    // Enable reflection
    reflection.Register(s)

    if err := s.Serve(listenAddr); err != nil {
        log.Fatal(err)
    }
}

せっかくv2で実装するのでreflectionを有効化しました。これにより、grpcurlなどのクライアントツールで proto ファイルを読み込む必要がなくなります。 このコマンドを bin/hello-server としてビルドします。

$ go build -o bin/hello-server ./cmd/server

クライアントコマンドの実装

cmd/client/main.go に記述します。オプションとしてサーバのアドレスを渡せるようにしました。

package main

import (
    "context"
    "flag"
    "fmt"
    "log"
    "os"
    "time"

    "google.golang.org/grpc"

    "github.com/pddg/go-protobuf-v2-sample/hello/pb"
)

var server string

func main() {
    flag.StringVar(&server, "server", "localhost:8080", "Server address")
    flag.Parse()
    if flag.NArg() == 0 {
        flag.Usage()
        os.Exit(1)
    }
    args := flag.Args()

    ctx, cancel := context.WithTimeout(context.Background(), 10 * time.Second)
    defer cancel()

    conn, err := grpc.DialContext(ctx, server, grpc.WithInsecure())
    if err != nil {
        log.Fatal(err)
    }

    client := pb.NewHelloServiceClient(conn)
    response, err := client.Hello(ctx, &pb.HelloRequest{Name: args[0]})
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(response.Message)
}

このコマンドを bin/hello-client としてビルドします。

$ go build -o bin/hello-client ./cmd/client

動かしてみる

適当にターミナルを立ち上げ、サーバを起動します。

$ ./bin/hello-server -port 8080 -host 0.0.0.0

別のターミナルからクライアントコマンドを使用して接続し、Hello worldしてみます。

$ ./bin/hello-client -server localhost:8080 world
Hello world

やったぜ。

grpcurlで叩いてみる

grpcurlはコマンドラインで使えるgRPC用の汎用ツールです。curlのような感覚で使えます。

$ gex --add github.com/fullstorydev/grpcurl/cmd/grpcurl

grpcurl サーバアドレス サービス名.呼び出す関数 という形式で使えます。 データを送信したい場合、JSON形式でPOSTできます。レスポンスもJSON形式で返ってきます。
今回のHelloServiceを叩く場合は以下の様になります。

$ gex grpcurl -plaintext -d '{"name": "world"}' localhost:8080 HelloService.Hello
{
  "message": "Hello world"
}

まとめ

これまでのprotoc-gen-goが単体でGo用のProtocol Buffersのコード・gRPCのスタブコードの両方を生成するスタイルよりも、役割が明確になったのはよかったのではないでしょうか*2
v2の恩恵をまだreflectionくらいしか理解していないので、もう少し色々探索していきたいですね。

[追記]
実装全体を以下で公開しました。

github.com


*1:モジュールパスが異なるので、一応Go Moduleのセマンティックバージョニングにはちゃんと従っている事になります

*2:ただ、正直github.com/protocolbuffers/protobuf-goのバージョニング戦略はイケてないな〜と感じてしまいました