Upgrade to Pro — share decks privately, control downloads, hide ads and more …

cobra-cmder: Goの言語機能を活用した シンプルなCLIツール構成法

cobra-cmder: Goの言語機能を活用した シンプルなCLIツール構成法

YAEGASHI Takeshi

April 24, 2021
Tweet

More Decks by YAEGASHI Takeshi

Other Decks in Technology

Transcript

  1. 自己紹介 八重樫 剛史 Takeshi Yaegashi 株式会社バンダイナムコスタジオ所属 Linux・Unix・OSS・Go 言語が好きなエンジニア 組み込みシステム開発、ゲームサーバ開発、 CI/CD

    インフラ開発、 開発環境のクラウドシフトなどの業務に従事 活動場所 ホームページ・ブログ https://l0w.dev GitHub https://github.com/yaegashi Twitter https://twitter.com/hogegashi
  2. 本日の話題 • アジェンダ ◦ cobra および cobra-cmder の紹介 ◦ CLI

    ツール実装・テスト実装のチュートリアル ◦ cobra-cmder の内部実装 ◦ cobra-cmder のまとめ • あまり難しい話はしません ◦ ただし Go 言語特有の機能やテクニックはわりと出てきます ◦ CLI ツールの構成・実装の例として参考にしてもらえればと思います
  3. cobra の紹介 https://github.com/spf13/cobra • Go のコマンドライン (CLI) ツール実装の定番ライブラリ • フラグ(オプション)、コマンド階層、ヘルプ出力などの処理をサポート

    • Hugo や Kubernetes といった著名なプロジェクトの CLI ツールで採用 • 便利だが、公式のチュートリアルの説明やコード生成ツールに難がある ◦ グローバル変数や init() を使っている ◦ コマンドごとのユニットテストが困難である var rootCmd = &cobra.Command{ /* ... */ } var versionCmd = &cobra.Command{ /* ... */ } func init() { rootCmd.AddCommand(versionCmd) }
  4. cobra-cmder の紹介 https://github.com/yaegashi/cobra-cmder • cobra の補助ライブラリ • このライブラリの「規約」に従うと、 コマンド階層型 CLI

    ツールがキレイに実装できる (気になれる) • Go の言語機能やテクニックを活用しているところが多い ◦ リフレクション、構造体の埋め込み、入出力のモッキング、 … • 簡単なサンプルを使って実装手順を説明します https://github.com/yaegashi/gocon2021sample
  5. CLI 基本実装チュートリアル • 例題:足し算と引き算ができるコマンド階層型 CLI ツール ◦ calc コマンドの下位に add/sub

    コマンドを持つ階層構造 ◦ calc コマンドの共通フラグ -a と -b により 2 つの数字を与えると、 足し算または引き算の結果を出力する コンソール:実行例 $ app calc add -a 1 -b 2 3 $ app calc sub -a 1 -b 2 -1 コンソール:ヘルプ $ app calc -h Calculate A and B Usage: app calc [command] Available Commands: add Calculate A + B sub Calculate A - B Flags: -h, --help help for calc -a, --value-a int value A -b, --value-b int value B Use "app calc [command] --help" for more information about a command.
  6. 1. 各コマンドに対応するコマンド構造体の定義 app.go // App - app コマンド type App

    struct { } app_calc.go // AppCalc - app calc コマンド type AppCalc struct { *App // 親コマンド埋め込み A int // -a フラグの値 B int // -b フラグの値 } app_calc_add.go // AppCalcAdd - app calc add コマンド type AppCalcAdd struct { *AppCalc // 親コマンド埋め込み } app_calc_sub.go // AppCalcSub - app calc sub コマンド type AppCalcSub struct { *AppCalc // 親コマンド埋め込み } • コマンドごとにファイルを作りコマンド構造体を定義する ◦ ファイルの名前およびコマンド構造体の名前は一定のルールでわかりやすく ◦ 子コマンド構造体には 親コマンド構造体のポインタを埋め込む (継承関係の表現) ◦ フラグの値などに使う 変数を追加する
  7. 2. 各コマンド構造体に Cmd() メソッドを追加① • cobra.Command を生成してポインタを返すメソッド Cmd() を定義する →

    cmder.Cmder インターフェースの実装 • cobra.Command については API ドキュメントを参照 → https://pkg.go.dev/github.com/spf13/cobra#Command package cmder // Cmder is an interface for objects that return cobra.Command type Cmder interface { Cmd() *cobra.Command }
  8. 2. 各コマンド構造体に Cmd() メソッドを追加② • レシーバ変数名は *App だけでなく全コマンド構造体で app を使用する

    • App の cobra.Command 生成 ◦ Use コマンド名 ◦ Short コマンドの短い説明 ◦ SilenceUsage エラー発生時のヘルプ出力を抑制するフラグ // Cmd - cmder.Cmder インターフェース実装 func (app *App) Cmd() *cobra.Command { cmd := &cobra.Command{ Use: "app", Short: "App", SilenceUsage: true, // エラー発生時のヘルプ出力を抑制 } return cmd } app.go // App - app コマンド type App struct { }
  9. 2. 各コマンド構造体に Cmd() メソッドを追加③ • AppCalc の cobra.Command 生成 ◦

    cmd.PersistentFlags().IntVarP() で -a -b フラグ値を A B 変数フィールドに格納 ◦ PersistentPreRunE にメインルーチン前に実行されるルーチンへのポインタを格納 ◦ フラグ存在チェックルーチンを AppCalc のメソッド PersistentPreRunE として実装 // Cmd - cmder.Cmder インターフェース実装 func (app *AppCalc) Cmd() *cobra.Command { cmd := &cobra.Command{ Use: "calc", Short: "Calculate A and B", PersistentPreRunE: app.PersistentPreRunE, } cmd.PersistentFlags().IntVarP(&app.A, "value-a", "a", 0, "value A") cmd.PersistentFlags().IntVarP(&app.B, "value-b", "b", 0, "value B") return cmd } // PersistentPreRunE - フラグ存在チェック func (app *AppCalc) PersistentPreRunE(cmd *cobra.Command, args []string) error { if !cmd.Flags().Changed("value-a") || !cmd.Flags().Changed("value-b") { return fmt.Errorf("missing flags -a and/or -b") } return nil } app_calc.go // AppCalc - app calc コマンド type AppCalc struct { *App // 親コマンド埋め込み A int // -a フラグの値 B int // -b フラグの値 }
  10. 2. 各コマンド構造体に Cmd() メソッドを追加④ • AppCalcAdd の cobra.Command 生成 ◦

    RunE にこのコマンドが実行されるときのメインルーチンへのポインタを格納 ◦ メインルーチンを AppCalcAdd のメソッド RunE として実装 app_calc_add.go // AppCalcAdd - app calc add コマンド type AppCalcAdd struct { *AppCalc // 親コマンド埋め込み } // Cmd - cmder.Cmder インターフェース実装 func (app *AppCalcAdd) Cmd() *cobra.Command { cmd := &cobra.Command{ Use: "add", Short: "Calculate A + B", RunE: app.RunE, } return cmd } // RunE - メインルーチン func (app *AppCalcAdd) RunE(cmd *cobra.Command, args []string) error { fmt.Println(app.A + app.B) return nil }
  11. 2. 各コマンド構造体に Cmd() メソッドを追加⑤ • AppCalcSub の cobra.Command 生成 ◦

    AppCalcAdd と同じように RunE でメインルーチンを実装する app_calc_sub.go // AppCalcSub - app calc sub コマンド type AppCalcSub struct { *AppCalc // 親コマンド埋め込み } // Cmd - cmder.Cmder インターフェース実装 func (app *AppCalcSub) Cmd() *cobra.Command { cmd := &cobra.Command{ Use: "sub", Short: "Calculate A - B", RunE: app.RunE, } return cmd } // RunE - メインルーチン func (app *AppCalcSub) RunE(cmd *cobra.Command, args []string) error { fmt.Println(app.A - app.B) return nil }
  12. 3. 各コマンド構造体の親子関係を定義① • App→AppCalc の親子関係を定義 ◦ 親コマンド構造体に子コマンド構造体の生成メソッドを追加する ◦ このメソッドの名前は任意だが New子コマンド構造体名

    () を推奨 ◦ 子コマンド構造体を生成して cmder.Cmder インターフェースとして返す ◦ 子コマンド構造体の生成時に 親コマンド構造体のポインタ を格納する ◦ 子コマンド構造体のソースファイルに記述することを推奨 app.go // App - app コマンド type App struct { } app_calc.go // AppCalc - app calc コマンド type AppCalc struct { *App // 親コマンド埋め込み A int // -a フラグの値 B int // -b フラグの値 } // NewAppCalc - AppCalc 作成 func (app *App) NewAppCalc() cmder.Cmder { return &AppCalc{App: app} }
  13. 3. 各コマンド構造体の親子関係を定義② • AppCalc→AppCalcAdd および AppCalc→AppCalcSub の親子関係を定義 ◦ App→AppCalc と同様にする

    今回は AppCalc にメソッドを追加する ◦ New子コマンド構造体名 () cmder.Cmder 親コマンド構造体のポインタ app_calc.go // AppCalc - app calc コマンド type AppCalc struct { *App // 親コマンド埋め込み A int // -a フラグの値 B int // -b フラグの値 } app_calc_add.go // AppCalcAdd - app calc add コマンド type AppCalcAdd struct { *AppCalc // 親コマンド埋め込み } // NewAppCalcAdd - AppCalcAdd 作成 func (app *AppCalc) NewAppCalcAdd() cmder.Cmder { return &AppCalcAdd{AppCalc: app} } app_calc_sub.go // AppCalcSub - app calc sub コマンド type AppCalcSub struct { *AppCalc // 親コマンド埋め込み } // NewAppCalcSub - AppCalcSub 作成 func (app *AppCalc) NewAppCalcSub() cmder.Cmder { return &AppCalcSub{AppCalc: app} }
  14. 4. main() 関数を実装 • トップレベルのコマンド構造体 App を作り cmder.Cmd() 関数に渡す ◦

    すべてがセットアップされた *cobra.Command が返ってくる ◦ Execute() で実行し、エラーが返ってきたら適切な終了コード 1 で終わる main.go import "os" import cmder "github.com/yaegashi/cobra-cmder" func main() { app := &App{} cmd := cmder.Cmd(app) err := cmd.Execute() if err != nil { os.Exit(1) } }
  15. CLI 基本実装を試してみる • 実際にビルドして正しく動作していることを確認してください コンソール $ git clone https://github.com/yaegashi/gocon2021sample $

    cd gocon2021sample/app1 $ go build . $ ./app1 -h cobra-comder application ... $ ./app1 calc add -a 1 -b 2 3 $ ./app1 calc sub -a 1 -b 2 -1 $ ./app1 calc add Error: missing flags -a and/or -b
  16. CLI 基本実装チュートリアルのまとめ • グローバル変数や init() を使う必要がない • コマンドごとに一定の命名規則に従ったソースファイルと構造体を作ることによって 変数やメソッドをカプセル化でき、見通しがよい •

    構造体の埋め込みによってコマンド間の継承関係が表現でき、上位コマンドの変数 やメソッドが、下位コマンドでも再利用できる • NewAppCalc() のようなコンストラクタメソッドの追加によりコマンド階層を定義し cmder.Cmd() 関数で *cobra.Command のセットアップが自動完了する
  17. 1. CLI 標準出力のモッキング対応① • 最上位のコマンド構造体 App に io.Writer の変数 Out

    を追加 • fmt の出力関数 Print Println Printf を App のメソッドとして追加 ◦ 可変引数 args をそのまま fmt.Fprint fmt.Fprintln fmt.Fprintf に渡す app.go // App - app コマンド (トップレベル) type App struct { Out io.Writer // 標準出力 } 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...) }
  18. 1. CLI 標準出力のモッキング対応② • 各コマンドのメインルーチンでは fmt.Println() の代わりに app.Println() を呼ぶ app_calc_add.go

    // RunE - メインルーチン func (app *AppCalcAdd) RunE(cmd *cobra.Command, args []string) error { app.Println(app.A + app.B) return nil } app_calc_sub.go // RunE - メインルーチン func (app *AppCalcSub) RunE(cmd *cobra.Command, args []string) error { app.Println(app.A - app.B) return nil }
  19. 1. CLI 標準出力のモッキング対応③ • main() 関数の変更 • トップレベルコマンド構造体 App の変数

    Out に os.Stdout を格納 main.go import "os" import cmder "github.com/yaegashi/cobra-cmder" func main() { app := &App{Out: os.Stdout} cmd := cmder.Cmd(app) err := cmd.Execute() if err != nil { os.Exit(1) } }
  20. 2. テーブル駆動テストの実装① • テストテーブル構造体の配列を作成する ◦ args CLI 引数リストとなる文字列配列 ◦ want

    標準出力に現れるべき文字列 ◦ err 結果がエラーになる場合は true app_test.go func TestApp(t *testing.T) { testCases := []struct { args []string want string err bool }{ {args: []string{"calc", "add", "-a", "1", "-b", "2"}, want: "3\n", err: false}, {args: []string{"calc", "sub", "-a", "1", "-b", "2"}, want: "-1\n", err: false}, {args: []string{"calc", "add"}, want: "Error: missing flags -a and/or -b\n", err: true}, {args: []string{"calc", "sub"}, want: "Error: missing flags -a and/or -b\n", err: true}, }
  21. 2. テーブル駆動テストの実装② • テストテーブルループを記述 ◦ bytes.Buffer を App の出力先とする (io.Writer

    インターフェース) ◦ cmd.SetArg() で cobra に渡すフラグを指定 ◦ cmd.SetOut() cmd.SetErr() で cobra の出力を buf へ ◦ buf.String() により App が出力した文字列を得る app_test.go func TestApp(t *testing.T) { for _, tC := range testCases { args := strings.Join(tC.args, " ") t.Run(args, func(t *testing.T) { buf := &bytes.Buffer{} cmd := cmder.Cmd(&App{Out: buf}) cmd.SetArgs(tC.args) cmd.SetOut(buf) cmd.SetErr(buf) err := cmd.Execute() if tC.err && err == nil { t.Errorf("%q returns no error", args) } if !tC.err && err != nil { t.Errorf("%q returns error: %s", args, err) } got := buf.String() if got != tC.want { t.Errorf("%q returns %q, want %q", args, got, tC.want) } }) }
  22. CLI テスト実装を試してみる • 実際にテストして正しく動作していることを確認してください コンソール $ git clone https://github.com/yaegashi/gocon2021sample $

    cd gocon2021sample/app2 $ go test -v . === RUN TestApp === RUN TestApp/calc_add_-a_1_-b_2 === RUN TestApp/calc_sub_-a_1_-b_2 === RUN TestApp/calc_add === RUN TestApp/calc_sub --- PASS: TestApp (0.00s) --- PASS: TestApp/calc_add_-a_1_-b_2 (0.00s) --- PASS: TestApp/calc_sub_-a_1_-b_2 (0.00s) --- PASS: TestApp/calc_add (0.00s) --- PASS: TestApp/calc_sub (0.00s) PASS ok app2 0.340s
  23. CLI テスト実装チュートリアルのまとめ • Go の出力のモッキング ◦ 最上位コマンド構造体 App に以下を追加する ▪

    出力先となる io.Writer インターフェースの変数 ▪ fmt 出力関数 Print Println Printf の各メソッド ◦ メインルーチンでは fmt.Println() の代わりに app.Println() で出力する • コマンドのテーブル駆動テスト ◦ SetArg() で cobra に渡すコマンドライン引数をセット ◦ SetOut() SetErr() で cobra からの出力先を変更
  24. cobra-comder の内部実装 • comder パッケージの構成要素 ◦ Cmder インターフェース型 ▪ *cobra.Command

    を返す Cmd メソッドを持っている ◦ Cmd 関数 ▪ Cmder インターフェース型を渡すと、すべてが整った *cobra.Command を返す package cmder type Cmder interface { Cmd() *cobra.Command } func Cmd(c Cmder) *cobra.Command { // 複雑な内部実装 }
  25. cobra-comder の動作原理 • func Cmd(c Cmder) *cobra.Command の動作 ◦ 引数を受け取る

    → Cmder (親) ◦ Cmder (親) の Cmd メソッドを呼ぶ → *cobra.Command (親) ◦ Cmder (親) のメソッドを reflect で列挙し Cmder を返すメソッドについて下記を実行 ▪ Cmder をメソッドを呼ぶ → Cmder (子) ▪ 再帰的に Cmd 関数を Cmder (子) で呼ぶ → *cobra.Command (子) ▪ *cobra.Command (親) の AddCommand メソッドで *cobra.Command (子) を登録 ◦ *cobra.Command (親) を返す • つまりメソッド定義によるコマンド構造体の親子関係をトラバースしている ◦ 文章で説明するとだいぶ難解ですので、理解したい方はコードを見てください https://github.com/yaegashi/cobra-cmder/blob/master/cmder.go // NewAppCalc - Cmder を返すメソッドの例 func (app *App) NewAppCalc() cmder.Cmder { return &AppCalc{App: app} }
  26. なぜこんなことをしているのか? • 簡単にコマンドを追加できる ◦ 親コマンド構造体に NewAppCalc のようなコンストラクタメソッドを追加するだけで コマンドの親子関係を定義できる • 非侵襲的なコマンド追加ができる

    ◦ コマンドを追加するときに既存のコードを編集する必要がない ◦ ソースファイルを追加するだけで追加できる ◦ ただし、別パッケージのコマンド型にメソッド定義はできないので、 パッケージをまたいだコマンド追加はできない • コード記述の繰り返しを避けられる
  27. cobra-comder のまとめ • cobra-comder の「規約」に従った CLI 実装の利点 ◦ グローバル変数や init()

    を使う必要がなくなる ◦ コマンドごとに一定の命名規則に従ったソースファイルと構造体を作ることによって 変数やメソッドをカプセル化でき、見通しがよくなる ◦ 構造体の埋め込みによってコマンド間の継承関係が表現でき、 上位コマンドの変数やメソッドが、下位コマンドでも再利用できるようになる ◦ 入出力のモッキングの手法により、ユニットテストも容易になる • 改善したい点 ◦ 似たようなコードの記述が多くなるのでジェネレータがほしくなる ◦ 変数名・型名・メソッド名の間違いを検出できないところがあるので静的解析で検出したい ◦ パッケージをまたいだコマンドの追加ができるようにしたい ◦ リフレクションではなく静的解析とコード生成によるコマンド階層構築ができないか?
  28. cobra-comder の利用例 • azbill - Azure の利用料金を出力するツール https://github.com/yaegashi/azbill • costomazed

    - Azure Image Builder などのフロントエンドツール https://github.com/yaegashi/customazed • contest.go - Goで競技プログラミングに出場するライブラリ、ツール https://github.com/yaegashi/contest.go