【Go】kong で CLI のトップレベルに --version フラグを実装する

お久しぶりです。SRE の市川恭佑です。

今回は Go で CLI ツールを作成する際の小ネタを紹介します。

そもそも CLI パーサの選定

Go でコマンドを解析する手法は多岐に渡ります。そもそも標準 flag パッケージだけで実装することも可能ですし、spf13/cobraurfave/cli をフラグパーサに採用することも多いかと思います。

好きなものを使っていただくのが一番ですが、今回の話題で取り上げる alecthomas/kong は、サブコマンドのサポートのみならず簡単な制約チェックも提供されているのが魅力的です。個人的には、不正なフラグが与えられたときに適切なエラーメッセージを返すことの面倒臭さを考えると、ここら辺もパーサ側にお願いしたいなって気持ちになることが多いです。

ちなみに kong は、alecthomas/kingpin と同じ作者によるツールで、みなさんお馴染みの(?) kayac/ecspressofujiwara/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 呼び出しは以下の流れで実装されています。

  1. kong.Parse() 等を経由してユーザーが *Kong の Parse() メソッドを実行
  2. 各 Hook の名前に対して *KongapplyHook(ctx, name) メソッドを実行
  3. 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 の実装および違和感との向き合い方

まだ実装を見ていない方は一度目を通してもらうとして、 VersionFlagBeforeReset() は第二引数に 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ツールを発明しちゃうエンジニアも募集しています!

hubspot.kayac.com

*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" になったりしますが、ビルド後の実行であればコマンド名になるようです。