Cobra の使い方とテスト

だいぶ spf13/cobra の扱いに慣れてきたので,そろそろブログにまとめておこうかなと。 以前に CLI (Command-Line Interface) とファサード・パターンについては以下の記事に

spf13/cobra の使い方については,触りの部分を以下の記事に書いた。

ただ spf13/cobra そのものについてちゃんと書いてない気がするので,今回はコードの書き方からテストまでをひと通り紹介していく。 漫然と紹介するのもアレなので,今回は以下の CLI を作ることを目標にする。

$ cli-demo show -i 123 -s 文字列 -b
Integer option value: 123
 String option value: 文字列
Boolean option value: true

なお,この記事の作業の結果は以下のリポジトリに置いてある。

spf13/cobra のインストール

spf13/cobra には CLI ツールもあるのだがバイナリは提供されていないので,自前で取ってきてコンパイルする。 といっても

$ go get -u github.com/spf13/cobra/cobra

とするだけだが。 github.com/spf13/cobra で get しようとすると CLI のコンパイルを行わないので注意が必要である。 CLI の Usage はこんな感じ。

$ cobra -h
Cobra is a CLI library for Go that empowers applications.
This application is a tool to generate the needed files
to quickly create a Cobra application.

Usage:
  cobra [command]

Available Commands:
  add         Add a command to a Cobra Application
  help        Help about any command
  init        Initialize a Cobra Application

Flags:
  -a, --author string    author name for copyright attribution (default "YOUR NAME")
      --config string    config file (default is $HOME/.cobra.yaml)
  -h, --help             help for cobra
  -l, --license string   name of license for the project
      --viper            use Viper for configuration (default true)

Use "cobra [command] --help" for more information about a command.

初期化処理

まずは init サブコマンドで雛形となるソースコードを展開する。 ソースを展開したいフォルダで

cobra --viper=false init .

とする。 今回は spf13/viper は使わないので --viper オプションは無効にしておく。

これで対象のフォルダに3つのファイルが出力される

  • main.go
  • LICENSE
  • cmd/root.go

LICENSE はライセンス・ファイルなのでそのまま利用するなり他のファイルに差し替えるなりすればいい。 main.gocmd/root.go が実際の雛形になるのだが,やたらコメントが多いので,整理したものを以下に示す。

まずは main.go

package main

import "github.com/spiegel-im-spiegel/cli-demo/cmd"

func main() {
    cmd.Execute()
}

次に cmd/root.go

package cmd

import (
    "fmt"
    "os"

    "github.com/spf13/cobra"
)

// RootCmd represents the base command when called without any subcommands
var RootCmd = &cobra.Command{
    Use:   "cli-demo",
    Short: "Short comment",
    Long:  "Long comment",
    //Run: func(cmd *cobra.Command, args []string) { },
}

//Execute is called from main function
func Execute() {
    if err := RootCmd.Execute(); err != nil {
        fmt.Println(err)
        os.Exit(1)
    }
}

func init() {
}

呼び出し関係は以下の通り。

graph LR main["main()"] exec1["cmd.Execute()"] exec2["cmd.RootCmd.Execute()"] main-->exec1 exec1-->exec2

もちろん main() 関数から直接 cmd.RootCmd.Execute() 関数を呼んでも構わない(このままであれば)。 この状態でコマンドを起動すると以下のようになる。

$ go run main.go -h
Long comment

まぁ,当然だよね。 現状での問題は3つある。

  1. cmd.RootCmd を他パッケージから直接操作できてしまう
  2. 引数と標準入出力を cmd.RootCmd が握っていてコントロールできない(ように見える)
  3. cmd.RootCmd.Execute() 関数がエラー時に os.Exit() 関数で強制終了してしまう

特に2番目が致命的。 何故ならこのままではテストができないからだ。 そこで cmd.Execute() 関数を改造することにする。

spiegel-im-spiegel/gocli の導入

その前に標準入出力の取り回しを簡単にするために,手前味噌で申し訳ないが, spiegel-im-spiegel/gocli を導入する。 これを使えば標準入出力をひとつのインスタンスで取り回しできる1

package main

import (
    "github.com/spiegel-im-spiegel/gocli/exitcode"
    "github.com/spiegel-im-spiegel/gocli/rwi"
)

func run(ui *rwi.RWI) exitcode.ExitCode {
    ui.Outputln("Hello world")
    return exitcode.Normal
}

func main() {
    run(rwi.New(
        rwi.Reader(os.Stdin),
        rwi.Writer(os.Stdout),
        rwi.ErrorWriter(os.Stderr),
    )).Exit()
}

rwi.New() の初期化は Functional Options パターンを使っている。 Functional Options パターンについて詳しくは以下を参照のこと。

cmd.Execute() 関数を改造する

では cmd.Execute() 関数の改造を行おう。 途中を省いて結果は以下の通り。

var (
    cui = rwi.New() //CUI instance
)

//Execute is called from main function
func Execute(ui *rwi.RWI, args []string) (exit exitcode.ExitCode) {
    defer func() {
        //panic hundling
        if r := recover(); r != nil {
            cui.OutputErrln("Panic:", r)
            for depth := 0; ; depth++ {
                pc, src, line, ok := runtime.Caller(depth)
                if !ok {
                    break
                }
                cui.OutputErrln(" ->", depth, ":", runtime.FuncForPC(pc).Name(), ":", src, ":", line)
            }
            exit = exitcode.Abnormal
        }
    }()

    //execution
    cui = ui
    rootCmd.SetArgs(args)
    rootCmd.SetOutput(ui.ErrorWriter())
    exit = exitcode.Normal
    if err := rootCmd.Execute(); err != nil {
        exit = exitcode.Abnormal
    }
    return
}

前半は panic 時のハンドリングで,スタック追跡できるようにしている。 panic 時のハンドリングについて詳しくは以下を参照のこと。

後半では cmd.rootCmd に引数と標準出力をセットしてから cmd.rootCmd.Execute() 関数を起動している2。 エラー時は os.Exit() 関数で強制終了するのではなく,ちゃんとステータスを返すようにした。

そうそう。 cmd.RootCmd ではパッケージ外から直接操作できてしまうので cmd.rootCmd と小文字にしている。 小さなことからコツコツと(笑)

これで main() 関数もこのように書き換えることができる。

func main() {
    cmd.Execute(
        rwi.New(
            rwi.Reader(os.Stdin),
            rwi.Writer(os.Stdout),
            rwi.ErrorWriter(os.Stderr),
        ),
        os.Args[1:],
    ).Exit()
}

サブコマンドの追加

ではいよいよサブコマンド show を追加する。 サブコマンドの追加は main.go のあるフォルダで以下のコマンドを実行する。

$ cobra add show

これにより cmd/show.go ファイルが追加された。 これも中身はコメントが山ほどあるので,整理したものを以下に示す。

package cmd

import (
    "fmt"

    "github.com/spf13/cobra"
)

// showCmd represents the show command
var showCmd = &cobra.Command{
    Use:   "show",
    Short: "Short comment for show sub-command",
    Long:  "Long comment for show sub-command",
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Println("show called")
    },
}

func init() {
    rootCmd.AddCommand(showCmd)
}

この状態で実際に動かしてみるとこうなる。

$ go run main.go -h
Long comment

Usage:
  cli-demo [flags]
  cli-demo [command]

Available Commands:
  help        Help about any command
  show        Short comment for show sub-command

Flags:
  -h, --help   help for cli-demo

Use "cli-demo [command] --help" for more information about a command.

$ go run main.go show -h
Long comment for show sub-command

Usage:
  cli-demo show [flags]

Flags:
  -h, --help   help for show

$ go run main.go show
show called

オプションの追加

このコードに対してオプションを追加していく。 こんな感じでいいだろう。

package cmd

import (
    "github.com/spf13/cobra"
)

// showCmd represents the show command
var showCmd = &cobra.Command{
    Use:   "show",
    Short: "Short comment for show sub-command",
    Long:  "Long comment for show sub-command",
    RunE: func(cmd *cobra.Command, args []string) error {
        i, err := cmd.Flags().GetInt("integer")
        if err != nil {
            return err
        }
        b, err := cmd.Flags().GetBool("boolean")
        if err != nil {
            return err
        }
        s, err := cmd.Flags().GetString("string")
        if err != nil {
            return err
        }
        cui.Outputln("Integer option value:", i)
        cui.Outputln(" String option value:", s)
        cui.Outputln("Boolean option value:", b)

        return nil
    },
}

func init() {
    rootCmd.AddCommand(showCmd)

    showCmd.Flags().IntP("integer", "i", 0, "integer option")
    showCmd.Flags().BoolP("boolean", "b", false, "boolean option")
    showCmd.Flags().StringP("string", "s", "", "string option")
}

cmd.showCmd 内の RunRunE に変わってる点に注目。 これにより,関数内で error が発生した場合に,それを返り値で渡すことができる。

試しにちょろんと動かしてみよう。

$ go run main.go show -h
Long comment for show sub-command

Usage:
  cli-demo show [flags]

Flags:
  -b, --boolean         boolean option
  -h, --help            help for show
  -i, --integer int     integer option
  -s, --string string   string option

$ go run main.go show -i 123 -s 日本語 -b
Integer option value: 123
 String option value: 日本語
Boolean option value: true

おおっ。 なんか動いてるような気がする。

じゃあテストを始めようか。

cobra.Command を返す関数を作る。

と言いたいところだけど,このままではまだテストができない。 何故かというと, cmd.rootCmdcmd.showCmd も static な変数として定義されているので,そのままテストを繰り返すと前回の状態が残ってしまって正しいテストにならないからだ。

じゃあどうすればいいかというと, cobra.Command を返す関数を作って,その中で cmd.rootCmdcmd.showCmd に相当するインスタンスを作ればいいのである。

じゃあ,まずは cmd/root.go の完全版から。

package cmd

import (
    "runtime"

    "github.com/pkg/errors"
    "github.com/spf13/cobra"
    "github.com/spiegel-im-spiegel/gocli/exitcode"
    "github.com/spiegel-im-spiegel/gocli/rwi"
)

var (
    cui = rwi.New() //CUI instance
)

//newRootCmd returns cobra.Command instance for root command
func newRootCmd(ui *rwi.RWI, args []string) *cobra.Command {
    cui = ui
    rootCmd := &cobra.Command{
        Use:   "cli-demo",
        Short: "Short comment",
        Long:  "Long comment",
        RunE: func(cmd *cobra.Command, args []string) error {
            return errors.New("no command")
        },
    }
    rootCmd.SetArgs(args)
    rootCmd.SetOutput(ui.ErrorWriter())

    rootCmd.AddCommand(newShowCmd())

    return rootCmd
}

//Execute is called from main function
func Execute(ui *rwi.RWI, args []string) (exit exitcode.ExitCode) {
    defer func() {
        //panic hundling
        if r := recover(); r != nil {
            cui.OutputErrln("Panic:", r)
            for depth := 0; ; depth++ {
                pc, src, line, ok := runtime.Caller(depth)
                if !ok {
                    break
                }
                cui.OutputErrln(" ->", depth, ":", runtime.FuncForPC(pc).Name(), ":", src, ":", line)
            }
            exit = exitcode.Abnormal
        }
    }()

    //execution
    exit = exitcode.Normal
    if err := newRootCmd(ui, args).Execute(); err != nil {
        exit = exitcode.Abnormal
    }
    return
}

次は cmd/show.go の完全版。

package cmd

import (
    "github.com/spf13/cobra"
)

//newShowCmd returns cobra.Command instance for show sub-command
func newShowCmd() *cobra.Command {
    showCmd := &cobra.Command{
        Use:   "show",
        Short: "Short comment for show sub-command",
        Long:  "Long comment for show sub-command",
        RunE: func(cmd *cobra.Command, args []string) error {
            i, err := cmd.Flags().GetInt("integer")
            if err != nil {
                return err
            }
            b, err := cmd.Flags().GetBool("boolean")
            if err != nil {
                return err
            }
            s, err := cmd.Flags().GetString("string")
            if err != nil {
                return err
            }
            cui.Outputln("Integer option value:", i)
            cui.Outputln(" String option value:", s)
            cui.Outputln("Boolean option value:", b)

            return nil
        },
    }

    showCmd.Flags().IntP("integer", "i", 0, "integer option")
    showCmd.Flags().BoolP("boolean", "b", false, "boolean option")
    showCmd.Flags().StringP("string", "s", "", "string option")

    return showCmd
}

なんで spf13/cobra が最初からこういう雛形を作ってくれないかというと,おそらく cobra add コマンドで cmd/root.go を触るわけにはいかないため(たぶん既にユーザが触ってる), static 変数にして init() 関数でコマンドを繋げるような組み方しかできなかったんだと思う。 でも手作業でこういう雛形を作ってしまえば以後はコピペでいくらでも量産できるので,テストのことを考えれば,面倒でも最初に手間を掛けたほうがいいかもしれない。

テストを書く

ようやくテストが書けるよ。 とりあえず正常系のみ。

package cmd

import (
    "bytes"
    "testing"

    "github.com/spiegel-im-spiegel/gocli/exitcode"
    "github.com/spiegel-im-spiegel/gocli/rwi"
)

func TestShowNormal(t *testing.T) {
    testCases := []struct {
        args []string
        want string
    }{
        {args: []string{"show", "-i", "123", "-s", "日本語", "-b"}, want: "Integer option value: 123\n String option value: 日本語\nBoolean option value: true\n"},
        {args: []string{"show", "-i", "123", "-s", "日本語"}, want: "Integer option value: 123\n String option value: 日本語\nBoolean option value: false\n"},
        {args: []string{"show", "-i", "123"}, want: "Integer option value: 123\n String option value: \nBoolean option value: false\n"},
        {args: []string{"show"}, want: "Integer option value: 0\n String option value: \nBoolean option value: false\n"},
    }

    for _, c := range testCases {
        out := new(bytes.Buffer)
        errOut := new(bytes.Buffer)
        ui := rwi.New(
            rwi.Writer(out),
            rwi.ErrorWriter(errOut),
        )
        exit := Execute(ui, c.args)
        if exit != exitcode.Normal {
            t.Errorf("Execute() err = \"%v\", want \"%v\".", exit, exitcode.Normal)
        }
        if out.String() != c.want {
            t.Errorf("Execute() Stdout = \"%v\", want \"%v\".", out.String(), c.want)
        }
        if errOut.String() != "" {
            t.Errorf("Execute() Stderr = \"%v\", want \"%v\".", errOut.String(), "")
        }
    }
}

標準出力と標準エラー出力を bytes.Buffer で代替えしているのがお分かりだろうか。 これなら CLI でもかなりの部分をテストでカバーできる。 ここには挙げていないが,当然パイプのテストも可能である。

depspf13/cobra を管理する

spf13/cobra をはじめとする外部パッケージを dep を使って vendor/ 配下に置くのなら, Gopkg.toml の記述は以下でいいだろう。

[[constraint]]
  name = "github.com/spf13/cobra"
  version = "~0.0.1"

[[constraint]]
  name = "github.com/spiegel-im-spiegel/gocli"
  version = "~0.5.0"

[[constraint]]
  name = "github.com/pkg/errors"
  version = "~0.8.0"

ちなみに今回のデモのパッケージ依存関係はこんな感じになっている。

$ dep status -dot | dot -Tpng -o dependency.png

ブックマーク

参考図書

photo
プログラミング言語Go (ADDISON-WESLEY PROFESSIONAL COMPUTING SERIES)
Alan A.A. Donovan Brian W. Kernighan 柴田 芳樹
丸善出版 2016-06-20
評価

スターティングGo言語 (CodeZine BOOKS) Go言語によるWebアプリケーション開発 Kotlinスタートブック -新しいAndroidプログラミング Docker実戦活用ガイド グッド・マス ギークのための数・論理・計算機科学

著者のひとりは(あの「バイブル」とも呼ばれる)通称 “K&R” の K のほうである。

reviewed by Spiegel on 2016-07-13 (powered by G-Tools)

photo
みんなのGo言語【現場で使える実践テクニック】
松木雅幸 mattn 藤原俊一郎 中島大一 牧 大輔 鈴木健太 稲葉貴洋
技術評論社 2016-09-09
評価

WEB+DB PRESS Vol.95 プログラミング言語Go (ADDISON-WESLEY PROFESSIONAL COMPUTING SERIES) プログラミングElixir Go言語によるWebアプリケーション開発 詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド (NEXT ONE) WEB+DB PRESS Vol.94 Docker スターティングGo言語 (CodeZine BOOKS) オブジェクト指向設計実践ガイド ~Rubyでわかる 進化しつづける柔軟なアプリケーションの育て方 WebデベロッパーのためのReact開発入門 JavaScript UIライブラリの基本と活用

リファレンス本なのに索引が貧弱。これなら Kindle で買ってもよかったか。 1.7 への言及があるのはよかった。

reviewed by Spiegel on 2016-11-17 (powered by G-Tools)


  1. コードを見れば分かると思うが spiegel-im-spiegel/gocli には最小限のコードしかないので,たとえば複数の goroutine が動いている状態で単一の入出力インスタンスを取り回すとか考えたくもない。なので,取り扱いには十分注意すること。その代わり spiegel-im-spiegel/gocli では CC0 つまり著作者人格権も含めて完全に権利を放棄しているので,自由に使っていただいて構わない。 [return]
  2. cobra.Command には出力先がひとつしかなく何故か標準出力と標準エラー出力を区別していない。そこで cmd.rootCmd には標準エラー出力をセットして標準出力へのアクセスは cuicmd パッケージ内のどのメソッドからも参照できるようにした(つまり,少なくとも cmd パッケージ内は単一の goroutine で動くことが前提)。 spf13/cobra 側が標準出力と標準エラー出力をちゃんと区別してくれれば,もう少しスマートにできるんだけどねぇ。(あと標準入力用の Reader も付けて欲しい。まぁなくても何とかなってるけどw[return]