技術選択編 が軽バズりして嬉しかったので続編.
TL;DR
- 便利ライブラリ & CLI つくったよ
- https://github.com/izumin5210/clig
- clig を見れば @izumin5210 が普段どうやって CLI を開発しているかがわかるよ
- いつも使ってる
Makefile
や.travis.yml
もあるよ
- 開発用ツールの依存は gex で管理してるよ
logging
zap
Blazing fast, structured, leveled logging
のとおり,はやくて構造化データを吐けてログレベルも設定できるロガー.これは知ってる人も多いハズ.
自分が使うときはデバッグフラグを定義しておき,cobra.OnInitialize
で logger を初期化して global logger にセットしている.
cobra.OnInitialize(func() {
zap.ReplaceGlobals(verboseLogger)
})
ReplaceGlobals
でセットした Logger は zap.L()
で取り出せるので,あとはコード中の任意の場所で zap.L().Error("failed to open file", zap.Error(err), zap.String("path", path))
とかできる.
ちなみに,ReplaceGlobals
を呼ばないと Nop logger が利用されるので,テストとかに影響を及ぼすことはない.便利.
verbose logger / debug logger
自分はだいたい --verbose
と --debug
の2種類のフラグを用意しておいて,それによって logger を使い分けている.
-v
もしくは --verbose
で INFO
level までのログを出す Logger を使う.これは一般ユーザでも使う想定で,みやすいログ形式でそこそこの情報を出すようにしている.
cfg := zap.NewDevelopmentConfig()
cfg.EncoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder
cfg.Level = zap.NewAtomicLevelAt(zapcore.InfoLevel)
cfg.EncoderConfig.EncodeTime = func(t time.Time, enc zapcore.PrimitiveArrayEncoder) {
enc.AppendString(t.Local().Format("2006-01-02 15:04:05 MST"))
}
一方で --debug
は主に自分が使うものなので,すべてのログ(DEBUG
level)を出力している.ProductionConfig
を利用すると JSON でログが出るようになるので,それをおもむろに jq
に食わせてデバッグしたりする.
cfg := zap.NewProductionConfig()
cfg.Level = zap.NewAtomicLevelAt(zapcore.DebugLevel)
cfg.DisableStacktrace = true
ery では各モジュールのインスタンスに named logger をもたせておいて,それを呼ぶようにしている.これでより jq
しやすくなる.
// https://github.com/srvc/ery/blob/v0.0.1/pkg/app/proxy/manager.go#L20-L25
return &serverManager{
mappingRepo: mappingRepo,
factory: factory,
cancellerByPort: new(sync.Map),
log: zap.L().Named("proxy"),
}
DEBUG
level のログは「ユーザは普段見ない」「JSON で出てくるので多少量が多くても目的のログを検索しやすい」ので,開発中の print debug 代わりに使って消さずに残しておくくらいでいいのかなと考えている.
フラグハンドリング
いつも↓みたいな感じのヘルパを定義して,フラグを定義し,アプリケーション起動タイミングで logger も初期化している.
func AddLoggingFlags(cmd *cobra.Command) {
var (
debugEnabled, verboseEnabled bool
)
cmd.PersistentFlags().BoolVar(
&debugEnabled,
"debug",
false,
"Debug level output",
)
cmd.PersistentFlags().BoolVarP(
&verboseEnabled,
"verbose",
"v",
false,
"Verbose level output",
)
cobra.OnInitialize(func() {
switch {
case debugEnabled:
enableDebugLogger()
case verboseEnabled:
enableVerboseLogger()
}
})
}
ref: pkg/cli/logging.go at master · izumin5210/clig
stdio
たとえば kubectl
の実装を読むと,かなり最初の方でコンストラクタから標準入出力を注入している.
// NewDefaultKubectlCommand creates the `kubectl` command with default arguments func NewDefaultKubectlCommand() *cobra.Command { return NewDefaultKubectlCommandWithArgs(&defaultPluginHandler{}, os.Args, os.Stdin, os.Stdout, os.Stderr) } // NewDefaultKubectlCommandWithArgs creates the `kubectl` command with arguments func NewDefaultKubectlCommandWithArgs(pluginHandler PluginHandler, args []string, in io.Reader, out, errout io.Writer) *cobra.Command { cmd := NewKubectlCommand(in, out, errout)
pkg/kubectl/cmd/cmd.go#L295-L303 at v1.13.1 · kubernetes/kubernetes
これをやっておくだけで,テスト時は os.Std*
の代わりに new(bytes.Buffer)
でモックできるようになる.
自分も CLI 実装時はこのやり方を踏襲していたが,最近はもう一段ラッパーを噛ませている.
type IO interface {
In() io.Reader
Out() io.Writer
Err() io.Writer
}
io.Writer
や io.Reader
だとあまりに広すぎるので,もうちょっと意味をもたせる形で interface で包んでいる.この副作用として,ちゃんと型が付くので wire などの型をみるタイプの DI ツールが使いやすくなる.
また,デフォルト値を返す Stdio()
という関数を用意しておいて,main()
からはそれを利用するようにしている.@mattn さんに grapi の Windows 対応してもらったときの経験を生かして,mattn/go-colorable を入れている.
func Stdio() IO {
io := &IOContainer{
InR: os.Stdin,
OutW: os.Stdout,
ErrW: os.Stderr,
}
if runtime.GOOS == "windows" {
io.OutW = colorable.NewColorableStdout()
io.ErrW = colorable.NewColorableStderr()
}
return io
}
ref: pkg/cli/io.go at master · izumin5210/clig
あとは,テスト用に *bytes.Buffer
が詰まった fake 実装とかを作ったりしている.
Makefile / CI
Go でボイラプレートといえば Makefile
と CI ですね(?)
build
cmd/<CLI_NAME>
以下に main
を置くようにしているので,それをいい感じにビルドするタスクを生成している.
たとえば cmd/foobar/main.go
なら
-
make foobar
- =>
go build -o ./bin/foobar ./cmd/foobar
- =>
-
make package-foobar
- =>
gox ... -output="dist/foobar_{{.OS}}_{{.Arch}}" ./cmd/foobar
- =>
みたいな感じになる.こういうのも Makefile
でよくわかんないことせずに Go でツールを作ってあげるといいのかもしれない….
SRC_FILES := $(shell go list -f '{{range .GoFiles}}{{printf "%s/%s\n" $$.Dir .}}{{end}}' ./...)
BIN_DIR := ./bin
OUT_DIR := ./dist
GENERATED_BINS :=
PACKAGES :=
XC_ARCH := 386 amd64
XC_OS := darwin linux windows
define cmd-tmpl
$(eval NAME := $(notdir $(1)))
$(eval OUT := $(addprefix $(BIN_DIR)/,$(NAME)))
$(OUT): $(SRC_FILES)
go build $(GO_BUILD_FLAGS) $(LDFLAGS) -o $(OUT) $(1)
.PHONY: $(NAME)
$(NAME): $(OUT)
.PHONY: $(NAME)-package
$(NAME)-package: $(NAME)
gox \
$(LDFLAGS) \
-os="$(XC_OS)" \
-arch="$(XC_ARCH)" \
-output="$(OUT_DIR)/$(NAME)_{{.OS}}_{{.Arch}}" \
$(1)
$(eval GENERATED_BINS += $(OUT))
$(eval PACKAGES += $(NAME)-package)
endef
$(foreach src,$(wildcard ./cmd/*),$(eval $(call cmd-tmpl,$(src))))
.DEFAULT_GOAL := all
.PHONY: all
all: $(GENERATED_BINS)
.PHONY: packages
packages: $(PACKAGES)
ldflags
たとえば Skaffold はバージョン情報とかも ldflags から読み込んでいるが,自分はバグレポート受け取るときに便利な最小限のみ ldflags 経由で注入して,バージョン情報などはコード中にハードコードするようにしている.これは go が「go get
だけでツールをビルド & インストールできる」文化で,ユーザがちゃんと brew や GitHub Release からアプリを落としてくれるとは限らないため.
REVISION ?= $(shell git describe --always)
BUILD_DATE ?= $(shell date +'%Y-%m-%dT%H:%M:%SZ')
LDFLAGS := -ldflags "-X main.revision=$(REVISION) -X main.buildDate=$(BUILD_DATE)"
Release
さっきしれっと出てきたけど,gox でクロスコンパイルしている.この生成物を CI から GitHub Release に投げ込む.
# .travis.yml
# 自分は Travis CI を使うことが多いけど,いまどきの CI as service なら何でもいいと思う
language: go
go: '1.11'
env:
global:
- FILE_TO_DEPLOY="dist/*"
# GITHUB_TOKEN
- secure: "..."
jobs:
include:
# snip.
- stage: deploy
install: make setup
script: make packages -j4
deploy:
- provider: releases
skip_cleanup: true
api_key: $GITHUB_TOKEN
file_glob: true
file: $FILE_TO_DEPLOY
on:
tags: true
if: type != 'pull_request'
lint / reviewdog
最低限のコードの品質保証とコードレビューの負担軽減用に,いくつかの linter を reviewdog を噛ませて使っている.たとえば grapi の .reviewdog.yml
では golint, govet, errcheck, wraperr, megacheck, unparam を有効にしている.
あとは Makefile
に lint 用のタスクを追加して,CI (pull-req)でチェックしている.
.PHONY: lint
lint:
ifdef CI
gex reviewdog -reporter=github-pr-review
else
gex reviewdog -diff="git diff master"
endif
# snip.
env:
global:
# snip.
- REVIEWDOG_GITHUB_API_TOKEN=$GITHUB_TOKEN
jobs:
include:
- name: lint
install: make setup
script: make lint
if: type = 'pull_request'
# snip.
↓こんな感じになる.
reviewdog を使うことで CI を fail させずに lint の指摘を残せる.なので, golint のコメント関係や errcheck の絶対問題ない系のエラーハンドリングなどをスルーできるようになる.
また,lint ツールやコード生成ツールなどは gex という tool 管理ツールで管理している.開発者や CI 環境によって利用するツールのバージョンに差異が発生するのを防ぐためである.gex については以前「gex で Go プロジェクトの開発用ツールの依存を管理する - Qiita」という記事で紹介したので,そちらも参考にしてほしい.
clone 直後や CI で便利なように,setup task を Makefile
に用意している.
.PHONY: setup
setup:
ifdef CI
curl https://raw.githubusercontent.com/golang/dep/master/install.sh | sh
endif
dep ensure -v -vendor-only
@go get github.com/izumin5210/gex/cmd/gex
gex --build --verbose
boilerplate generator & utility package
と,ここまでで「いつも書いてる boilerplate」を紹介した.紹介してないものもいくつかあるので,実際にプロジェクト新規作成時にはもっとたくさん書いている(プロジェクトの性質によって取捨選択はするが).
流石に自分でもこれを毎回書いてるのはアホらしくなってたのでプロッジェクトジェネレータとライブラリを作った.
こんな感じでプロジェクトが生成されたり
$ clig init your-app-name
$ cd your-app-name
$ tree -I 'bin|vendor'
.
├── Gopkg.lock
├── Gopkg.toml
├── Makefile
├── cmd
│ └── awesomecli
│ └── main.go
├── pkg
│ └── awesomecli
│ ├── cmd
│ │ └── cmd.go
│ ├── config.go
│ └── context.go
└── tools.go
よく書くコードがまとめられたパッケージがあったりする.
また,clig 自身も clig が吐くものとほぼ同じ構成をとっている.
新しく CLI ツールを作ることがあれば参考にしてもらえるといいかもしれない.