Goのcode generatorの作り方: 諸注意とtext/templateの使い方
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のcode generatorの作り方: jenniferの使い方でgithub.com/dave/jenniferを用いる方法
- Goのcode generatorの作り方: ast(dst)を解析して書き換えるでastutilおよびgithub.com/dave/dstを用いる方法
についてそれぞれ述べます。
前提知識
-
The Go programming languageの基本的文法、プロジェクト構成などある程度
Go
を書けるだけの知識
環境
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
を活用しており、
以上のように検索してみればたくさんヒットします。
goのreflect/generic
Go
ではreflectを使うことで型情報をany
な値から取り出すことができ、これを元に動的な挙動を行うことができます。
また、Go 1.18で追加されたGenericsを用いることで、ある制約を満たす複数の型に対して処理を共通化できます。
例えば、以下のようなサンプルを定義します。
サンプルでは、あるstruct(Sample
)に対して、フィールド名と定義順が一致するが、型がPatcher[T]
(T
は元のstruct fieldの型)で置き換えられたstruct(SamplePatch
)を用意することで、部分的なフィールドの変更(=Patch)をする挙動をreflect
を使って実装できることを示します。
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定義に対して
以下のようにString
メソッドを生成します。
これらはソースコードの解析その他を行わない限り不可能なことですので、こういったことをしたい場合は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を代わりに用います
上記を整理しなおすを以下のような関係図になります
おおむねコード生成のためのメタデータ取得部と、コード生成部と、コードの書き出し部分、最後のポストプロセスとしてフォーマットに分かれると思います。
メタデータ取得部分はast
を用いる場合はgo source codeを入力とし、go/parser
を用いてast
の解析します。ast
は、さらにtype checker
で解析すること型情報を得ることもできます(e.g. skeleton)。この一連の記事ではtype checker
関連の話には踏み込みません。
他の方法ではJSON
, YAML
のようなフォーマットで書かれたデータ構造(ここにcli引数も含む)をjson.Unmarshal
やyaml.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などのフォーマッターを用いることでフォーマットをできます。goimports
はgofmt
と同じルールでフォーマットを行ったうえで、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
のほうが使いやすいのは当然ではあります。
-
- 利点: stdで終始できる
-
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する方法しか想定しません。
-
- 利点: 既存のgo source codeを入力とできる。
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
ソースはここでもホストされます
生成したコードは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.Writer
にGo source code
を書き出すだけの方法について述べます。
とは言え実際シンプルなのであまり述べることはありません。
例えば、Go
のruntime
では以下のような単にテキストを書くだけのcode generatorを見つけることができます。
生成対象は.s
のGo assemblyファイルですが、まあ言いたいことはかわらないのでいいとしましょう。
このコードによって以下の3つのファイルが生成されます。
これらのファイルは以下のような、ほぼ同じパターンを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
stdライブラリに組み込まれたテンプレート機能です。
テンプレートなので、既存の型板となるテキストの、特定の部分を入力によって切り替えるものです。
それに加えてif
、for
、関数の呼び出しなどが機能として組み込まれているのでおおよそ何でもできてしまいます。
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そのもののトークンの色がかなり変わって表示されますのでびっくりするかもしれません。
現状gopls
のsemanticTokens
はexperimentalですがもうすぐenabled by defaultになるかもしれません(#45313)。
基本的な使用法
例示されるコードは以下でもホストされます。
構文
パラメータ、関数その他の呼び出しは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
例示されるコードは以下でもホストされます。
range
range
でGo
のfor-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}}
という構文で、Go
のrange
構文のようにindex
とelement
を変数にセットします。
range
は{{end}}
までスコープを作り、このスコープ内では{{.}}
は、iterateされているデータの各項目をさします。[]T
ならT
, map[K]V
ならV
になります。
上記の$index
,$element
ももちろんこのスコープ内でのみ有効です。
このスコープ内では{{break}}
と{{continue}}
を使用してGo
のbreak
とcontinue
と同等の制御をできます。どちらにもlabel
を指定できるという記載はありません。
When execution begins, $ is set to the data argument passed to Execute, that is, to the starting value of dot.
とある通り、このスコープ内では$
がExecute
関数に渡されたデータになります。
if
if
で、Go
のif
のように条件による分岐ができます
{{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
以下でrange
とif
を使った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: 関数の追加
例示されるコードは以下でもホストされます。
template actionの中で実行できる関数は以下で定義される通りいろいろありますが
それ以外にも、(*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
例示されるコードは以下でもホストされます。
{{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
に共有されています。
-
Parse
はこの*common
を上書きします。そのため、Parse
やFuncs
は*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
(言語サーバー)の支援が受けられます。
強調したくて前述しましたが、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
例示されるコードは以下でもホストされます。
ディレクトリにtemplateを保存して丸ごとソースに埋め込みたいというケースはあると思いますが、go:embed
とtemplate.ParseFS
によりそれが可能です。
例としてファイルを以下のように配置します。
前述のgopls
の支援を受けるために拡張子は.tmpl
にしてあります。
.template/
|-- tmp1.tmpl
|-- tmp2.tmpl
|-- tmp3.tmpl
`-- tmp4.tmpl
各templateの中身のはmultiple-templateの同名ものとそれぞれ変わりませんが、以下のように名前だけ若干変わります。
sub1: {{template "tmp2.tmpl" .Sub1}}
sub2: {{template "tmp3.tmpl" .Sub2}}
sub3: {{template "tmp4.tmpl" .Sub3}}
{{block "additional" .}}{{end}}
これはParseFS
でファイルを読み込むと以下の行の挙動によりBase
が名前になってしまうためです。
gopls
の支援により以下ようなsyntax highlightがかかります。
main.go
と同階層にこのtemplateディレクトリがあるものとして、以下のようなコードで読み込んで実行します。
事項結果自体はmultiple-templateのものと変わりません。
ポイントとしては//go:embed
でディレクトリを指定すると、そのディレクトリまでのパス構造がそのまま保たれます。つまり//go:embed foo/bar/baz
とすると、embed.FS
はfoo/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
例示されるコードは以下でもホストされます。
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}}
としないといけない - ユーザーの入力文字列を
Go
のident
(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/template
とtext/template
のように名前が同じ、かつ異なるパッケージは当然のように存在します。そのため、かぶりが起きたときにqualifier名を被らない何かにfallbackする仕組みが必要です。
また、math/rand/v2
のv2
のような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にはアクセスできませんが、importPath
のinit
などが実行され、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は生成されたコードのほかの部分を壊しかねないため許容しないほうがいい気もするんですが、今回は単に例示なので許しています。
Template
がtext/template
で解析/実行が可能なtemplate textです。
importPathからqualifierを取り出す
html/template
とtext/template
,crypto/rand
とmath/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する必要があります。
// 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、userImports
はUserInput
のImports
です。
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
)の返り値、userImports
はUserInput
のImports
です。
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は以下でもホストされます
以下のように実行します。特に解説していませんが、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
を述べました。
さらに後続の記事で、それぞれ以下について説明します。
- Goのcode generatorの作り方: jenniferの使い方でgithub.com/dave/jenniferを用いる方法
- Goのcode generatorの作り方: ast(dst)を解析して書き換えるでastutilおよびgithub.com/dave/dstを用いる方法
text/template
は機能が豊富で柔軟にコード生成できますが、Go
のsource codeを生成するための専用というわけではないので可読性を保ちながら記述するのに苦労します。
記事の最後のほうで説明した通り、importの取り扱いは結構面倒でいろいろな落とし穴が存在しえますね。
ただし、この一連の記事が説明する方法はそれぞれ組み合わせてよいので、用途に合わせて使い分け、組み合わせるのがよいでしょう。特にimport周りはjennifer
はうまく取り扱ってくれます。
text/template
はtemplate textをutf-8のテキストとして持ち回るため、ユーザーからの入力を受け付けて挙動をカスタマイズさせたい場合に特に便利だと思います。
Discussion