😶‍🌫️

connect-goとprotovalidateでブラウザとgRPC互換のAPIを作る

2024/06/03に公開

こんにちは。株式会社バニッシュ・スタンダードの中村です。
最近Connectというライブラリを使ってブラウザとgRPC互換のAPIを作る機会がありましたので、知識の整理も兼ねてまとめてみました。
最初は概念的に触れてから実際に1からAPIを作り、簡単なバリデーションの実装やミドルウェアの実装まで触れていきたいと思います。

本記事の内容において事実と記載に乖離がある場合はコメントで指摘くださると嬉しいです。

RPCとは

そもそもRPCとはRemote Procedure Callの略で、ネットワーク上の他のコンピュータのProcedure(関数)を直接呼び出す仕組みです。
RPCをプロトコルとして具象化したものの1つに、Googleが開発したRPCフレームワークであるgRPCがあります。

gRPCと密接に関わるProtocol Buffersの存在

https://grpc.io/

https://protobuf.dev/
gRPCは実際にバイナリ形式に変換するためのフォーマットとして、Googleが開発したプロトコルバッファ(Protocol Buffersまたはprotobuf)を使用しています。これによりバイナリ形式でのエンコード/デコードを可能にしています。

protobufは具体的には.protoファイルを記述することでクライアント/サーバのインタフェースを定義します。JSONなどのプレーンテキストとは違い、protobufは型の種類が多いため、より型安全なデータのやり取りが可能です。

Connectとは

https://connectrpc.com/

ConnectはブラウザとgRPC互換のAPIを楽に開発するためのライブラリです。

ConnectはConnect独自プロトコル、gRPC、gRPC-Webのプロトコルをサポートしてくれています。これにより、アプリケーション間での通信は純粋なgRPC、ブラウザとのやり取りはgRPC-Webなどプロトコルを柔軟に受け付けるAPI開発が可能になります。

そして.protoファイルからのコード自動生成が可能であり、これにはHTTPハンドラやリクエスト/レスポンス定義、コンストラクタなどが含まれるため、型安全で楽にgRPC互換のAPIを作ることが可能になります。

Connectを使ったgRPC互換APIを作ってみる

以下のドキュメントに沿って進めていきます。
https://connectrpc.com/docs/go/getting-started

まずは新しくGoモジュールを作成し、コード生成に必要なパッケージをインストールします。

$ mkdir connect-go-example
$ cd connect-go-example
$ go mod init example
$ go install github.com/bufbuild/buf/cmd/buf@latest
$ go install github.com/fullstorydev/grpcurl/cmd/grpcurl@latest
$ go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
$ go install connectrpc.com/connect/cmd/protoc-gen-connect-go@latest

Protocol Bufferのスキーマ定義を書く

次にプロトコルバッファのスキーマ定義を記述していきます。
まずはprotoファイルを作成します。

$ mkdir -p greet/v1
$ touch greet/v1/greet.proto

作成したprotoファイルにAPIのインターフェース定義を書いていきます。

greet.proto
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) {}
}

上記のサンプルコードの補足は以下です。

syntax = "proto3";

protobufのバージョン3の構文を使用することを宣言しています。

package greet.v1;

このファイル内の全ての型(message, serviceなど)に対して名前空間を定義しています
他のプロトコルファイルと名前の衝突を避けることができます。

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

生成されるGoのコードのディレクトリパスとパッケージ名を指定しています。
ここではexample/gen/greet/v1がディレクトリパスで、greetv1がパッケージ名になります。

message GreetRequest {
  string name = 1;
}

message GreetResponse {
  string greeting = 1;
}

messageキーワードで構造体を定義することができ、この構造体定義を元にリクエストやレスポンスをマッピングします。
それぞれのフィールドであるnameとgreetingには固有のタグ(ここでは1)が割り当てられており、タグはフィールドの一意の識別子として機能し、バイナリ形式のエンコード/デコードに使用されます。

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

ここではserviceキーワードでgRPCサービスであるGreetServiceを定義しています。
rpc Greet(GreetRequest) returns (GreetResponse) {}はそのサービス内のメソッドを定義しており、このServiceの中にメソッドを追加していくことができます。

コード生成

Bufと呼ばれるツールを使って自動でコード生成します。
https://buf.build/

まず最初にbuf config initコマンドでbuf.yamlを生成します。
(Connectのドキュメントにはbuf mod initと記載ありますが最新版ではbuf config initに置き換わっているみたいです)

$ buf config init

次にbuf.gen.yamlを作成し、以下の内容で記述します。

$ touch buf.gen.yaml
buf.gen.yaml
version: v1
plugins:
  - plugin: go
    out: gen
    opt: paths=source_relative
  - plugin: connect-go
    out: gen
    opt: paths=source_relative

out: genにしていることからコード自動生成先はgenディレクトリ配下、
opt: paths=source_relativeなことから.protoファイルに対応するディレクトリパスで
コードが自動生成されます。

ここまでのディレクトリツリー構造は以下です。

❯ tree .
.
├── buf.gen.yaml
├── buf.yaml
├── go.mod
└── greet
    └── v1
        └── greet.proto

buf.gen.yamlが存在するディレクトリで以下のコマンドを実行し、コードを自動生成します。

$ buf lint
$ buf generate

実際に自動生成したファイルは以下のようになります。

❯ tree .
.
├── 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はGoogleのproto-gen-goによって生成され、RequestやResponseの構造体定義を含みます。
greet.connect.goはproto-gen-connect-goによって生成され、HTTPハンドラやクライアントのインターフェース、コンストラクタを含みます。

ハンドラを実装し、実際にローカルサーバーに対してAPIリクエストしてみる

以下のコマンドを実行してmain.goを作ります。

$ mkdir -p cmd/server
$ touch cmd/server/main.go

このような構成になります。

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

次にmain.goを編集します。

main.go
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" // generated by protoc-gen-go
    "example/gen/greet/v1/greetv1connect" // generated by protoc-gen-connect-go
)

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)
    http.ListenAndServe(
        "localhost:8080",
        // Use h2c so we can serve HTTP/2 without TLS.
        h2c.NewHandler(mux, &http2.Server{}),
    )
}

必要なパッケージをgo.modに追加します

$ go get golang.org/x/net/http2
$ go get connectrpc.com/connect

ローカルでサーバーを起動します

$ go run ./cmd/server/main.go

実際にAPIリクエストを送ってみます。
(Connectプロトコルは単純なHTTP1.1をサポートしているため、curlコマンドが使えます)

curl \
    --header "Content-Type: application/json" \
    --data '{"name": "Jane"}' \
    http://localhost:8080/greet.v1.GreetService/Greet

実際にレスポンスが返ってきました🙆‍♀️

{"greeting":"Hello, Jane!"}

protovalidateを使ってバリデーションしてみる

ここではリクエストに対してバリデーションをかけてみたいと思います。
以下のライブラリを使って実装します。
https://github.com/bufbuild/protovalidate

We recommend that new and existing projects transition to using protovalidate instead of protoc-gen-validate.

proto-gen-validateはもう古いから今後はprotovalidateを使ってね、だそうです。

以下のコマンドを実行します

$ go get github.com/bufbuild/protovalidate-go

必要なパッケージを追加できたので、リクエストにバリデーションを追加してみましょう。
修正する時はprotoファイルを編集した後にbuf generateしてコード自動生成するという流れになります。(buf generateのスコープは基本的には対象となるprotoファイル全てになります。)

READMEに沿って進めていきます。
まずはbuf.yamlを編集します。

buf.yaml
version: v1
breaking:
  use:
    - FILE
lint:
  use:
    - DEFAULT
deps:
  - buf.build/bufbuild/protovalidate

以下コマンドを実行して編集したbuf.yamlを元に必要な依存関係を更新します。
(Connectのドキュメントではbuf mod updateと記載ありますが最新版ではbuf dep updateに置き換わっているみたいです)

buf dep update

greet/v1/greet.protoを編集します。
リクエストパラメータのnameとして空文字は受け付けないようにバリデーションルールを追加します。

syntax = "proto3";

import "buf/validate/validate.proto"; // import文を追加

package greet.v1;

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

message GreetRequest {
  string name = 1 [(buf.validate.field).string.min_len = 1]; // バリデーションルールを追加
}

message GreetResponse {
  string greeting = 1;
}

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

protoを更新したらお決まりのコマンドを実行します。

$ buf lint
$ buf generate

以下を参考にしながらprotovalidateの実装をしてみます。
https://github.com/bufbuild/protovalidate-go

cmd/server/main.go
package main

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

	"connectrpc.com/connect"
	"github.com/bufbuild/protovalidate-go"
	"golang.org/x/net/http2"
	"golang.org/x/net/http2/h2c"

	greetv1 "example/gen/greet/v1"        // generated by protoc-gen-go
	"example/gen/greet/v1/greetv1connect" // generated by protoc-gen-connect-go
)

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())

	// バリデーションを行う
	// ここから追加
	v, err := protovalidate.New()
	if err != nil {
		return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to initialize validator: %v", err))
	}
	if err = v.Validate(req.Msg); err != nil {
		return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("validation failed: %v", err))
	}
	// ここまで追加

	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)
	http.ListenAndServe(
		"localhost:8080",
		// Use h2c so we can serve HTTP/2 without TLS.
		h2c.NewHandler(mux, &http2.Server{}),
	)
}

リクエストが成功する場合

curl \
    --header "Content-Type: application/json" \
    --data '{"name": "Jane"}' \
    http://localhost:8080/greet.v1.GreetService/Greet

{"greeting":"Hello, Jane!"}

リクエストが失敗する場合(nameが空)

curl \
    --header "Content-Type: application/json" \
    --data '{"name": ""}' \
    http://localhost:8080/greet.v1.GreetService/Greet

{"code":"invalid_argument","message":"validation failed: validation error:\n - name: value length must be at least 1 characters [string.min_len]"}

ちゃんとリクエストに対してバリデーションをかけることができました🙆‍♀️

Interceptorの導入

Interceptors are similar to the middleware or decorators you may be familiar with from other frameworks: they're the primary way of extending Connect. They can modify the context, the request, the response, and any errors. Interceptors are often used to add logging, metrics, tracing, retries, and other functionality. This document covers unary interceptors — more complex use cases are covered in the streaming documentation.

Interceptorを使うことでAPIのリクエスト、レスポンスの前後で処理を実行することができます。
他のフレームワークではミドルウェアにあたるものがInterceptorということになりそうです。

先ほどServiceのメソッド内でprotovalidateのインスタンスを生成していましたが、毎回メソッドが追加される度にインスタンスをNewしてバリデーションをかけていると、同じ記述が増えていくばかりなのでこういうのはInterceptorとして切り出してしまいましょう。

こちらが参考になります。
https://connectrpc.com/docs/go/interceptors

cmd/server/main.go
package main

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

	"connectrpc.com/connect"
	"github.com/bufbuild/protovalidate-go"
	"golang.org/x/net/http2"
	"golang.org/x/net/http2/h2c"
	"google.golang.org/protobuf/proto"

	greetv1 "example/gen/greet/v1"        // generated by protoc-gen-go
	"example/gen/greet/v1/greetv1connect" // generated by protoc-gen-connect-go
)

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()
	interceptors := connect.WithInterceptors(NewValidateInterceptor())
	path, handler := greetv1connect.NewGreetServiceHandler(greeter, interceptors) // optionとしてInterceptorを渡す
	mux.Handle(path, handler)
	http.ListenAndServe(
		"localhost:8080",
		// Use h2c so we can serve HTTP/2 without TLS.
		h2c.NewHandler(mux, &http2.Server{}),
	)
}

// Interceptorの実装
func NewValidateInterceptor() connect.UnaryInterceptorFunc {
	interceptor := func(next connect.UnaryFunc) connect.UnaryFunc {
		return connect.UnaryFunc(func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
			v, err := protovalidate.New()
			if err != nil {
				return nil, connect.NewError(connect.CodeInternal, fmt.Errorf("failed to initialize validator: %v", err))
			}
			msg, ok := req.Any().(proto.Message)
			if !ok {
				return nil, errors.New("failed to type assertion proto.Message")
			}
			if err = v.Validate(msg); err != nil {
				return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("validation failed: %v", err))
			}
			return next(ctx, req)

		})
	}
	return connect.UnaryInterceptorFunc(interceptor)
}

もう一度ローカルサーバーを立ち上げ直してから
バリデーションが効いているか検証します

成功するパターン

curl \
    --header "Content-Type: application/json" \
    --data '{"name": "Jane"}' \
    http://localhost:8080/greet.v1.GreetService/Greet

{"greeting":"Hello, Jane!"}

成功しないパターン

curl \
    --header "Content-Type: application/json" \
    --data '{"name": ""}' \
    http://localhost:8080/greet.v1.GreetService/Greet

{"code":"invalid_argument","message":"validation failed: validation error:\n - name: value length must be at least 1 characters [string.min_len]"}

Interceptorを使ってちゃんとバリデーションが期待通り動いていることが確認できました🙆‍♀️

最後に

実際に触ってみた所感としては慣れるまでの学習コストがやや高いかなと感じました。
protobufは多くの言語に対応していて、フロントとバックエンドで共通のproto定義からコード自動生成させることでデータモデルが一致し、予期しないデータの不整合を防ぐことができるのは魅力だと思いました。
あとはコード自動生成させることで構造体やコンストラクタを手動で書くコストを削減できるのも嬉しいポイントです。

Discussion