Go
タスクランナー

Goで書けるタスクランナーMageが快適すぎて捗る

Goを始めてからずっと RoboTask といったYAMLベースのものでビルドタスクを書いていたのですが、プロジェクトが増えてきて限界を感じました。
YAMLベースと言ってもタスクの記述部はあくまでもただのシェルであり、せいぜい「Makefileよりは読みやすいよね」程度。シェルへの依存度が高いと、どうしてもこういう問題が目立ってきます。

  • 環境依存で「動きません」って他のメンバーに言われがち。MacとLinuxで date コマンドのオプションが違うとかよくある。かといってビルド用のDockerイメージを作るほどではまだない
  • grep sed 等で正規表現の魔術的な操作が増えると、一見何やってるのか分からなくなりがち
  • エラー処理が雑になりがち。パイプの途中のエラー拾うとかしんどい
  • 遅い。並行処理書くのもしんどい
  • 個別のタスクをライブラリ化してインポートしたいけど、決まったやり方がない
  • 内部的に使うツールのバージョンを固定したいけど、決まったやり方がない

RubyにおけるRake、Node.jsにおけるGulpのようなものってGoにはないの? ということで Mage というツールを使ってみた報告です。

Mage

https://magefile.org/

Mage is a make/rake-like build tool using Go. You write plain-old go functions, and Mage automatically uses them as Makefile-like runnable targets.

まさに求めていたものです :thumbsup: 魔法使いコスのGopherくんがかわいい。

特徴

  • magefile.go 内の関数としてタスクを記述し、mage <タスク名> で実行
  • 他言語の同種のツールに比べると方言っぽいものが少なく(ほぼ皆無)、普通のGoプログラムと同じ感覚で書ける(もっとも、その分お便利なプラグインとかは揃ってないので自己流でやりたい人向き)
  • それでいて最低限のヘルパー関数は備わっているので、トータルの学習コストは抑えられている印象
  • vendoringしてあるMageを直接実行する方法(Zero Install Option)もあり、CI環境でタスク実行したいときなど大変お手軽

仕組みとしては、magefile.go の内容を元に このような 内容の main.go が自動生成され、ビルド・実行される、というもののようです。

インストール

正式には以下の手順でインストールします。バージョン情報が分からなくても良ければ go get github.com/magefile/mage だけでも一応動きます。

go get -u -d github.com/magefile/mage
cd $GOPATH/src/github.com/magefile/mage
go run bootstrap.go

基本的な使い方

Hugoでも使われている ようで、Exampleとして読むには良いボリュームです。複数ファイル処理してるあたりなどは、Goで整理して書けるというメリットが特に活かされているように見えます。

仕様がコンパクトなので、詳しい解説については直接 公式ドキュメント 読むのが早いと思います。

初期化

まずは mage -init すると、以下のようなテンプレ内容の magefile.go が作成されます。
通常のGoプログラム本体に混入しないよう、ビルドタグ // +build mage が付いています。

// +build mage

package main

import (
    "fmt"
    "os"
    "os/exec"

    "github.com/magefile/mage/mg" // mg contains helpful utility functions, like Deps
)

// Default target to run when none is specified
// If not set, running mage will list available targets
// var Default = Build

// A build step that requires additional params, or platform specific steps for example
func Build() error {
    mg.Deps(InstallDeps)
    fmt.Println("Building...")
    cmd := exec.Command("go", "build", "-o", "MyApp", ".")
    return cmd.Run()
}

// A custom install step if you need your bin someplace other than go/bin
func Install() error {
    mg.Deps(Build)
    fmt.Println("Installing...")
    return os.Rename("./MyApp", "/usr/bin/MyApp")
}

// Manage your deps, or running package managers.
func InstallDeps() error {
    fmt.Println("Installing Deps...")
    cmd := exec.Command("go", "get", "github.com/stretchr/piglatin")
    return cmd.Run()
}

// Clean up after yourself
func Clean() {
    fmt.Println("Cleaning...")
    os.RemoveAll("MyApp")
}

ターゲット

以下のいずれかのシグネチャを持った公開関数がターゲットとして拾われます。

func()
func() error 
func(context.Context)
func(context.Context) error

前述のテンプレを作成した状態で mage コマンドを実行してみると、

$ mage
Targets:
  build          A build step that requires additional params, or platform specific steps for example
  clean          up after yourself
  install        A custom install step if you need your bin someplace other than go/bin
  installDeps    Manage your deps, or running package managers.

という具合に、camelCase化された関数名が一覧化されているのが分かります。関数に付いたコメントは各ターゲットの説明文になっています。

で、mage build すると Build 関数が実行されるという寸法です。ターゲットは複数指定可能で、例えば mage clean build とすると Clean 関数が実行された後に Build 関数が実行されます。なお、ターゲット名はcamelCaseでもPascalCaseでも認識するようです。

エイリアスも以下のように定義できます。

var Aliases = map[string]interface{} {
  "i":     Install,
  "build": Install,
  "ls":    List,
}

シェルコマンドの実行

テンプレでは

cmd := exec.Command("go", "build", "-o", "MyApp", ".")
return cmd.Run()

のように exec パッケージを使っていますが、Mageの shパッケージ にシェルまわりのヘルパ関数が用意されています。Hugoのmagefile ではこちらを使用しているようですね。シェルコマンドの標準出力を sh.Output 一発で拾えたりと、そこそこスッキリ書けます。

hash, _ := sh.Output("git", "rev-parse", "--short", "HEAD")

機能的に物足りないという場合は、別途 go-sh みたいなものをインポートして使えばOK。magefileはあくまでもGoのプログラムなので、Goのパッケージなら何でも利用できるということです。環境に配慮したパスの処理なんかも、普通に path/filepath 使って書けます。

ソース・デスティネーションの指定

Makefileでよくやる main.o: main.c みたいなやつです。ソースが更新されたときだけバイナリをリビルドする、というような依存関係を記述できます。

例えば protobuf のコード生成はこんな感じにしておくとムダな再生成を防げます。

func GenerateProtobuf() error {
    if ok, _ := target.Path("pb/service.pb.go", "pb/service.proto"); ok {
        return nil
    }
    return sh.RunV("protoc", "-I", "pb", "pb/service.proto", "--proto_path=.", "--go_out=plugins=grpc:pb")

}

ターゲットの依存関係

あるターゲットが別のターゲットの実行後に(または実行と同時に)行われることを想定している場合、その依存関係を記述できます。

func Build() {
    mg.Deps(f, g)
    fmt.Println("Build running")
}

func f() {
    mg.Deps(h)
    fmt.Println("f running")
}

func g() {
    mg.Deps(h)
    fmt.Println("g running")
}

func h() {
    fmt.Println("h running")
}

Running mage build will produce the following output:

h running
g running
f running
Build running

上記の例では fg の2つのターゲットが h に依存していますが、単純に関数やgoroutineとして h を呼び出すのとは違い、mg.Deps を用ることで一度だけ実行されているのが分かります。

応用編

Goで書かれたツールならCLIを使わずGo関数を直接呼べる

例えば go-assets でアセットファイルをGoコード化するとき、従来は go-assets-builder をインストールしてからシェルコマンドとして実行する必要がありましたが、Mageではこの手間が要らなくなります。Go関数として直接呼ぶことで、オプションもstrictに指定することができるようになります。

import "github.com/jessevdk/go-assets"

func GenerateAssets() error {
    gen := assets.Generator{
        PackageName: "assets",
    }
    gen.Add("images/")
    f, err := os.Create("assets/assets.go")
    if err != nil {
        return err
    }
    defer f.Close()
    return gen.Write(f)
}

もちろん、まとまった機能がツール側で公開関数としてexportされている必要はありますが、他にも例えばgoimportsなんかは同様に直接呼べます。

CLIを介さずにツールを利用できるメリットは、他にもいろいろありますね。

  • 標準入力に流し込む文字列を作成したり、標準出力のパースとかもしなくて済む
  • 厳密なエラー処理がしやすい
  • vendoringすれば環境を汚さず、バージョン固定も簡単

複数プロジェクトの共通処理をGoパッケージとして共有

個人的に今回一番やりたかったことです。ビルド情報の埋め込み方を統一するために、毎回同じスクリプトをコピペしたり、「こういう手順でやってね」というお作法を整えるのが面倒くさかったのですが、タスクがGoのコードになったので、普通にGoパッケージとして取り扱えるようになりました。これが大変分かりやすい。

ただ、タスクそのものを共有する手段はまだ用意されていません(issue は上がっているようです)ので、今のところ以下のようにラッパー関数だけはいちいち書く必要があります。

import "example.com/hoge/magelib" // 共通タスクの本体をこちらのパッケージ内に記述

// プロジェクトをビルドします
func Build() error {
    return magelib.Build()
}

注意点

  • タスクを高速に実行するため、一度ビルドされた magefile はキャッシュされます。magefile.go 変更後のタスク実行時は自動的にリビルドされますが、magefile.go からimportしている他のパッケージ内を書き換えただけのときはリビルドされないようです。「書き換えたはずなのに動作が変わらない、おかしい」みたいなときは、mage -f <ターゲット> … のように -f オプションを付けて強制リビルドしましょう。mage -clean でキャッシュ全削除もできます。
  • タスク中で log.Print 等でデバッグ情報を表示したいときは、mage -v <ターゲット> … のように -v オプションを付ける必要があります。
  • 同じく -v オプションを付けることで、Mage付属の sh パッケージを使っている場合はコマンドの実行履歴も見られます。逆に言うと、Go標準の exec パッケージ等を使っている箇所については実行履歴が表示されないので注意が必要です。

不満点

しいて言えばこれくらいかなと思います。

  • タスクに引数を渡せないので、オプション付きのビルドなどはそれぞれ専用のタスクを書くか、環境変数に頼る必要があります。issue は上がっているようです。
  • target.Path target.Dir にglobが使えないので困ることがあります。Goでは src みたいなディレクトリを掘る文化がなく、生成物とソースのルートディレクトリが共通だったりするので、!out/**/* みたいにexcludeできる機能も欲しいところです。

感想

最初は「シェルコマンドの単なる羅列として書けなくなるので読みづらくなるかなー」という心配がありましたが、「本来必要なエラー処理やエスケープ処理をちゃんと書いたらコードはどのみち多少長くなるよ」という点で、やはりGoで書くとGoの作法が効いてくるなと思いました。「簡単に書ける」ことよりも「整理して書ける」ことにメリットがあると思います。

とはいえ、手軽さの面ではMakefileが手っ取り早いのは明らかなので、タスクの複雑度に合わせて導入するのが良いでしょう。「既にごりごりシェルスクリプト書いてしまったので一部のタスクは移行に時間がかかる」みたいな場合は、取り急ぎ以下のようにしておけばなんとかなります。

func Hoge() error {
    return sh.RunV("bash", "-c", `
        for i in {1..5}; do
            echo "ふつうのシェルスクリプト"
        done
    `)
}

ということで、ぜひ皆様もMageを活用して快適なGopherライフをお送りください ∩ʕ◔ϖ◔ʔ∩