🎅

Connectで動くサービスをCLIとして実行するためのCLIジェネレータ clio を実装した

2024/12/09に公開

GMOペパボエンジニア Advent Calendar 2024🎅会場の8日目の記事です。
昨日はtetsuwoによる「新卒エンジニアがPRのレビュイー/レビュワーにおいてそれぞれ気をつけていること」でした。
レビュワー、レビュイー双方の視点からtetsuwoが心掛けているプラクティスを紹介してくれる記事でした。特に 「セルフレビューを徹底する」は大事なことではありながら、疲れたときには忘れかねないでの明日から改めて気をつけようと思います。
プラクティスの直後の具体例があることで、僕の理解がズレていないか安心できて読者として嬉しかったです。

さて今回は、ぼくとtetsuwoの共通点である、connectに関する話題です。

三行でまとめる

  • clioを実装しました。ConnectサービスがgRPCやHTTPだけでなく、CLIでも喋れるようになります
  • protoc プラグインとして実装してます、bufで生成しているなら簡単に組み込めます
  • CLIはcobra.Commandとして得られます。色々やりやすいはずです

三行では足りなかった

こちらが実装した clio-go です。

https://github.com/naoyafurudono/clio-go

デバッグが足りてないだろうと思います。IssueやPull Requestをお待ちしています。

では本題に入ります。

Connectとは

clioが依存するConnectとはなんでしょう。 https://connectrpc.com/docs/go/getting-started から引用します。

Connect is a slim library for building browser- and gRPC-compatible HTTP APIs. You define your service with a Protocol Buffer schema, and Connect generates type-safe server and client code. Fill in your server's business logic and you're done — no hand-written marshaling, routing, or client code required!

日本語訳は以下のような感じでしょうか。

ConnectはブラウザやgRPC互換なHTTP APIを実装するための薄いライブラリです。Protocol Bufferスキーマを用いてサービスを定義すれば、Connectが型安全なサーバとクライアントのコードを生成します。あとはサーバにビジネスロジックを実装すれば終わりです -- marshallingやルーティング、クライアントコードを実装する必要はありません!

箇条書きにするとこうなります:

  1. Protocol BufferでAPIスキーマを定義する
  2. サーバとクライアントのコードはConnectが生成する
  3. サーバに組み込むビジネスロジックを実装したら終わり

このようにConnectは型安全なスキーマ駆動開発するために一役買ってくれるツールです[1]

CLI欲しくないですか?

  1. Protocol BufferでAPIスキーマを定義する
  2. サーバとクライアントのコードはConnectが生成する
  3. サーバに組み込むビジネスロジックを実装したら終わり

先ほど述べたConnectの概要は上記の内容でした。ただ二つ目の「サーバとクライアントのコード」に限定しているところがぼくにとっては不足です。

というのも、サーバを立てずに実装したビジネスロジックを動かしたかったのです。ちょっとした動作確認やバッチジョブの実行のためにはサーバを立てるのではなくCLIとしてサービスのrpcを実行できると便利です。

もちろん以下の性質は捨てたくありません

  • スキーマ駆動で型安全に開発できること
  • 既存の実装を流用できること
  • ボイラープレートを人間が書かないこと

そこでclioを作りました

そこで、Connectで動くサービスをそのままCLIから動かせるようにするためのCLIジェネレータnaoyafurudono/clio-goを実装しました。

https://github.com/naoyafurudono/clio-go

clioはConnectサーバを受け取って、cobra.Commandを返す機能を提供します。この設計によって先ほど述べた以下の要件を全て満たします。

  • スキーマ駆動で型安全に開発できること
  • 既存の実装を流用できること
  • ボイラープレートを人間が書かないこと

もう少し詳細に説明します。

clioはConnectと同様に、protocプラグイン(バイナリ)とGoパッケージとして提供されています[2]。プラグインはProtocol Bufferスキーマを参照した上でコード生成をし、パッケージは必要な依存を提供します。

プラグインは Protocol Bufferで定義されたサービスごとに以下のような関数を生成します。

func NewGreetServiceCommand(
  ctx context.Context,
  s greetv1connect.GreetServiceHandler
  w io.Writer
) *cobra.Command
  • ctx はCLIを実行するときに呼び出されるrpcの実装に渡されるcontext.Context、
  • sはサービスのビジネスロジックを実装するConnectのサービスハンドラです。
  • コマンドが正常終了した場合、実行結果はio.Writer w にJSON形式で書き込まれます。

それらを受け取りcobra.Commandを生成します。このコマンドはサービスに対応するコマンドです。サービスが提供するrpcごとにサブコマンドを提供します。rpcへのリクエストはJSON形式の文字列を -d オプションで指定することでコマンドに入力します。

$ greeting -d '{ "name": "Furudono" }' hello
{ "greeting": "hello Furudono" }

現時点では、protovalidateなどのインターセプタには対応していません。将来の開発で対応することを目指します。

ここで紹介する例はnaoyafurudono/greetingに公開してあります。

https://github.com/naoyafurudono/greeting

以下ではConnectを用いたAPIサーバをすでに実装してあることを仮定して、そこにclioでCLIを追加する方法を紹介します。Connectを用いたAPIサーバの開発についてはConnectの公式ドキュメントを参考にしました。

https://connectrpc.com/docs/go/getting-started/

以下のようなProtocol Bufferによるサービス定義があるとします。

greeting/greet/v1/greet.proto
syntax = "proto3";

package greet.v1;

option go_package = "github.com/naoyafurudono/proto-cli/gen/greet/v1;greetv1";

// Important service.
service GreetService {
  // basic greeting
  rpc Hello(HelloRequest) returns (HelloResponse) {}
  // you cannot live alone
  rpc Thanks(ThanksRequest) returns (ThanksResponse) {}
}

message HelloRequest {
  string name = 1;
}

message HelloResponse {
  string greeting = 1;
}

message ThanksRequest {
  string name = 1;
  string why = 2;
}

message ThanksResponse {
  string greeting = 1;
}

適切な設定をすると以下のようにコマンド実行できるようになります。

$ ./main -d '{ "name": "furudono" }' hello
{ "greeting": "hello, furudono!" }
$ ./main -d '{ "name": "furudono", "why": "kindness" }' thanks
{ "greeting": "thank you furudono for your kindness!" }

設定

まずprotoc-gen-clio-goをインストールします。これが今回実装したprotocプラグインです。

go install github.com/naoyafurudono/clio-go/cmd/protoc-gen-clio-go@latest

次に、buf.gen.yaml に設定を追記します。

buf.gen.yaml
  version: v2
  plugins:
    - local: protoc-gen-go
      out: gen
      opt: paths=source_relative
    - local: protoc-gen-connect-go
      out: gen
      opt: paths=source_relative
+   - local: protoc-gen-clio-go
+     out: gen
+     opt: paths=source_relative

これで、buf generate を呼び出したときに、protoc-gen-clio-go が呼び出されるようになりました。
生成されるコードは naoyafurudono/clio-go に依存します。ですので依存パッケージに加えます。

go get github.com/naoyafurudono/clio-go

ここまできたら準備完了です。CLIコード生成しましょう。

buf generate

生成されるコード

buf generate を実行すると以下のようなGoコードが生成されます。
このパッケージは単一の関数 NewGreetServiceCommand(ctx context.Context, s greetv1connect.GreetServiceHandler) *cobra.Command を提供します。

Connectで動くサービス(s)とcontext.Context(ctx)を渡すと、サービスを実行するcobraコマンドを返します。

gen/greet/v1/greetv1clio/greet.clio.go
// Code generated by protoc-gen-clio-go. DO NOT EDIT.
//
// Source: greet/v1/greet.proto

package greetv1clio

import (
	context "context"
	clio_go "github.com/naoyafurudono/clio-go"
	greetv1connect "github.com/naoyafurudono/proto-cli/gen/greet/v1/greetv1connect"
	cobra "github.com/spf13/cobra"
	io "io"
)

func NewGreetServiceCommand(ctx context.Context, s greetv1connect.GreetServiceHandler, w io.Writer) *cobra.Command {
	var greetservice = cobra.Command{
		Use:   "greet",
		Long:  "Important service.",
		Short: "Important service.",
	}
	var reqData *string = greetservice.PersistentFlags().StringP("data", "d", "{}", "request message represented as a JSON")
	var hello = clio_go.RpcCommand(ctx,
		s.Hello,
		"hello",
		"basic greeting",
		"basic greeting",
		reqData,
		w,
	)
	var thanks = clio_go.RpcCommand(ctx,
		s.Thanks,
		"thanks",
		"you cannot live alone",
		"you cannot live alone",
		reqData,
		w,
	)
	greetservice.AddCommand(
		hello,
		thanks,
	)
	return &greetservice
}

あとはmain関数からこれを実行するようにすれば、仕事はおしまいです。

cmd/server.go
package main

import (
	"context"
	"fmt"
	"os"

	"github.com/naoyafurudono/proto-cli/gen/greet/v1/greetv1clio"
	"github.com/naoyafurudono/proto-cli/service"
)

// The entry point (what you implement)
func main() {
	greetCmd := greetv1clio.NewGreetServiceCommand(context.Background(), &service.GreetServer{}, os.Stdout)
	if err := greetCmd.Execute(); err != nil {
		if errors.Is(err, clio.CLIFailed) {
			panic(err)
		} else if errors.Is(err, clio.RPCFailed) {
			fmt.Fprintln(os.Stderr, err)
			os.Exit(1)
		} else {
			panic("never")
		}
	}
}

あとはビルドをして、実行しましょう。[3]

go build ./cmd/server/main.go
$ ./main -d '{ "name": "furudono" }' hello
{ "greeting": "hello, furudono!" }
$ ./main -d '{ "name": "furudono", "why": "kindness" }' thanks
{ "greeting": "thank you furudono for your kindness!" }

これで例はおしまいです。

おわり

  • clioを使うとあなたのサービスがgRPCやHTTPだけでなく、CLIでも喋れるようになります!
  • protoc プラグインとして実装してます、スキーマ駆動でCLIツールを書きましょう
  • cobra.Commandを返すので、色々やりやすいです

デバッグが足りてないだろうと思います。IssueやPull Requestをお待ちしています。

https://github.com/naoyafurudono/clio-go

今後

  • 使用例を増やして機能追加などの要求を集めます
  • protovalidateなどのインターセプタに対応します。このためにインターフェースが変わるかもしれません
  • ヘルプメッセージをより良くします。protoファイルに書かれたコメントをまだ拾いきっていません

感想

protoc プラグイン、楽しいです。

脚注
  1. 「ブラウザやgRPC互換なサーバってどうやって提供するんだ!そっちが気になるぞ」と思った方は、同僚の @yoshihiro_shuによる3プロトコルを実現する connect-go をご覧ください。 ↩︎

  2. porotoc pluginの学習には protocプラグインの書き方protoc-gen-connect-goのソースコードを参考にしました。protoc pluginの開発については別途改めてアウトプットしようと思います ↩︎

  3. ここではコマンドをそのまま実行しましたが、生成されるのは普通のcobra.Commandなので既存のルートコマンドにAddすることも可能です(詳細はcobraのCommand.AddCommandを見てください)。また、普通のconnectサーバと同居させる例をサンプルリポジトリに置いています。 ↩︎

Discussion