2024-11-20

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

  • proto2cliを実装する
    • やった
    • 名前を決める: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)  // 予期しないサブコマンドが来たらエラーを返すのもまた一興かな。
	}
}

便利じゃない?

< 2024-11-132024-11-25 >