概要
Go 言語における CLI アプリ作成で一番使われる定番のライブラリといえば spf13/cobra でしょう。 Hugo や Kubernetes といった著名なプロジェクトでも cobra を使っているそうです。
しかしながら自分の場合、フラグの値を格納するのにグローバル変数を使ったり、初期化で init()
を使ったりという cobra の流儀が気に入らず、これまで利用を敬遠してきました。一番問題だと考えているのはユニットテストの記述が困難であることです。
もちろん cobra でもグローバル変数や init()
を一切使わず構造体のフィールドやメソッドだけで構成することは可能ですが、その場合 cobra コマンド (コードジェネレータ) の支援はなく、すべて手で書く必要があります。
先日より、上記の問題点を解消しようと取り組んだところ、簡単かつエレガントな cobra の構成手法を確立することができました。そのためのライブラリを yaegashi/cobra-cmder というモジュールとして公開したので紹介します。
基本的な使用法
cobra-cmder で CLI を作る例題として、次のような呼び出し方ができる app というアプリを考えてみます。
$ app alpha one -h
Usage:
app alpha one [flags]
Flags:
-h, --help help for one
-i, --int int Int flag
Global Flags:
-b, --bool Bool flag
-s, --string string String flag
$ app alpha one -b -s abc -i 123
true abc 123
フラグ (オプション) は次のように各コマンドで定義されています。また、上位コマンドのフラグは下位コマンドでも使うことができるものとします。
フラグ | 型 | コマンド |
---|---|---|
-b --bool
|
bool |
app |
-s --string
|
string |
app alpha |
-i --int
|
int |
app alpha one |
コマンド型の定義
まず、各コマンドの専用型となる構造体を定義します。各コマンド型には、フラグの値を格納するフィールドと、上位コマンド型を参照する埋め込みフィールド (最上位のコマンド型を除く) を用意しておきます。
// App - app command
type App struct {
Bool bool // storage for flag -b
}
// AppAlpha - app alpha command
type AppAlpha struct {
*App // storage for parent Cmder (embedded)
String string // storage for flag -s
}
// AppAlphaOne - app alpha one command
type AppAlphaOne struct {
*AppAlpha // storage for parent Cmder (embedded)
Int int // storage for flag -i
}
コマンド型の名前はなんでもかまいませんが、この例のようにコマンド階層を反映したものにしておくことをおすすめします。
cmder.Cmder インターフェースの実装
cobra-cmder で重要な役割を演じるのは、次に示す cmder.Cmder
インターフェースです。
type Cmder interface {
Cmd() *cobra.Command
}
次の手順では、各コマンド型について cobra.Command
を返す Cmd()
メソッドを実装することで、 この cmder.Cmder
インターフェースに適合するようにしていきます。
func (app *App) Cmd() *cobra.Command {
cmd := &cobra.Command{
Use: "app",
}
cmd.PersistentFlags().BoolVarP(&app.Bool, "bool", "b", false, "Bool flag")
return cmd
}
func (app *AppAlpha) Cmd() *cobra.Command {
cmd := &cobra.Command{
Use: "alpha",
}
cmd.PersistentFlags().StringVarP(&app.String, "string", "s", "", "String flag")
return cmd
}
func (app *AppAlphaOne) Cmd() *cobra.Command {
cmd := &cobra.Command{
Use: "one",
Run: app.Run,
}
cmd.Flags().IntVarP(&app.Int, "int", "i", 0, "Int flag")
return cmd
}
func (app *AppAlphaOne) Run(cmd *cobra.Command, args []string) {
fmt.Println(app.Bool, app.String, app.Int)
}
最後の AppAlphaOne
型のメソッドについて、次の点に注目してください。
-
AppAlphaOne.Cmd()
で特定インスタンスに対するメソッド呼び出しapp.Run
を関数として設定しています。このような呼び出しは Go 言語では method values としてサポートされています。 -
AppAlphaOne.Run()
ではapp.Bool
app.String
のように上位のコマンド型で定義されたフィールドに直接アクセスしています。これができるのは各コマンド型の定義で上位コマンド型の埋め込みをしているからです。
上位コマンドを含むフラグの値を格納した変数へのアクセスが、グローバル変数を使うことなく自然な形で実現できています。
コマンド階層の関連付け
次のようなメソッド定義により、各コマンドの階層の関連付けを行います。
func (app *App) AppAlphaCmder() cmder.Cmder { return &AppAlpha{App: app} }
func (app *AppAlpha) AppAlphaOneCmder() cmder.Cmder { return &AppAlphaOne{AppAlpha: app} }
上位コマンド型に下位コマンド型のインスタンスを生成し cmder.Cmder
として返すメソッドを追加します。また下位コマンド型インスタンスには上位コマンド型インスタンスのポインタを設定します。
これらのメソッドの名前はなんでも構いません。ただし cmder.Cmder
の全階層にわたりユニークな必要があるので、この例のようにコマンド型の名前を一部に使用することをおすすめします。
cobra.Command の生成と実行
最後に main()
を実装します。次のように cmder.Cmd()
関数に最上位コマンド型のインスタンスを渡して呼び出すことにより、コマンド型の階層をトラバースしてすべての設定が反映された cobra.Command
を生成することができます。これはそのまま Execute()
メソッドで実行できます。
func main() {
app := &App{}
cmd := cmder.Cmd(app)
err := cmd.Execute()
if err != nil {
os.Exit(1)
}
}
以上で CLI の実装は完了です。完全なソースコードが Go Playground にあるので参照してください。 main()
の cmd.SetArgs()
により、様々なコマンドライン引数を与えて動作確認ができます。
ユニットテスト
cobra-cmder を使えばグローバル変数や init()
を使わずに cobra を構成できますので、CLI のユニットテストが容易にできます。 Go Playgound に例があるので参照してください。
CLI からの出力をモッキングするため、最上位コマンド型 App
に対して変数 Out
とメソッド Print
Println
Printf
を追加しています。
type App struct {
Out io.Writer // mocking output
Bool bool // storage for flag -b
}
func (app *App) Print(args ...interface{}) (int, error) {
return fmt.Fprint(app.Out, args...)
}
func (app *App) Println(args ...interface{}) (int, error) {
return fmt.Fprintln(app.Out, args...)
}
func (app *App) Printf(format string, args ...interface{}) (int, error) {
return fmt.Fprintf(app.Out, format, args...)
}
これらは AppAlphaOne
のような下位コマンド型からも呼び出して使うことができます。
func (app *AppAlphaOne) Run(cmd *cobra.Command, args []string) {
app.Println(app.Bool, app.String, app.Int)
}
テスト本体では次のように Go 言語で標準的なテーブルベースのテストが実現できています。
func TestApp(t *testing.T) {
tests := []struct {
args []string
want string
err bool
}{
{args: []string{"alpha", "one", "-b", "-s", "abc", "-i", "123"}, want: "true abc 123\n", err: false},
{args: []string{}, want: "", err: false},
{args: []string{"alpha"}, want: "", err: false},
{args: []string{"alpha", "one"}, want: "false 0\n", err: false},
{args: []string{"beta"}, want: "", err: false},
{args: []string{"alpha", "-i", "123"}, want: "false 123", err: false},
}
for _, tt := range tests {
args := strings.Join(tt.args, " ")
buf := &bytes.Buffer{}
cmd := cmder.Cmd(&App{Out: buf})
cmd.SetArgs(tt.args)
cmd.SetOut(ioutil.Discard)
cmd.SetErr(ioutil.Discard)
err := cmd.Execute()
if tt.err && err == nil {
t.Errorf("%q returns no error", args)
}
if !tt.err && err != nil {
t.Errorf("%q returns error: %s", args, err)
}
got := string(buf.Bytes())
if got != tt.want {
t.Errorf("%q returns %q, want %q", args, got, tt.want)
}
}
}
このテストの実行結果は次のとおりです。
=== RUN TestApp
TestApp: prog.go:103: "beta" returns error: unknown command "beta" for "app"
TestApp: prog.go:103: "alpha -i 123" returns error: unknown shorthand flag: 'i' in -i
TestApp: prog.go:107: "alpha -i 123" returns "", want "false 123"
--- FAIL: TestApp (0.00s)
FAIL
その他の使用例
サンプルコード cmd/sample は、 cobra-cmder を使ったより複雑なコマンド階層の構築例です。
このサンプルでは、ひとつのコマンドごとにひとつのファイルを使用しています。メソッド定義の追加という非侵襲的な手法でコマンド階層の関連付けしていることから、このように各コマンドの実装をファイル単位で独立させることができ、コマンドの追加・削除などのメンテナンスが容易になっています。
cobra-cmder は次のような CLI アプリケーションでも使われています。
cobra-cmder の実装
cobra-cmder は cmder.Cmder
インターフェースと cmder.Cmd()
という小さな関数で構成されます。これらは cmder.go ファイルに実装されています。
// Cmd traverses a Cmder hierarchy and returns a configured cobra.Command.
// It recursively calls all methods that return a Cmder
// to collect and associate cobra.Command instances.
func Cmd(c Cmder) *cobra.Command {
return recCmd(c, map[string]bool{})
}
// outTypes is a constant for the array of function output types
var outTypes = []reflect.Type{reflect.TypeOf((*Cmder)(nil)).Elem()}
// recCmd is the actual worker function to visit and collect Cmder instances.
// mmap is for bookkeeping already visted method names.
func recCmd(c Cmder, mmap map[string]bool) *cobra.Command {
cmd := c.Cmd()
inV := reflect.ValueOf(c)
inT := reflect.TypeOf(c)
funcT := reflect.FuncOf([]reflect.Type{inT}, outTypes, false)
methods := []reflect.Method{}
for i := 0; i < inT.NumMethod(); i++ {
m := inT.Method(i)
if m.Func.Type() != funcT || mmap[m.Name] {
continue
}
methods = append(methods, m)
}
for _, m := range methods {
mmap[m.Name] = true
}
for _, m := range methods {
subC := m.Func.Call([]reflect.Value{inV})[0].Interface().(Cmder)
subCmd := recCmd(subC, mmap)
cmd.AddCommand(subCmd)
}
for _, m := range methods {
mmap[m.Name] = false
}
return cmd
}
関数の本体である cmder.recCmd()
はリフレクションを利用し、引数のコマンド型インスタンスに定義されたメソッドで cmder.Cmder
を返すものを呼び出し、得られた cmder.Cmder
を下位のコマンド型として、自分自身を再帰的に呼び出します。
そうして集めた下位コマンドの cobra.Command
は、引数のコマンド型インスタンスの Cmd()
メソッドで得た cobra.Command
に AddCommand()
メソッドで関連付けられます。これを戻り値として関数を終了します。
上位コマンド型の埋め込みをしている場合、上位コマンド型で定義されたメソッドもリフレクションで取得できてしまいますが、それらは呼び出しのループを避けるために呼ばないようにする必要があります。
しかしながら Go 言語のランタイムでは、埋め込みのフィールドに由来するメソッドとそうでないメソッドを区別する方法がありません。そこでやむなく mmap
マップにより一度呼んだメソッド名を記録する手段を採用しています。コマンド型の階層間でメソッド名がユニークな必要があるという制約はこの実装により生じています。
まとめ
cobra-cmder を使えば cobra でグローバル変数や init()
を使うことなく CLI のコマンド階層の構築ができることを示しました。この手法には次のような特長があります。
- 各コマンドの実装の記述が簡単
- グローバル変数および
init()
の排除によりユニットテストの記述が容易 - method values や埋め込みフィールドの活用による、コマンド階層を反映したわかりやすいストレージアクセス
- メソッド定義を用いた非侵襲的なコマンド階層の関連付け手法による、各コマンドの追加・削除などのメンテナンス性の向上
その一方で次の改善点が考えられます。
-
cmder.Cmd()
ではリフレクションを利用しており実行の都度コマンド型の階層をトラバースしているため、実行開始までに時間がかかる。ビルド前に静的なコマンド階層構築のコードを自動生成できることが望ましい。
以上です。気に入ったらぜひ使ってみてください。プルリクエストもお待ちしております!