59
37

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

Go2Advent Calendar 2018

Day 15

ボイラプレート編 - #golang で CLI 作るときにいつもつかうやつ

Posted at

技術選択編 が軽バズりして嬉しかったので続編.

TL;DR

  • 便利ライブラリ & CLI つくったよ
  • 開発用ツールの依存は 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 もしくは --verboseINFO 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.Writerio.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.

↓こんな感じになる.

pull-request review by reviewdog

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 ツールを作ることがあれば参考にしてもらえるといいかもしれない.

59
37
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
59
37

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?