💮

Make の代わりに Task を使ってみる

2021/04/18に公開

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]。たとえばこんな感じ。

Taskfile.yml
version: '3'

tasks:
  default:
    cmds:
      - echo Hello, World!

これで Task を実行すると

$ task
task: [default] echo Hello, World!
Hello, World!

となった。

コマンドライン引数でタスク名を指定して実行することもできる。たとえば,タスクファイルの内容を

Taskfile.yml
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 を生成した結果が以下の通り。

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 ファイルをコンパイルして実行バイナリを生成するところまでを記述してみる。こんな感じかな。

Taskfile.yml
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 とするとsourcesgenerates プロパティで指定したファイルのタイムスタンプを比較して更新を判定する。この場合 .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 は向いてないんじゃないのかなぁ...

脚注
  1. タスクファイルの規定の名前は taskfile.yml ではなく Taskfile.yml なので注意。なお --taskfile (短縮名 -t) オプションでタスクファイルを指定することもできる。 ↩︎

GitHubで編集を提案

Discussion