2024-11-20

スキーマ駆動CLIツール開発を支援するツールをprotoに乗っかって作りたい

以下のようなprotoスキーマを定義する。

syntax = "proto3";

package nfurudono.sample.v1;

service SamplService {
  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;
}

こんな定義があるときに

```sh
$ ./sample say --sentence "hello world" --dryrun
sentence: "I will say: hello world"

みたいなやりとりを定義するためのCLIテンプレート生成ツールを作る。あくまでテンプレートなので、生成ツールではインターフェースとかグルーコードだけ提供して、ユーザは以下のようなコードを書くことになる。

  • connectとかでサービスとサーバの起動を定義してmainからいい感じに呼ぶようにすると、gRPCサーバがたつのに対して、
  • CLIテンプレート作成ツールでサービスとCLIコマンドの呼び出しを定義してmainからいい感じに呼ぶようにすると、CLIから実行できる

サービスの実装

package service

improt (
	"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 inpu: %w", err)  // 予期しないサブコマンドが来たらエラーを返すのもまた一興かな。
	}
}

便利じゃない?