みなさんこんにちは。サーバエンジニアの長南です。
ここ数か月の間、新型コロナウィルス感染症(COVID-19)の流行が世間を騒がせていました。Timersでもさまざまな影響があり、エンジニアの働き方もテレワーク中心となっていましたが、そんな中でも職種を問わずメンバーは全員しっかり成果を出しています。私も単発のGo言語開発案件を手がけました。
Go言語開発の現場では、よく使われるタスクをmakeコマンドのタスクとしてMakefileに記述するということがよく行われています。今回のプロジェクトでも当然のようにMakefileを書いたわけですが、Go言語開発向けのMakefileの書き方を解説している資料を見ると、みなさんそれなりに苦労されているように見えたので、私が使っているMakefileのテンプレを紹介したいと思います。
(makeコマンドやMakefileそのものについての説明はざっくり省略してしまいましたので必要な方はマニュアルなどを参照してください )
おおまかな方針
makeコマンドはUnix likeなOSを中心に古くから使われているコマンドです。このような「古典」といえるものはシンプルかつ普遍性があるために時間が経っても陳腐化しない側面があります。こういった性質を活かすために
- 可読性を維持した状態で可能な限り短く書く
- シンプルさを維持し、Makefileの書法を知らないエンジニアでも類推しやすく書く
- 環境に依存するところは可能な限り排除する。そのために変数を活用する
といったところを考えました。
テンプレ全体像
最初にテンプレの全体像を掲載します。テンプレといってもこの状態で動作する内容となっています。
.PHONY: build test clean static_analysis lint vet fmt chkfmt
### コマンドの定義
GO = go
GO_BUILD = $(GO) build
GO_FORMAT = $(GO) fmt
GOFMT = gofmt
GO_LIST = $(GO) list
GOLINT = golint
GO_TEST = $(GO) test -v
GO_VET = $(GO) vet
GO_LDFLAGS = -ldflags="-s -w"
GOOS = linux
### ターゲットパラメータ
EXECUTABLES = bin/main
TARGETS = $(EXECUTABLES)
GO_PKGROOT = ./...
GO_PACKAGES = $(shell $(GO_LIST) $(GO_PKGROOT) | grep -v vendor)
### PHONY ターゲットのビルドルール
build: $(TARGETS)
test:
env GOOS=$(GOOS) $(GO_TEST) $(GO_PKGROOT)
clean:
rm -rf $(TARGETS) ./vendor Gopkg.lock
static_analysis: chkfmt lint vet
lint:
$(GOLINT) -set_exit_status $(GO_PACKAGES)
vet:
$(GO_VET) $(GO_PACKAGES)
fmt:
$(GO_FORMAT) $(GO_PKGROOT)
chkfmt:
bash -c "diff -u <(echo -n) <($(GOFMT) -d $(shell git ls-files | grep ".go$$"))"
### 実行ファイルのビルドルール
bin/main:
env GO111MODULE=on GOOS=$(GOOS) $(GO_BUILD) $(GO_LDFLAGS) -o $@
各パートごとの解説
PHONY ターゲット
makeコマンドは、「C言語のソースファイル(拡張子 .c) をコンパイルしオブジェクトファイル(拡張子 .o)を生成、複数のオブジェクトファイルをまとめてリンクして実行バイナリを生成する」といった、ファイル間の依存関係をMakefile に記述することでジョブの定義を行いますが、 特別な意味を持つPHONY(=にせもの)のターゲットを定義することができます。テンプレでは冒頭の
.PHONY: build test clean static_analysis lint vet fmt chkfmt
の部分です。この例では、それぞれ build(実行ファイルの生成), test(テストコードの走行), clean(生成したファイルの消去), static_analysis(ソースコードの静的解析), lint(go lintによる構文チェック), vet(go vet による表記チェック), fmt(go ソースコードの整形), checkfmt(go ソースコードのフォーマットチェック) の用途でのターゲットを定義しています。
解説資料によっては、必要になったところで都度 .PHONY 行を追加している記述がありますが、私の場合は先頭にまとめて記述しています。これはMakefileの内容を見た時に「どのようなPHONYターゲットが利用できるのか」を簡単に知ることができることも狙っています。いろいろな例を見てみると先頭ではなく一番最後に記述するケースもあり好みが分かれるところですが、先頭か最後にまとめて書くことが大事だと思います。
コマンドの変数定義
次のブロックはMakefileで使用するコマンドの変数定義です。
### コマンドの定義 GO = go GO_BUILD = $(GO) build GO_FORMAT = $(GO) fmt GOFMT = gofmt GO_LIST = $(GO) list GOLINT = golint GO_TEST = $(GO) test -v GO_VET = $(GO) vet GO_LDFLAGS = -ldflags="-s -w" GOOS = linux
Makefileでは、このように設定した変数を$(GO)というふうに参照することができます。可能性が低いかもしれませんがGo言語に関するコマンドやオプションについては仕様が変わることを考慮してこのように変数に定義しています。後のビルドルールのところで「変数を参照している」ということを明確化するために、大文字の変数名で実際のコマンド記述を連想させるような名前をつけるとわかりやすいと思います($(GO_VET)は実際にコマンドラインで$ go vetとして実行することを連想させることができます)。
また、コマンドを変数に指定することで、環境ごとの差にある程度対応することができます。たとえば /opt/foo/bar/go/bin/goを使ってビルドしたいときには、Makefileを修正しなくても
$ make GO=/opt/foo/bar/go/bin/go build
などとして、変数を上書きしてタスクを実行させることができます。
この例ではGoに関するコマンドを変数に定義しましたが、rmなどのOSコマンドも変数に定義することも場合によっては必要かもしれません(現代のLinux環境ではほとんど必要ありません。オプションや挙動が異なるコマンドが複数準備されているSolarisなどでも動作することを考える場合には、変数に定義してもよいかもしれません)。
ターゲットパラメータの変数定義
次の部分では、最終的にどんなファイルを生成したいのか、処理対象となるGoパッケージの情報は何か、といったことを変数に定義しています。
### ターゲットパラメータ EXECUTABLES = bin/main TARGETS = $(EXECUTABLES) GO_PKGROOT = ./... GO_PACKAGES = $(shell $(GO_LIST) $(GO_PKGROOT) | grep -v vendor)
この例の場合は実行ファイルとしてbin/main、最終的に生成したいものとして同じくbin/mainが指定されるようにしています。実行ファイルとは別に成果物にしたいファイルがある場合にはTARGETSに追加しておくとよいでしょう。実行ファイルは仮にbin/mainと書きましたが、プロジェクトに合わせて変更しておきましょう。
TARGETSについてはMakefileの後続の部分で
build: $(TARGETS)
と記述されているので、$ make buildあるいは単に$ make(ターゲット名が省略されたときにはMakefileに記述されている最初のものを生成しようとします)でTARGETSに指定したファイルを生成(あるいはPHONYターゲットの実行)しようとします。
PHONY ターゲットのビルドルール
続いてPHONYターゲットを生成するためのルールを記述しています。
ターゲット: 依存するファイルやターゲット
ターゲットを生成するためのコマンド
の形で記述します。コマンド行の部分のインデントはタブ1個で記述します(案外ハマるところなので注意が必要です)。
ソースコードの静的解析に関するところを例にすると、
static_analysis: chkfmt lint vet
lint:
$(GOLINT) -set_exit_status $(GO_PACKAGES)
vet:
$(GO_VET) $(GO_PACKAGES)
fmt:
$(GO_FORMAT) $(GO_PKGROOT)
chkfmt:
bash -c "diff -u <(echo -n) <($(GOFMT) -d $(shell git ls-files | grep ".go$$"))"
とありますが、例えばfmtターゲットについては変数を考慮して考えると「go fmt ./...をタスクとして実行せよ」という意味になります。
lintやvetターゲットに出てくるGO_PACKAGESはターゲットパタメータ指定のところでgo listした結果からvendorを省いたものが格納されているので、プロジェクトに含まれる(つまり./...以下の)すべてについてgolintなりgo vetなりを実行するということになります。
また、static_analysisターゲットを見ると、コマンドの記述が省略され、依存するファイルやターゲットにchkfmt,lint,vetの3つが記述されているので、それぞれのターゲットに指定されたコマンドを順次実行するということになります(わかりやすさを優先したので、コマンドの長さとしては短くなっていませんね...)。
実行ファイルのビルドルール
最後の部分はEXECUTABLESに指定したbin/mainを生成するためのルールを記述しています。
bin/main:
env GO111MODULE=on GOOS=$(GOOS) $(GO_BUILD) $(GO_LDFLAGS) -o $@
定義した変数を参照しているところががありますが、go buildを-ldflags="-s -w"付きで実行する指定です。$@はMakefileでの特殊変数で、ターゲット名(この場合はbin/main)に置き換えられます。
留意しておきたい点
テンプレで掲載された各パートについて解説してきましたが、Makefileに記載されていないところで、いくつか留意しておきたい点やTipsを紹介します。
CIで便利に使えるターゲットを準備しよう
CircleCIなどのCI基盤を使っている場合には、ビルドだけでなくテストコードの実行やソースコードの静的解析チェックの確認を行うターゲットを準備しておくと便利です。
今回のテンプレを使ったときには、CI側でmake static_analysisを実行する設定を行うことで「静的解析に失敗したらソースコードに不備があるとしてCIでエラーにしてしまう」ということが簡単に設定できます。単体テストの走行や静的解析についてはぜひMakefileにターゲットを準備しておきましょう。
ビルドに関係ないものは記載しない(Keep it simple stupid)
解説資料によってはgo getを使ってコマンドやツールをインストールするターゲットを記述する例が掲げてありますが、環境に依存するうえにそれぞれの環境で一度しか実行しないものなので、Makefileには書くべきではないと考えています。環境を整備するのも手間がかかるのは事実ですが、それはMakefileとは別の仕組みで実現したいところです。Dockerコンテナを使うような場合には事前にツールが整備されたイメージを準備しておくべきだと思います。
また、make2helpを使ってMakefileの使い方をヘルプとして表示させる仕組みを記述している例もありますが、個人的にはヘルプが必要なMakefileを書いてしまった段階で負けだと思っています。せめて.PHONY行を見ることで何が準備されているのかを把握できるような状態にしておくのが大切だと思います。
Makefile自体の保守が大変で専門のMakefile職人を必要とするようなような状態は本末転倒ですし、できればMakefileそのものはシンプルな状態を保ち、本題の開発のほうに注力したいところですね。
Go言語以外でも活用を
makeは開発現場のために作られたものではありますが、依存性を考慮してタスクを実行するのが得意です。開発現場だけでなくデータの解析の現場などにも活用してみると作業効率が上がるかもしれません。ツールをシンプルに使い組み合わせて、便利に開発やお仕事できるように工夫してみてくださいね。
積極採用中!!
子育て家族アプリFammを運営するTimers inc.では、現在エンジニアを積極採用中!
急成長中のサービスの技術の話を少しでも聞いてみたい方、スタートアップで働きたい方など、是非お気軽にご連絡ください!
採用HP: http://timers-inc.com/engineerings