chroju.dev/blog

the world as code

HashiCorp ファン必須科目である mitchellh/cli を履修する

mitchellh/cli について

Go にはコマンドラインツールを書くのに役立つフレームワークが数多くありますが、今回はググってもあんまり日本語情報が出ず、また godoc に example も少なくて掴みどころが難しい、 mitchell/cli に触れてみます。正直、有名どころの urfave/clialecthomas/kingpin に比べて使い勝手が良いとは言いづらいフレームワークだと感じていますが、 mitchellh氏のレポジトリにあることからもわかる通り、 Terraform 等の HashiCorp OSS 群で使われているフレームワークということで、あえて学んでみました。

なお、ただ実装を読むだけではつまらないですし、せっかくなら使ってみたかったので、現在拙作の nature-remo-cli 内でも活用しています。実例として良ければご参照ください。

基本

まずはとにかく README.md にある example コードを見てみます。

package main

import (
	"log"
	"os"

	"github.com/mitchellh/cli"
)

func main() {
	c := cli.NewCLI("app", "1.0.0")
	c.Args = os.Args[1:]
	c.Commands = map[string]cli.CommandFactory{
		"foo": fooCommandFactory,
		"bar": barCommandFactory,
	}

	exitStatus, err := c.Run()
	if err != nil {
		log.Println(err)
	}

	os.Exit(exitStatus)
}

mitchellh/cli は基本的にサブコマンドのあるコマンドを実装するためのツールです。 c.Commands にセットしている map[string]cli.CommandFactory がサブコマンドの定義箇所で、キーになっている文字列をサブコマンドとして実行されたとき、その値の CommandFactory が実行される、という形を取ります。ではこの cli.CommandFactory は何者かと言うと、以下のように cli.Commanderror を返す関数として定義されています。

type CommandFactory func() (Command, error)

従って上記の example のような変数で CommandFactory を返す形ではなく、ここで無名関数を定義してしまう形も可能です。具体的なところだと Terraform の実装 (https://github.com/hashicorp/terraform/blob/master/commands.go#L80-L85) などを見るとイメージが掴みやすいと思います。私はこちらの書き方のほうが明示的で好みです。この Terraform の実装では error にあたる返り値として決め打ちで nil を返していますが、私も同様に実装しています。あんまりここで nil 以外の値を返すパターンが思いつきません。

cli.Command の実装

次に、先の CommandFactory が返していた cli.Command の実装を見てみます。 cli.Commandhelp, Run, Synopsis の3つのメソッドを必要とする interface です。

type Command interface {
	Help() string
	Run(args []string) int
	Synopsis() string
}

Synopsis() は、サブコマンド無しでコマンド実行したときに出力される、そのサブコマンドの使い方を記した50字程度の注釈を返すよう設定します。 例として nature-remo-cli を使ってみると、こんな感じで表示されます。

Available commands are:
    aircon    Control Air Conditionar
    init      Initialize remo with your OAuth token
    signal    Control Signals
    sync      Sync local config with your latest one

Help() はそのままの意味で、 -h--help を付けて実行した場合に返す内容を設定します。よくある Usage の自動生成のような機能はありませんので、愚直にヒアドキュメントなどで書いていく必要があります。

Run() で実行したい処理を書いていきます。1つ注意したいのが、返り値として渡せるのが int 型、つまりはリターンコードのみであり、 error インターフェースや、エラーメッセージを含めた string などは返せないという点です。従ってエラーの処理やエラーメッセージの出力は、 Run() の中ですべて済ませる必要があります。

cli.Command 内での出力処理

ではエラーメッセージをどのように出力するかですが、 cli.Command の中に cli.Ui を埋め込んで活用するのが一般的です。

type HogeCommand struct {
	UI cli.Ui
}

func (c *HogeCommand) Run(args []string) int {
	c.UI.Output("Normal Message")
	c.UI.Error("Error Message")

	return 0
}

c.UI.Output() は標準出力に、 c.UI.Error() は標準エラー出力にいい感じに吐いてくれます。他にも対話式にユーザーの入力を待ってくれる Ask() や、さらにはユーザー入力を隠してくれるので秘密情報を入力させるのに便利な AskSecret() といった便利なメソッドも cli.Ui に用意されています。

さて、 fmt.Println() などを使わず cli.Ui をわざわざ使うと何が嬉しいのかと言えば、お察しのとおりかと思いますが一つにはテストが書きやすくなる面があります。 cli.MockUi という、標準出力先と標準エラー出力先にあたる buffer を有した構造体があるので、テストのときにはこれを使うことで出力のチェックができます。この点については Vault での実装 (https://github.com/hashicorp/vault/blob/df18871704fe869e9be45b542a6b1eb2fe46c293/command/audit_disable_test.go) などが参考になるかと思います。他にも cli.Ui を満たす構造体がいくつか用意されていて、ほしい機能があれば追加できるようになっており、例えば cli.ColoredUi は出力の色指定ができるので非常に便利です。ANSI エスケープシーケンスの8色を指定するための定数も用意されています ので、簡単にターミナルへ色付きの出力をすることができます。

極めてシンプルな CLI ツール

ざっくりここまでの説明で主要な使い方は網羅しました。説明を省いたものとして AutoComplete の支援機能などがありますが、せいぜいがそんなもので、非常に薄い実装のフレームワークになっています。他のコマンドラインフレームワークによくある機能はほとんどなく、オプションの処理すら担ってくれません(-h--version はデフォルトで対応していますが、それだけです。nature-remo-cli では spf13/pflag を合わせて使う形にしました)。さすがにちょっと不親切というか、あくまで HashiCorp で使うための最低限の機能だけを実装したものなのかな、という感触があります。

ただ一方では、サブコマンドごとに cli.Command を満たす構造体を別々に実装する、つまりはコードがサブコマンド単位で強制的に分かれる形になるので、コードの可読性は高い状態を保てるように感じています。 Terraform のコマンド実装 など、一度でも Terraform を使ったことがある人が見れば、すぐに知りたい実装がどこにあるのか見通せるようなファイル構成になっています。したがって冒頭にも書いた通り、 HashiCorp のツールをよく使う人であれば、知っておいて損はないのではないかなと思っています。