Help us understand the problem. What is going on with this article?

cobra-cmder で Go の CLI を簡単に作る

概要

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.CommandAddCommand() メソッドで関連付けられます。これを戻り値として関数を終了します。

上位コマンド型の埋め込みをしている場合、上位コマンド型で定義されたメソッドもリフレクションで取得できてしまいますが、それらは呼び出しのループを避けるために呼ばないようにする必要があります。

しかしながら Go 言語のランタイムでは、埋め込みのフィールドに由来するメソッドとそうでないメソッドを区別する方法がありません。そこでやむなく mmap マップにより一度呼んだメソッド名を記録する手段を採用しています。コマンド型の階層間でメソッド名がユニークな必要があるという制約はこの実装により生じています。

まとめ

cobra-cmder を使えば cobra でグローバル変数や init() を使うことなく CLI のコマンド階層の構築ができることを示しました。この手法には次のような特長があります。

  • 各コマンドの実装の記述が簡単
  • グローバル変数および init() の排除によりユニットテストの記述が容易
  • method values や埋め込みフィールドの活用による、コマンド階層を反映したわかりやすいストレージアクセス
  • メソッド定義を用いた非侵襲的なコマンド階層の関連付け手法による、各コマンドの追加・削除などのメンテナンス性の向上

その一方で次の改善点が考えられます。

  • cmder.Cmd() ではリフレクションを利用しており実行の都度コマンド型の階層をトラバースしているため、実行開始までに時間がかかる。ビルド前に静的なコマンド階層構築のコードを自動生成できることが望ましい。

以上です。気に入ったらぜひ使ってみてください。プルリクエストもお待ちしております!

yaegashi
Linux や Unix が得意で低レベルなことが好きなエンジニアです
https://l0w.dev
bandainamcostudios
バンダイナムコスタジオは、家庭用ゲームソフト、モバイルコンテンツ、の企画・開発・運営、ゲームに関する技術研究・開発を行っている会社です。
https://www.bandainamcostudios.com
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした