Connect for Go のチュートリアルをやりつつ何をしているのか調べた話


この記事は私が gRPC API を作るためのライブラリである Connect for GoGetting Started をやって疑問に感じたことをメモしていく記事です。

gRPC とは

https://grpc.io/docs/what-is-grpc/introduction/

gRPC は RPC(Remote Procedure Call)の一種で、インターフェイスの定義やメッセージのシリアライズに Protocol buffers を使う。実際の通信では HTTP/2 の POST リクエストのヘッダやボディにメソッド名や RPC に使う引数などを詰めて送る。ボディは HTTP/2 のデータフレームを通じて送られる。仕様は以下の通り。

https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-HTTP2.md

作ってわかる!はじめての gRPC が参考になった。

Protocol buffers とは

https://protobuf.dev/overview/

  • RPC のインターフェイスを定義するフォーマット
  • データをシリアライズするフォーマット
  • JSON とか YAML とか XML のようなシリアライズ形式の一種
  • Google が開発した

今さら Protocol Buffers と、手に馴染む道具の話が参考になった。

Connect とは

Connect は gRPC 互換の HTTP API を構築するためのライブラリで、

https://connectrpc.com/docs/introduction

リリース時のブログによると、開発に至った経緯としては以下のようなものがあるようだ。

  • Google が開発した gRPC の Go 実装である grpc-go があまりに巨大で複雑なため、よりシンプルで開発やデバッグしやすいものが欲しい
  • grpc-go は go の net/http ではなく HTTP/2 を独自実装しているため標準的なミドルウェアが使えない
  • ブラウザのような一般的 HTTP クライアントから gRPC を使うにはプロキシが必要になり面倒

詳しくは Connect Protocol Reference を参照。

ちなみに他の Protobuf プラグインと異なり別パッケージとしてコードを生成するのは(protoc-gen-connect-go がさまざまな type を出力するので)名前空間の衝突を避けるためらしい。

https://connectrpc.com/docs/faq#why-generate-connect-specific-packages

Buf 社について

Connect を開発・公開している Buf Technologies, Inc. は Connect の他に Buf Schema RegistryCLI を提供している。

gRPC を便利に使うためのライブラリを開発して収益化する会社という点では GraphQL における Apollo Graph Inc. に近いかもしれない。

Connect for Go: Getting Started をやってみる

Getting Started を進めていく。

1. Install tools

まずは Getting Started で使うツール群をインストールする。

進める前にインストールしたツールが PATH 上にあるかを下記のコマンドで確認しておく。なければ追加する必要がある。

$ which buf grpcurl protoc-gen-go protoc-gen-connect-go

2. Define a service

次に protobuf でサービスを定義していく。

syntax = "proto3";

package greet.v1;

option go_package = "example/gen/greet/v1;greetv1";

message GreetRequest {
  string name = 1;
}

message GreetResponse {
  string greeting = 1;
}

service GreetService {
  rpc Greet(GreetRequest) returns (GreetResponse) {}
}

message は protobuf で扱うデータ構造の定義で、Go の世界における構造体のようなもの。service は RPC のインターフェイスを定義するための仕組みで、ここで定義した rpc をクライアントから呼び出すことでサーバー側で実装された処理を行うことができる。

3. Generate code

protobuf をコンパイルして Go のコードを自動生成する。

buf mod init コマンドで buf.yaml を生成できる。buf コマンドはこのファイルがあるディレクトリより下で .proto ファイルを探す。

buf.yaml では lint オプションで .proto ファイルを lint する際のルール を設定できる。また breaking オプションで破壊的変更の検知ルールを設定できたりする。ここで設定したルールは buf breaking コマンドの実行時に使われる。

buf コマンドのオプションを設定するためのファイルは 4 種類(buf.yaml, buf.lock, buf.gen.yaml, buf.work.yaml)あるが、基本的にはこれと次の buf.gen.yaml を覚えておけばよさそう。

version: v1
breaking:
  use:
    - FILE
lint:
  use:
    - DEFAULT

次に buf.gen.yaml で .proto ファイルから生成するファイルの設定をする。以下では plugin や出力先ディレクトリの設定を行なっている。

plugin はいわゆる protoc プラグインで、protobuf からのコード生成をカスタマイズ(例えば .proto の message に対応する特別なバリデーションルールを追加したり API ドキュメントを自動生成したり)できる。plugin にはローカルとリモートの 2 種類あり、リモート plugin は Buf が別途提供する Buf Schema Registry で使うことができる。

version: v1
plugins:
  - plugin: go
    out: gen
    opt: paths=source_relative
  - plugin: connect-go
    out: gen
    opt: paths=source_relative

これができたら buf generate コマンドで Go のコードを自動生成できる。

.
├── buf.gen.yaml
├── buf.yaml
├── gen
│   └── greet
│       └── v1
│           ├── greet.pb.go
│           └── greetv1connect
│               └── greet.connect.go
├── go.mod
└── greet
    └── v1
        └── greet.proto

7 directories, 6 files

greet.pb.go は gRPC のクライアントとサーバーのスタブが含まれており、これを使って通信の実装ができる。protobuf から生成した構造体も入っている。

greet.connect.go は Connect プロトコル用にスタブを提供する。

4. Implement handler

.proto から生成したメソッドを使ってサーバー側のロジックを実装する。今回はクライアントから name を受け取ってそれを入れたレスポンスを返すだけ。コードは Getting Started からそのまま引用したが、コメントは筆者が挿入した。

package main

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

    "connectrpc.com/connect"
    "golang.org/x/net/http2"
    "golang.org/x/net/http2/h2c"

    greetv1 "example/gen/greet/v1"
    "example/gen/greet/v1/greetv1connect"
)

type GreetServer struct{}

func (s *GreetServer) Greet(
    ctx context.Context,
    req *connect.Request[greetv1.GreetRequest],
) (*connect.Response[greetv1.GreetResponse], error) {
    log.Println("Request headers: ", req.Header())
    res := connect.NewResponse(&greetv1.GreetResponse{
        Greeting: fmt.Sprintf("Hello, %s!", req.Msg.Name),
    })
    res.Header().Set("Greet-Version", "v1")
    return res, nil
}

func main() {
    greeter := &GreetServer{}
    mux := http.NewServeMux()
    path, handler := greetv1connect.NewGreetServiceHandler(greeter)
    mux.Handle(path, handler)
    // 1. localhost:8080 でクライアントからのリクエストを待つ
    http.ListenAndServe(
        "localhost:8080",
        // 2. Use h2c so we can serve HTTP/2 without TLS.
        h2c.NewHandler(mux, &http2.Server{}),
    )
}

5. Make requests

自動生成したコードを使うクライアントを書く。コメントは筆者が挿入した。

package main

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

    greetv1 "example/gen/greet/v1"
    "example/gen/greet/v1/greetv1connect"

    "connectrpc.com/connect"
)

func main() {
    // 1. greet.connect.go に自動生成されたメソッドでクライアントを作る
    // デフォルトで Connect プロトコルを使う
    client := greetv1connect.NewGreetServiceClient(
        http.DefaultClient,
        "http://localhost:8080",
    )
    res, err := client.Greet(
        context.Background(),
        // 2. greet.pb.go に自動生成された構造体を詰めて connect の API でリクエストを送る
        // 詳細は https://pkg.go.dev/connectrpc.com/connect を参照
        connect.NewRequest(&greetv1.GreetRequest{Name: "Jane"}),
    )
    if err != nil {
        log.Println(err)
        return
    }
    log.Println(res.Msg.Greeting)
}

Getting Started の最終的なディレクトリ構成は以下のようになる。

.
├── buf.gen.yaml
├── buf.yaml
├── cmd
│   ├── client
│   │   └── main.go
│   └── server
│       └── main.go
├── gen
│   └── greet
│       └── v1
│           ├── greet.pb.go
│           └── greetv1connect
│               └── greet.connect.go
├── go.mod
├── go.sum
└── greet
    └── v1
        └── greet.proto

6. Use the gRPC protocol instead of the Connect protocol

connect-go クライアントはデフォルトで Connect プロトコルを使用するが、gRPC または gRPC-Web プロトコルを使用するよう変更することもできる。

Connect はデフォルトで JSON とバイナリエンコードされた protobuf がサポートされていて gRPC/gRPC-Web と互換性がある。Getting Started の例では client/main.go で connect.WithGRPC() を使うと gRPC を使うようにクライアントを設定できる。

    client := greetv1connect.NewGreetServiceClient(
        http.DefaultClient,
        "http://localhost:8080",
        connect.WithGRPC(),
    )

ktr0731/evans を使う

evans は REPL や CLI から使える gRPC のクライアント。上記の Getting Started ではクライアントのコードを Go で書いたが、開発中に手軽に試したい場合は REPL を使いたい。REPL では賢く補完してくれてとても便利。

brew install して以下のコマンドで起動できる(詳しくは README 参照)。

$ evans --proto greet/v1/greet.proto repl

.sproto の定義を引っ張ってきて show service で service を、show message で message を表示できる。

show

call で RPC を呼び出すことができる。下記のように補完してくれて入力しやすい。

completion

call Greet で Connect の Getting Started で作った RPC を呼び出してみる。引数を入力すると JSON でレスポンスが返ってくる。

response

このように、クライアントのコードを書かずに gRPC の API を試せて大変便利。

Getting Started をやって抱いた疑問点と調べたメモ

  • 最初にインストールしたツール群はそれぞれ何の役割を負うのか?
    • Install tools のところでインストールしたやつ
    • buf: proto ファイルをコンパイルしてコードを生成する generator や linter, formatter など便利な機能を備えた CLI
    • grpcurl: gRPC にリクエストを送るための curl。evans 使うなら要らないかも
    • protoc-gen-go: gRPC であれこれするコードを生成するためのプラグイン
    • protoc-gen-connect-go: Connect であれこれするコードを生成するためのプラグイン
  • Buf はどうやって .proto ファイルからコードを自動生成しているのか?
    • buf 自体はコード生成のためのプラグインとして protoc-gen-go や protoc-gen-connect-go を呼び出している
    • プラグインとして何を使うかは buf.gen.yaml で設定する
    • protoc-gen-go が proto ファイルのデータ構造を解析し Go の構造体やメソッドを生成する際の仕様は Protocol Buffers 公式ドキュメントの Go Generated Code Guide を参照
    • protoc-gen-connect-go の役割は protoc-gen-go とは異なる
    • proto で定義されたメッセージを実際のリクエストやレスポンスで実現するために Protocol Buffers で serialize/deserialize する定型的なコードや構造体を自動生成するのが protoc-gen-go の役割
    • ↑ で生成した構造体を活用しつつ Connect プロトコルで通信するための構造体やインターフェイス、メソッドを自動生成するのが protoc-gen-connect-go の役割
    • 例えば generateClientImplementation という関数を見ると、google.golang.org/protobuf/compiler/protogenfunc (*GeneratedFile) P を使って地道に出力ファイルに文字列を書き込んでいる

まとめ

Connect はいわゆる “A better gRPC” で、grpc-go と異なり Go の HTTP のエコシステムと互換性がある。そのため、gRPC や HTTP 関連のさまざまなツールを使いながら他の HTTP トラフィックと一緒に gRPC リクエストを処理できて便利。

proto ファイルを用意してコマンドを実行するだけで Connect プロトコルを使うための定型的なコードを自動生成できるので始めやすい。

Connect for Go に関して参考になりそうな記事