概要
Go 言語におけるコード生成 (go generate) について、自分の中でベストプラクティスと思えるものが増えてきたので、ここでまとめて紹介してみたいと思います。
- 2020/05/30 初版
- 2020/06/03 次の節を追加
- 2020/06/03 次の資料を公開
wtz.go と time について
go generate のベストプラクティスを説明するにあたり、この記事では wtz.go と time の 2 つのライブラリを実例としてとりあげます。
wtz.go は筆者が Go 標準ライブラリの time の Windows ランタイム部分を参考にして作成したもので、 Windows タイムゾーンをどの OS や実行環境でも扱えるようにするためのライブラリです。これを使うと、例えば Tokyo Standard Time
のような Windows 独自のタイムゾーン文字列と time.Location とで相互変換ができるようになります。
wtz.go と time のいずれのライブラリも Unicode CLDR Project が管理する windowsZones.xml をダウンロードしてそれを基にタイムゾーン変換表の Go ソースコードを出力するコードジェネレータを含んでおり、これを //go:generate
ディレクティブにより呼び出しています。
wtz.go 自体の説明や Windows タイムゾーンの話題については、別の記事に改めて書きたいと思います。
go generate のベストプラクティス
基本的または対応が容易と思えることから、順に紹介していきます。
生成するコードに Code generated
コメントを追加する
go generate のマニュアルで説明されていますが、Go の世界では次の正規表現にマッチするコメントを含むファイルは自動生成されたファイルとして扱うという取り決めがあります。
^// Code generated .* DO NOT EDIT\.$
なので、コードジェネレータを作るときには、まず出力ファイルの先頭にこのコメントを出力するようにしましょう。例えば wtz.go でも生成したファイル wtz_maps.go の先頭には次のようなコメントを出力しています。
// Code generated by genmaps.go; DO NOT EDIT.
// Based on information from https://raw.githubusercontent.com/unicode-org/cldr/master/common/supplemental/windowsZones.xml
package wtz
golint などの静的解析ツールは、このコメントが含まれるファイルのチェックをスキップしますし、 Visual Studio Code のような IDE でも警告が出なくなるので、見通しがよくなります。コードジェネレータとしても golint などの基準に従ったコードを出力する必要がなくなるので、開発が楽になります。
生成したコードをきれいにする
コードジェネレータが出力したコードはツールできれいにフォーマットしましょう。 生成したコードを常にフォーマットすると決めておけば、適切なインデントや改行を気にすることなくコード出力してよくなるので、コードジェネレータの開発が楽になります。
Go の開発環境では gofmt が標準コマンドとして利用可能なので、生成したファイルに対してこれを実行するのが最も簡単です。
//go:generate go run generator.go
//go:generate gofmt -w genarted.go
標準コマンドではありませんが gofmt の代わりに goimports を使うと、パッケージの import 文も勝手に追加・削除してくれるので開発がさらに楽になります。
gofmt も goimports もその機能はライブラリパッケージとして公開されており、コードジェネレータに組み込んで呼び出し可能です。 gofmt 相当の処理は format.Source() 関数でできます (time のコードジェネレータで使っています)。 goimports 相当の処理は imports.Process() 関数を使います (wtz.go のコードジェネレータで使っています)。
どちらの関数も Go のソースコードを []byte
で受け取って []byte
で返すので、コード生成部分では bytes.Buffer
のようなメモリバッファに書き出すように作るのがよいでしょう。この構成にすればフォーマットによる文法チェックが通った場合のみ実際のファイルに出力されるようにできるので、安全性が増します。
マップを元データとするときは要素の出力順をソートする
マップの要素を元データとしてコードを出力するときには、その要素の出力の順序が常に一定となるようにしましょう。具体的には、適当な基準で要素をソートするようにしてください。
Go のマップはメモリの格納場所の決定に乱数が使われるため、単純にマップ型から range
で全要素を取り出すと、その順番は実行のたびに異なります。要素の出力の順序が変わると、コードの意味や内容は変わらなくてもソースコードの変更があるとみなされ、無駄なリポジトリへのコミットが発生することがあります。
簡単な例で説明します。コードの生成元として次のようなマップが定義されているとします。
var srcMap = map[string]string{
"A": "a",
"B": "b",
"C": "c",
"D": "d",
}
これを文字列の配列の配列に変換してそのリテラルを dstArray
としてコード出力するとき、単純に range srcMap
でループを回すと、その要素の出力の順序は不定になってしまいます。
fmt.Println("var dstArray = [][]string{")
for k, v := range srcMap {
fmt.Printf("[]string{%q, %q},\n", k, v)
}
fmt.Println("}")
面倒ですが、順序を固定にするために例えば最初にキーだけを取り出した配列を作り、それをソートした順でマップから要素を取り出してコード出力するようにします。
var keys []string
for k := range srcMap {
keys = append(keys, k)
}
sort.Strings(keys)
fmt.Println("var dstArray = [][]string{")
for _, k := range keys {
v := srcMap[k]
fmt.Printf("[]string{%q, %q},\n", k, v)
}
fmt.Println("}")
Go Playground でも動作を確認できます。何度か実行してみてください: https://play.golang.org/p/UwY_S77tYZF
独立したフォルダに //go:generate
とコードジェネレータを書く
これは実践している人をあまり見ないのですが、 //go:generate
は実際の出力先フォルダ内には書かず、 gen といった決まった名前の独立したフォルダに分離して配置するのがよいと思っています。ついでにコードジェネレータ本体も同じフォルダに格納すると、まとまりがよくなります。
実例としては wtz.go のフォルダ構成を見てください。
wtz.go
|-- gen
| |-- genmaps.go ← (package main) (//go:generate) (コードジェネレータ本体)
|-- go.mod ← (コードジェネレータの依存関係も記録される)
|-- go.sum ← (同上)
|-- wtz.go ← (package wtz)
|-- wtz_maps.go ← (package wtz) (コードジェネレータ出力先)
コードジェネレータ genmaps.go には次のような //go:generate
が書かれています。このコマンドは gen フォルダで実行されるので、 go run .
でコードジェネレータをビルド・実行できますし、また相対パスを使って正しい出力先を指定できます。
//go:generate go run . -output ../wtz_maps.go
wtz.go の元となった time パッケージは独立したフォルダを作らない、標準的なコード生成の例として参考になります。
time
|-- genzabbrs.go ← (package main) (// +build ignore) (コードジェネレータ本体)
|-- zoneinfo.go ← (package time) (//go:generate)
|-- zoneinfo_abbrs_windows.go ← (package time) (コードジェネレータ出力先)
zoneinfo.go に //go:generate
が書かれています。コードジェネレータ genzabbrs.go は main パッケージのプログラムですが // +build ignore
のビルドタグを追加することでライブラリのビルド時には無視されるようにしています。
//go:generate env ZONEINFO=$GOROOT/lib/time/zoneinfo.zip go run genzabbrs.go -output zoneinfo_abbrs_windows.go
独立したフォルダに配置することについては //go:generate
とコードジェネレータのそれぞれについて個別の利点があります。どちらか片方だけを採用したり、全く別のフォルダに置いたりすることもありだと思います。
-
//go:generate
を独立フォルダに置く理由- まずいコードを生成してしまったときの問題を回避できる
- 大規模プロジェクトに含まれる多数の go generate を効率的に実行できる
- コードジェネレータを独立フォルダに置く理由
-
// +build ignore
のようなビルドタグが不要になる - コードジェネレータが依存するモジュールを go.mod や go.sum に記録できる
- コードジェネレータを複数のファイルで実装でき、テストも書ける
-
上記の //go:generate
に関する理由 2 点について補足説明します。
ひとつめの理由は、まずいコードを生成してしまったときに発生する問題を回避するためです。例えばコードジェネレータがなにかの不具合で空ファイルを作って終わってしまうと、 package 宣言がないというエラーでそれ以上 go generate できなくなり、元に戻すには手動でそのファイルを消す必要があります。これはコードジェネレータの開発中には特に頻繁に発生する煩わしい問題です。
$ go generate .
can't load package: package .:
generated.go:1:1: expected 'package', found 'EOF'
ただし、この挙動については cmd/go: generate shouldn't require a complete valid package という issue で改善が進められており、次の Go のリリース (1.15?) では気にする必要がなくなるかもしれません。
もうひとつの理由は、大規模なプロジェクトにおいて、フォルダ名を揃えておくことで go generate の実行効率が向上できることです。 //go:generate
を gen という名前のフォルダに入れるルールにすれば、次の 1 行のコマンドでソースツリー内の必要なフォルダだけを効率よく処理することができます。
$ go generate ./gen ./.../gen
サードパーティのコードジェネレータも go run で実行する
Go Modules が導入されてから、公開されている Go 製のツールは go run によるダウンロード・ビルド・実行が一度にできるようになりました。 //go:generate
でもなるべくこの機能を利用するようにしましょう。
例えば次のように書くと、開発者には go generate の実行前に stringer コマンドのインストールを要求することになりますが、
//go:generate stringer -type=Pill
代わりに次のように書けば、開発者は事前の準備なしに go generate を実行するだけですべてが終わるようにできます。
//go:generate go run golang.org/x/tools/cmd/stringer -type=Pill
使用するコードジェネレータのバージョンをモジュールに記録する
前節のように go run でコードジェネレータを呼び出す場合、使用したコードジェネレータのバージョンを記録することができます。
次のような内容の tools.go ファイルを作ることで、 go.mod と go.sum にコードジェネレータを含むモジュールのバージョンが記録でき、その後の go generate でもそのバージョンを使用することを強制できます。
// +build tools
package main
import _ "golang.org/x/tools/cmd/stringer"
ビルドタグ // +build tools
が書かれているので、パッケージのビルドにこのファイルが含まれることはありません。
このテクニックは Go Wiki の How can I track tool dependencies for a module? で紹介されています。
Go だけでコードを生成する (ように努力する)
ビルド・リリースのプロセスに make を使ったり cp や tar などのコマンドや Bash のシェルスクリプトを使っている Go のプロジェクトをよく見かけます。しかしながら、これをやると Windows のような Unix 以外のプラットフォームでの開発環境のセットアップが面倒になるのでよろしくないと個人的には思っています。
go generate についてもこれは同様で、 //go:generate
のコマンドラインで使用するのは、 Go にバンドルされている標準コマンドか go run による Go ソースファイルまたはパッケージの実行に限るのが望ましいと言えます。
ただしそうはいってもシェルのコマンドひとつで済むことを Go で書くのもしんどいので、実際にはそのコストとメリットを考慮して総合的に判断するべきことでしょう。
やや脱線しますが、ビルド・リリースのプロセスもなるべくクロスプラットフォームにするため、筆者は GoReleaser をよく使っています。この他にも Go で書かれたクロスプラットフォームなシェルや make となるライブラリやツール (Ruby の FileUtils とか rake のようなもの) で何かいいものがないかと思っています。
まとめ
go generate のベストプラクティスを実例とともに何点か紹介しました。
どういうわけか、これまで筆者は所属組織の Go プロジェクトでも msgraph.go や wtz.go といったオープンソースプロジェクトでも go generate するコードばかりを書いてきたので、ここいらで知見やノウハウ、普段思っていることをまとめておこうと思い、この記事を書き始めました。
まだ書き足りない点がいくつかあるので、この記事は今後も更新していきたいと思います。内容について、意見、感想、質問、誤りの指摘などがありましたらお気軽にお知らせください。よろしくお願いします。