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

Golangのコマンドライブラリcobraを使って少しうまく実装する

More than 1 year has passed since last update.

APIに対してリクエストしてレスポンスを受けるクライアントツールをGolangで作成しています。

もともとアプリケーションエンジニアでもない自分がCLIを作るには、何をすればいいのか全く分からなかったところ、下記が非常に参考になって、100回は読んだと思います。本当に感謝しかない…!

GolangでwebサービスのAPIを叩くCLIツールを作ろう

一方で、この記事であったり、そもそもcobraの特性でもあったりで、物凄く素直に実装してしまうと後々困ったりして、自分がハマったところもあるので、「少しうまく作る」ポイントを紹介します。

サンプルコードは tkit/go-cmd-exampleに格納しています。
また、下記のリファクタリングにあわせてcommitしているので、都度どのように書き換えているかを分かるようにしていますので参考にしてください。

取り扱うコード

cobraでは、 例えば cobra add show -p "RootCmd" みたいな感じでサブコマンドを次々と作ることができます。このコマンドを実行すると、以下のようにshowコマンドのテンプレートが自動的に作られます。

このうち、Run の部分に処理本体を記載すればいいのですが、テストや再利用性を考えて、少し書き直してみます。(デフォルトで作られるものから、今後のリファクタリングを踏まえて少しオプションなどを追加しています)

show.go
package cmd

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

type Options struct {
    optint int
    optstr string
}

var (
    o = &Options{}
)

var showCmd = &cobra.Command{
    Use:   "show",
    Short: "A brief description of your command",
    Run: func(cmd *cobra.Command, args []string) {
        fmt.Printf("show called: optint: %d, optstr: %s", o.optint, o.optstr)
    },
}

func init() {
    RootCmd.AddCommand(showCmd)
    showCmd.Flags().IntVarP(&o.optint, "int", "i", 0, "int option")
    showCmd.Flags().StringVarP(&o.optstr, "str", "s", "default", "string option")
}

このコードでは、例えば以下のような出力になります。

$ cmd-test show --int 10 --str test
show called: optint: 10, optstr: test

このコードを中心にして、少しずつ書き換えていきます。

コマンド出力結果をまとめる

cobraのテンプレートを使うと、全てのコマンドは、結果的に以下によって実行され、出力を制御されます。

root.go(変更前)
func Execute() {
       if err := RootCmd.Execute(); err != nil {
               fmt.Println(err)
               os.Exit(1)
       }
}

しかし、show.go中にもある通り、実行結果の出力はデフォルトだと全てのコマンド(ファイル)に分散して記述されており、エラー時は…成功時は…という制御を毎回書かなければならなくなります。単純に動かすだけであれば何もしなくても構いませんが、今後出力結果をテストすることを考えると、まとめておいた方がよさそうです(次の章で価値を発揮してきます)。

そこで、このExecuteを書き換えて、「通常の出力は標準出力」「エラーはエラー出力」に表示するようにします。
cobraではcmd.SetOutputによって出力先を指定できるので、Execute時に指定できるようにします。

root.goのリファクタリング
func Execute() {
    RootCmd.SetOutput(os.Stdout)
    if err := RootCmd.Execute(); err != nil {
        RootCmd.SetOutput(os.Stderr)
        RootCmd.Println(err)
        os.Exit(1)
    }
}

同時に、各コマンドの出力結果をcobraに委ねます。といっても簡単で、fmt.Printlncmd.Printlnに変更するだけです。(デフォルトではos.Stderrに出力されるため注意)

show.goのリファクタリング
import (
    // "fmt"
    "github.com/spf13/cobra"
)
// snip
var showCmd = &cobra.Command{
    Use:   "show",
    Short: "A brief description of your command",
    Run: func(cmd *cobra.Command, args []string) {
        cmd.Printf("show called: optint: %d, optstr: %s", o.optint, o.optstr) // fmt.Printfから変更
    },
}
// snip

これで、出力結果を一元的に管理できるようになりました。

この章の変更: コマンド出力結果の制御

コマンド出力結果のテスト

上記は show called: optint: %d, optstr: %s という出力結果が得られるshowというサブコマンドです。すると、「showコマンドではshow called: optint: %d, optstr: %sという出力結果が得られる」というテストを行いたくなります。

ここまでの記述では、グローバルに(しかも最初に)定義されたRootCmdと、それの実行時のExecute()関数によって出力結果は完全に制御されており、その出力結果を奪い取ることはできません。ここからが大改修で、グローバルに定義されているvarを関数にしてしまいます。後述しますが、こうすることによって出力結果をbufferに取り込むことができ、結果的にテストを行いやすくします。

この手法は(おそらく)一般的なようで、例えば同じくcobraを使っているKubernetes(kubectl)でも、cmdを関数化しています。

https://github.com/kubernetes/kubernetes/blob/v1.8.0/pkg/kubectl/cmd/cmd.go#L255

というより、これをデフォルトにしてcobra initcobra addのテンプレートとしたほうがいいような気がするのですが、導入としては複雑になるからやめたのかな?と思いました。

主だった変更は以下です。

show.go
func NewCmdShow() *cobra.Command { // 関数で囲む
// snip

  cmd := &cobra.Command { // `showCmd`を`cmd` にする
    // snip 
  }
  // snip

  // init()内で実行していたフラグ設定は、関数呼び出し時に行われるようにする
  cmd.Flags().IntVarP(&o.optint, "int", "i", 0, "int option")
  cmd.Flags().StringVarP(&o.optstr, "str", "s", "default", "string option")

  return cmd // *cobra.Commandを返却するようにする
}

差分は多いので以下のdiffをご覧ください。

この章の変更: コマンドを関数化して制御しやすくした

コマンドを関数化することで、出力結果をテスト用に制御できるようになります。

show_test.go
package cmd

import (
    "bytes"
    "fmt"
    "strings"
    "testing"
)

func TestShow(t *testing.T) {
    cases := []struct {
        command string
        want    string
    }{
        {command: "cmd-test show", want: "show called: optint: 0, optstr: default"},
        {command: "cmd-test show --int 10", want: "show called: optint: 10, optstr: default"},
        {command: "cmd-test show --str test", want: "show called: optint: 0, optstr: test"},
    }

    for _, c := range cases {
        buf := new(bytes.Buffer)
        cmd := NewCmdRoot()
        cmd.SetOutput(buf)
        cmdArgs := strings.Split(c.command, " ")
        fmt.Printf("cmdArgs %+v\n", cmdArgs)
        cmd.SetArgs(cmdArgs[1:])
        cmd.Execute()

        get := buf.String()
        if c.want != get {
            t.Errorf("unexpected response: want:%+v, get:%+v", c.want, get)
        }
    }
}

上記では、テストの場合はbufferに取り込むことができ、それにより実行結果を比較しています。テストケースは「コマンド実行」「期待する結果」を元に生成しており、自然なテストになっていることが分かると思います。

Go言語でテストしやすいコマンドラインツールをつくる | SOTA

副次的に、コードのほとんどが関数内に含まれるようになり、グローバル変数を汚染せずに済むようになりました。(とはいえこれはごくシンプルな例であるからで、例えば同一APIを叩く複数のコマンドだったり、実行結果をさらに加工するコマンドがあったりすると、グローバル側に切り出すことになるとは思います)

ここまでの変更: showコマンドのテストケース追加

ちなみに、単純にスペース区切りでコマンドを分割すると、例えば--str "hoge fuga" のようにダブルクウォート区切りをうまくテストしてくれなくなるため、go-shellwordsを使ってparseした方がよいです。

ここまでの変更: テスト実行時のコマンドパース方式変更

入力値のバリデーション

今回の例では--int--strで入力値を受け付けていますが、実際のところはこれらの入力引数はバリデーションすることがほとんどだと思います。

そこで、ここではvalidatorパッケージを利用してバリデーションを行うようにします。

例えば、 --intが0〜10まで、--strが入力必須で文字列は半角英数のみ、という場合を想定します。

validatorパッケージは非常に高機能で、複雑なことをしなければ大抵のバリデーションを行ってくれます。

まずは、入力引数がバリデーションを受け付けるように変更します。Options構造体の各フィールドの変数を大文字開始にして、他から読み取れるようにする必要があります。同時に、validateタグを付与して、必要なバリデーションを追記します。

show.goの抜粋
type Options struct {
  Optint int    `validate:"min=0,max=10"` // optintから変更し、validateタグを追記
  Optstr string `validate:"required,alphanum"` // optstrから変更し、validateタグを追記
}

あとは、各コマンドのPreRunEで、実際にバリデーションを行います。PreRunEは、cobraでコマンドの処理を本実行するRunよりも前に実行されるものを記載するところで、事前チェックなどに用いることができます。また、エラーを受け取らないPreRunと、エラーを受け取るPreRunEが存在します(ここではバリデーションをするのでPreRunEを利用しています)

show.goの抜粋
PreRunE: func(cmd *cobra.Command, args []string) error {
  return validateParams(*o)
},

バリデーションはOptions構造体の全てを対象にします。実際のバリデーション処理は、別ファイルに記載しています。

validation.go
package cmd

import (
    "fmt"
    "gopkg.in/go-playground/validator.v9"
    "strings"
)

var validate = validator.New()

func validateParams(p interface{}) error {

    errs := validate.Struct(p)

    return extractValidationErrors(errs)
}

func extractValidationErrors(err error) error {

    if err != nil {
        var errorText []string
        for _, err := range err.(validator.ValidationErrors) {
            errorText = append(errorText, validationErrorToText(err))
        }
        return fmt.Errorf("Parameter error: %s", strings.Join(errorText, "\n"))
    }

    return nil
}

func validationErrorToText(e validator.FieldError) string {

    f := e.Field()
    switch e.Tag() {
    case "required":
        return fmt.Sprintf("%s is required", f)
    case "max":
        return fmt.Sprintf("%s cannot be greater than %s", f, e.Param())
    case "min":
        return fmt.Sprintf("%s must be greater than %s", f, e.Param())
    }
    return fmt.Sprintf("%s is not valid %s", e.Field(), e.Value())
}

複雑なことを行っているように見えますが、実際はvalidateParams関数が全てで、単にタグに基づいてチェックを発動させているだけです。他の関数は、エラーメッセージを整形するために記載していますが、なくてもチェック自体は行われています。

このあたりのエラーメッセージのハンドリングについては、以下を参考にしました。

gin-gonic/gin » Custom Binding Error Message | Bountysource

validatorパッケージは非常に高機能なので、他にもいろいろなバリデーションが用意されていますし、自分で関数を定義してバリデーションを追加することもできます。

ここまでの変更: バリデーションの追加

終わりに

ここまでで、cobraを使った保守しやすいコマンド開発の紹介を行いました。
これでリファクタリングは終わりではなく、これでもまだまだ改良の余地があると思っています。

  • 標準出力とエラー出力が混在するケースに対応したい
    今回は出力が標準出力のみ、あるいはエラー出力のみ、というケースに対応してリファクタリングを行いましたが、本来であれば1回のコマンド実行で標準出力とエラー出力が混在するケースもあるはずですが、これでは対応できていません。
  • テスト関数の切り出し
    今回はshow_test.goに全ての処理を書いてしまいましたが、テスト用のutilを別途作ったほうがいいはずです。

今回はAPIクライアントとしてというより、cobraとvalidatorを組み合わせて少しだけうまく実装してみました。ここに至るまでに死ぬほど悩んでこういう実装にしてみたわけですが、それでもここまでこれたのは冒頭のminamijoyoさんの記事があったからこそです。この場を借りてお礼を申し上げます。

tkit
Golang書いたりPython書いたりDevOpsしたりしているインフラエンジニアです。
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