Goのtoolディレクティブで開発ツールを管理する—tools.goからの移行手順

Goのtoolディレクティブで開発ツールを管理する—tools.goからの移行手順 | mohablog

golangci-lint のバージョンがローカルとCIでずれて、手元で通った lint がCIで落ちる。Goのモジュールはアプリの依存を go.sum で厳密に固定する一方、開発ツールの依存はずっと管理の外に置かれていました。Go 1.24で入った tool ディレクティブ が、この穴を塞ぎます。

目次

tools.goパターンは何を我慢していたか

Go 1.24より前は、ツールの依存をモジュールに含める標準の手段がありませんでした。go install golangci-lint@latest のようにグローバルへ入れると、入れた人ごとにバージョンが変わる。これを避けるための定番が tools.go でした。

blank importでツールを依存に紛れ込ませる

ビルド対象に含めたくないので、専用のビルドタグでガードしたファイルにツールのパッケージを空インポートします。

//go:build tools
// +build tools

package tools

import (
	_ "golang.org/x/tools/cmd/stringer"
	_ "golang.org/x/tools/cmd/goimports"
)

空インポートなので go.modrequire に乗り、go.sum でバージョンが固定される。ツール本体は go run golang.org/x/tools/cmd/stringer とフルパス指定で呼ぶ運用でした。

ダミーパッケージとビルドタグが要る

この方式は動きます。ただし中身が空の package tools をリポジトリに置く必要がある。//go:build tools を書き忘れると、通常ビルドにツールの依存が混ざります。公式ドキュメントもこの回避策を過去のものと位置づけていて、Go 1.24のリリースノートには次のようにあります。

Go modules can now track executable dependencies using tool directives in go.mod. This removes the need for the previous workaround of adding tools as blank imports to a file conventionally named “tools.go”.

go get -tool でgo.modに書き込む

新しいやり方はコマンド1本。go get-tool を付けると、指定パッケージが tool ディレクティブとして go.mod に書き込まれます。

tool ディレクティブが1行追加される

$ go get -tool golang.org/x/tools/cmd/stringer
go: downloading golang.org/x/sync v0.21.0
go: downloading golang.org/x/mod v0.37.0
go: added golang.org/x/mod v0.37.0
go: added golang.org/x/sync v0.21.0
go: added golang.org/x/tools v0.46.0

go.mod を開くと tool 行が増えています。require には // indirect 付きで実体が並ぶ。

module example.com/tooldemo

go 1.25.0

tool golang.org/x/tools/cmd/stringer

require (
	golang.org/x/mod v0.37.0 // indirect
	golang.org/x/sync v0.21.0 // indirect
	golang.org/x/tools v0.46.0 // indirect
)

2つ目を足すとブロックにまとまる

ツールを追加すると、tool 行は自動でブロック表記に変わります。import文と同じ感覚。

$ go get -tool golang.org/x/tools/cmd/goimports
tool (
	golang.org/x/tools/cmd/goimports
	golang.org/x/tools/cmd/stringer
)

これでツールのバージョンはアプリの依存と同じ go.sum に乗る。チーム全員が同じ golang.org/x/tools v0.46.0 を使う状態になります。

go tool で実行する

追加したツールは go tool から呼びます。Go同梱の vetcover と同じ入り口。

パス末尾だけで呼べる

インポートパスの最後の要素を渡すだけで実行できます。goimports で整形差分を出してみます。

$ go tool goimports -d main.go
--- main.go.orig
+++ main.go
@@ -1,6 +1,7 @@
 package main

 import "fmt"
+
 func main() {
 	m := map[string]int{"b": 2, "a": 1}
 	fmt.Println(m["a"])

フルパス go tool golang.org/x/tools/cmd/goimports でも動く。末尾名が他のツールと衝突するときはフルパスが要ります(後述)。

go tool 単体で一覧を出す

引数なしの go tool は、使えるツールを全部並べます。上半分がGo同梱、下半分が go.mod で追加したもの。

$ go tool
asm
cgo
compile
cover
fix
link
preprofile
vet
golang.org/x/tools/cmd/goimports
golang.org/x/tools/cmd/stringer

tools.goから移行する

既存プロジェクトの移行は機械的に進みます。tools.go に並んでいたパッケージを、そのまま go get -tool に渡し替えるだけ。

importをgo get -toolに置き換える

tools.go の空インポート1行が、コマンド1本に対応します。

# 旧: tools.go に _ "golang.org/x/tools/cmd/stringer"
# 新:
$ go get -tool golang.org/x/tools/cmd/stringer
$ go get -tool golang.org/x/tools/cmd/goimports

tools.goを消してgo mod tidy

ツールを全部 tool ディレクティブに移したら、tools.go を削除します。残った require の整理は go mod tidy に任せる。

$ rm tools.go
$ go mod tidy

呼び出し側も go run …/stringer から go tool stringer へ短くなる。Makefilego:generate コメントの該当箇所を置換すれば移行完了です。手で go.modtool 行を書いた場合も、require が無ければ go mod tidy が補います。

実行ファイルはビルドキャッシュに乗る

Go 1.24では go tool の実行ファイルがビルドキャッシュに保存されるようになりました。リリースノートの記述。

Executables created by go run and the new behavior of go tool are now cached in the Go build cache. This makes repeated executions faster at the expense of making the cache larger.

初回と2回目で速度が変わる

go clean -cache 直後の初回はツールをビルドするので時間がかかる。2回目以降はキャッシュから起動します。同じ go tool goimports --help を3回叩いた実測。

$ go clean -cache
$ time go tool goimports --help   # 1回目: ビルドあり
        3.023 total
$ time go tool goimports --help   # 2回目: キャッシュヒット
        0.196 total
$ time go tool goimports --help   # 3回目
        0.059 total

初回の3.0秒に対し、2回目は0.2秒、3回目は0.06秒。CIでキャッシュを温めておけば、2回目以降の起動は0.1秒を切ります。

キャッシュが膨らむ代わり

速くなる代償として、ビルドキャッシュのサイズは増えます。リリースノートも “at the expense of making the cache larger” と明記している。CIのキャッシュ容量を切り詰めている環境では、この増加分が効いてきます。

全ツールをまとめて扱うメタパターン

go.mod 内の全ツールは tool という名前でまとめて指せます。個別パスを並べずに一括操作できる。

$ go get -u tool        # 全ツールをアップグレード
go: upgraded golang.org/x/telemetry => v0.0.0-20260611...

$ GOBIN=$(pwd)/bin go install tool   # 全ツールをGOBINへ
$ ls bin/
goimports  stringer

3つの方式を並べると、何が go.mod 側に移ったかが見えます。

方式バージョン固定追加で要るもの実行方法
go install …@latestされないなし(GOBIN汚染)PATH上のバイナリ
tools.go + blank importgo.sumダミーpkg + build taggo run フルパス
tool ディレクティブgo.sumgo.mod の1行go tool 末尾名

踏みやすい2つの罠

移行時に引っかかる点もあります。どちらも仕組みを知っていれば避けられる。

末尾名が衝突するとフルパスが要る

go tool の短縮呼び出しは、インポートパスの末尾要素を使います。違うモジュールでも末尾が同じだと、どちらを指すか決まらない。たとえば自作の example.com/a/cmd/genexample.com/b/cmd/gen を両方ツール登録すると、go tool gen は曖昧になります。

# NG: 末尾 gen が衝突して解決できない
$ go tool gen

# OK: フルパスで一意に指す
$ go tool example.com/a/cmd/gen

Go同梱ツールと同じ名前(vet など)を登録したときも、同じ理由でフルパス指定になります。

モジュールグラフに依存が増える

tool ディレクティブは、ツールの依存をあなたのモジュールの require に取り込みます。golangci-lint のような巨大ツールを足すと、go.mod// indirect が一気に増える。アプリの実行バイナリには含まれませんが、go mod graph は太ります。

これが気になる規模なら、CIでしか使わない重量級ツールだけ tool から外し、go run …@version でバージョン指定実行に留める手もあります。全部を go.mod に押し込むのが常に正解とは限らない。判断の軸は「チーム全員がローカルで同じバージョンを使う必要があるか」です。手元で頻繁に叩く stringermockgentool 向き。CIの最終チェックだけの govulncheck なら、バージョン固定実行でも足ります。

まとめ

Go 1.24のtoolディレクティブは、tools.goの空インポートという回避策を正式な機能に置き換えました。現行のGo 1.26でも挙動は同じです。

  • go get -tool パスgo.modtool 行が追加され、バージョンが go.sum で固定される
  • 実行は go tool に末尾名を渡す。一覧は引数なしの go tool
  • 移行は go get -tool へ置き換えて tools.go を消し、go mod tidy で整える
  • 実行ファイルはビルドキャッシュに乗り、2回目以降は初回の数十分の1で起動する
  • 末尾名の衝突はフルパスで回避。重量級ツールはモジュールグラフとの相談で tool 登録を見送る選択もある
よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
目次