2024-11-20
- proto2cliを実装する
- やった
- zennに紹介記事を書いた https://zenn.dev/nfurudono/articles/719e3aafac6065
- kamakura.go #7で発表した https://kamakurago.connpass.com/event/336353/
- まだ本番環境に組み込めてないのと、機能開発とか開発環境の整備ができていない。やる
- 名前を決める:connectはブラウザやgRPCと互換性のあるAPIを提供するのに必要なボイラープレートが必要なのを軽減してくれるやつ。
- これはそれらにCLIをインターフェースとして加える。つまりローカルから、サーバを立てる事なく実行できるようにする。そういう立ち位置がいい。単にインターフェースを一個加えるだけ。実装はできるだけシェアしたい。
- まずはconnectを採用するアプリケーションを一個用意して、それにCLIを入れる。ここで上手い入れ方を探る
- 次にその入れる作業を自動化する。そのためのCLIがproto2cli
- サブコマンドでサービス、位置引数でメソッド、フラグ引数(
-d
)でリクエストメッセージを受け取る。- サブコマンドと位置引数は最初は特に区別しなくていいか。
cmd <service> <method> -d <request message json>
の形式で呼び出すだけ。 - CLIへの入力はそのままサービス名、メソッド名、リクエストメッセージがそれぞれbyte列かstringのどちらかで得られる。それらをなんとかするのは一旦別のコンポーネントの役割にしよう
- ここまでは普通にflagsパッケージとかを使うだけでいける
- サブコマンドと位置引数は最初は特に区別しなくていいか。
- 次にリクエストメッセージのデコードを自動化する。
func unmarshalRPC(service, method, req string) (MessageInterface, error)
- 対応する型のunmarshellerへのディスパッチとその呼び出しが責務
dispatchUnMarshelelr(service, method string) (func(byte[], MessageInterface) error, error)
- ディスパッチだけでも良さそう
- 次に、サービスの呼び出しを行う
- connect サーバをclient経由で呼び出す
- 単にサービスを呼び出す
- インターセプタを通過できない、特にproto validateを通せない
- インターセプタとサービスを合成する
- できればhttpサーバの仕組みに乗っかりたい
- でも無理がありそう、intercepter (unaryfunc) を受け入れてデコレートする感じにしそう
- やった
以下のようなprotoスキーマを定義する。
syntax = "proto3";
package nfurudono.sample.v1;
service SampleService {
rpc Say(SayRequest) returns (SayResponse) {}
}
// SayRequest is a single-sentence request.
message SayRequest {
string sentence = 1;
}
// SayResponse is a single-sentence response.
message SayResponse {
string sentence = 1;
bool dry_run = 2;
}
こんな定義があるときに
$ ./sample say --sentence "hello world" --dryrun
sentence: "I will say: hello world"
みたいなやりとりを定義するためのCLIテンプレート生成ツールを作る。あくまでテンプレートなので、生成ツールではインターフェースとかグルーコードだけ提供して、ユーザは以下のようなコードを書くことになる。
- connectとかでサービスとサーバの起動を定義してmainからいい感じに呼ぶようにすると、gRPCサーバがたつのに対して、
- CLIテンプレート作成ツールでサービスとCLIコマンドの呼び出しを定義してmainからいい感じに呼ぶようにすると、CLIから実行できる
package service
import (
"fmt"
sample "github.com/nfurudono/gen/go/sample"
)
type SampleImpl struct {}
// sample.SampleServiceはprotocとかが生成するようなinterface。gRPCとかconnectとかで使われているようなやつ。
var _ sample.SampleService = &NewSampleImpl()
func NewSampleImpl() { return SampleImple{} }
func (* SampleImpl) Say(ctx *context.Context, req *connect.Request[samplev1.SayRequest]) (*connect.Response[samplev1.Response], error) {
s := req.GetSentence()
d := req.GetDryRun()
if d {
return fmt.Sprintf("I will say: %s", s), nil
}
return fmt.Sprintf("%s!!", s), nil
}
package main
import (
slog
tool "github.com/naoyafurudono/good-tool"
"github.com/naoyafurudono/sample-cli/service"
)
func main() {
s := service.NewSampleImpl()
// NewCLI()の結果(CLI)はサービスを登録される。
// サービスを保持するCLIはサービスが契約するrpc(名前、入力、出力)を知っている。
// これらはprotoの仕組みで生成される。protovalidateなどのプラグインもそのレイヤで対応できるはず。
cli := tool.NewCLI().AddService(s) // このあたりのインターフェースはもうちょい考えても良いかも?
// Runがコマンドライン引数を読んで以下を半ドアリングする
// - 呼び出すrpcの判定(ルーティング)、サブコマンドの名前が対応する
// - rpcに渡す入力messageのデコード、サブコマンドへのフラグ引数が対応する
// - rpcの出力メッセージやエラー内容の出力、コマンドの標準出力、標準エラー出力、コマンドのステータスコードの出しわけが対応する
if err := cli.Run(); err != nil {
slog.Fatalf("unexpected input: %w", err) // 予期しないサブコマンドが来たらエラーを返すのもまた一興かな。
}
}
便利じゃない?