Make の代わりに Task を使ってみる
Twitter の TL で見かけたのだが,名前もそのまんま Task というツールがあるらしい(Docker 関連でよく使われている?)。 Task の特徴は
- Easy installation: just download a single binary, add to $PATH and you’re done! Or you can also install using Homebrew, Snapcraft, or Scoop if you want;
- Available on CIs: by adding this simple command to install on your CI script and you’re done to use Task as part of your CI pipeline;
- Truly cross-platform: while most build tools only work well on Linux or macOS, Task also supports Windows thanks to this awesome shell interpreter for Go;
- Great for code generation: you can easily prevent a task from running if a given set of files haven’t changed since last run (based either on its timestamp or content).
とのことで,あの鬱陶しい make コマンドから置き換えて使うことができるらしい。ホンマかな? 試してみよう。
インストール
Task は Homebrew, Snap, Scoop 等のパッケージマネージャを使ってインストールできる。 Scoop を使う場合は
$ scoop bucket add extras
$ scoop install task
と extras バケットを追加することでインストール可能なようだ。また GitHub Actions では
- name: Install Task
  uses: Arduino/actions/setup-taskfile@master
てな感じに組み込める。
Go のコンパイラを持っている場合は GitHub リポジトリから
$ go install github.com/go-task/task/v3/cmd/task@latest
でビルド&インストールできる。
みんな大好き Hello World
インストールができたところで,簡単な手順を書いて実行してみよう。
Task で手順を指示するには Taskfile.yml ファイルに YAML 形式で記述する[1]。たとえばこんな感じ。
version: '3'
tasks:
  default:
    cmds:
      - echo Hello, World!
これで Task を実行すると
$ task
task: [default] echo Hello, World!
Hello, World!
となった。
コマンドライン引数でタスク名を指定して実行することもできる。たとえば,タスクファイルの内容を
version: '3'
tasks:
  default:
    deps:
      - task: hello
        vars:
          RECIPIENT: "World"
  hello:
    vars:
      RECIPIENT: '{{default "there" .RECIPIENT}}'
    cmds:
      - echo Hello, {{.RECIPIENT}}!
と書き換えて実行すると
$ task hello
task: [hello] echo Hello, there!
Hello, there!
となる。また,同じタスクファイルで引数なしで実行すると
$ task
task: [hello] echo Hello, World!
Hello, World!
となる。
という依存関係で default タスクから hello タスクに RECIPIENT 変数を渡しているのが分かると思う。
ちなみに {{...}} というのは Go の標準テンプレートの記述フォーマットである。 Go のテンプレートはクセがあるが慣れると割と便利なので,興味があれば調べてみるのもいいだろう。とりあえずは「そういうもんだ」と覚えておいて欲しい。
Makefile を Taskfile.yml に置き換えれるか
次に make を使った処理を Task で置き換えれるか試してみる。適当な例が思い浮かばなかったので,山本和彦さんの kazu-yamamoto/pgpdump で試してみる。
kazu-yamamoto/pgpdump のソースコードを取ってきて configure で Makefile を生成した結果が以下の通り。
prefix = /usr/local
exec_prefix = ${prefix}
bindir = ${exec_prefix}/bin
mandir = ${prefix}/share/man
LIBS = -lbz2 -lz 
CFLAGS  = -g -O2 -O -Wall
LDFLAGS = 
CC = gcc
VERSION = `git tag | tail -1 | sed -e 's/v//'`
RM = rm -f
INSTALL  = install
INCS = pgpdump.h
SRCS = pgpdump.c types.c tagfuncs.c packet.c subfunc.c signature.c keys.c \
       buffer.c uatfunc.c
OBJS = pgpdump.o types.o tagfuncs.o packet.o subfunc.o signature.o keys.o \
       buffer.o uatfunc.o
PROG = pgpdump
MAN  = pgpdump.1
CNF = config.h config.status config.cache config.log
MKF = Makefile
.c.o:
	$(CC) -c $(CPPFLAGS) $(CFLAGS) $<
all: $(PROG)
$(PROG): $(OBJS)
	$(CC) $(CFLAGS) -o $(PROG) $(OBJS) $(LIBS) $(LDFLAGS)
clean:
	$(RM) $(OBJS) $(PROG)
distclean:
	$(RM) $(OBJS) $(PROG) $(CNF) $(MKF)
install: all
	$(INSTALL) -d $(DESTDIR)$(bindir)
	$(INSTALL) -cp -pm755 $(PROG) $(DESTDIR)$(bindir)
	$(INSTALL) -d $(DESTDIR)$(mandir)/man1
	$(INSTALL) -cp -pm644 $(MAN) $(DESTDIR)$(mandir)/man1
archive:
	git archive master -o ~/pgpdump-$(VERSION).tar --prefix=pgpdump-$(VERSION)/
	gzip ~/pgpdump-$(VERSION).tar
この内容を基に,今回は *.c ファイルをコンパイルして実行バイナリを生成するところまでを記述してみる。こんな感じかな。
version: '3'
vars:
  LIBS: -lbz2 -lz
  CFLAGS : -g -O2 -O -Wall
  LDFLAGS:
  INCS: pgpdump.h
  CC: gcc
  SRCS: |
    pgpdump.c
    types.c
    tagfuncs.c
    packet.c
    subfunc.c
    signature.c
    keys.c
    buffer.c
    uatfunc.c
  OBJS: pgpdump.o types.o tagfuncs.o packet.o subfunc.o signature.o keys.o buffer.o uatfunc.o
  PROG: pgpdump
tasks:
  default:
    deps: [link]
  c-compile:
    cmds:
      - |
        {{range .SRCS | splitLines -}}
        {{if .}}{{$.CC}} -c {{$.CFLAGS}} {{.}}{{end}}
        {{end -}}
    sources:
      - '{{.INCS}}'
      - ./*.c
    generates:
      - ./*.o
    method: checksum
  link:
    deps: [c-compile]
    cmds:
      - '{{.CC}} {{.CFLAGS}} -o {{.PROG}}{{exeExt}} {{.OBJS}} {{.LIBS}} {{.CFLAGS}}'
    sources:
      - ./*.o
    generates:
      - '{{.PROG}}{{exeExt}}'
    method: checksum
これを実行すると
$ task
task: [c-compile] gcc -c -g -O2 -O -Wall pgpdump.c
gcc -c -g -O2 -O -Wall types.c
gcc -c -g -O2 -O -Wall tagfuncs.c
gcc -c -g -O2 -O -Wall packet.c
gcc -c -g -O2 -O -Wall subfunc.c
gcc -c -g -O2 -O -Wall signature.c
gcc -c -g -O2 -O -Wall keys.c
gcc -c -g -O2 -O -Wall buffer.c
gcc -c -g -O2 -O -Wall uatfunc.c
task: [link] gcc -g -O2 -O -Wall -o pgpdump pgpdump.o types.o tagfuncs.o packet.o subfunc.o signature.o keys.o buffer.o uatfunc.o -lbz2 -lz -g -O2 -O -Wall
てな感じになる。そのままもう一度実行すると
$ task
task: Task "c-compile" is up to date
task: Task "link" is up to date
などと再実行が抑止される。ここまでどえら苦労したよ。
気が付いた点をかいつまんで挙げると
- 分岐や繰り返しといった制御構造がない
- 外部コマンドやテンプレートの機能を使って擬似的な制御はできる
 
- 
varsプロパティで定義できる変数の値も構造化できない
- 
sourcesおよびgeneratesプロパティで指定したファイルはタスクごとにハッシュ値を保持って更新の有無を調べる- ハッシュ値は .task/cheksum ディレクトリにタスクごとに保持する
- 
methodプロパティの値をtimestampとするとsourcesとgeneratesプロパティで指定したファイルのタイムスタンプを比較して更新を判定する。この場合 .task ディレクトリ以下にアクセスはしないようだ
 
制御も変数も構造化できないためファイル単位の細かい制御が難しい。たとえば,手順をソースファイル毎にロールアウトして
  pgpdump:
    vars:
      SRC: "pgpdump"
    cmds:
      - '{{$.CC}} -c {{$.CFLAGS}} {{.SRC}}.c'
    sources:
      - '{{.INCS}}'
      - '{{.SRC}}.c'
    generates:
      - '{{.SRC}}.o'
    method: checksum
のように書けばできなくもないが,冗長すぎるよねぇ。
Make コマンドおよび Makefile は依存関係に紐づく制御を拡張子単位で汎化できるのが特徴である。言い方を変えるとそれしか取り柄がない。一方で Task は単純な制御フローは書きやすいが,手順を(依存関係と共に)ルールとして汎化できないため,どうしても記述が煩雑になってしまう。
Task は make コマンドの代替というよりは相補的な位置付けとして考えるのがいいだろう。あと YAML は本当に面倒。つか,手順を記述するのに YAML は向いてないんじゃないのかなぁ...
- 
タスクファイルの規定の名前は taskfile.yml ではなく Taskfile.yml なので注意。なお --taskfile(短縮名-t) オプションでタスクファイルを指定することもできる。 ↩︎

Discussion