休日Gopherが育ててきたMakefile


はじめに

この記事は Go Advent Calendar 2022 22日目の記事です。

休日Gopherシリーズ第2弾です。 Goの業務経験が少ない私が個人開発をするときに育ててきたMakefileを公開します。

※ 第1弾:休日Gopherとしての1年半を振り返る ※ 休日Gopherとは私が勝手に作った言葉です。休日にしかGoを触る機会がないGopherをそう呼んでいます。

想定読者

  • Makefileを利用するGopher
  • Makefileに興味があるGopher

実行環境

Mac/Linuxでの実行となります。 私が実行している環境は以下となります。 (uname -aの結果となります。)

Linux HP-Spectre-x360 5.4.72-microsoft-standard-WSL2 #1 SMP Wed Oct 28 23:40:43 UTC 2020 x86_64 x86_64 x86_64 GNU/Linux
Darwin MacBook-Pro-7.local 22.1.0 Darwin Kernel Version 22.1.0: Sun Oct  9 20:14:30 PDT 2022; root:xnu-8792.41.9~2/RELEASE_ARM64_T8103 arm64

Makefileとは

Wikipediaによると以下のように説明されています。

make(UNIX)

make(メイク)は、プログラムのビルド作業を自動化するツール。コンパイル、リンク、インストール等のルールを記述したテキストファイル (makefile) に従って、これらの作業を自動的に行う。

Node.jsを学んでいた私としてはnpm run xxxyarn xxxと同じようなものだろうと認識しています。

GoではTaskMage, roboといったタスクランナーが使われることもあるようです。

この記事を書こうといろいろ調べているとオライリー本GNU Make 第3版が無料で公開されていることを知りました。 Makefileに関する詳しい情報が載っていそうなのであとで熟読して理解を深めようと思います。 はい。そのくらいの理解度のものが育ててきたMakefileとなります。

Makefileの基本構文

Makefileの基本構文は以下のような形式となります。

[コマンド名]: [材料]
(TAB) コマンド

私は主に以下の形式を基本構文としています。

.PHONY: [コマンド名]
[コマンド名]: ## コマンドの説明
(TAB) @コマンド
  • .PHONY: ダミーターゲットです。[コマンド名]と同じファイル名があるとmake: '[コマンド名]' is up to date.というようなエラーが発生してしまいます。これを回避するために記述します。

    例)以下のようなディレクトリ構成において.PHONYを指定しないと

    .
    ├── Makefile
    └── test
    test:
    	@go test ./...

    以下のような出力となりコマンドの実行ができません。

     make test
    make: 'test' is up to date.

    私はコマンドと同じ名前のディレクトリ・ファイルがあるかどうかに関わらず全てのコマンドにおいて.PHONYを指定するようにしています。 また、以下のような形式で1度に複数のコマンドを指定することもできますが、私は可読性が悪く感じるため冗長でも1コマンドにつき1.PHONYを記述します。

    .PHONY: test, fmt, ...
  • @: 実行したコマンドを表示しないためのおまじないです。

    例)

    @あり

     make fmt
    

    @なし

     make fmt
    go fmt ./...

Makefile

本題のMakefileを以下に記載します。 コメントにてそれぞれ補足を記述しています。 このコメントはmake helpを実行して確認することができます。 一部記事執筆の都合上改変しているものもあります。

# ローカル開発で使う環境変数を.envに記述します & include .envで読込 & export で make コマンド実行時に環境変数が有効になるように設定しています。
include .env
export

# ifやcaseを使うためのおまじない
SHELL=/bin/bash

.PHONY: name
name: ## アプリ名を表示します。(環境変数が使えるよのサンプルです。)Makefileの1番上に記述されているターゲットはデフォルトターゲット:ターゲット指定なしのmakeコマンドでも実行されます。
	@$(call _empty,${APP_NAME})
	@echo "Welcome ${APP_NAME} development"

## 実行に引数が必須の場合にチェックするための関数
## 今回は動作確認のため.env内にAPP_NAMEが設定されているかを確認するために利用
define _empty
if [ -z "$1" ]; then \
	echo "Please specify APP_NAME"; \
	exit 1; \
fi
endef

.PHONY: aqua
aqua: ## aquaをインストールします。(brewが入っていること前提なのでフレンドリーではないですね。)
	@brew install aquaproj/aqua/aqua

.PHONY: tool
tool: ## aquaを使って開発用のツールをインストールします。プロジェクト clone 時に `make aqua`と`make tool`コマンドを実行するだけで開発が開始できるのを理想としています。
	@aqua i

.PHONY: compile
compile: ## Goコードをビルドします。VSCodeの拡張機能を使っているのであまり使用タイミングはないです。ビルドが通るかがみたいだけなのでお掃除しておきます。
	@go build -v ./... && go clean

.PHONY: fmt
fmt: ## Goコードをフォーマットします。VSCodeの拡張機能を使っているのであまり使用するタイミングはないです。
	@go fmt ./...

.PHONY: lint
lint: ## 最強最高のツールgolangci-lintを使って静的解析します。
	@golangci-lint run --fix

.PHONY: modules
modules: ## モジュールの依存関係を表示します。
	@go list -u -m all

.PHONY: updata
update: ## 依存関係を更新します。
	@go get -u -t ./...

.PHONY: test
test: ## テストを実行します。`make test c=x`とmakeの第二引数にc=任意の文字列を与えるとキャッシュを無効化してテストを実行します。
	@$(call _test,${c})

# キャッシュを消してテストを実行するための分岐処理
define _test
if [ -z "$1" ]; then \
	go test ./... ; \
else \
	go test ./... -count=1 ; \
fi
endef

.PHONY: run
run: ## アプリを起動します。
	@go run main.go

.PHONY: help
help: ## 各種コマンドのヘルプを表示します。
	@awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n  make \033[36m<target>\033[0m\n"} /^[a-zA-Z_-]+:.*?##/ { printf "  \033[36m%-15s\033[0m %s\n", $$1, $$2 } /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) } ' $(MAKEFILE_LIST)

## 分岐するようなコマンドがなかったのでシンプルなサンプルコードを掲載しておきます。
.PHONY: case
case: ## c=xx で文字列を指定すると a or b or default の文字列を出力します。
	@$(call _case,${c})

define _case
case "$1" in \
	a) echo a ;; \
	b) echo b ;; \
    *) echo default ;; \
esac
endef

## --- 以下別途説明が必要だが使っているもの

.PHONY: up
up: ## バックグラウンドでコンテナでサーバーを立ててホットリロードで実行しておく
	@docker compose --project-name ${APP_NAME} --file docker-compose.yaml up -d

.PHONY: down
down: ## コンテナ環境を停止します。
	@docker compose --project-name ${APP_NAME} down

.PHONY: psql
psql:v ## docker-composeで起動しているpostgresに接続します。
	@docker exec -it ${APP_NAME}-db psql -U postgres

.PHONY: log
log: ## docker-composeで起動しているappサーバーのログを表示します。
	@docker logs ${APP_NAME}-app

.PHONY: image
image: ## koというツールを使ったコンテナイメージの作成動作確認をします。(CIで回すのでほぼ使わないです。)
	@ko publish --local .

aquaGoで記述されたCLIバージョン管理ツールです。

なぜMakefileを使うのか

Go開発で必須ではないのと思いますが、私は以下の理由で好んでMakefileを使っています。

  • Node.js勢の習慣でnpm run xxxのようなことに慣れていたため
  • コマンドを覚えるのが苦手なため
  • README.mdの管理コストを減らすため

悩み事

Makefileを利用する上で2点悩みごとがあります。

CIとどう共存させるか

ローカル環境とCI環境で同じコマンドを実行したい場合が多々あります。 私はMakefileとCIで別々に記述する形式にしているのですが、ダブルメンテナンスするような状態になっているので修正漏れが発生する可能性があります。 その状態を回避するためにscriptsディレクトリを作成してスクリプトファイルを管理しMakefileとCIそれぞれスクリプトファイルを呼び出す形式にするのもよいと思いますが、ファイル管理するほどのことでもないと思い実装するに至っていないです。

第2引数をスマートに渡したい

makeコマンドの第1引数はコマンドを示します。 第2引数はコマンド実行時に必要な変数を示します。 makeコマンドでなにか変数を渡したいときはmake test c=xのように渡しています。 理想としてはmake test nocacheのように実行したいのですがmakeコマンドの場合は複数のコマンド(タスク)を実行する命令となってしまいます。(この場合はtestnocacheコマンドが指定されます。) ここを工夫してもう少し直感的に実行できるようにしたいなと思っています。

おわりに

まだまだ改善する余地はありますが、休日Gopherが育ててきたMakefileを公開してみました。 みなさんが使っているMakefileでの小技やテクニックがあればぜひ教えていただければと思います。