お久しぶりです。SRE の市川恭佑です。
今回は Go で CLI ツールを作成する際の小ネタを紹介します。
そもそも CLI パーサの選定
Go でコマンドを解析する手法は多岐に渡ります。そもそも標準 flag
パッケージだけで実装することも可能ですし、spf13/cobra や urfave/cli をフラグパーサに採用することも多いかと思います。
好きなものを使っていただくのが一番ですが、今回の話題で取り上げる alecthomas/kong は、サブコマンドのサポートのみならず簡単な制約チェックも提供されているのが魅力的です。個人的には、不正なフラグが与えられたときに適切なエラーメッセージを返すことの面倒臭さを考えると、ここら辺もパーサ側にお願いしたいなって気持ちになることが多いです。
ちなみに kong は、alecthomas/kingpin と同じ作者によるツールで、みなさんお馴染みの(?) kayac/ecspresso や fujiwara/lambroll でも使われています*1。
kong による制約チェック
kong の README から Supported tags を覗いてみると、いくつかの制約を指定できることが分かります*2。
今回はわかりやすさのため、 required
に絞って紹介します。読んで字の如く、特定のフラグが必須であることを示すために使います。
type CLI struct { // `required:""` でフラグを必須にできる Mandatory string `required:"" short:"m" help:"Mandatory flag."` Optional bool `short:"o" help:"Optional flag."` // `arg:""` の場合はデフォルトで必須 Arg string `arg:"" help:"An argument."` }
なお、サブコマンドがあるネスト構造において、存否の制約は親子関係を考慮して適用されます。
早い話、以下のようなユースケースにおいて --bar
が必須となるのは、サブコマンド foo
を実行したときだけです。
サブコマンド version
でも --bar
が要求される、ということはありません。
type CLI struct { Foo struct { Bar bool `required:"" short:"b" help:"Do foo with bar."` } `cmd:"" help:"Do foo."` Version struct{} `cmd:"" help:"show version"` }
困ったポイント
先ほどの例で「そりゃ version
コマンドで他のフラグが要求される訳がないだろ」と思った方もいるかもしれません。
その感覚はごもっともなのですが、サブコマンドを持たないシンプルなユースケースの場合、どうなるでしょう?
筆者はここでハマりました。具体的には、以下のように超絶シンプルなコマンド piyo
を実装しました。
// cmd/piyo/main.go (一部抜粋) type CLI struct { Foo string `arg:"" help:"Foo string."` Bar string `required:"" short:"b" help:"Bar string."` // (任意フラグ省略) Version bool `short:"v" help:"Show version and exit."` } func main() { var cli CLI kong.Parse(&cli) if cli.Version { // Version はビルド時に -ldflags で埋め込む fmt.Printf("piyo %s\n", Version) return } // ここから通常の処理 }
Foo は arg
なので必須です。Bar も required
タグを付けています。
つまり、通常は $ piyo xxx --bar fuga
といった形で使って欲しいということになります。
また、main 関数からも読み取れるとおり、 $ piyo --version
とした場合はバージョン情報を出力して素直に終了することを期待していました。
しかし、実際に $ piyo --version
を実行したところ、以下のようなエラーが出力されました。
piyo: error: missing flags: --bar
どういうことかというと、 kong.Parse(&cli)
を抜ける前に制約チェックが実行され、 --bar
制約の巻き添えで発生したエラーを基にkongがプログラムを終了 *3 したのです。
ちょっと深掘り: --help
という例外
(結論だけ知りたい方は 実装方法のところ までスキップしてください)
この問題に直面したとき、最初に浮かんだ疑問は「でも、なぜ --help
は成功するのだろう」というものでした。
実際、 $ piyo --help
を実行したところ、以下のようにヘルプが表示され、コマンドは正常終了しました。
$ piyo --help Usage: piyo --repository-name=STRING <foo> [flags] Arguments: <foo> Foo string. Flags: -h, --help Show context-sensitive help. -b, --bar=STRING Bar string. // (略) -v, --version Show version and exit.
何故でしょう?
その答えにつながるカギは、「そもそも我々は --help
オプションなど定義していない」ということです。
この時点で、 「kong.Parse
の内部処理で、ビルトインの -h, --help
が特別扱いされていて、制約チェックの前に(ヘルプ情報だけ表示して)プログラムを終了させるという振る舞いになっているのではないか」 という仮説を立てました。
実際に kong のソースコードを読んでみると、該当する処理を担っている箇所を見つけました。 helpValue
という型に BeforeReset()
メソッドが実装されています。
さらに深掘り: Hooks と Bindings
--help
の実装に関して、実は README にもサラッと記載があったのですが、 BeforeReset()
などの 'Hooks' と呼ばれているメソッドたちは、通常の Go のメソッド呼び出しとは異なる方法で発火されます。
大雑把に解説すると、Hooks 呼び出しは以下の流れで実装されています。
kong.Parse()
等を経由してユーザーが*Kong
のParse()
メソッドを実行- 各 Hook の名前に対して
*Kong
のapplyHook(ctx, name)
メソッドを実行 - 2つの内部関数(
getMethod()
およびcallMethod()
)を用いて当該 Hook を特定・実行
これを踏まえて 第2ステップ および 第3ステップ のコードを眺めていただくと、Bindings という概念が見えてくると思います*4。Bindings は、型と値が対になっている map です*5。 これを用いて Hooks がどのように呼び出されているかを簡単な図にしてみました。(Foo, Bar, Piyoは先ほど例示したコードとは無関係です💦🙏)
補足すると、 *N
をレシーバとしている BeforeReset()
メソッドが呼び出せないのは、Bindings に含まれない Piyo
という型を引数に持っているからです。その他のレシーバに対するメソッドは、いずれも引数が Bindings の部分集合となっているため呼び出せます。なお、引数の順番は関係ないようです。
トップレベル --version
フラグの実装
さて、それでは本題の --version
フラグを実装していきましょう。
深掘りで紹介したアクロバティックなメタプログラミングを目の前にすると不安の波が押し寄せてくる Gopher の方々も少なくないかと思われますが......安心してください、簡単ですよ。
というのも、kong のソースコードにある util.go
という便利そうなファイルで、今回のユースケースに当てはまる VersionFlag
という型が用意されており......なんと丁寧に BeforeReset()
メソッドまで実装されているのです。
先ほどの piyo
コマンドについても、以下のようにすれば --version
フラグを実装できます。
type CLI struct { Foo string `arg:"" help:"Foo string."` Bar string `required:"" short:"b" help:"Bar string."` // (任意フラグ省略) // 型が bool ではなく kong.VersionFlag になっている! Version kong.VersionFlag `short:"v" help:"Show version and exit."` } func main() { var cli CLI kong.Parse(&cli, kong.Vars{"version": fmt.Sprintf("piyo %s", Version)}) // if cli.Version のような処理は要らない! // ここから通常の処理 }
結論だけ知りたい方は、ここまでの内容で十分です。お疲れ様でした。
以下は、深掘りを読んでいただいた方向けの内容です。
kong.VersionFlag
の実装および違和感との向き合い方
まだ実装を見ていない方は一度目を通してもらうとして、 VersionFlag
の BeforeReset()
は第二引数に kong.Vars
を受け取っていることが分かります。
つまり、これも Bindings に含まれているということです*6。
この kong.Vars
を介してバージョン情報が出力されていますが、 kong.Vars
の基底型は map[string]string
で、内部実装では map のキーが "version"
とハードコードされています。
それゆえ kong.Parse()
のオプション*7として kong.Vars
を渡す際に、利用者側もハードコードで "version"
というキーを指定する必要があります。
ここで、 main()
またはその付近に記述する内容が、依存パッケージの内部実装と密結合になることに違和感を感じる方もいるかもしれません。
その場合は、以下のように VersionFlag
を再実装してあげると、 kong.Parse()
にオプションを渡す必要もなくなり、少しすっきりした気持ちになるかもしれません。
var Version = "(dev)" // ビルド時に -ldflags で埋め込む type VersionFlag bool // BeforeReset は kong における Hook の一つで、制約チェックよりも前に実行されます。 func (v VersionFlag) BeforeReset(app *kong.Kong) error { fmt.Fprintf(app.Stdout, "piyo %s\n", Version) app.Exit(0) return nil }
なお、 --version
結果の冒頭にコマンド名を出すか否かも好みによる部分だと思いますが、ここの部分を動的に出力したい場合は app.Model.Name
を利用することも可能です*8。
まとめ
CLI のパースは、コマンド実装者にとってはできるだけ手を抜きたいところですが、利用者の体験に直結する部分であるにも拘らず、自動テストが行き届かない傾向にあるのも事実です。
kong に限らず、ある程度柔軟性の高い CLI パーサを使うのであれば、少しだけ時間をとって内部処理を追ってみることをお勧めします。
CLI パーサへの理解は、日々の業務においては些細な要素に過ぎませんが、緊急でスクリプトを書かないといけない機会においては意外と役立ったりするものです。
カヤックでは、息を吐くようにCLIツールを発明しちゃうエンジニアも募集しています!
*1:kong の基本的な使い方については README の Introduction をご参照ください。
*2:xor などについては テスト を読むと理解しやすいと思います。
*3:kong.Parse の代わりに kong.New を使ったりオプションを渡すことで os.Exit を回避する方法はありますが今回の問題の解決には繋がらないので割愛します。
*4:もし reflect のコードを読むのに慣れていない場合は、callFunction内の In や Out といった語彙が、関数の入出力を指していることがヒントになるかもしれません。
*5:厳密には、値というよりも値の getter の方が正確です。コードをご参照ください。
*6:Bindings に kong.Vars を追加している箇所はこちらです。
*7:Uberスタイルで、kong.Vars に Apply メソッドが実装されています。
*8:開発中に go run すると値が "main" になったりしますが、ビルド後の実行であればコマンド名になるようです。