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

    View Slide

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

    View Slide

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

    View Slide

  4. cobra-cmder の紹介

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  8. 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.

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  19. 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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  28. 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

    View Slide

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

    View Slide

  30. cobra-cmder の内部実装

    View Slide

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

    View Slide

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

    View Slide

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

    View Slide

  34. cobra-cmder のまとめ

    View Slide

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

    View Slide

  36. 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

    View Slide

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

    View Slide