🧰

Goのcode generatorの作り方: jenniferの使い方

2024/08/18に公開

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

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

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

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

について述べました。

この記事では

について述べます

さらに後続の記事で

について述べます。

github.com/dave/jenniferは、code generatorを作るためのライブラリで、Goのトークンや構文に紐づいた関数をメソッドチェーンで順番に呼び出すことでcodeを生成することができます。
README.mdをしっかり読めば特に説明が必要なことはないのですが、いくらかコードサンプルを示すことで、READMEを読むための勘所をえられるような手助けをする記事となります。
そのため前段、後段の記事に比べてこの記事はずいぶん文字数が少ないです。

前提知識

環境

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を参照したままです。マニアワナカッタ。。。

github.com/dave/jennifer

github.com/dave/jenniferを利用する方法です。

このライブラリはcode generatorを作成するためのライブラリですので、前段のtext/templateを用いる方法よりもはるかに簡単に記述することが可能です。

github.com/dave/jenniferを使うと、

  • Goのトークンや構文に対応づいた関数群をメソッドチェインで呼び出すことでコードを生成することができます。
  • Qualによって自動的にimport declが管理されるため、同名のパッケージをインポートする際の名前被りも自動的に回避されます。
  • ○○Func系のメソッドやDoで関数を受けとることができるので容易にfor-loopを回したパラメータに基づく生成が可能です。

利点と欠点

利点:

  • 書きやすい
    • Qualによってimport declを自動的に管理してくれるのでimportの名前かぶりに関して気を使う必要がない。
  • ごちゃごちゃしてるように見えてメンテしやすい(体感上)
  • 単なるGoコードであるので任意に分割して再利用できる

欠点:

  • std外のライブラリをインポートしてしまう。
  • ユーザーからファイルを通して入力を受けとる方法が特に決まっていない

基本的な使用方法

コードは以下でホストされます

https://github.com/ngicks/go-example-code-generation/tree/main/jennifer/basic

README.mdでしっかり説明がなされているので特に説明することはないかと思います、APIの様式がわかる程度のことを書いておいたほうが読みやすいかもしれないので先にここでそれについて述べておきます。

宣言、書き出し

jen.NewFile, jen.NewFilePath, jen.NewFilePathNameのいずれかで*jen.File(=1つの.goファイルに対応づくもの)をallocateし、そこからメソッドをいろいろ呼び出します。最後に(*jen.File).Renderでファイルなどに生成したコードを書き出して終了します。

package main

import (
	"bytes"

	"github.com/dave/jennifer/jen"
)

func main() {
	f := jen.NewFile("baz")
	f.Var() //...

	buf := new(bytes.Buffer)
	if err := f.Render(buf); err != nil {
		panic(err)
	}
	if err := os.WriteFile("path/to/dest", buf.Bytes(), fs.ModePerm); err != nil {
		panic(err)
	}
}

RenderNoFormattrueにしない限りformat.Sourceによってフォーマットをかける挙動があります。そのため、出力がGoのソースコードとして正しくない場合にformat部分でエラーを吐くことがあります。
上記サンプルでは一旦Renderの結果を*bytes.Bufferに受けてからファイルに書き出していますが、こうすることで、format.Sourceの成功までos.Createによる対象ファイルのtruncateを遅延しています。

print

*jen.Statementなどの各typeにはデバッグ用途としてGoStringメソッドが実装されており、それによってコード断片状態の*jen.Statementなどを書き出すことができます。
fmt.Printf("%#v\n", v)でprintするのが最も便利でしょう。

decoratePrint := func(v any) {
	fmt.Println("---")
	fmt.Printf("%#v\n", v)
	fmt.Println("---")
	fmt.Println()
}

decoratePrint(jen.Var().Id("yay").Op("=").Lit("yay yay"))
/*
	---
	var yay = "yay yay"
	---
*/

以後のコードスニペットはdecoratePrintの宣言は省略されます。

*jen.File

*jen.Filejen.NewFile, jen.NewFilePath, jen.NewFilePathNameのいずれかで作成します。

それぞれは以下のようにQualを使った場合の挙動が違います。
NewFilePath, NewFilePathNameは生成対象のパッケージパスを認識しますので、Qualが参照するのが生成対象そのものだった時はPackageNameが省略されます。

f = jen.NewFile("baz")
f.NoFormat = true
f.Qual("foo/bar/baz", "Wow")
decoratePrint(f)
/*
	---
	package baz

	import baz "foo/bar/baz"


	baz.Wow
	---
*/

f = jen.NewFilePath("foo/bar/baz")
f.NoFormat = true
f.Qual("foo/bar/baz", "Wow")
decoratePrint(f)
/*
	---
	package baz


	Wow
	---
*/

f = jen.NewFilePathName("foo/bar/baz", "hoge")
f.NoFormat = true
f.Qual("foo/bar/baz", "Wow")
decoratePrint(f)
/*
	---
	package hoge


	Wow
	---
*/

Package comment

*jen.FilePackageCommentpackage clauseより先にコメントを書き出します。

var f *jen.File

f = jen.NewFile("foo")
f.PackageComment("// Code generated by me. DO NOT EDIT.")
decoratePrint(f)
/*
	---
	// Code generated by me. DO NOT EDIT.
	package foo

	---
*/

if err != nil { return err }

decoratePrint(jen.If(jen.Err().Op("!=").Nil()).Block(jen.Return(jen.Err())))
/*
	---
	if err != nil {
			return err
	}
	---
*/

struct def

decoratePrint(jen.Type().Id("foo").Struct(
	jen.Id("A").String().Tag(map[string]string{"json": "a"}),
	jen.Id("B").Int().Tag(map[string]string{"json": "b", "bar": "baz"}),
))
/*
	---
	type foo struct {
			A string `json:"a"`
			B int    `bar:"baz" json:"b"`
	}
	---
*/

[]T{}

Values{...}をレンダーします。自分でLineを追加しない限り改行しません。

decoratePrint(jen.Var().Id("bar").Op("=").Index(jen.Op("...")).String().Values(jen.Lit("foo"), jen.Lit("bar"), jen.Lit("baz")))
/*
	---
	var bar = [...]string{"foo", "bar", "baz"}
	---
*/

[]T{}+自動改行

Valuesは自動的に1項目ごとに改行しません。Customを用いると項目ごとに改行できます。

decoratePrint(
	jen.Var().Id("bar").Op("=").Index(jen.Op("...")).String().
		Custom(jen.Options{
			Open:      "{",
			Close:     "}",
			Separator: ",",
			Multi:     true,
		},
			jen.Lit("foo"), jen.Lit("bar"), jen.Lit("baz"),
		),
)
/*
	---
	var bar = [...]string{
			"foo",
			"bar",
			"baz",
	}
	---
*/

関数を受けとるメソッド: ○○Func

○○Funcという風にFuncが,例えば、StructFuncを用いると関数を受けることができるので、ここでfor-loopを回すなりするとよいでしょう。

fields := []struct {
	name string
	def  *jen.Statement
}{
	{"foo", jen.String()},
	{"bar", jen.Int()},
	{"baz", jen.Op("*").Qual("bytes", "Buffer")},
}
decoratePrint(jen.Type().Id("foo").StructFunc(func(g *jen.Group) {
	for _, f := range fields {
		g.Id(f.name).ががdd(f.def)
	}
}))
/*
	---
	type foo struct {
			foo string
			bar int
			baz *bytes.Buffer
	}
	---
*/

関数を受けとるメソッド: Do

同様にDoもコールバック関数を受け取ります。
○○Funcと違って受け入れる関数のシグネチャはfunc(s *jen.Statement)です。

	decoratePrint(jen.Type().Id("bar").Op("struct").Op("{").
		Do(func(s *jen.Statement) {
			for _, f := range fields {
				s.Id(f.name).Add(f.def).Line()
			}
		}).
		Op("}"),
	)
	/*
		---
		type bar struct {
		        foo string
		        bar int
		        baz *bytes.Buffer
		}
		---
	*/

いい例が思いつかなくて少々ぎこちない感じですが、Doで受け取ったコールバックで書きだす内容は何でもいいので任意に関数分割を行えます。

Add: *jen.Statementや*jen.Groupを追加する

import pathが長くなるとQualを何度も書くと冗長なので関数に切り分けたくなると思います。

Addを用いればQualなど、繰り返すには長すぎる表現を関数に切り出せます。

	randomIdentName := func(leng int) string {
		var buf strings.Builder
		buf.Grow(1 + 2*leng)
		_ = buf.WriteByte('_')
		for range leng {
			_, _ = buf.WriteString(fmt.Sprintf("%x", [1]byte{rand.N[byte](255)}))
		}
		return buf.String()
	}
	bytesQual := func(ident string) *jen.Statement {
		return jen.Qual("bytes", ident)
	}
	imageQual := func(ident string) *jen.Statement {
		return jen.Qual("image", ident)
	}
	decoratePrint(jen.Var().DefsFunc(func(g *jen.Group) {
		g.Id(randomIdentName(4)).Op("=").Op("*").Add(bytesQual("Buffer"))
		g.Id(randomIdentName(4)).Op("=").Add(imageQual("Image"))
	}))
	/*
		---
		var (
		        _76ef57e5 = *bytes.Buffer
		        _5d5cdb42 = image.Image
		)
		---
	*/

HACK: Idでコード片を挿入

Idなどはノーチェックで渡されたstringの内容を書き出しているだけなのでGo source codeとして有効ならなんでも書き出すことができます。
ただし手動でLineで囲まないと改行なしでトークンが出力される可能性があるのでそこだけ注意が必要です。

decoratePrint(jen.Type().Id("Yay").String().Line().Id(`func foo() bool { return true }`).Line().Type().Id("Nay").Bool())
/*
	type Yay string

	func foo() bool { return true }

	type Nay bool
*/

この方法で差し込まれたコード片はimport declを更新できないので新しいimportがここで追加される場合うまく機能しません。
import declの内容を追加するのは筆者が見たところQualのみです。
そのため何かしらのHACKをさらに重ねない限り、text/templateの出力結果をIdで埋め込む・・・みたいなことはできません。

jennifer example: enum

text/template example: enumと同じものをgithub.com/dave/jenniferで再実装します。

関数呼び出しがどういったコードと対応づいているのかをコメントしておいたので、これでおそらく呼び出し方の様式がわかるでしょう。

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

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

func capitalize(s string) string {
	if len(s) == 0 {
		return s
	}
	if len(s) == 1 {
		return strings.ToUpper(s)
	}
	return strings.ToUpper(s[:1]) + s[1:]
}

func replaceInvalidChar(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)
}

func fillName(p EnumExceptParam, name string) EnumExceptParam {
	p.Name = name
	return p
}

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

	param := 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"},
			},
		},
	}

	f := jen.NewFile(param.PackageName)

	f.PackageComment("// Code generated by me. DO NOT EDIT.")

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

	f.Type().Id(param.Name).String() // type Enum string

	// const (
	f.Const().DefsFunc(func(g *jen.Group) {
		for _, variant := range param.Variants {
			g.
				Id(param.Name + replaceInvalidChar(capitalize(variant))). // EnumFoo
				Id(param.Name).                                           // Enum
				Op("=").                                                  // =
				Lit(variant)                                              // "foo"\n
		}
	}) // )

	// var _EnumAll = [...]Enum
	f.Var().Id("_" + param.Name + "All").Op("=").Index(jen.Op("...")).Id(param.Name).
		ValuesFunc(func(g *jen.Group) { // {
			for _, variant := range param.Variants {
				g.Line().Id(param.Name + replaceInvalidChar(capitalize(variant))) // EnumFoo,
			}
			g.Line() // \n
		}) // }

	// func IsEnum(v Enum) bool
	f.Func().Id("Is" + param.Name).Params(jen.Id("v").Id(param.Name)).Bool().Block( // {
		jen.Return( // return
			jen.Qual("slices", "Contains").Call( // slices.Contains
				jen.Id("_"+param.Name+"All").Index(jen.Op(":")), // _EnumAll[:],
				jen.Id("v"), // v,
			),
		),
	) // }

	f.Line()

	for _, except := range param.Excepts {
		except = fillName(except, param.Name)
		// func IsEnumExceptFoo(v Enum) bool
		f.Func().Id("Is" + except.Name + "Except" + replaceInvalidChar(capitalize(except.ExceptName))).Params(jen.Id("v").Id(except.Name)).Bool().Block( // {
			jen.Return( // return
				jen.Op("!").Qual("slices", "Contains").Params( // !slice.Contains(
					jen.Line().Index().Id(except.Name).ValuesFunc(func(g *jen.Group) { //[]Enum{
						for _, e := range except.ExcludedValiants {
							g.Line().Id(param.Name + replaceInvalidChar(capitalize(e))) // EnumFoo,
						}
						g.Line()
					}), // },
					jen.Line().Id("v"), // v,
					jen.Line(),
				), // )
			),
		) // }
		f.Line()
	}

	err = f.Render(out)
	if err != nil {
		panic(err)
	}
}

おわりに

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

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

を述べました。

この記事ではGoのcode generatorを作るためのライブラリであるgithub.com/dave/jenniferの使い方を軽く紹介し、text/templateで実装したenumのcode generatorをjenniferで再実装しました。

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

github.com/dave/jenniferではGoのトークンや構文に対応づいた関数をメソッドチェーンで順繰りに呼び出すことでコードを生成します。
○○Func, Doで関数を受け取れるため、ここで反復可能なデータを取り扱うことができ、さらにAddも組み合わせると適当にgeneratorを分割することができます。
またQualによって自動的にimport declが管理されるため、同名のパッケージをインポートする際の名前被りも自動的に回避されます。

jenniferは書きやすい反面、ユーザーから部分的なcode generatorを受けとって挙動をカスタマイズさせるような機能を作りづらいため、そういった機能が必要なケースではtext/templateを組み合わせて使用するのがよいかと思います。

GitHubで編集を提案

Discussion