🕒

その make、 task に置換可能です!

2024/12/12に公開

https://adventar.org/calendars/10918

はじめに

弊社では、ビルド、マイグレーション、Lint など、頻繁に実行する長いコマンドを簡単に扱うため、make コマンドを利用していました。

make コマンドのいいところは、ほとんどの Linux ディストリビューションが標準で用意しているところで、Docker コンテナであっても簡単にインストールすることができます。
構文はかなり難解ですが、頑張れば大抵のことはできます。
しかし、コマンドライン引数を扱うには ARG=foo のように独特な形式で指定しなければならず、不便さを感じていました。

そこで、インストールが容易で、引数の扱いが柔軟なタスクランナーを探していたところ、task コマンドに出会いました。

https://taskfile.dev/

task と make の比較

task コマンドと make コマンドを簡単に比較してみます。

task のいいところ

  • タスクランナーとして作られている
  • コマンドライン引数を受け取れる
    • コマンドライン引数の前に -- を入れなければならない点には注意
  • .env を読み込める
    • Rails などのアプリケーションで使っている設定をそのまま使える
  • 実行前に Y/N の入力確認ができる
  • make だとシェル芸で実現しないといけなかったことが、task だと task の機能を使って実現できることが多い
  • make のように変更がなければ再コンパイルしないような設定もできる
  • 設定が YAML で読みやすい
  • 実行時の表示が見やすい
  • make には劣るが、Go で作られているのでインストールは比較的容易

make のいいところ

  • OS のパッケージ管理ソフトでインストールできる
  • 知ってる人が多い
    • でも使いこなせる人はそこまで多くはない

インストールの問題さえ気にならなければ「make を使う理由はない」と言ってしまってもいいかもしれません。

使用例

task コマンドのイメージを掴んでもらうために、実際に弊社で使っている例をいくつか紹介したいと思います。

コマンドライン引数を使う

私たちが make から task に乗り換える動機にもなった、コマンドライン引数を扱う方法です。

shell:
  desc: アプリケーションのコンテナでコマンドを実行
  cmd: docker compose exec app {{.CLI_ARGS | default "bash"}}
# コンテナの中で `bash` を実行
$ task shell
# コンテナの中で `ls -lF` を実行
$ task shell -- ls -lF

-- の後に指定した引数が全て .CLI_ARGS という変数に入ります。
この例では、引数が指定された場合はそれを、指定されなければ bash を実行するようにしています。

ただし、.CLI_ARGS の内容によって処理を分けたりしたい場合は、工夫が必要そうでした。
task には変数の内容を簡単にパースする方法が用意されてなさそうだったので、シェルの機能などを使って自前でパースする必要がありそうです。

.env を読み込む

昨今のフレームワークでは環境変数を設定する代わりに .env というファイルに設定したい内容を書いておくものが多いですが、task コマンドでもそれと同じことができます。

この機能のいいところは、フレームワークで使っている .env ファイルを task コマンドでもそのまま流用できるところです。

db:console:
  desc: .env.development の設定を使って DB に接続
  dotenv: ['.env.{{.RAILS_ENV | default "development"}}']
  cmd: MYSQL_PWD="${DB_PASSWORD}" mysql -h "${DB_HOST}" -u "${DB_USER}" "${DB_NAME}" {{.CLI_ARGS}}
$ task db:console

.env.development に記載された接続情報を使って DB に接続します。

$ RAILS_ENV=test task db:console

この例では読み込む .env ファイルの指定に環境変数を使っているので、この環境変数を設定すれば、.env.development の代わりに .env.test を読み込ませることもできます。

また、task コマンドではタスクごとに別々の .env ファイルを読み込めるので、このタスクでは開発環境用の .env.development を読み込み、別のタスクではテスト用の .env.test を読み込むといった使い分けもできます。

コマンド実行前のチェック

ないと困るというほどの機能ではないのですが、コマンドの実行と実行条件のチェックを別々に書けるので、タスク定義が読みやすくなります。

fmt:
  desc: コンテナの中で go fmt を実行
  preconditions:
    - sh: test ! -f /.dockerenv
      msg: "Warning: このタスクはコンテナの外から実行してください"
  cmd: docker compose exec app go fmt
$ task fmt
task: Warning: このタスクはコンテナの外から実行してください
tasl: precondition not met

この例では、コンテナの外から実行するコマンドを間違ってコンテナの中で実行してしまわないように、実行する環境に /.dockerenv というファイルがないことを確認しています。

プロンプトで確認

make でも頑張ればできますが、これが 1 行で書けるのはかなり嬉しいです。

db:reset:
  desc: データベースをリセット
  prompt: 本当にデータベースをリセットしますか?
  cmds:
    - task: db:drop
    - task: db:create
    - task: db:migrate
$ task db:reset
本当にデータベースをリセットしますか? [y/N]:

実行すると確認のプロンプトが表示され、Y を入力した場合だけ DB がリセットされます。

ディレクトリ一覧を表示

これは、他のコマンドと組み合わせて使うことを想定したタスクの例になります。

module-dirs:
  desc: ワークスペースに含まれるモジュールのディレクトリのパスを表示
  cmd: go work edit -json | jq -r '.Use[].DiskPath'
  silent: true

以下のように使います。

$ task module-dirs | while read dir; do touch "$dir/README.md"; done

この際に余計な出力があると邪魔なので、silent: truetask コマンド自体の出力をさせないようにしています。

各ディレクトリに対してコマンドを実行

複数の対象に対して同じコマンドを実行したい場合は for: を使います。

goget:
  desc: ワークスペースに含まれる全てのモジュールで go get . を実行
  vars:
    MODULES:
      sh: task module-dirs
  cmds:
    - for:
        var: MODULES
      cmd: cd "{{.ITEM}}" && go get {{.CLI_ARGS | default "."}}
  ignore_error: true
$ task goget -- -t .

この例では、先ほどの module-dirs で得た全てのディレクトリに順に入って go get . を実行しています。

ignore_error: true を指定することで、エラーがあっても続きを処理するようにしています。

変数を使う

vars: を使うと環境変数とは別に Taskfile.yml の中でだけ有効な変数を定義することができます。
特定のタスクだけで有効な変数も、全てのタスクで有効なグローバル変数も定義できます。

vars:
  GOLANGCI_LINT_VERSION: v1.62.2

tasks:
  install:lint:
    desc: Linter をインストール
    cmd: |-
      wget -O- -nv https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh |
        sh -s -- -b $(go env GOPATH)/bin {{.GOLANGCI_LINT_VERSION}}
    requires:
      vars: [GOLANGCI_LINT_VERSION]
$  task install:lint
task: [install:lint] wget -O- -nv https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh |
  sh -s -- -b $(go env GOPATH)/bin v1.62.2
2024-12-10 16:27:16 URL:https://raw.githubusercontent.com/golangci/golangci-lint/master/install.sh [11591/11591] -> "-" [1]
golangci/golangci-lint info checking GitHub for tag 'v1.62.2'
golangci/golangci-lint info found version: 1.62.2 for v1.62.2/linux/arm64
golangci/golangci-lint info installed /go/bin/golangci-lint

この例ではインストールするツールのバージョンをグローバル変数で定義しています。

CPU アーキテクチャごとに実行する内容を変える

x86_64 と ARM64 で実行する内容を変えたり、あるいは想定してないアーキテクチャではタスクを実行させないこともできます。

install:aws-cli:
  desc: AWS CLI をインストール
  platforms: [linux/amd64, linux/arm64]
  dir: /usr/src
  cmds:
    - cmd: curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"
      platforms: [linux/amd64]
    - cmd: curl "https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip" -o "awscliv2.zip"
      platforms: [linux/arm64]
    - unzip awscliv2.zip
    - ./aws/install
    - defer: rm -rf awscliv2.zip aws
$ task install:aws-cli
task: [install:aws-cli] curl "https://awscli.amazonaws.com/awscli-exe-linux-aarch64.zip" -o "awscliv2.zip"
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100 62.3M  100 62.3M    0     0  35.5M      0  0:00:01  0:00:01 --:--:-- 35.5M
task: [install:aws-cli] unzip awscliv2.zip
Archive:  awscliv2.zip
   creating: aws/
   creating: aws/dist/
  inflating: aws/README.md
  ...
  inflating: aws/dist/docutils/parsers/rst/include/s5defs.txt
task: [install:aws-cli] ./aws/install
You can now run: /usr/local/bin/aws --version
task: [install:aws-cli] rm -rf awscliv2.zip aws

この例では、x86_64 上の Linux か、ARM64 上の Linux でしかタスクを実行できないようにした上で、それぞれで別々のソースからファイルをダウンロードするようにしています。

最後のコマンドに付けている defer: は、手前のコマンドに成功しても失敗しても実行するようにする指示で、この例では一時ファイルを削除しています。

シェルで頑張る

もちろん make のようにシェルで頑張ることもできます。

skipped-tests:
  desc: `t.Skip()` を含むテストのパスを表示
  cmd: |-
    tests=$(find . -name '*_test.go' -exec grep -l 't\.Skip()' '{}' + | sort)
    if [ -n "$tests" ]; then
      echo "$tests"
    else
      echo "No skipped tests found." >&2
    fi
  silent: true
$ task test:skipped
No skipped tests found.

この例ではシェルの if を使って条件分岐をさせていますが、実際に使ってみると、シェルで頑張ってなんとかしないといけない場面はほとんどありませんでした。

おわりに

普段使うコマンドをどんどん追加していき、気づいたら 100 個近くのタスクを登録していました。
お陰でほとんどの作業を簡単なタスク名の入力だけで実行できるようになりました。

引数の問題にぶつかる前は、make コマンドでも十分なんじゃないかと思っていたのですが、make コマンドで色々なタスクをやらせようとすると、どうしても Makefile が複雑になりがちで、シェルを使いこなしている人にしかメンテナンスできなくなってしまいます。

task コマンドを使うには、Taskfile.yml の仕様を覚える必要はありますが、ドキュメントもしっかりしていますし、何より仕様がシンプルでわかりやすいです。
使いやすければ、あれもこれもタスクにしようと考えるようになり、開発者体験もよくなっていきます。

この記事を読んで task コマンドに興味を持たれた方は、ぜひ試しに使ってみてください。

We are hiring!!

株式会社カウンターワークスでは、開発者体験を向上させながら事業を推進できるメンバーを募集しています。
興味のある方はぜひ以下のリンクからご応募ください!

COUNTERWORKS テックブログ

Discussion