2016-12-22

golangでCLIを作ったときの備忘録

先日、SPMというコマンドラインツールをリリースしましたが、golangでコマンドラインツールを作成する上で便利だったツールや考え方等を備忘として残します。

CLIのベース

3rd Partyのライブラリを利用せずとも標準のflagパッケージで処理を書けますが、色々と面倒なのでurfave/cliのパッケージを利用しました。このパッケージは以下のように、簡単にサブコマンドやフラグを定義できます。

app := cli.NewApp()
app.Name = "spm"

app.Usage = "Salesforce Package Manager"
app.Version = APP_VERSION
app.Commands = []cli.Command{
	{
		Name:    "install",
		Aliases: []string{"i"},
		Usage:   "Install salesforce packages on public remote repository(i.g. github)",
		Flags: []cli.Flag{
			cli.StringFlag{
				Name:        "username, u",
				Destination: &c.Config.Username,
				EnvVar:      "SF_USERNAME",
			},
			cli.StringFlag{
				Name:        "password, p",
				Destination: &c.Config.Password,
				EnvVar:      "SF_PASSWORD",
			},
			...
		},
		Action: func(ctx *cli.Context) error {
			fmt.Println(ctx.Args().First()) // 最初の引数を取得
			...
			return nil
		},
	},
}

app.Run(args)

エラーハンドリングはActionの関数の戻り値にerrorを入れれば良く、errorがある場合にはHandleExitCoderを呼び出します。HandleExitCoder内ではErrWriter経由でerror#Error()が出力され、OsExiterが呼び出されます。デフォルト値はErrWriterがos.Stderr、OsExiterがos.Exitなので、そのまま使うと標準エラー出力に出力して終了コード付きでプロセスが終了します。ErrWriterとOsExiterは外部公開されているので、他のio.Writerや関数をセットすれば柔軟にエラーハンドリングが可能です。

YAMLのロード

設定のロードでYAMLを利用する際はgopkg.in/yaml.v2を使います。

使い方はxmlやjsonのUnmarshalと同じく、structを定義してマッピングします。

type Config struct {
	Aaa struct {
		Bbb string `yaml:"bbb"`
	} `yaml:"aaa"`
	Ccc string `yaml:"ccc"`
}

config := &Config{}
err := yaml.Unmarshal([]byte("aaa:\n  bbb: hoge\nccc: fuga"), &config)

ロガー

標準のロガーであるlogパッケージはレベル付きのロギングをサポートしていなかったり、フォーマッティングは自分でやらないといけなかったりと不便なので、3rd PartyのSirupsen/logrusを使いました。

使い方はこんな感じ。

import log "github.com/Sirupsen/logrus"

func main() {
	log.Info("hoge")
	log.Infof("hoge %s", "aaa")
	log.Warning("hoge")
	log.Warningf("hoge %s", "bbb")
	log.Error("hoge")
	log.Errorf("hoge %s", "ccc")
}

あるいは

import (
	"github.com/Sirupsen/logrus"
	"os"
)

func main() {
	var logger = logrus.New()
	logger.Out = os.Stderr
	logger.Info("aaabbb")
}

のような使い方でもOK。前者の方法は一つのチャネルにしかロギングできませんが、後者の方法であれば複数のloggerインスタンスを作成できるため、複数のチャネル(例えばstdoutとstderr)にロギングすることができます。

実際には、このloggerを直接使うのではなくラップした独自のloggerを定義して利用した方が変更に強い実装になりそうです。

テスト

DIの話と同様に、入出力のストリームを置き換えできるようなインターフェースにしておくとテストが楽です。CLIの場合は入力のストリームはコマンドライン引数であり、出力ストリームは標準出力になります。これらをテスト時には文字列の配列、Bufferに置き換えることになります。

SPMだとロガーのデフォルトはStdout、Stderrにしており、テスト時はbytes.Bufferに書き込むようにしています。StdoutはFileの型なので、コマンド利用時とテスト時の両方に適用できる型として、io.Writerのインターフェースを指定していることに注意してください。

type CLI struct {
	Client *ForceClient
	Config *Config
	Logger *Logger
	Error  error
}

func (c *CLI) Run(args []string) (err error) {
	if c.Logger == nil {
		c.Logger = NewLogger(os.Stdout, os.Stderr)
	}
	...
}

func NewLogger(outStream io.Writer, errStream io.Writer) *Logger {
...
}

func main() {
	cli := &CLI{}
	...
}

// For Test
func before() (*CLI, *bytes.Buffer, *bytes.Buffer) {
	outStream, errStream := new(bytes.Buffer), new(bytes.Buffer)
	cli := &CLI{Logger: NewLogger(outStream, errStream)}
	return cli, outStream, errStream
}

またテストはstretcher/testifyのライブラリを利用するとアサーションが楽です。

import (
	"testing"
	"github.com/stretchr/testify/assert"
)

func TestHoge(t *testing.T) {
	assert.Equal(t, 123, 123, "they should be equal")
	assert.Contains(t, "aaabb", "aaa")
}

参考URL

このエントリーをはてなブックマークに追加