Taskfileを有効活用して、Makefileのシェル芸から逃げる
はじめに
皆さん、Makefileは使っていらっしゃるでしょうか?
Makefileは、ソフトウェアのビルドプロセスを自動化するための設定ファイルです。主にUNIX系OSで使用され、プログラムのコンパイル、リンク、インストールなどの手順を記述することで、簡単に実行できます。
今回はBetter MakefileとしてTaskfileを紹介したいと思います。
サンプルなどを以下に置いてあります。ただ読むもよしですが、devcontainer.jsonを用意してあるので手元のdevcontainerで開くか、codespacesで開くと即座に実行可能な環境を作成することができます。よしなに使ってください。
この記事では、Makefileの記法とTaskfileの記法を比較して紹介します。
Taskfile前提知識
Taskfileは、Go言語で書かれたタスクランナー兼ビルドツールです。YAML形式で記述することができ、シンプルに書けることが特徴です。インストールはドキュメントに従ってください。
なお、GitHub Actionsでも導入可能なActionsが公式から公開されています。やさしいですね。
基本記法
Makefileの基本記法は以下のようになります。
.PHONY: greet
greet:
echo 'Hello World from Make!'
make greet
で実行できます。非常に簡単ですね。
しかし、.PHONY
とは何でしょうか?
これはダミーターゲットと呼ばれるものです。Makefileはビルドツールであるという性質上、コマンドを実行した結果の生成物という意味でターゲットを指定します。このとき、ターゲットが既に存在している場合はコマンドの実行をスキップするようになっています。
タスクランナーとして使用する場合、生成物が存在しないのでgreet
というファイルがディレクトリに存在するだけでコマンドの実行がスキップされては困ります。そのためダミーターゲットを設定し、ディレクトリからgreet
ファイルを検索しないようにしているのです。直感的ではないですね。
version: "3"
tasks:
greet:
cmd: echo 'Hello World from Task!'
このファイルが存在するディレクトリでtask greet
を実行できます。簡単そうですが、現時点ではMakefileのほうがシンプルですね。
記法の比較
変数を使う
GREETING = Hello!
.PHONY: greet-var
greet-var:
echo $(GREETING)
greet-var:
cmd: echo {{ .GREETING }}
vars:
GREETING: "Hello!"
変数に関してはあまり記法は変わりませんね。しかしTaskfileの方は、greet_var
に変数のスコープが閉じており、より安全です。
ループする
LIST = "Good_Morning!", "Good_Afternoon!", "Good_Evening!"
.PHONY: greet-loop
greet-loop:
for i in $(LIST); do \
make -s greet-var GREETING=$$i; \
done
greet-loop:
cmds:
- for: ["Good Morning!", "Good Afternoon!", "Good Evening!"]
task: greet-var
vars:
GREETING: "{{ .ITEM }}"
だんだんと複雑になってきましたね。Taskfileのほうが、実動作がどの部分なのかがわかりやすく、認知コストがかなり低いように思います。
ループのリストを定義する際、Makefileではコマンドの一部として渡す関係上、スペースを入れることができません。(_で置き換えています)
シェルコマンドであるという部分がネックになっていて、かなり工夫が必要なイメージです。
無駄な実行を避ける
自分自身をコピーするスクリプトです。
.PHONY: source-and-generate
source-and-generate:
make makefile.copy
makefile.copy: Makefile
cp Makefile makefile.copy
source-and-generate:
cmd: cp Taskfile.yaml Taskfile.yaml.copy
sources:
- Taskfile.yaml
generates:
- Taskfile.yaml.copy
Makefile本来の使い方といったところですね。Makefileではターゲット名のあとにソースを指定することで、そのソースが変更されたときのみ実行するようになっています。しかし問題点として、生成されるファイル名を知っている必要があり、生成物として単一のファイルしか指定できません。
生成されるファイル名に関しては、上記のように別のスクリプトを挟むことで任意の名前で実行できるようにしています。
Taskfileの方は、それぞれに説明がわかりやすくついているように見えます。生成されるファイル名を知らなくても実行できますし、生成物が複数の場合にも対応しています。
状態に応じて動作を切り替える
.vscodeディレクトリ、またはMakefile(Taskfile)が存在していない場合にのみスクリプトを実行するようになっています。
.PHONY: check-status
check-status:
@if [ -d .vscode ] && [ -f Makefile ]; then \
exit 0; \
else \
echo 'something went wrong'; \
fi
check-status:
cmd: echo 'something went wrong'
status:
- test -d .vscode
- test -f Taskfile.yaml
Makefileではこのような条件分岐は、&&
、||
を使った表現や、if文を使った表現があります。
Makefile、もといシェルの条件分岐はかなり煩雑な書き方になりやすい印象があるのに対し、Taskfileではstatusのいずれかがfalseの場合、cmdを実行するというわかりやすい記載になっています。その分柔軟な条件には対応できませんが、cmdの中でMakefileと同じ表現が使えるので大した問題にはならないかと思います。
Yes/Noプロンプトを表示する
.PHONY: yes-or-no
yes-or-no:
@read -p "Are you sure? [y/N] " answer; \
if [ "$$answer" = "y" ]; then \
echo 'You said yes'; \
else \
echo 'You said no'; \
fi
yes-or-no:
prompt: Are you sure?
cmd: echo 'You said yes'
こちらはTaskfileのほうが圧倒的にわかりやすいですね。Makefileのほうはシェルコマンドの知識がかなり必要になってきます。
ここまでのまとめ
ここまで比較を見てもらった人にはおわかりだと思いますが、Makefileはシェルの機能を最大限活用してタスクを実行してね、という方針なのに対し、Taskfileは用意してあるシンプルな文法に沿ってわかりやすくタスクを実行してね、という方針です。
タスクランナーという性格上、あまり頻繁に編集をする部分ではないため、個人的にはMakefileのような認知負荷の高いものよりTaskfileで管理をしたほうが誰にとっても読みやすいと思います。
よりTaskfileを活用するために
シェル補完を導入しよう
各種シェルに対して補完の導入ガイドが用意されているので、導入しましょう。入力が快適になります。
スキーマを導入しよう
スキーマを導入すると、書くときにフィールドがサジェストされ、調べる手間が限りなくゼロに近づきます。これも便利。
拡張機能を入れよう(VS Code)
拡張機能をいれると、サイドバーからタスクの一覧を見れたり、実行したりできます。task -a
でもタスク一覧は見れますが、サイドバーからはスキップされるタスクかどうかの状態まで見ることができます。
Taskfileのここすき
ドキュメントが見やすい
Makefileの書き方、というものはかなり複雑で、一応公式からドキュメントは出されていますがかなり読む気が失せるような内容です。複雑で、一見して意味のわからない記法などもたくさん出てきます。
さらにMakefileは基本シェルの知識でゴリ推していくのが作法のような面はあるので、シェルの知識を記法として頭に入れなければいけません。これはかなり大変ですよね。
一方Taskfileは、様々な機能をシンプルな記法で、Taskfileの知識で組み立てられるようになっているため、Taskfileのドキュメントを読めば大抵のことはできるようになっています。そしてドキュメントも非常に綺麗で見やすく、簡潔に記載してあるので読んでいて楽しいです。
全部を流し読みしてもそこまで苦痛ではないと思います。
watchが便利
Makefileでは、ファイルの変更を検知して自動でタスクを実行しようとすると、外部のスクリプトファイルを使用したり、inotifyなどをインストールして使ったりする必要があり、なかなか面倒くさいですが、Taskfileではデフォルトでwatchフィールドが用意されています。
# Makefileの変更を監視してタスクを実行
watch-makefile:
cmd: echo 'Makefile changed!'
watch: true
sources:
- Makefile
このように書くとwatch-makefile
を実行した状態でMakefileを変更するとコマンドが実行されますし、watchを設定していないタスクでも-w
フラグを付けるとwatch状態になります。これだけでもとても助かります。
参考
おまけ: より複雑なタスクをやってみる
長くなるので、余裕のある方だけお読みください。
以下ではGitHub CLIを使ってリポジトリの作成、シークレットの作成、最終的なクリアまで行います。
TEST_REPO = MakeToTask-test2
TEST_REMOTE_REPO = upstream
TEST_GITHUB_ENV = TEST_ENV_VAR
GITHUB_USER = $(shell gh config get -h github.com user || echo unknown)
.PHONY: show-installation
show-installation:
@if [ -x "$(command -v gh)" ]; then \
echo "github cli seems to be not installed. Please see https://github.com/cli/cli";\
fi
.PHONY: clean
clean:
@read -p "Are you sure? [y,N]:" ans; \
if [ "$$ans" = y ]; then \
make delete-repo-env; \
make delete-test-repo; \
gh auth logout; \
fi
STATUS = $(shell gh auth status)
.PHONY: login
login: show-installation
@if [ -z "$(STATUS)" ]; then \
gh auth login -h github.com -s delete_repo || exit 0; \
fi
.PHONY: create-test-repo
create-test-repo: login
@gh repo create $(TEST_REPO) --private --source=. --remote=$(TEST_REMOTE_REPO) || exit 0
.PHONY: delete-test-repo
delete-test-repo: login
@gh repo delete $(TEST_REPO) --yes; \
git remote remove $(TEST_REMOTE_REPO) || exit 0
.PHONY: set-repo-env
set-repo-env: create-test-repo
@gh secret --repo $(GITHUB_USER)/$(TEST_REPO) set $(TEST_GITHUB_ENV) -b "test-value"
.PHONY: delete-repo-env
delete-repo-env:
@gh secret --repo $(GITHUB_USER)/$(TEST_REPO) delete $(TEST_GITHUB_ENV) || exit 0
version: "3"
env:
TEST_REPO: MakeToTask-test2
TEST_REMOTE_REPO: upstream
TEST_GITHUB_ENV: TEST_ENV_VAR
GITHUB_USER:
sh: gh config get -h github.com user || echo unknown
tasks:
show-installation:
cmd: echo "github cli seems to be not installed. Please see https://github.com/cli/cli"
silent: true
status:
- test -x "$(command -v gh)"
clean:
prompt: "Are you sure?"
deps:
- login
cmds:
- task: delete-repo-env
- task: delete-test-repo
- gh auth logout
silent: true
login:
deps:
- show-installation
cmd: gh auth login -h github.com -s delete_repo
status:
- gh auth status
silent: true
create-test-repo:
deps:
- login
cmds:
- cmd: gh repo create {{ .TEST_REPO }} --private --source=. --remote={{ .TEST_REMOTE_REPO }}
ignore_error: true
delete-test-repo:
deps:
- login
cmds:
- cmd: gh repo delete {{ .TEST_REPO }} --yes
ignore_error: true
- cmd: git remote remove {{ .TEST_REMOTE_REPO }}
ignore_error: true
set-repo-env:
deps:
- create-test-repo
cmd: gh secret --repo {{ .GITHUB_USER }}/{{ .TEST_REPO }} set {{ .TEST_GITHUB_ENV }} -b "test-value"
delete-repo-env:
cmd: gh secret --repo {{ .GITHUB_USER }}/{{ .TEST_REPO }} delete {{ .TEST_GITHUB_ENV }}
ignore_error: true
この2つのファイルを見比べると、全体的な分量としてはTaskfileのほうが多くなっているものの、Makefileを初見で理解できる人は多くはないと思います。初学者の身からすると、「@を書くと出力を出さない」や、「$(shell ~~)で動的変数を埋め込める」といったルールは、書く側にとってハードルを高くしている文法だと思います。これらをyamlのフィールドとして用意しているのは非常に書きやすいです。わざわざ記法を調べる必要がなくなります。
Taskfileの特徴として、依存関係が非常にわかりやすいです。depsに記載してあるものに依存していることが明確にわかります。
新しくJoinしてきた人にはMakefileよりもTaskfileのほうが見やすいのではないでしょうか。
ということで長々と話しましたが、最後に言いたいことは
Discussion
最初の例のタスク名のとこ間違ってるようです(make も taskfile も)
これでは
make hello
できません。あ、ほんとですねw
ありがとうございます !