🧰

Goのcode generatorの作り方: 諸注意とtext/templateの使い方

2024/08/18に公開

Goのcode generatorの作り方についてまとめる

Goのcode generationについてまとめようと思います。

この記事では

  • Rationale: なぜGoでcode generationが必要なのか
  • code generatorを実装する際の諸注意
  • io.Writerに書き出すシンプルな方法
  • text/templateを使う方法
    • text/templateのcode generationにかかわりそうな機能性について説明します。
    • 実際にtext/templateを使ってcode generatorを実装します。

について述べ、

後続の

についてそれぞれ述べます。

前提知識

環境

Goのstdに関するドキュメントおよびソースコードはすべてGo1.22.6のものを参照します。
golang.org/x/toolsに関してはすべてv0.24.0を参照します。

コードを実行する環境は1.22.0です。

# go version
go version go1.22.0 linux/amd64

書いてる途中で1.23.0がリリースされちゃったんですがでたばっかりなんで1.22.6を参照したままです。マニアワナカッタ。。。
間に合わなかったので一部のリンクは1.23.0rc2へのままです。

Rationale: なぜGoでcode generationが必要なのか

似たようなことを繰り返し行う必要があるとき、繰り返し同じコードを書く代わりに処理を共通化しておくほうが間違えにくいのでまずそうしたいと思います。

共通化の方法に

  • 関数、struct
  • interfaceによるdynamic dispatch
  • generics
  • reflect
  • macro
  • code generation

などがあります。
プログラミング言語が要求を満たす共通化の方法を提供しないとき、ソースコードを生成することになります。これはたぶん最後の手段です。

Goは、上記で言うとmacroをサポートしません。Cだとよくマクロを使いますし、Rust強力なマクロ機能を備えていますよね。

Goでは代わりにたびたびcode generationを行いますし、それを行うことは前提のようになっています。
それはgo generateというサブコマンドが存在することや、Goのstd自身がそれを多用することから様式として存在していることがわかります。
また、Goにはgenericsによる「ある型セットに対する共通した処理」と、reflectによる「型情報を使った動的な処理」を実装できますが、これらは当然ソースコードの解析を必要とするような挙動は実装できませんので、それらを必要とする場合はcode generationが必要となります。

go generate

The Go BlogのGenerating codeにも書かれている通り、Go 1.4からGoにはgo generateというサブコマンドが追加されました。
これはgo source codeに書かれた//go:generateマジックコメントのあとにスペースを挟んで書かれた任意をコマンドを、そのソースファイルの位置をcwdに指定して実行するというものです。
go generateは任意のコマンドを実行できますが、基本的にcode generatorを実行してコードを生成することを想定した仕組みです。

Go自身も//go:generateを活用しており、

https://github.com/search?q=repo%3Agolang%2Fgo %2F%2Fgo%3Agenerate&type=code

以上のように検索してみればたくさんヒットします。

goのreflect/generic

Goではreflectを使うことで型情報をanyな値から取り出すことができ、これを元に動的な挙動を行うことができます。
また、Go 1.18で追加されたGenericsを用いることで、ある制約を満たす複数の型に対して処理を共通化できます。

例えば、以下のようなサンプルを定義します。

サンプルでは、あるstruct(Sample)に対して、フィールド名と定義順が一致するが、型がPatcher[T](Tは元のstruct fieldの型)で置き換えられたstruct(SamplePatch)を用意することで、部分的なフィールドの変更(=Patch)をする挙動をreflectを使って実装できることを示します。

playground

type Sample struct {
	Foo string
	Bar int
	Baz bool
}

type SamplePatch struct {
	Foo Patcher[string]
	Bar Patcher[int]
	Baz Patcher[bool]
}

type Patcher[T any] struct {
	Present bool
	V       T
}

func (p Patcher[T]) IsPresent() bool {
	return p.Present
}

func patch(target, patch any) {
	tgtRv := reflect.ValueOf(target).Elem()
	patchRv := reflect.ValueOf(patch)

	for i := 0; i < tgtRv.NumField(); i++ {
		ft := tgtRv.Field(i)
		fp := patchRv.Field(i)

		if !fp.Interface().(interface{ IsPresent() bool }).IsPresent() {
			continue
		}

		ft.Set(fp.Field(1))
	}
}

func main() {
	s := Sample{}
	fmt.Printf("0: %#v\n", s)
	// 0: main.Sample{Foo:"", Bar:0, Baz:false}

	patch(&s, SamplePatch{Foo: Patcher[string]{true, "foo"}})
	fmt.Printf("1: %#v\n", s)
	// 1: main.Sample{Foo:"foo", Bar:0, Baz:false}

	patch(
		&s,
		SamplePatch{
			Foo: Patcher[string]{true, "bar"},
			Bar: Patcher[int]{true, 123},
		},
	)
	fmt.Printf("2: %#v\n", s)
	// 2: main.Sample{Foo:"bar", Bar:123, Baz:false}

	patch(&s, SamplePatch{Baz: Patcher[bool]{true, true}})
	fmt.Printf("3: %#v\n", s)
	// 3: main.Sample{Foo:"bar", Bar:123, Baz:true}
}

上記のサンプルではSample, SamplePatchに対してのみしか動作が確かめられていませんが、実際には条件を守るあらゆるstructのペアに対してPatchを行うことができます。

  • reflectを使用することでstructなどのデータ構造に対して動的な処理を実装できます
  • genericsを利用することで任意の制約を満たす型に対して共通した処理を実装できます
    • (サンプルでは特に示していないが)「特定のinterfaceを実装する」という型制約をかけることもできます。

ただし、

  • reflectを使って動的に処理を行う場合、静的に型を当てた関数で包んでtype assertionを行わない限り型安全性を失います。

つまりこういうことです。

func patchSample(s *Sample, patcher SamplePatch) {
	patch(s, patcher)
}

patchの引数はどちらもanyでしたが、実際には第一引数はfoobarへのポインターで、第二引数はfoobarPatchのnon-pointer型でないと想定通りの動作をしませんから、
こうやって具体的な型の書かれた関数を定義したほうが利用しやすいことになります。

このケースでは返り値がないのでピンときにくいかもしれませんが、reflect.Valueから値を取り出そうと思うと、(basicな型でないときは)Interface()メソッドでany型の値を取り出すしかありませんので、
type assertionを関数内で行うことで返り値の型を具体的なものにするのが普通だと思います。

内部的な挙動はreflectで作りこむにしろ、具体的な型を当てたラッパーはcode generatorで作成したいということはよくあるはずだ、ということです。
(reflect使わずcode generatorでこういう挙動をするコードを吐き出してもいいんですがここでは気にしません!)

  • genericsでは#49085がないためにmethodにtype paramを与えることができません。

つまり以下のようなことはできないということです。

// compilation error
func (p Patch[T]) Convert[U any](converter func(t T) U) U {
	// ...
}

メソッドにtype paramが持てないため複数の型にほぼ同じ処理のメソッドを実装したい場合はcode generatorを作ったほうがメンテが楽だったりすることもあるということです。

また、双方ともに型情報に含まれないような情報を用いた処理を行えません。

例えば、code generatorであるgolang.org/x/tools/cmd/stringerは以下のような、iotaを用いたenum風なconst定義に対して

https://github.com/golang/go/blob/go1.22.6/src/html/template/context.go#L80-L161

以下のようにStringメソッドを生成します。

https://github.com/golang/go/blob/go1.22.6/src/html/template/state_string.go

これらはソースコードの解析その他を行わない限り不可能なことですので、こういったことをしたい場合はcode generatorが必要になります。

4つの(おそらく)代表的な方法

大雑把に言って4つの方法が代表的なのではないかと思います

  • simple text emitter: io.Writerにテキストを書くだけ
    • プログラムによってgo source fileとなるテキストを書きだすだけの方法です
  • text/templateを用いる方法
    • stdで実装されるテンプレートエンジンを用いる方法です
  • github.com/dave/jenniferを用いる方法
    • サードパーティで実装されるcode generatorを記述するためのライブラリを用いる方法です
    • goのトークンや構文に対応した各種関数をメソッドチェーンで記述していく方式です。
  • ast(dst)-rewriteを行う方法
    • Goのsource codeを解析しast(abstract syntax tree)を得てそれをもとにコードを生成する方法です。
    • go/ast, go/parser, go/printerなどのstd libraryを用います。
    • astで1からコードをくみ上げることも当然可能ですが、前述のいずれかの方法をとったほうが簡単なので、rewriteする方法についてのみ述べます
    • astのrewriteではコメントのオフセット周りに問題があるため、github.com/dave/dstを代わりに用います

上記を整理しなおすを以下のような関係図になります

code-generation-data-flow

おおむねコード生成のためのメタデータ取得部と、コード生成部と、コードの書き出し部分、最後のポストプロセスとしてフォーマットに分かれると思います。

メタデータ取得部分はastを用いる場合はgo source codeを入力とし、go/parserを用いてastの解析します。astは、さらにtype checkerで解析すること型情報を得ることもできます(e.g. skeleton)。この一連の記事ではtype checker関連の話には踏み込みません。
他の方法ではJSON, YAMLのようなフォーマットで書かれたデータ構造(ここにcli引数も含む)をjson.Unmarshalyaml.Unmarshalしてデータ構造にbindしたり、text/template向けのテキストを読み込んだりします。

コード生成部(図のjennifer,simple text emitter, text/templateおよびast rewrite部分のこと)では、えられたメタデータを元にio.Writerに書き出したり、text/templateを用いるなど前述の方法の一部または全部を組み合わせて行います。astをrewriteする方法ではgo/printerの機能を利用することで、あるnodeのみを出力するようなことができるので、これも他の方法と組み合わせて1つのテキストファイルを形成することができます。

コード生成部によってテキストを出力します。このテキストは有効なgo source codeの文法を満たしてさえいれば(i.e. package pkgnameから始まるgo code)この時点でファイルとして書きだされている必要はありません。

go source codeのテキスト、またはテキストのストリームはgofmt, github.com/mvdan/gofumpt, golang.org/x/tools/cmd/goimportsなどのフォーマッターを用いることでフォーマットをできます。goimportsgofmtと同じルールでフォーマットを行ったうえで、import declが正しくなかった場合修正をこころみます。code generatorの実装する際、完璧なインデントを保ったり、使わないimportを削除したりが大変なことがあります。Goでは不要なimportが存在するとコンパイルが通りませんので、goimportsにそれらを修正してもらうことで楽ができます。

最後に、ファイルとして書きだされたgo source codeはgoplsの機能を用いてフォーマットをかけることができます。ユーザーのgopls設定を元にフォーマットを行いたい場合は便利かもしれませんが基本的にはしません。理由はよくわかっていませんが、goimportsなどを直接呼び出す方法に比べてずいぶん動作速度が遅い(0.1秒オーダーに比べて数秒オーダー)ためです。

それぞれの方法の利点と欠点

  • simple text emitter: io.Writerにテキストを書くだけ
    • 利点: すごいシンプルなのですぐかける
    • 欠点: シンプルなので複雑なケースに対応できない
      • パラメータを複数回使いまわすとか
      • ifで分岐するとか
      • ユーザーから入力を受けたい場合、などに対応しにくいです。
        • するならほかの方法を使うほうが良いです
  • text/templateを用いる方法
    • 利点: stdで終始できる
      • ここが最大の利点だと思います。std以外を全く何もimportしないで済みます
      • 複数templateへの分割、関数の任意な追加、ユーザーからtemplateの入力を受け付けなど複雑なケースに対応できます
    • 欠点: 読みにくい
      • gopls(Goの言語サーバー)によるsyntax highlightなどの支援を受けられますが、生来の複雑さを持っためforがネストしだす本当に読みにくいです。
      • そもそもtemplate用途なので、Goのcode generator専用にしつらえられたjenniferのほうが使いやすいのは当然ではあります。
  • github.com/dave/jenniferを用いる方法
    • 利点: 読みやすい/書きやすい
      • Goの関数呼び出しをチェーンさせるだけなのでsyntax highlightがしっかりかかる
      • それぞれの関数はGoのトークンや構文ルールに一致するので、違和感は少ない
      • 単なるGoコードなので、任意に分割できる
      • importの取り扱いを自動的に行う機能があるため、楽
    • 欠点: とくにない?
      • しいて言えばユーザーからシリアライズされた部分的なtemplateを受けとる方法が特に決まっていないので、自ら実装する必要があります
      • ただしその場合、text/templateのテンプレートを受け付けて別ファイルに出力すればよいだけにも思います。
      • サードパーティのライブラリをインポートすることなりますので、そこが気になるケースでは採用しずらいです。
  • ast(dst)-rewriteを行う方法
    • 利点: 既存のgo source codeを入力とできる。
      • 入力をGo source codeとできるのは当然この方法だけです。
    • 欠点: astの変更や、1からastをくみ上げるのは手間がかかる
      • Goのソースコードを直接書きに行くほかの方法に比べてたった1つのトークンを書くだけでも何倍もの文字を打つ必要があってかなり面倒です。
      • そのためこの記事ではrewriteする方法しか想定しません。

misc: codeを生成する際の注意点

どの方法にもよらない注意点などをここにまとめておきます。

ファイル先頭に// Code generated ... DO NOT EDIT.をつける

go generateのドキュメントにもある通り、
^// Code generated .* DO NOT EDIT\.$という正規表現にマッチする行がpackage clauseより前に含まれる場合、go toolはこれをcode generatorによって生成されたファイルであるとみなします。
Code generatedの後の.*の部分にcode generatorのpackage pathを書いておくとどうやって生成したのかわかってよいのではないかと思います。

Go1.21よりast.IsGeneratedという関数がexportされるようになったので、ast解析を行って*ast.Fileがえられており、それがcode generatorに生成されたファイルかの確認が行いたい場合はこれを用いるとよいでしょう。

for-range-mapの部分で毎回異なる順序で生成してしまうことがあるので注意する

code generator実装の内部でfo-range-mapをしてしまうと、実行ごとに異なる順序になることがあるため、そうならないための気遣いが必要です。

https://go.dev/ref/spec#For_range

The iteration order over maps is not specified and is not guaranteed to be the same from one iteration to the next.

Goの言語仕様によりfor-range-mapの順序は未定義です。

code generatorが内部でfor-range-mapを行っており、これがそのまま結果の出力順序に反映されていると実行のたびに結果が異なることがありあえます。
筆者が利用するサードパーティのcode generatorの中にも、生成する度に順序の入れ替わるものがありますが、生成対象が多くなるにつれて出てくるdiffの量が多くなってセルフレビューが大変になっています。
基本的にそうならないように作ったほうが利用者とっては便利です。

代わりにGo 1.22.x以前では

keys := make([]K, len(m))
var i int
for k := range m {
	keys[i] = k
	i++
}
slices.Sort(keys)
for _, k := range keys {
	_ = m[k]
}

とすることで、stableな順序でmapをiterateできます。
Go1.23以降ならもっと簡単に

for _, k := range slices.Sorted(maps.Keys(m)) {
	_ = m[k]
}

とできます。

動作することはplaygroundで確認してください

go:generate go run -mod=mod

これはREADME.mdなどの中で、あなたの作成したcode generatorの呼び出し方をどのように指示するかという話なんですが、

あなたの作るcode generatorが生成するコードが何かしらの外部パッケージを必要とし、それがcode generatorと同じモジュールで管理されているとき、以下のように、go run -mod=modで実行するよう指示するとよいでしょう。

# 架空のURLを取り扱うのでexample.comのサブドメインとして書いています
# url自体は興味のあるところではありません!
//go:generate go run -mod=mod fully-qualified.example.com/package/path/cmd/path/to/main/pkg@version

https://go.dev/ref/mod#build-commands

-mod=mod tells the go command to ignore the vendor directory and to automatically update go.mod, for example, when an imported package is not provided by any known module.

とある通り、-mod=modで動作させるとcode generatorのバージョンが、生成物の配置先となるgo moduleのgo.modに追加されるなり更新されるなりするらしいです。

post process: goimports

ソースはここでもホストされます

https://github.com/ngicks/go-example-code-generation/tree/main/misc/apply-goimports

生成したコードはgoimportsによってフォーマットをかけてから書き出すとよいでしょう。
これにより、

  • 万一code generatorの実装ミスや、ユーザーが指定できるパラメータのvalidationががおかしくて生成されたコードがGoの文法を満たさない場合にエラーとして検知が可能です。
  • インデントか崩れていたり、使われていないimportがあったりしたとき修正してもらえます。

gofmt, gofumptと同様にgoimportsはstdinにGoのsource codeを入力するとstdoutに出力する挙動があるので入力はファイルシステムに書き出されている必要はありません。

goimportsは以下のコマンドでインストールします。

go install golang.org/x/tools/cmd/goimports@latest

呼び出しは例えば以下のような感じで行えばよいです。

func checkGoimports() error {
	_, err := exec.LookPath("goimports")
	return err
}

func applyGoimportsPiped(ctx context.Context, r io.Reader) (io.ReadCloser, error) {
	cmd := exec.CommandContext(ctx, "goimports")
	return newCmdPipeReader(cmd, r)
}

func applyGoimports(ctx context.Context, r io.Reader) (*bytes.Buffer, error) {
	p, err := applyGoimportsPiped(ctx, r)
	if err != nil {
		return nil, err
	}

	var buf bytes.Buffer
	_, err = io.Copy(&buf, p)
	cErr := p.Close()

	switch {
	case err != nil && cErr != nil:
		err = fmt.Errorf("copy err: %w, wait err: %w", err, cErr)
	case err != nil:
	case cErr != nil:
		err = cErr
	}

	return &buf, err
}

type cmdPipeReader struct {
	cmd      *exec.Cmd
	pipe     io.Reader
	stderr   *bytes.Buffer
	waitOnce sync.Once
	err      error
}

func newCmdPipeReader(cmd *exec.Cmd, r io.Reader) (*cmdPipeReader, error) {
	stderr := new(bytes.Buffer)

	cmd.Stdin = r
	cmd.Stderr = stderr

	p, err := cmd.StdoutPipe()
	if err != nil {
		return nil, err
	}

	err = cmd.Start()
	if err != nil {
		return nil, err
	}

	return &cmdPipeReader{cmd: cmd, pipe: p, stderr: stderr}, nil
}

func (r *cmdPipeReader) Read(p []byte) (n int, err error) {
	return r.pipe.Read(p)
}

func (r *cmdPipeReader) Close() error {
	r.waitOnce.Do(func() {
		err := r.cmd.Wait()
		if err != nil {
			err = fmt.Errorf("%s failed: err = %w, msg = %s", r.cmd.Path, err, r.stderr.Bytes())
		}
		r.err = err
	})
	return r.err
}

こんな感じでpipeを返すことでfilter的にgoimportsを使うことができます。

このコードではgo run golang.org/x/tools/cmd/goimports@latestとするのではなく、システムにインストール済みのgoimportsを利用します。
なので、checkGoimportsを他の生成ロジックより前に呼び出して、実行プロセスが見ることができる位置にgoimportsが存在するかを確認しておくほうが無難です。

別にgo run ...しても問題ないとは思うんですが、go runはモジュールが$GOPATH以下にキャッシュされていない場合はホストからモジュールをダウンロードします。この行でダウンロード周りのエラーを起されたくないですのでこうしています。

simple text emitter: io.Writerに書くだけの方法

まずsimple text emitterと呼んでいた単にio.WriterGo source codeを書き出すだけの方法について述べます。

とは言え実際シンプルなのであまり述べることはありません。

例えば、Goruntimeでは以下のような単にテキストを書くだけのcode generatorを見つけることができます。

https://github.com/golang/go/blob/go1.22.6/src/runtime/wincallback.go

生成対象は.sGo assemblyファイルですが、まあ言いたいことはかわらないのでいいとしましょう。

このコードによって以下の3つのファイルが生成されます。

https://github.com/golang/go/blob/go1.22.6/src/runtime/zcallback_windows_arm.s
https://github.com/golang/go/blob/go1.22.6/src/runtime/zcallback_windows_arm64.s
https://github.com/golang/go/blob/go1.22.6/src/runtime/zcallback_windows.s

これらのファイルは以下のような、ほぼ同じパターンを2000(=maxCallback)回繰り返すだけの単純なものです。

	MOVD	$i, R12
	B	runtime·callbackasm1(SB)

このように、単純なコード断片を何度も書きだすだけのようなケースでは、単なるio.Writerへの書き出しで十分機能します。

text/templateを用いる方法

text/templateを用いる方法について述べます。

text/templateは高機能でぱっと見難しいので、code generatorを作る際にかかわりそうな機能性について説明し、最後にcode generatorを実装してみることとします。

利点と欠点

利点:

  • stdのみで終始できる
  • 十分柔軟で便利
  • 何ならtemplateそのものをユーザーに入力させて、code generatorの挙動をカスタマイズさせるようなことができる
    • 当然テキストなので、cliやネットワーク経由でも容易に受け取ることができます。

欠点:

  • code generationのためのものではない
    • github.com/dave/jenniferに比べると大分書きにくい
    • forがネストしだすと劇的に視認性が落ちる
    • 空白の取り扱いが難しい。
      • 筆者は無駄な改行を甘んじて受け入れている
      • 生成後のコードをgoimportsによってフォーマットをかけることでいくらか改善する
    • importの取り扱いが大変。
      • ユーザーにtemplateを入力させる系を想定すると、ここで新しく追加されたimportをどう取り扱うか、自分で決める必要があります。

text/template

https://pkg.go.dev/text/template@go1.22.6

stdライブラリに組み込まれたテンプレート機能です。

テンプレートなので、既存の型板となるテキストの、特定の部分を入力によって切り替えるものです。
それに加えてiffor、関数の呼び出しなどが機能として組み込まれているのでおおよそ何でもできてしまいます。

html/templateも存在しますが、こちらはhtmlを出力するための各種サニタイズを実装したtext/templateのラッパーみたいなものですので、テキストの出力に関してはtext/templateを使用します。
エディターの自動補完に任せるとhtml/templateのほうがimportされることがありますので、なぜか出力文字列がエスケープされていたらimportを確認しましょう。

詳細な使い方の説明は上記のtext/templateのdoc comment、ないしは実装そのものに当たってほしいと思いますが、筆者は初めて読んだときあまりにピンときませんでした。
なのでcode generatorとして使うときにかかわりそうなところはここで説明しておきます。

エディターのサポート(syntax highlight, Go to definition, etc...)

goplsの設定をしたうえでtemplateを.gotmpl.tmplの拡張子で保存すると、goplsによるsyntax highlightなどのサポートを得られます(experimental)。

vscodeの場合、settings.jsonに以下を追加します。

{
  // ...other settings...
  "gopls": {
    // ...other settings...
    "ui.semanticTokens": true,
    "build.templateExtensions": ["gotmpl", "tmpl"]
    // ...other settings...
  }
  // ...other settings...
}

他のエディターの場合、goplsを似たような感じで設定します。

syntax highlight以外の機能は現状でも機能しているように見えるので、そこが不要なら設定は不要です。
"ui.semanticTokens"を有効にするとtemplateのみならず、Go source codeそのもののトークンの色がかなり変わって表示されますのでびっくりするかもしれません。

現状goplssemanticTokensはexperimentalですがもうすぐenabled by defaultになるかもしれません(#45313)。

基本的な使用法

例示されるコードは以下でもホストされます。

https://github.com/ngicks/go-example-code-generation/blob/main/template/basic

構文

パラメータ、関数その他の呼び出しはdelimiter({{}})で囲まれたブロックの中で行います。

An example template.
Hello {{.Gopher}}.
Yay Yay.

というtemplate textでは{{.Gopher}}の部分が入力のパラメータによって動的に変更されることになります。
上記は、パラメータとして渡された任意のGo structの、Gopherというexported fieldの値でここを置き換えるという意味になります。

このdelimiter({{,}})は(*Template).Delimsで任意の文字列に変更できます。
基本的には変えないほうが良いです: goplsのドキュメントにも、delimiterは変えられるが変えたら構文解析が機能しないようなことが書いてあります。
筆者はこの記事を書くまで変更できることすら知りませんでした。

初期化、解析

template.New()で新しい*Templateをallocateし、Parseによってtemplateテキストを解析して*Templateオブジェクトを得ます。

var example = template.Must(template.New("").Parse(
	`An example template.
Hello {{.Gopher}}.
Yay Yay.
`,
))

template.Newにはnameを渡せますが、今回のように単一のtemplateしか解析しない場合は特に名づける必要はありません。
template.Must(*Template, error)を引数にとって、第二引数のエラーがnon-nilだった場合panicするヘルパー関数です。

実行(Execute)

上記でParseから返された*Templateオブジェクトのメソッドを呼び出すことでこのtemplateを実行できます。

type sample struct {
	Gopher string
}

err := example.Execute(os.Stdout, sample{Gopher: "me"})

で、渡されたio.Writerにtemplate実行結果を書き出します。
os.Stdoutを渡しているのでstdoutに書き出されます。

An example template.
Hello me.
Yay Yay.

上記がstdoutに出力されます。

パラメータへのアクセス

Executeの第二引数にはパラメータを詰め込んだデータを渡します。
{{.}}.はcontextualな値で、トップレベルではExecuteに渡したデータそのものをさしています。

渡すパラメータは任意のGoの値です。
structもしくはmap[K]Vであれば、dot selectorで紐づけられた値にアクセスできます。

structを指定する場合はreflectパッケージを使って値にアクセスしますので、reflectでアクセスできるフィールドを指定する必要があります(=Exported)。

var example = template.Must(template.New("").Parse(
	`An example template.
Hello {{.Gopher}}.
Yay Yay.
`,
))

type sample struct {
	Gopher string
}

err := example.Execute(os.Stdout, sample{Gopher: "me"})
/*
An example template.
Hello me.
Yay Yay.
*/

メソッドでもよいとドキュメントされています。

type sampleMethod1 struct {
}

func (s sampleMethod1) Gopher() string {
	return "method"
}

_ = example.Execute(os.Stdout, sampleMethod1{})
/*
An example template.
Hello method.
Yay Yay.
*/

関数は全般的に第二返り値でエラーを返してもよいということがドキュメントされています。
関数から返されるエラーがnon-nilであるとその時点でtemplateの実行が止まって、そのエラーがExecuteから返ってきます

type sampleMethod2 struct {
	err error
}

func (s sampleMethod2) Gopher() (string, error) {
	return "method2", s.err
}

_ = example.Execute(os.Stdout, sampleMethod2{})
/*
An example template.
Hello method2.
Yay Yay.
*/
fmt.Println("---")
err := example.Execute(os.Stdout, sampleMethod2{err: errors.New("sample")})
fmt.Println("---")
fmt.Printf("error: %v\n", err)
/*
---
An example template.
Hello ---
error: template: :2:8: executing "" at <.Gopher>: error calling Gopher: sample
*/

同様に、map[K]Vでもいいです。map[K]Vの場合は先頭が小文字なフィールドにもアクセスできます。
template textが先頭が小文字なフィールドにアクセスしていたらmap[K]Vを使うしかなくなってしまいます。
型を決められるstructのほうが管理が容易であるため、基本的には先頭が大文字なフィールドしか使われることはないと思います。

_ = example.Execute(os.Stdout, map[string]string{"Gopher": "from map[string]string"})
/*
An example template.
Hello from map[string]string.
Yay Yay.
*/

var accessingUnexported = template.Must(template.New("").Parse(
	`accessing unexported field: {{.unexportedField}}
`,
))

_ = accessingUnexported.Execute(os.Stdout, map[string]string{"unexportedField": "unexported field"})
/*
accessing unexported field: unexported field
*/

type unexported struct {
	unexportedField string
}

fmt.Println("---")
err := accessingUnexported.Execute(os.Stdout, unexported{})
fmt.Println("---")
fmt.Printf("error: %v\n", err)
/*
---
accessing unexported field: ---
error: template: :1:30: executing "" at <.unexportedField>: unexportedField is an unexported field of struct type main.unexported
*/
// reflectはこのフィールドにアクセスできないのでエラーが返される。

さらに、このdot selectorはchainさせることもできます。

chained = template.Must(template.New("").Parse(`**chained**{{.Chain.Gopher}}
`))

type chainedData struct {
	v   any
	err error
}

func (c chainedData) Chain() (any, error) {
	return c.v, c.err
}


_ = chained.Execute(os.Stdout, chainedData{v: sampleMethod2{}})
/*
**chained**method2
*/

_ = chained.Execute(os.Stdout, chainedData{v: map[string]string{"Gopher": "map"}})
/*
**chained**map
*/

制御構文: range, if

例示されるコードは以下でもホストされます。

https://github.com/ngicks/go-example-code-generation/blob/main/template/control-flow

range

rangeGofor-rangeのようにデータをiterateできます。

{{range pipeline}} T1 {{end}}
The value of the pipeline must be an array, slice, map, or channel.
If the value of the pipeline has length zero, nothing is output;
otherwise, dot is set to the successive elements of the array,
slice, or map and T1 is executed. If the value is a map and the
keys are of basic type with a defined order, the elements will be
visited in sorted key order.

とる通り、rangeが引数に取れるのはarray, slice, map, channelのいずれかであり、Go 1.23リリース時点ではrange-over-funcはできないようです(#66107が未実装であるので)。
map[K]Vに関してはKの型がbasicなordered typeである場合はソートしてからiterateを行うと書かれています。range-over-mapみたいに順序が未定義でないことに逆に注意が必要ですかね?

{{range $index, $element := pipeline}}

という構文で、Gorange構文のようにindexelementを変数にセットします。

range{{end}}までスコープを作り、このスコープ内では{{.}}は、iterateされているデータの各項目をさします。[]TならT, map[K]VならVになります。
上記の$index,$elementももちろんこのスコープ内でのみ有効です。

このスコープ内では{{break}}{{continue}}を使用してGobreakcontinueと同等の制御をできます。どちらにもlabelを指定できるという記載はありません。

When execution begins, $ is set to the data argument passed to Execute, that is, to the starting value of dot.

とある通り、このスコープ内では$Execute関数に渡されたデータになります。

if

ifで、Goifのように条件による分岐ができます

{{if pipeline}} T1 {{end}}
If the value of the pipeline is empty, no output is generated;
otherwise, T1 is executed. The empty values are false, 0, any
nil pointer or interface value, and any array, slice, map, or
string of length zero.
Dot is unaffected.

とある通り、emptyの条件はfalse, 0, nil, len(a)==0であるとのことなので、falsyな値の判定の関数を作りこむ必要がない場面も多いでしょう。

example

以下でrangeifを使ったexampleを示します。

var (
	example = template.Must(template.New("").Parse(
		`Hi {{.Gopher}}.
{{range $idx, $el := .Iter}}    {{if not .}}Hey {{$.Gopher}} this is empty
	{{- if not $.Continue}}{{break}}{{end -}}
{{else}}Iterating at {{$idx}}: {{.Field}} {{end}}
{{end}}
`,
	))
)

func main() {
	decoratingExecute := func(data any) {
		fmt.Println("---")
		err := example.Execute(os.Stdout, data)
		fmt.Println("---")
		fmt.Printf("error: %v\n", err)
		fmt.Println()
	}

	decoratingExecute(map[string]any{
		"Gopher": "you",
		"Iter":   []map[string]string{{"Field": "foo"}, {"Field": "bar"}, {}, {"Field": "baz"}},
	})
	/*
		---
		Hi you.
			Iterating at 0: foo
			Iterating at 1: bar
			Hey you this is empty
		---
		error: <nil>
	*/
	decoratingExecute(map[string]any{
		"Gopher":   "you",
		"Continue": "ok",
		"Iter":     []map[string]string{{"Field": "foo"}, {"Field": "bar"}, {}, {"Field": "baz"}},
	})
	/*
		---
		Hi you.
			Iterating at 0: foo
			Iterating at 1: bar
			Hey you this is empty
			Iterating at 3: baz

		---
		error: <nil>
	*/

	decoratingExecute(map[string]any{
		"Gopher": "you",
		"Iter":   map[string]map[string]string{"0": {"Field": "foo"}, "1": {"Field": "bar"}, "2": {"Field": "baz"}},
	})
	/*
		---
		Hi you.
			Iterating at 0: foo
			Iterating at 1: bar
			Iterating at 2: baz

		---
		error: <nil>
	*/
}

何気なく使っていますが、{{- pipeline}}, {{pipeline -}}で前の/後ろの空白を削除する機能があります。

For this trimming, the definition of white space characters is the same as in Go: space, horizontal tab, carriage return, and newline.

この「空白」の条件はGo source codeのそれと一致します。割とこの挙動が難しいので筆者は場合により無駄な空白や改行を甘んじて受け入れています。

Funcs: 関数の追加

例示されるコードは以下でもホストされます。

https://github.com/ngicks/go-example-code-generation/blob/main/template/funcmap

template actionの中で実行できる関数は以下で定義される通りいろいろありますが

https://pkg.go.dev/text/template@go1.22.6#hdr-Functions

それ以外にも、(*Template).Funcsで任意に追加できます。

関数は、それを参照するtemplateがParseされるより前に追加されている必要がありますが、あとから上書きすることもできます。

以下でいろいろ試してみます。

var (
	example = template.Must(
		template.
			New("").
			Funcs(template.FuncMap{"customFunc": func() string { return "" }}).
			Parse(
				`{{customFunc .}}
`,
			),
	)
)

func main() {
	decoratingExecute := func(funcs template.FuncMap, data any) {
		fmt.Println("---")
		err := example.Funcs(funcs).Execute(os.Stdout, data)
		fmt.Println("---")
		fmt.Printf("error: %v\n", err)
		fmt.Println()
	}

	decoratingExecute(nil, "foo")
	/*
		---
		---
		error: template: :1:2: executing "" at <customFunc>: wrong number of args for customFunc: want 0 got 1
	*/
	decoratingExecute(
		template.FuncMap{"customFunc": func(v any) string { return fmt.Sprintf("%s", v) }},
		"foo",
	)
	/*
		---
		foo
		---
		error: <nil>
	*/
	decoratingExecute(
		template.FuncMap{"customFunc": func(v ...any) string {
			fmt.Printf("customFunc: %#v\n", v)
			return "ah"
		}},
		"bar",
	)
	/*
		---
		customFunc: []interface {}{"bar"}
		ah
		---
		error: <nil>
	*/
	decoratingExecute(
		template.FuncMap{"customFunc": func(v string) string {
			fmt.Printf("customFunc: %#v\n", v)
			return "ah"
		}},
		"baz",
	)
	/*
		---
		customFunc: "baz"
		ah
		---
		error: <nil>
	*/
	decoratingExecute(
		template.FuncMap{"customFunc": func(v int) string {
			return "ah"
		}},
		"qux",
	)
	/*
		---
		---
		error: template: :1:13: executing "" at <.>: wrong type for value; expected int; got string
	*/
	type sample struct {
		Foo string
		Bar int
	}
	decoratingExecute(
		template.FuncMap{"customFunc": func(v any) int {
			fmt.Printf("customFunc: %#v\n", v)
			return v.(sample).Bar
		}},
		sample{Foo: "foo", Bar: 123},
	)
	/*
	   ---
	   customFunc: main.sample{Foo:"foo", Bar:123}
	   123
	   ---
	   error: <nil>
	*/
		decoratingExecute(
		template.FuncMap{"customFunc": func(v sample) string {
			return v.Foo
		}},
		sample{Foo: "foo", Bar: 123},
	)
	/*
		---
		foo
		---
		error: <nil>
	*/
}

関数の引数の型は何でもいいですが、入力パラメータと一致しなければエラーになるようです。見たところ(reflect.Type).AssignableToがfalseの場合エラーです。

multiple-template

例示されるコードは以下でもホストされます。

https://github.com/ngicks/go-example-code-generation/blob/main/template/multiple

{{template "name"}}
The template with the specified name is executed with nil data.

{{template "name" pipeline}}
The template with the specified name is executed with dot set
to the value of the pipeline.

とる通り、templateで、別の名付けられたtemplateを、pipelineの評価結果を引数に実行できます。

別の名付けられたtemplateは(*Template).New(name)で作成し、返り値の(*Template).Parseを呼び出すか、
{{define "name"}}_template definition_{{end}}で定義することで作成することができます。

{{block}}{{define}}して{{template}}するショートハンドです。

筆者もこの記事を書くまで全くわかっていなかったのですが、*Templateは以下の通り*commonという構造体で解析されたtemplateを保持し、この*common(*Template).Newで作成されたすべての*Templateに共有されています。

https://github.com/golang/go/blob/go1.22.6/src/text/template/template.go#L13-L35

  • Parseはこの*commonを上書きします。そのため、ParseFuncs*commonを共有するすべての*Templateに影響します。
  • 同名のtemplateを複数定義している場合などではParseする順序によって結果が変わることになります。
  • template同士はヒエラルキーのないフラットな構造で、お互い名前で参照しあうことができます。

以下で複数のtemplateを使用するサンプルを示します。

tmp2.Parseなどを呼び出すことで、ユーザーから渡されたtemplate definitionによって元のtemplate構造を上書きしてカスタマイズが行えることを示します。

複数のtemplateを用い、さらにadditionalというデフォルトでは何も出力しないtemplateを後からParseによって追加することで、ユーザーからのtemplateの入力ができることを示します。

var (
	tmp1 = template.Must(template.New("tmp1").Parse(
		`tmp2: {{template "tmp2" .Tmp2}}
tmp3: {{template "tmp3" .Tmp3}}
tmp4: {{template "tmp4" .Tmp4}}
{{block "additional" .}}{{end}}
`))
	tmp2 = template.Must(tmp1.New("tmp2").Parse(`{{.Yay}}`))
	_    = template.Must(tmp1.New("tmp3").Parse(`{{.Yay}}`))
	tmp4 = template.Must(tmp1.New("tmp4").Parse(`{{.Yay}}`))
)

type param struct {
	Tmp2, Tmp3, Tmp4 sub
}
type sub struct {
	Yay string
	Nay string
}

func main() {
	decoratingExecute := func(data any) {
		fmt.Println("---")
		err := tmp1.Execute(os.Stdout, data)
		fmt.Println("---")
		fmt.Printf("error: %v\n", err)
		fmt.Println()
	}
	data := param{
		Tmp2: sub{
			Yay: "yay2",
			Nay: "nay2",
		},
		Tmp3: sub{
			Yay: "yay3",
			Nay: "nay3",
		},
		Tmp4: sub{
			Yay: "yay4",
			Nay: "nay4",
		},
	}

	decoratingExecute(data)
	/*
		---
		tmp2: yay2
		tmp3: yay3
		tmp4: yay4

		---
		error: <nil>
	*/
	_, _ = tmp2.Parse(`{{.Nay}}`)
	decoratingExecute(data)
	/*
		---
		tmp2: nay2
		tmp3: yay3
		tmp4: yay4

		---
		error: <nil>
	*/

	_, _ = tmp4.New("additional").Parse(`{{.Tmp2.Yay}} and {{.Tmp3.Nay}}`)
	decoratingExecute(data)
	/*
		---
		tmp2: nay2
		tmp3: yay3
		tmp4: yay4
		yay2 and nay3
		---
		error: <nil>
	*/
}

.tmpl / .gotmpl拡張子で保存する

ソースコード中にstring literalとしてtemplateを記述することもできますが、個別のファイルに保存するとgopls(言語サーバー)の支援が受けられます。

https://github.com/golang/tools/blob/55d718e5dba2aaaa12d0a2ab2c11c7ac7eb84fcb/gopls/doc/features/templates.md

強調したくて前述しましたが、goplsを以下のように設定するとsyntax highlightがかかります。それ以外の機能は設定なしでも機能しているようです。

vscodeの場合、settings.jsonに以下を追加します。

{
  // ...other settings...
  "gopls": {
    // ...other settings...
    "ui.semanticTokens": true,
    // どうもGo vscode extensionが以下と同様の
    // デフォルト値を入れているような振る舞いをするので、
    // これでいいなら設定は不要と思われる
    "build.templateExtensions": ["gotmpl", "tmpl"]
    // ...other settings...
  }
  // ...other settings...
}

"ui.semanticTokens": trueを有効にするとtemplateのみならず、Goのソースコードが全体的にトークンの色の付け方が変わるので、びっくりするかもしれません。

embed.FS, ParseFS

例示されるコードは以下でもホストされます。

https://github.com/ngicks/go-example-code-generation/tree/main/template/parse-fs

ディレクトリにtemplateを保存して丸ごとソースに埋め込みたいというケースはあると思いますが、go:embedtemplate.ParseFSによりそれが可能です。

例としてファイルを以下のように配置します。
前述のgoplsの支援を受けるために拡張子は.tmplにしてあります。

.template/
|-- tmp1.tmpl
|-- tmp2.tmpl
|-- tmp3.tmpl
`-- tmp4.tmpl

各templateの中身のはmultiple-templateの同名ものとそれぞれ変わりませんが、以下のように名前だけ若干変わります。

tmp1.tmpl
sub1: {{template "tmp2.tmpl" .Sub1}}
sub2: {{template "tmp3.tmpl" .Sub2}}
sub3: {{template "tmp4.tmpl" .Sub3}}
{{block "additional" .}}{{end}}

これはParseFSでファイルを読み込むと以下の行の挙動によりBaseが名前になってしまうためです。

https://github.com/golang/go/blob/go1.22.6/src/text/template/helper.go#L172-L178

goplsの支援により以下ようなsyntax highlightがかかります。

tmpl-syntax-highlighting-by-gopls

main.goと同階層にこのtemplateディレクトリがあるものとして、以下のようなコードで読み込んで実行します。
事項結果自体はmultiple-templateのものと変わりません。

ポイントとしては//go:embedでディレクトリを指定すると、そのディレクトリまでのパス構造がそのまま保たれます。つまり//go:embed foo/bar/bazとすると、embed.FSfoo/bar/bazというパス以下にbazディレクトリの中身を埋め込みます。今回の場合このtemplates FSの直下にtemplateディレクトリがあってその中に各ファイルがある状態となります。
また、fs.FSのルールにより、./templateは適切なパスではないのでtemplateで指定します(fs.ValidPath)。

template.ParseFSの第二引数にvariadicなpatterns ...stringを渡すことができますが、それぞれがfs.Globに渡されるのため、path.Matchの条件を満たす必要があります。

//go:embed template
var templates embed.FS

var (
	root = template.Must(template.ParseFS(templates, "template/*"))
)

type param struct {
	Tmp2, Tmp3, Tmp4 sub
}
type sub struct {
	Yay string
	Nay string
}

func main() {
	root = root.Lookup("tmp1.tmpl")
	data := param{
		Tmp2: sub{
			Yay: "yay2",
			Nay: "nay2",
		},
		Tmp3: sub{
			Yay: "yay3",
			Nay: "nay3",
		},
		Tmp4: sub{
			Yay: "yay4",
			Nay: "nay4",
		},
	}
	fmt.Println("---")
	err := root.Execute(os.Stdout, data)
	fmt.Println("---")
	fmt.Printf("err: %v\n", err)
	/*
		---
		tmp2: yay2
		tmp3: yay3
		tmp4: yay4

		---
		err: <nil>
	*/

	_, _ = root.New("additional").Parse(`{{.Tmp2.Yay}} and {{.Tmp3.Nay}}`)

	fmt.Println()
	fmt.Println("---")
	err = root.Execute(os.Stdout, data)
	fmt.Println("---")
	fmt.Printf("err: %v\n", err)
	/*
		---
		tmp2: yay2
		tmp3: yay3
		tmp4: yay4
		yay2 and nay3
		---
		err: <nil>
	*/
}

各templateの名前から拡張子を取り除きたい場合は以下のように手動で挙動を作るしかないかと思います。


//go:embed template
var templates embed.FS

var (
	extTrimmed *template.Template
)

func init() {
	tmpls, err := templates.ReadDir("template")
	if err != nil {
		panic(err)
	}
	baseNameCutExt := func(p string) string {
		p, _ = strings.CutSuffix(path.Base(p), path.Ext(p))
		return p
	}
	for _, tmpl := range tmpls {
		if tmpl.IsDir() {
			continue
		}
		if extTrimmed == nil {
			extTrimmed = template.New(baseNameCutExt(tmpl.Name()))
		}
		bin, err := templates.ReadFile(path.Join("template", tmpl.Name()))
		if err != nil {
			panic(err)
		}
		_ = template.Must(extTrimmed.New(baseNameCutExt(tmpl.Name())).Parse(string(bin)))
	}
}

text/template example: enum

例示されるコードは以下でもホストされます。

https://github.com/ngicks/go-example-code-generation/tree/main/template/go-enum

code generatorとしてかかわりそうな機能は一通り説明したと思います。このまま終わってもいいんですが、code generatorという立て付けで記事を作っているのですから最後にcode generatorのサンプルを示します。

以下のざっくり仕様を満たすものを作ることとします

  • パラメータはstructで受け付けます(=json.UnmarshalなどでJSONなどのデータフォーマットで入力可能)
  • type Foo stringな、string-base typeのみを生成します。
  • const (...)でvariantsを列挙し、
  • IsFooで入力がvariantsかどうかを判定します。
  • これだけだとつまらないので「特定のvariantsではない」という判定も作れるようにします(IsFooExceptBar)

このExceptの生成部分はサンプルにするために無理くり別のtemplateにくくりだしていますが、このぐらいのサイズなら1つのままにしておいたほうが読みやすいと思います。

ポイント的には

  • {{{pipeline}}という感じで{の後にaction({{pipeline}})を実行したい場合{{"{"}}{{pipeline}}としないといけない
  • ユーザーの入力文字列をGoident(identifier)として出力するにはGo specを満たすようにエスケープが必要(identifier = letter { letter | unicode_digit }.)
    • 以下のサンプルではunicode.IsLetter(r) || unicode.IsDigit(r) || r == '_'でないとき_に置き換えるという少々雑な処理でごまかしていますが、実際にはu1234という感じでunicode番号に置き換えるとかそういうことをしたほうが良いのだと思います

このサイズでも結構読むのはしんどいと思います。とはいえ機能が豊富で何でもできるのは便利ですね。

type EnumParam struct {
	PackageName string
	Name        string
	Variants    []string
	Excepts     []EnumExceptParam
}

type EnumExceptParam struct {
	Name             string
	ExceptName       string
	ExcludedValiants []string
}

var funcs = template.FuncMap{
	"capitalize": func(s string) string {
		if len(s) == 0 {
			return s
		}
		if len(s) == 1 {
			return strings.ToUpper(s)
		}
		return strings.ToUpper(s[:1]) + s[1:]
	},
	"replaceInvalidChar": func(s string) string {
		// As per Go programming specification.
		// identifier = letter { letter | unicode_digit }.
		// https://go.dev/ref/spec#Identifiers
		return strings.Map(func(r rune) rune {
			if unicode.IsLetter(r) || r == '_' || unicode.IsDigit(r) {
				return r
			}
			return '_'
		}, s)
	},
	"quote": func(s string) string {
		return strconv.Quote(s)
	},
	"fillName": func(p EnumExceptParam, name string) EnumExceptParam {
		p.Name = name
		return p
	},
}

var (
	pkg = template.Must(template.New("package").Funcs(funcs).Parse(
		`// Code generated by me. DO NOT EDIT.
package {{.PackageName}}

import (
	"slices"
)

type {{.Name}} string

const (
{{range .Variants}}	{{$.Name}}{{replaceInvalidChar (capitalize .)}} {{$.Name}} = {{quote .}}
{{end -}}
)

var _{{.Name}}All = [...]{{.Name}}{{"{"}}{{range .Variants}}
	{{$.Name}}{{replaceInvalidChar (capitalize .)}},{{end}}
}

func Is{{.Name}}(v {{.Name}}) bool {
	return slices.Contains(_{{.Name}}All[:], v)
}

{{range .Excepts}}
{{template "except" (fillName . $.Name)}}{{end}}`))
	_ = template.Must(pkg.New("except").Parse(
		`func Is{{.Name}}Except{{replaceInvalidChar (capitalize .ExceptName)}}(v {{.Name}}) bool {
	return !slices.Contains(
		[]{{.Name}}{{"{"}}{{range .ExcludedValiants}}
			{{$.Name}}{{replaceInvalidChar (capitalize .)}},{{end}}
		},
		v,
	)
}
`))
)

func main() {
	pkgPath := filepath.Join("template", "go-enum", "example")
	err := os.MkdirAll(pkgPath, fs.ModePerm)
	if err != nil {
		panic(err)
	}

	f, err := os.Create(filepath.Join(pkgPath, "enum.go"))
	if err != nil {
		panic(err)
	}

	err = pkg.Execute(
		f,
		EnumParam{
			PackageName: "example",
			Name:        "Enum",
			Variants:    []string{"foo", "b\"ar", "baz"},
			Excepts: []EnumExceptParam{
				{
					ExceptName:       "foo",
					ExcludedValiants: []string{"foo"},
				},
				{
					ExceptName:       "Muh",
					ExcludedValiants: []string{"foo", "b\"ar"},
				},
			},
		},
	)
	if err != nil {
		panic(err)
	}
}

これを実行すると以下を出力します

// Code generated by me. DO NOT EDIT.
package example

import (
	"slices"
)

type Enum string

const (
	EnumFoo Enum = "foo"
	EnumB_ar Enum = "b\"ar"
	EnumBaz Enum = "baz"
)

var _EnumAll = [...]Enum{
	EnumFoo,
	EnumB_ar,
	EnumBaz,
}

func IsEnum(v Enum) bool {
	return slices.Contains(_EnumAll[:], v)
}


func IsEnumExceptFoo(v Enum) bool {
	return !slices.Contains(
		[]Enum{
			EnumFoo,
		},
		v,
	)
}

func IsEnumExceptMuh(v Enum) bool {
	return !slices.Contains(
		[]Enum{
			EnumFoo,
			EnumB_ar,
		},
		v,
	)
}

ユーザーからtemplateを入力させるときのimportの取り扱い

この記事の話はここまでで終わりでもよかったんですが、欠点のところで「importの取り扱いが難しい」と正直に述べてしまったので、それへのアンサーとしてどのように処理すべきかの例を示します。

みなさんご存じの通り、Goのimportはimportされるpackageにアクセスするためのqualifierをimport "packagePath"で宣言し、qualifier.ExportedIdentifierで各要素にアクセスします。
当然qualifierはidentifierなので名前のかぶりを起こすとcompilation errorですし、html/templatetext/templateのように名前が同じ、かつ異なるパッケージは当然のように存在します。そのため、かぶりが起きたときにqualifier名を被らない何かにfallbackする仕組みが必要です。
また、math/rand/v2v2のようなmajor versionはパッケージ名にならないのが普通なので、この場合randがパッケージ名になりますのでこれを考慮した処理も必要になります。

ユーザーが入力するtemplateが他のパッケージをimportするためには、単にtemplate textのみを入力とすると、非常に面倒な解析処理とテキスト置換処理が必要になります。
基本的にはimport package群も同様に入力させるほうがよいでしょう。

以下で、具体的な処理方法などの例示を行います。

Goのimport declの記法

The Go Programming Language specificationによると、import declarationは

https://go.dev/ref/spec#Import_declarations

ImportDecl = "import" ( ImportSpec | "(" { ImportSpec ";" } ")" ) .
ImportSpec = [ "." | PackageName ] ImportPath .
ImportPath = string_lit .

です。

  • import "importPath"である場合、importPathで指定されたパッケージがpackage foobarで宣言しているパッケージ名がqualifierとなります。
    • 大抵の場合、importPathの末尾の要素とパッケージ名は一致します。(e.g. "foo/bar/baz"ならばbaz)
  • import packageName "importPath"である場合、packageNameがqualifierとなります。
  • import . "importPath"である場合、qualifierなしでimportPathのexported identifierにアクセスできます
  • import _ "importPath"である場合、exported identifierにはアクセスできませんが、importPathinitなどが実行され、importによる副作用のみを実行できます。

qualifierはidentifierであるため、当然名前は被ってはいけません。
一方で., _はidentifierを定義しません。

ユーザー入力のフォーマット

上記の通り、ユーザーに入力させるtemplate textがほかのパッケージに依存する場合、import pathも同様に入力に持たせるほうがよいでしょう。
例えば

type UserInput struct {
	// package name of generated code.
	PackageName string
	// Imports describes dependencies to other packages to which the Template text depends.
	// Template will not use packages other than described in this field.
	// Template may use all, some of, or even none of imported packages in the generated code.
	//
	// Imports maps the import path (key) to the template arg name (value).
	// Template refers to import qualifiers by template arg name(value) and
	// it expects values are provided under Imports key,
	// e.g. Template may describe imports by map[string]string{"bytes": "Bytes"} and refer to it as {{.Imports.Bytes}}.
	//
	// The values are also allowed to be `.` or `_`.
	// In those cases, the generated code will have dot or underscore imports
	// and Template will not receive those values.
	Imports map[string]string
	// template text
	Template string
}

という感じです。この構造体と相互に変換可能なJSONやYAMLなどで入力を受け付けることになるでしょう。
doc commentをそれなりに丁寧に書いていますが、例としては以下のような入力を想定します。

UserInput{
	Imports: map[string]string{
		"fmt":           "Fmt",
		"math/rand/v2":  "MathRand",
	},
	Template: `func example() string {
	buf := make([]byte, 0, 16)
	for range 8 {
		buf = {{.Imports.Fmt}}.Appendf(buf, "%x", {{.Imports.MathRand}}.N[byte](255))
	}
	return string(buf)
}
`
}

Importsで、keyにpackage path, valueにTemplate中で参照できる変数名を指定させます。
正直key-valueは逆のほうがいい気もするんですが、同一のパッケージに複数のqualifierでアクセスしたいケースはかなり珍しいと思うのでシンプルさためにこうしています。
dot importは生成されたコードのほかの部分を壊しかねないため許容しないほうがいい気もするんですが、今回は単に例示なので許しています。

Templatetext/templateで解析/実行が可能なtemplate textです。

importPathからqualifierを取り出す

html/templatetext/template,crypto/randmath/randのように、std範疇ですら同名の別パッケージが存在します。
こういった同名パッケージをインポートしたい場合、qualifierをそれぞれ別名にしてかぶりを起こさないようにする必要があります。
被りが起きるかどうかを検査するために、まずlexicalな解析でpackage pathからqualifierを取り出す処理を記述する必要があります。

ここで、importPathの末尾の要素(e.g. "foo/bar/baz"のときbaz)と、importPathが指し示すパッケージがpackage foobarで宣言するパッケージ名が一致していることを前提とします。
この前提が崩れると、ユーザーにパッケージ名も入力させなければlexicalな処理で事足りる範疇を超えてしまい、type checkのようなことが必要になってしまうためです。
このサンプルでは一致しているもの思い込みます: 一致しないのはディレクトリ名を書き換えたけどpackage宣言を修正し忘れているときだけだと思います。別にするメリットは基本ないはずですね。

大抵はpath.Base(packagePath)でよいのです。
ただしmath/rand/v2のように、major version suffixがある場合はこれを無視するのが一般的なGoのやり口なので、このケースを特別に処理する必要があります。

func qualFromPkgPath(pkgPath string) string {
	base := path.Base(pkgPath)
	if base == pkgPath {
		// contains no `/`
		return pkgPath
	}
	majorVersion, has := strings.CutPrefix(base, "v")
	if !has {
		// no major version.
		return base
	}
	if len(strings.TrimLeftFunc(majorVersion, func(r rune) bool {
		return '0' <= r && r <= '9'
	})) == 0 {
		// suffix is major version
		return path.Base(path.Dir(pkgPath))
	}
	return base
}

import specを生成する

ユーザーによってimportも入力されるため、メインとなるtemplateのimport decl部分はもはやあらかじめ書いておくことができなくなります。
そのため以下のように何かのパラメータをrangeする必要があります。

pkg.tmpl
// Code generated by me. DO NOT EDIT.
package {{.PackageName}}

import (
{{range .Imports}}	{{if .Qual}}{{.Qual}} {{end}}{{quote .PkgPath}}
{{end -}}
)

// ...rest of code...

importはqual nameとpackage pathから構成されるため、上記パラメータは以下のように定義できます。

type ImportSpec struct {
	// Qual is the import qualifier name. Maybe empty.
	// If empty, the qual must be lexically inferred from PkgPath.
	Qual    string
	PkgPath string
}

type TemplateParam struct {
	Imports         []ImportSpec
}

前述のユーザー入力と、ユーザー入力でないtemplateが元からimportするpackageを組み合わせて[]ImportSpecを生成するには以下のようにします。

ポイントとしては、

  • ユーザー入力のmap[string]stringをfor-range-mapしないようにする
    • してしまうと実行のたびに順序が異なるため
  • qual名が被るがpackage pathが異なる場合、_数字でsuffixして被らなくします。

引数のpreDeclaredはユーザー入力でないtemplate部分のimport specs、userImportsUserInputImportsです。

func makeImportSpecs(preDeclared []ImportSpec, userImports map[string]string) []ImportSpec {
	importSpecs := slices.Clone(preDeclared)

	// maps qualifier name to package path.
	qualToPkgPath := make(map[string]string, len(importSpecs)+len(userImports))

	for _, spec := range importSpecs {
		if spec.Qual == "." || spec.Qual == "_" {
			continue
		}
		name := spec.Qual
		if name == "" {
			name = qualFromPkgPath(spec.PkgPath)
		}
		qualToPkgPath[name] = spec.PkgPath
	}

	userPackagePaths := make([]string, 0, len(userImports))
	for k := range userImports {
		userPackagePaths = append(userPackagePaths, k)
	}
	slices.Sort(userPackagePaths)
USER_PKG:
	for _, pkgPath := range userPackagePaths {
		arg := userImports[pkgPath]
		switch arg {
		case ".", "_":
			importSpecs = append(importSpecs, ImportSpec{arg, pkgPath})
		default:
			name := qualFromPkgPath(pkgPath)
			org := name
			fallenBack := false
			for i := 0; ; i++ {
				knownPkgPath, has := qualToPkgPath[name]
				if knownPkgPath == pkgPath {
					continue USER_PKG
				}
				if !has {
					qualToPkgPath[name] = pkgPath
					break
				}
				fallenBack = true
				name = org + "_" + strconv.FormatInt(int64(i), 10)
			}
			if !fallenBack {
				name = ""
			}
			importSpecs = append(importSpecs, ImportSpec{name, pkgPath})
		}
	}

	slices.SortFunc(importSpecs, func(i, j ImportSpec) int {
		if c := strings.Compare(i.PkgPath, j.PkgPath); c != 0 {
			return c
		}
		return strings.Compare(i.Qual, j.Qual)
	})

	importSpecs = slices.CompactFunc(importSpecs, func(i, j ImportSpec) bool {
		return i.Qual == j.Qual && i.PkgPath == j.PkgPath
	})

	return importSpecs
}

ユーザーのtemplateに渡すパラメータを生成する

前述のとおり、Importsのvalueの値でpackage qualifierにアクセスする仕様にしたため、ユーザーtemplateに渡されるパラメータも生成する必要があります。

UserInput{
	Imports: map[string]string{
		"fmt":           "Fmt",
		"math/rand/v2":  "MathRand",
	},
	Template: `func example() string {
	buf := make([]byte, 0, 16)
	for range 8 {
		buf = {{.Imports.Fmt}}.Appendf(buf, "%x", {{.Imports.MathRand}}.N[byte](255))
	}
	return string(buf)
}
`
}

以下のパラメータをユーザーのtemplateに渡します。

type UserTemplateArg struct {
	// Imports maps arg name to package qualifier.
	Imports map[string]string
}

以下のように変換します。引数のspecsは前述のimport spec作成部分(makeImportSpecs)の返り値、userImportsUserInputImportsです。

func makeUserImportArg(specs []ImportSpec, userImports map[string]string) map[string]string {
	pkgNames := make(map[string][]string)
	for _, spec := range specs {
		if spec.Qual == "." || spec.Qual == "_" {
			continue
		}
		name := spec.Qual
		if name == "" {
			name = qualFromPkgPath(spec.PkgPath)
		}
		pkgNames[spec.PkgPath] = append(pkgNames[spec.PkgPath], name)
	}

	userImportArg := make(map[string]string)
	for pkgPath, arg := range userImports {
		if arg == "." || arg == "_" {
			continue
		}
		userImportArg[arg] = pkgNames[pkgPath][0]
	}

	return userImportArg
}

複数のqualが同じpackage pathにアクセスすることがあり得るものとしてこういう処理になっていますが実際今回組んだサンプルでは1-1関係が保たれますので無用なオーバーヘッドです。

実行

完成したexampleは以下でもホストされます

https://github.com/ngicks/go-example-code-generation/tree/main/template/handle-imports

以下のように実行します。特に解説していませんが、goimportsによるフォーマットをかけてからファイルに出力するようにしています。

package main

import (
	"bytes"
	"context"
	"errors"
	"fmt"
	"io"
	"io/fs"
	"os"
	"os/exec"
	"path"
	"path/filepath"
	"slices"
	"strconv"
	"strings"
	"text/template"
	"unicode"
)

var funcs = template.FuncMap{
	"quote": func(s string) string {
		return strconv.Quote(s)
	},
}

var pkg = template.Must(template.New("pkg").
	Funcs(funcs).
	Parse(
		`// Code generated by me. DO NOT EDIT.
package {{.PackageName}}

import (
{{range .Imports}}	{{if .Name}}{{.Name}} {{end}}{{quote .PkgPath}}
{{end -}}
)

var bufPool = &sync.Pool{
	New: func() any {
		return new(bytes.Buffer)
	},
}

func getBuf() *bytes.Buffer {
	return bufPool.Get().(*bytes.Buffer)
}

func putBuf(b *bytes.Buffer) {
	if b == nil || b.Cap() > 64<<10 {
		return
	}
	b.Reset()
	bufPool.Put(b)
}

{{block "user-input" .UserTemplateArg}}{{end}}
`))

type UserInput struct {
	// ...
}

type TemplateParam struct {
	// ...
}

type ImportSpec struct {
	// ...
}

type UserTemplateArg struct {
	// ...
}

func main() {
	err := checkGoimports()
	if err != nil {
		panic(err)
	}

	targetDir := filepath.Join("template", "handle-imports", "target")
	err = os.Mkdir(targetDir, fs.ModePerm)
	if err != nil && !errors.Is(err, fs.ErrExist) {
		panic(err)
	}

	userInput := UserInput{
		PackageName: "main",
		Imports: map[string]string{
			"bytes":         "Bytes",
			"crypto":        "Crypto",
			"crypto/rand":   "CryptoRand",
			"crypto/sha256": "_",
			"crypto/sha512": "_",
			"encoding/hex":  "Hex",
			"fmt":           ".",
			"io":            "Io",
			"math/rand/v2":  "MathRand",
		},
		Template: `func main() {
	randBuf := getBuf()
	defer putBuf(randBuf)

	var err error

	_, err = {{.Imports.Io}}.CopyN(randBuf, {{.Imports.CryptoRand}}.Reader, 16)
	if err != nil {
		panic(err)
	}
	for i := 0; i < 16; i++ {
		_ = randBuf.WriteByte({{.Imports.MathRand}}.N(byte(255)))
	}

	_, _ = Printf("rand bytes=%q\n", {{.Imports.Hex}}.EncodeToString(randBuf.Bytes()))

	h := {{.Imports.Crypto}}.SHA256.New()
	_, err = {{.Imports.Io}}.Copy(h, {{.Imports.Bytes}}.NewReader(randBuf.Bytes()))
	if err != nil {
		panic(err)
	}
	_, _ = Printf("sha256sum=%q\n", {{.Imports.Hex}}.EncodeToString(h.Sum(nil)))

	h = {{.Imports.Crypto}}.SHA512.New()
	_, err = {{.Imports.Io}}.Copy(h, {{.Imports.Bytes}}.NewReader(randBuf.Bytes()))
	if err != nil {
		panic(err)
	}
	_, _ = Printf("sha512sum=%q\n", {{.Imports.Hex}}.EncodeToString(h.Sum(nil)))
}
`,
	}

	_, err = pkg.New("user-input").Parse(userInput.Template)
	if err != nil {
		panic(err)
	}

	var buf bytes.Buffer
	specs := makeImportSpecs([]ImportSpec{{"", "bytes"}, {"", "sync"}}, userInput.Imports)
	err = pkg.Execute(&buf, TemplateParam{
		PackageName: userInput.PackageName,
		Imports:     specs,
		UserTemplateArg: UserTemplateArg{
			Imports: makeUserImportArg(specs, userInput.Imports),
		},
	})
	if err != nil {
		panic(err)
	}

	formatted, err := applyGoimports(context.Background(), &buf)
	if err != nil {
		panic(err)
	}

	targetFile := filepath.Join(targetDir, "main.go")
	f, err := os.Create(targetFile)
	if err != nil {
		panic(err)
	}
	defer f.Close()
	_, err = io.Copy(f, formatted)
	if err != nil {
		panic(err)
	}
}

func qualFromPkgPath(pkgPath string) string {
	// ...
}

func makeImportSpecs(preDeclared []ImportSpec, userImports map[string]string) []ImportSpec {
	// ...
}

func makeUserImportArg(specs []ImportSpec, userImports map[string]string) map[string]string {
	// ...
}

func checkGoimports() error {
	_, err := exec.LookPath("goimports")
	return err
}

func applyGoimports(ctx context.Context, r io.Reader) (*bytes.Buffer, error) {
	cmd := exec.CommandContext(ctx, "goimports")
	cmd.Stdin = r
	formatted := new(bytes.Buffer)
	stderr := new(bytes.Buffer)
	cmd.Stdout = formatted
	cmd.Stderr = stderr
	err := cmd.Run()
	if err != nil {
		return nil, fmt.Errorf("goimports failed: err = %v, msg = %s", err, stderr.Bytes())
	}
	return formatted, nil
}

以下が生成されます。

// Code generated by me. DO NOT EDIT.
package main

import (
	"bytes"
	"crypto"
	"crypto/rand"
	_ "crypto/sha256"
	_ "crypto/sha512"
	"encoding/hex"
	. "fmt"
	"io"
	rand_0 "math/rand/v2"
	"sync"
)

var bufPool = &sync.Pool{
	New: func() any {
		return new(bytes.Buffer)
	},
}

func getBuf() *bytes.Buffer {
	return bufPool.Get().(*bytes.Buffer)
}

func putBuf(b *bytes.Buffer) {
	if b == nil || b.Cap() > 64<<10 {
		return
	}
	b.Reset()
	bufPool.Put(b)
}

func main() {
	randBuf := getBuf()
	defer putBuf(randBuf)

	var err error

	_, err = io.CopyN(randBuf, rand.Reader, 16)
	if err != nil {
		panic(err)
	}
	for i := 0; i < 16; i++ {
		_ = randBuf.WriteByte(rand_0.N(byte(255)))
	}

	_, _ = Printf("rand bytes=%q\n", hex.EncodeToString(randBuf.Bytes()))

	h := crypto.SHA256.New()
	_, err = io.Copy(h, bytes.NewReader(randBuf.Bytes()))
	if err != nil {
		panic(err)
	}
	_, _ = Printf("sha256sum=%q\n", hex.EncodeToString(h.Sum(nil)))

	h = crypto.SHA512.New()
	_, err = io.Copy(h, bytes.NewReader(randBuf.Bytes()))
	if err != nil {
		panic(err)
	}
	_, _ = Printf("sha512sum=%q\n", hex.EncodeToString(h.Sum(nil)))
}

もちろん正しく動作します。

...# go run ./template/handle-imports/target/
rand bytes="5c65391ca536b23733d81e0c67d2f9ca1c183d0f028a339fbeba68c6f0bf2d16"
sha256sum="bb863290d8f0699dd9d7feb16c2bf44b340ba98ada8d37f3401538d82dbe70cf"
sha512sum="5f06276c8c00bb1bab175d2c1f3f92332a3383bd7bf2f8f550f59cf69a8d1af6cddaf5fc005d01d5bade14b4bd618019501deffbbfe92b9e62979226ebe80f21"

おわりに

この記事では

  • なぜcode generatorが必要なのか
  • 一連の記事で述べることになる、代表的と思われる4つのcode generatorの実装方法についての概説
  • code generatorの諸注意などについて
  • io.Writerにテキストを書き出すだけのシンプルなcode generator
  • text/templateの使い方
  • text/templateを用いるcode generator

を述べました。

さらに後続の記事で、それぞれ以下について説明します。

text/templateは機能が豊富で柔軟にコード生成できますが、Goのsource codeを生成するための専用というわけではないので可読性を保ちながら記述するのに苦労します。
記事の最後のほうで説明した通り、importの取り扱いは結構面倒でいろいろな落とし穴が存在しえますね。
ただし、この一連の記事が説明する方法はそれぞれ組み合わせてよいので、用途に合わせて使い分け、組み合わせるのがよいでしょう。特にimport周りはjenniferはうまく取り扱ってくれます。
text/templateはtemplate textをutf-8のテキストとして持ち回るため、ユーザーからの入力を受け付けて挙動をカスタマイズさせたい場合に特に便利だと思います。

GitHubで編集を提案

Discussion