🐷

urfave/cliで選択型の引数解析を実現する

2023/04/30に公開

選択型の引数解析

Goのcliアプリにおける引数解析にurfave/cliを利用している。このライブラリを利用しているときに、引数の指定において選択型の引数解析を実現したい。
引数で--format jsonのように指定可能にしつつ、サポートしていない値が指定されたら引数エラーにしたい。他言語でいえば、pythonのargparseにおけるchoicesのようなもの。

よくありそうなユースケースだけれど、urfave/cliではこのような引数解析はサポートされていなかった。これを実現する方法として大きく以下の3つがありそう。
サンプルコードの全体はこちら

アプリ本体で解析する

urfave/cliのライブラリ側で引数解析にするのではなく、アプリ側でチェックする方法がわかりやすい。この方法であれば実装量少なく簡単に実現できる。

func main() {
	app := &cli.App{
		Action: sample,
		Flags: []cli.Flag{
			&cli.StringFlag{
				Name:  "lang",
				Value: "english",
				Usage: "selected language [english, japanese]",
			},
		},
	}

	err := app.Run(os.Args)
	if err != nil {
		fmt.Println(err)
	}
}

func sample(ctx *cli.Context) error {
	lang := ctx.String("lang")
	switch lang {
	case "english", "japanese":
		fmt.Printf("lang: %s\n", lang)
	default:
		cli.ShowAppHelp(ctx)
		return fmt.Errorf("invalid lang specified: %s\n", lang)
	}

	return nil
}

実行例は以下の通り。
引数エラー時にヘルプメッセージを表示するなど、やりたいことが実現できる。
一方で、引数解析をアプリ側で実装しなくてはいけない点は気になる。引数解析の問題なのでライブラリ側で完結して欲しい。また、上記実装では選択可能な引数をハードコーディングしている。解消することは容易だが、見通し良く実装したい。

$ ./urfavecli --lang ja
NAME:
   urfavecli - A new cli application

USAGE:
   urfavecli [global options] command [command options] [arguments...]

COMMANDS:
   help, h  Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --lang value      selected language [english, japanese] (default: "english")
   --help, -h        show help
invalid lang specified: ja

urfave/cliで解析する

Flag Actionsでバリデーションする

対象引数においてFlag Actionsを利用することで、指定の関数をコールバックすることができる。
これを用いてバリデーションを行い、意図しない入力の場合はエラーを返すようにすればよい。

func main() {
	app := &cli.App{
		Action: sample,
		Flags: []cli.Flag{
			&cli.StringFlag{
				Name:  "protocol",
				Value: "https",
				Action: func(ctx *cli.Context, v string) error {
					valid := []string{"http", "https"}
					if !slices.Contains(valid, v) {
						return fmt.Errorf("Flag protocol value %v must be one of %v", v, valid)
					}
					return nil
				},
			},
		},
	}

	err := app.Run(os.Args)
	if err != nil {
		fmt.Println(err)
	}
}

func sample(ctx *cli.Context) error {
	protocol := ctx.String("protocol")
	fmt.Printf("protocol: %s\n", protocol)

	return nil
}

実行例は以下の通り。
この方法であれば簡単に実現できる上にライブラリ側で引数解析を処理してくれるのでうれしい。
一方で、この方法だと入力が不正なときにヘルプメッセージを表示することができなかった。引数指定を間違えたときには、該当引数だけでなくcli全体のヘルプの表示まで実現したい。ライブラリ側のオプションで実現できそうだが、それらしきオプションは見付けられなかった。

$ ./urfavecli --protocol tcp
invalid protocol: tcp (must be one of [http https])

GenericFlagでEnum指定する

GenericFlagおよびEnumを利用することで選択型の引数解析でやりたいことは実現できる。
ヘルプメッセージなども自動で生成してくれるのでこの方法がよさそう。これはEmum Support#602というIssueで紹介されていた。Enumの定義やGeneric interfaceを実装するためのSet関数およびString関数を実装する必要はあるが見通しよく実現できる。

type EnumValue struct {
	Enum     []string
	Default  string
	selected string
}

func (e *EnumValue) Set(value string) error {
	for _, enum := range e.Enum {
		if enum == value {
			e.selected = value
			return nil
		}
	}

	return fmt.Errorf("allowed values are %s", strings.Join(e.Enum, ", "))
}

func (e EnumValue) String() string {
	if e.selected == "" {
		return e.Default
	}
	return e.selected
}

func main() {
	app := &cli.App{
		Action: sample,
		Flags: []cli.Flag{
			&cli.GenericFlag{
				Name: "format, f",
				Value: &EnumValue{
					Enum:    []string{"json", "plist", "xml"},
					Default: "json",
				},
			},
		},
	}

	err := app.Run(os.Args)
	if err != nil {
		fmt.Println(err)
	}
}

func sample(ctx *cli.Context) error {
	format := ctx.String("format")
	fmt.Printf("format: %s\n", format)

	return nil
}

実行例は以下の通り。
意図した通り動作してくれているが、ヘルプメッセージの前後で2回エラーメッセージが表示されている点が気になる。

$ ./urfavecli --format txt
Incorrect Usage: invalid value "txt" for flag -format: allowed values are json, plist, xml

NAME:
   urfavecli - A new cli application

USAGE:
   urfavecli [global options] command [command options] [arguments...]

COMMANDS:
   help, h  Shows a list of commands or help for one command

GLOBAL OPTIONS:
   --format value    (default: json)
   --help, -h        show help
invalid value "txt" for flag -format: allowed values are json, plist, xml

結論

アプリの規模が小さい内はアプリ側で実装、アプリの規模が大きくなったらGenericFlag+Enumで実装するのがよさそう。

Discussion