Slide 1

Slide 1 text

cobra-cmder: Goの言語機能を活用した シンプルなCLIツール構成法 2021/04/24 Go Conference 2021 Spring 八重樫 剛史 Takeshi Yaegashi

Slide 2

Slide 2 text

自己紹介 八重樫 剛史 Takeshi Yaegashi 株式会社バンダイナムコスタジオ所属 Linux・Unix・OSS・Go 言語が好きなエンジニア 組み込みシステム開発、ゲームサーバ開発、 CI/CD インフラ開発、 開発環境のクラウドシフトなどの業務に従事 活動場所 ホームページ・ブログ https://l0w.dev GitHub https://github.com/yaegashi Twitter https://twitter.com/hogegashi

Slide 3

Slide 3 text

本日の話題 ● アジェンダ ○ cobra および cobra-cmder の紹介 ○ CLI ツール実装・テスト実装のチュートリアル ○ cobra-cmder の内部実装 ○ cobra-cmder のまとめ ● あまり難しい話はしません ○ ただし Go 言語特有の機能やテクニックはわりと出てきます ○ CLI ツールの構成・実装の例として参考にしてもらえればと思います

Slide 4

Slide 4 text

cobra-cmder の紹介

Slide 5

Slide 5 text

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) }

Slide 6

Slide 6 text

cobra-cmder の紹介 https://github.com/yaegashi/cobra-cmder ● cobra の補助ライブラリ ● このライブラリの「規約」に従うと、 コマンド階層型 CLI ツールがキレイに実装できる (気になれる) ● Go の言語機能やテクニックを活用しているところが多い ○ リフレクション、構造体の埋め込み、入出力のモッキング、 … ● 簡単なサンプルを使って実装手順を説明します https://github.com/yaegashi/gocon2021sample

Slide 7

Slide 7 text

CLI 基本実装チュートリアル

Slide 8

Slide 8 text

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.

Slide 9

Slide 9 text

CLI 基本実装チュートリアル ● サンプルプログラムのコードを見ながら実装手順を確認してください https://github.com/yaegashi/gocon2021sample/tree/master/app1 ● 実装手順 1. 各コマンドに対応するコマンド構造体を定義 2. 各コマンド構造体に Cmd() メソッドを追加 3. 各コマンド構造体の親子関係を定義 4. main() 関数を実装

Slide 10

Slide 10 text

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 // 親コマンド埋め込み } ● コマンドごとにファイルを作りコマンド構造体を定義する ○ ファイルの名前およびコマンド構造体の名前は一定のルールでわかりやすく ○ 子コマンド構造体には 親コマンド構造体のポインタを埋め込む (継承関係の表現) ○ フラグの値などに使う 変数を追加する

Slide 11

Slide 11 text

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 }

Slide 12

Slide 12 text

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 { }

Slide 13

Slide 13 text

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 フラグの値 }

Slide 14

Slide 14 text

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 }

Slide 15

Slide 15 text

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 }

Slide 16

Slide 16 text

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} }

Slide 17

Slide 17 text

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} }

Slide 18

Slide 18 text

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) } }

Slide 19

Slide 19 text

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

Slide 20

Slide 20 text

CLI 基本実装チュートリアルのまとめ ● グローバル変数や init() を使う必要がない ● コマンドごとに一定の命名規則に従ったソースファイルと構造体を作ることによって 変数やメソッドをカプセル化でき、見通しがよい ● 構造体の埋め込みによってコマンド間の継承関係が表現でき、上位コマンドの変数 やメソッドが、下位コマンドでも再利用できる ● NewAppCalc() のようなコンストラクタメソッドの追加によりコマンド階層を定義し cmder.Cmd() 関数で *cobra.Command のセットアップが自動完了する

Slide 21

Slide 21 text

CLI テスト実装チュートリアル

Slide 22

Slide 22 text

CLI テスト実装チュートリアル ● サンプルプログラムのコードを見ながら実装手順を確認してください https://github.com/yaegashi/gocon2021sample/tree/master/app2 ● 実装手順 1. CLI 標準出力のモッキング対応 2. テーブル駆動テストの追加

Slide 23

Slide 23 text

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...) }

Slide 24

Slide 24 text

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 }

Slide 25

Slide 25 text

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) } }

Slide 26

Slide 26 text

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}, }

Slide 27

Slide 27 text

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) } }) }

Slide 28

Slide 28 text

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

Slide 29

Slide 29 text

CLI テスト実装チュートリアルのまとめ ● Go の出力のモッキング ○ 最上位コマンド構造体 App に以下を追加する ■ 出力先となる io.Writer インターフェースの変数 ■ fmt 出力関数 Print Println Printf の各メソッド ○ メインルーチンでは fmt.Println() の代わりに app.Println() で出力する ● コマンドのテーブル駆動テスト ○ SetArg() で cobra に渡すコマンドライン引数をセット ○ SetOut() SetErr() で cobra からの出力先を変更

Slide 30

Slide 30 text

cobra-cmder の内部実装

Slide 31

Slide 31 text

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 { // 複雑な内部実装 }

Slide 32

Slide 32 text

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} }

Slide 33

Slide 33 text

なぜこんなことをしているのか? ● 簡単にコマンドを追加できる ○ 親コマンド構造体に NewAppCalc のようなコンストラクタメソッドを追加するだけで コマンドの親子関係を定義できる ● 非侵襲的なコマンド追加ができる ○ コマンドを追加するときに既存のコードを編集する必要がない ○ ソースファイルを追加するだけで追加できる ○ ただし、別パッケージのコマンド型にメソッド定義はできないので、 パッケージをまたいだコマンド追加はできない ● コード記述の繰り返しを避けられる

Slide 34

Slide 34 text

cobra-cmder のまとめ

Slide 35

Slide 35 text

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

Slide 36

Slide 36 text

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

Slide 37

Slide 37 text

おわり 今回のサンプルを参考にして CLI を作ってみてください! ご清聴ありがとうございました