[Go]ast(dst)と型情報からコードを生成する(partial-json patcher etc)
EDIT: 2024/11/27
急いで書いたら読みにくかったので大幅改修。
差分(見なくていい): https://github.com/ngicks/zenn-article/pull/1
EDIT: 2024/12/04
Go1.24からomitzeroがencoding/jsonに追加される。
この記事はそのことに気付かず書かれている。
ast(dst)と型情報からコードを生成する(partial-json patcher etc)
こんにちは
この記事ではGoのast(dst)と型情報を用いたコードジェネレーターの実装を例にしながらポイントや考慮すべきことをまとめます。
似たような感じでコードジェネレーターを作りたい人や、go/astやgo/types以下で実装される型や関数の使い方がわからなくてとっかかりがつかめない人(かつての私)が進みやすくなるかもしれないことを目指しています。
Overview
プログラムを書いていると時たま、コードジェネレーターの吐いたコードの結果を受けてさらにコードを編集したいときがあります。例としてはgithub.com/oapi-codegen/oapi-codegenが生成したコードの特定パス以下(e.g. /config)のrequest bodyをパスごとに保存できる簡単なconfig storeを作ったりなどですね。
Goのソースコードを引数にコードジェネレーターを作成する際、単にテキストファイルとしてソースコードを解析してもよいのですが、astや型情報を用いることができたほうが改行やコメントその他で意味論的に違いのないソースコードを違いなく処理できるため、その観点からはできるならそうしたほうがいいと言えます。
そこで本記事では以下のようなことをします。
-
astと型情報の解析 -
astの書き換え- 実際にはgithub.com/dave/dstを用います。
-
astのnode単位の部分的なプリント - ファイルのimportの解析と連携
- struct tagの編集
- 型情報をグラフ化して、型定義の依存関係を上に向けて探索する
これらを具体的な実装物を通じて説明します。
- Partial-JSONによるPatchを実現するPatch
- 特定の型(github.com/ngicks/undで定義される型)の状態をvalidateするValidator
- 上記特定の型を、struct tagに応じてPlainな型に変換するPlain
の三つを実装し、それらの構成要素で重要なものを解説します。
概説的、網羅的にはなりません。あくまで今回実装したものの周りについてのみ説明します。
前提知識
-
Goの構文ルールがわかる -
Goにおけるjsonの取り扱いがわかる -
astや型情報といったものがどういうものかある程度わかる-
Go固有と思しきast構造についてはいくらか触れますが、astという言葉自体を解説しません。
-
-
Go1.23で追加されたiterator仕様とxiter proposalで載せられたadapter群を知っている
- サンプルコードは何も言わずにそれらを使います。
- 見たらなんとなくで意味は分かるとは思います。
構文ルール(やGoプロジェクトの始め方)はGoで開発して3年のプラクティスまとめという一連の記事でまとめています・・・というか、A Tour of Goをやったら30分~数時間ぐらいで分かるよということだけのべておきます。
jsonを含めたデータの取り扱いはGoで開発して3年のプラクティスまとめ(2/4)のデータのシリアライズ/デシリアライズの項目やGoのJSONのT | null | undefinedは[]Option[T]で表現できるですでにそこそこ深めに述べましたので、そちらを読んでいただければわかることもあるかもしれません。
コードの中でxiterパッケージを使います。これは以前の記事で作ったモジュール下でベンダーされたものなので、golang.org/x/expにxiterパッケージが存在しているわけではありません。
基本的にはある程度実践的にGoを使ったことがあることを前提とします。
対象環境
-
linux/amd64- ただし
OS/archの差は外部ライブラリによって吸収されるので影響しないものと想定します。
- ただし
検証はgo 1.23.2、リンクとして貼るドキュメントは1.23.3のものになります。
# go version
go version go1.23.2 linux/amd64
各種ライブラリは以下のバージョンを用います。特にgolang.org/x/toolsは作成途中にv0.27.0がリリースされていますがこのバージョンで本記事に書いたいくつかの挙動が改善されているかもしれないので注意してください。
require (
github.com/dave/dst v0.27.3
github.com/google/go-cmp v0.6.0
github.com/ngicks/go-iterator-helper v0.0.16-0.20241102133946-d622279c83c3
github.com/ngicks/und v1.0.0-alpha5.0.20241108225608-67d88238795b
github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
golang.org/x/tools v0.26.0
gotest.tools/v3 v3.5.1
)
実現したいもの
具体的にどういったものを実装するかについて述べます
下記は以前書いたものですが
この記事の中で作成したgithub.com/ngicks/undで定義される型をstruct fieldに指定すると
-
sliceund.Und: JSONの
undefined | null | T -
sliceelastic.Elastic: JSONの
undefined | null | T | (T | null)[]
をそれぞれ表現することができます。(ただしjson:",omitempty"を必要とする)
記事内で課題感を述べましたが、Goでstructを定義し、そのfieldでT | null | undefinedを表現し分けることは普通にはできません。null | undefinedを表現し分ける方法が普通にはないからです。sliceelastic.ElasticはElasticsearchに格納することができるJSONのフィールドを((T|null)[][]などのネストしたArray以外)表現しきるためにあります。
本記事ではこれらを用いて以下を実現するコードを生成するコードジェネレーターを実装します。
- Patcher
- Partial JSONを受けとってデータの部分的更新(Patch)を行うことができるようにする
- Validator
-
sliceund.Und
[T], sliceelastic.Elastic[T]などの値の状態をvalidateできるようにする
-
sliceund.Und
- Plain
-
sliceund.Und
[T], sliceelastic.Elastic[T]など値をTや[]TのようなPlainなものに置き換えた型を生成する - 元の型(Raw)と相互に変換できるようにする。
-
sliceund.Und
生成されるコードのイメージ
まずどういったコードを生成したら目標が実現できるかを思い描き、そこから具体的に何を実装すべきかについて考えます。
この記事で一番話したかったのは機能の実装のところなんですが、たてつけ上説明しないと意味不明なのでここでどういったコードを生成するか紹介します。
興味なかったらとばしてください。
Patcher
Patcherが実現したいのはPartial JSONを受けとって元となるデータ構造にパッチを当てられるようにすることです。
Partial JSONとここで呼んでいるのは各fieldがT | undefinedで表現できるJSON Objectやそれを含むJSON Valueのことです。
Patchの対象がfieldName *Tを持つとき、Patchの対応するfieldはnullとundefinedを表現し分ける必要があり、前述のとおりそのためにsliceund.Und[T]を用います。
そこで方式としては
- Patch専用の型を元となるstruct typeから生成し
- fieldをすべて
sliceund.Und[T]でwrapします。 - Patch typeにメソッドを実装
- 元となった型からPatchへの変換
- Patch同士のMerge
- 元となった型を受けとってPatchを適用するApply
とします。
つまり、以下が入力であるとき
type PatchExample struct {
Foo string
Bar *int `json:",omitempty"`
Baz []string `json:"baz,omitempty"`
}
以下が出力される
type PatchExamplePatch struct {
Foo sliceund.Und[string] `json:",omitempty"`
Bar sliceund.Und[*int] `json:",omitempty"`
Baz sliceund.Und[[]string] `json:"baz,omitempty"`
}
func (p *PatchExamplePatch) FromValue(v PatchExample) {
*p = PatchExamplePatch{
Foo: sliceund.Defined(v.Foo),
Bar: sliceund.Defined(v.Bar),
Baz: sliceund.Defined(v.Baz),
}
}
func (p PatchExamplePatch) ToValue() PatchExample {
return PatchExample{
Foo: p.Foo.Value(),
Bar: p.Bar.Value(),
Baz: p.Baz.Value(),
}
}
func (p PatchExamplePatch) Merge(r PatchExamplePatch) PatchExamplePatch {
return PatchExamplePatch{
Foo: sliceund.FromOption(r.Foo.Unwrap().Or(p.Foo.Unwrap())),
Bar: sliceund.FromOption(r.Bar.Unwrap().Or(p.Bar.Unwrap())),
Baz: sliceund.FromOption(r.Baz.Unwrap().Or(p.Baz.Unwrap())),
}
}
func (p PatchExamplePatch) ApplyPatch(v PatchExample) PatchExample {
var orgP PatchExamplePatch
orgP.FromValue(v)
merged := orgP.Merge(p)
return merged.ToValue()
}
- 前述のとおりsliceund.UndでJSONの
T | null | undefinedをstruct fieldとして表現できますが、json:",omitempty"を必要とするのでない場合付け足します。- 付け足す、というのがキモです。元からあった
jsonstructタグはなるだけそのままにする必要があります。
- 付け足す、というのがキモです。元からあった
-
FromValueで元となった型からpatchへ変換、入力patchとMergeでマージ、ToValueで元となった型に逆変換することでパッチの挙動を実現します。 -
Mergeはgithub.com/ngicks/undの機能をふんだんに使ってOrをとることで実現します。
元の型->パッチ型な変換は元の型にメソッドとして実現するか、NewFooBarPatchという関数で実現するかしたほうがよかったかもしれませんが、下記理由でしないこととします。
- なるだけ元の型には何も追加したくないので、メソッドの追加もしたくありません。
- 追加すると名前被りのリスクがあります。
- リスク回避のために自然に感じられないPrefixをつけて被りにくくするとかがありえますが、これを避けたいわけです
- NewFooBarPatch的な関数も同様で名前被りのリスクがあります。
Validator
sliceund.UndはT | null | undefinedを表現できるがゆえ、入力されるJSONなどの対応するfieldが存在しない(undefinedである)ことを検知することができます。
T | nullであってもよいが、undefinedではいけないというケースにおいてnull | undefinedを分けて表現できることが強みとなります。
(fieldに必ずnullを指定させることでtypoを検知するというプラクティスもあり得ます。)
特にsliceelastic.Elasticはとれる状態がT | null | undefined | (T | null)[]ととにかく多いです。Elasticsearchからすると[]とnullとundefinedはどれも同じフィールドが存在しないという意味になりますが、JSONとしては別の値ですからvalidateはこちらのほうが重要です。
Validatorのメインの目的はundefinedあることを禁じたいケースで手軽に違反がないかを検知する方法を提供することです。
Validator自体はgithub.com/ngicks/undで実装済みです。
github.com/ngicks/undではcode generator機能を有していないので(できる限りgo.modに書かれる内容を軽量化するためです)reflectを利用して上記機能を実現します。
こちらではund:"" struct tagでdef(defined=T), null, undefinedなどのとっても良い状態を指定させ、これをreflect経由で解析してfieldのvalidationに利用します。
und:"" struct tagの解析と、それ利用したfield-validatorの定義はgithub.com/ngicks/undですでにされており、code generatorはこれを利用することができます。reflect版を実装しているときからこのcode generator版を作ることはある程度念頭に置いていたため、機能は外部から利用できるように分離する考慮をある程度していました。
und:"" struct tagが取れる値の一覧はよりそれが重要であるPlainの項で行います。
code generatorは型情報からstruct tagを取り出し、内容を解析、field-validatorを定義して各fieldをsource-code orderで順繰りにvalidateし、違反があったときにエラーを返素メソッドを定義します。メソッド名は上記のreflect版との連携を意識してUndValidateとします。
つまり入力が以下であるとき
type Example struct {
Foo string
Bar option.Option[string] // no tag
Baz option.Option[string] `und:"def"`
Qux und.Und[string] `und:"def,und"`
Quux elastic.Elastic[string] `und:"null,len==3"`
Corge sliceund.Und[string] `und:"nullish"`
Grault sliceelastic.Elastic[string] `und:"und,len>=2,values:nonnull"`
}
以下のUndValidateメソッドが出力される
func (v Example) UndValidate() (err error) {
{
validator := undtag.UndOptExport{
States: &undtag.StateValidator{
Def: true,
},
}.Into()
if !validator.ValidOpt(v.Baz) {
err = fmt.Errorf("%s: value is %s", validator.Describe(), validate.ReportState(v.Baz))
}
if err != nil {
return validate.AppendValidationErrorDot(
err,
"Baz",
)
}
}
{
validator := undtag.UndOptExport{
States: &undtag.StateValidator{
Def: true,
Und: true,
},
}.Into()
if !validator.ValidUnd(v.Qux) {
err = fmt.Errorf("%s: value is %s", validator.Describe(), validate.ReportState(v.Qux))
}
if err != nil {
return validate.AppendValidationErrorDot(
err,
"Qux",
)
}
}
{
validator := undtag.UndOptExport{
States: &undtag.StateValidator{
Def: true,
Null: true,
},
Len: &undtag.LenValidator{
Len: 3,
Op: undtag.LenOpEqEq,
},
}.Into()
if !validator.ValidElastic(v.Quux) {
err = fmt.Errorf("%s: value is %s", validator.Describe(), validate.ReportState(v.Quux))
}
if err != nil {
return validate.AppendValidationErrorDot(
err,
"Quux",
)
}
}
{
validator := undtag.UndOptExport{
States: &undtag.StateValidator{
Null: true,
Und: true,
},
}.Into()
if !validator.ValidUnd(v.Corge) {
err = fmt.Errorf("%s: value is %s", validator.Describe(), validate.ReportState(v.Corge))
}
if err != nil {
return validate.AppendValidationErrorDot(
err,
"Corge",
)
}
}
{
validator := undtag.UndOptExport{
States: &undtag.StateValidator{
Def: true,
Und: true,
},
Len: &undtag.LenValidator{
Len: 2,
Op: undtag.LenOpGrEq,
},
Values: &undtag.ValuesValidator{
Nonnull: true,
},
}.Into()
if !validator.ValidElastic(v.Grault) {
err = fmt.Errorf("%s: value is %s", validator.Describe(), validate.ReportState(v.Grault))
}
if err != nil {
return validate.AppendValidationErrorDot(
err,
"Grault",
)
}
}
return
}
validate.AppendValidationErrorDotとvalidate.AppendValidationErrorIndexはナイスなエラーメッセージを表示するためのヘルパーです。どのフィールドが違反したかを.Foo.Bar.Bazのようなチェーンの表現で表示できるようにします。それらは内部的にエラーをValidationErrorでラップします。こちらはPointerメソッドを備えており、RFC6901のJSON Pointer形式で違反フィールドを表現できるため、機械的に処理することもできます。
さらに、fieldの型がUndValidateを実装する際にはそれを呼び出せるようにします。
JSON valueにほかのJSON Objectが含まれることはごく自然なことに思いますし、その場合Goでは普通各部をばらばらのnamed typeとして定義すると思います。
JSON Objectにネスト複数のJSON Objectが含まれるがトップレベルのフィールドだけのvalidationを行いたいいうケースはなくはないでしょうがそんなに多くはないだろうと予測します。
下記スニペットのように、Exampleをフィールドに含むDependantのような型を定義することはよくあるだろうし、何ならBarのようにExample | null | undefinedなフィールドを定義しすることはよくあるでしょう。JSONは通信に乗りますから、(MessagePackなどより効率的なフォーマットを使わなくても)省略できるものを省略してI/Oの負担を減らしたいケースはよくあると思います。
// Exampleは上記スニペットのExampleです
type Dependent struct {
Foo Example
Bar sliceund.Und[Example] `und:"required"`
}
func (v Dependent) UndValidate() (err error) {
{
err = v.Foo.UndValidate()
if err != nil {
return validate.AppendValidationErrorDot(
err,
"Foo",
)
}
}
{
validator := undtag.UndOptExport{
States: &undtag.StateValidator{
Def: true,
},
}.Into()
if !validator.ValidUnd(v.Bar) {
err = fmt.Errorf("%s: value is %s", validator.Describe(), validate.ReportState(v.Bar))
}
if err == nil {
err = sliceund.UndValidate(v.Bar)
}
if err != nil {
return validate.AppendValidationErrorDot(
err,
"Bar",
)
}
}
return
}
こうすればund typeを含むstructが複数ネストした場合でもフィールドをすべてvalidateして回れるようになります。
Plain
sliceund.UndはT | null | undefinedを表現できますが、これはI/Oなどを通じて得られた外部からのデータをうまく取り扱うことを目的としています。
前述のValidatorを実施したり、undefined時のフォールバック用のデフォルト値をPatcherなどで適用した後はもっとGoの「普通の」型のようなものに変換して処理できたほうが便利です。
そこで、Plainはund:"" struct tagの内容に基づいてund typeでwrapされた型(sliceund.Und[T]ならTのこと)をunwrapしたPlainな型を作り、これと元となった型(Raw)との相互変換を行うことを実現します。
特にsliceelastic.Elasticはとれる状態が前述通りT | null | undefined | (T | null)[]ととにかく多いです。sliceelastic.Elasticは実装の都合上、json.Marshal時に値が1要素であっても[T]のようにJSON Arrayで出力してしまいますから、フィールドのlengthが1で固定であるときに、Tにunwrapした型を生成し、そちらからjson.Marshalを出力できるようにすることは柔軟な運用を簡単にできるようになるという意味で価値があると考えています。
unwrapの仕方はund:"" struct tagに従います。
指定できる値は
-
def(=defined) null-
und(=undefined) -
required=defのshorthand -
nullish=null,undのshorthand -
len=Elasticの長さを指定、-
len>n,len>=n,len==n,len<n,len<=nでそれぞれ要素数の制限を指定できます - どうしてここまで柔軟な仕様に・・・?
-
-
values=Elasticの各要素の状態を指定-
values:nonnullで各要素はnullになってはならないことを表現できる。
-
となります。それに基づいてunwrapは以下のように行います。
-
Und+und:"def"->T -
Und+und:"def,null"->option.Option[T] -
Elastic+und:"def,len==n"->[n]option.Option[T] -
Elastic+und:"len>2,values:nonnull"->und.Und[[]T]
つまり以下のような型が入力であるとき
type Example struct {
Foo string
Bar option.Option[string] // no tag
Baz option.Option[string] `und:"def"`
Qux und.Und[string] `und:"def,und"`
Quux elastic.Elastic[string] `und:"null,len==3"`
Corge sliceund.Und[string] `und:"nullish"`
Grault sliceelastic.Elastic[string] `und:"und,len>=2,values:nonnull"`
}
unwrapのルールにもどついて以下のExamplePlainが書きだされます。
type ExamplePlain struct {
Foo string
Bar option.Option[string] // no tag
Baz string `und:"def"`
Qux option.Option[string] `und:"def,und"`
Quux option.Option[[3]option.Option[string]] `und:"null,len==3"`
Corge option.Option[conversion.Empty] `und:"nullish"`
Grault option.Option[[]string] `und:"und,len>=2,values:nonnull"`
}
相互変換は以下のUndPlain/UndRawで行われます。Example --(UndPlain)--> ExamplePlain --(UndRaw)--> Exampleと循環的に変換が行えるようになります。
func (v Example) UndPlain() ExamplePlain {
return ExamplePlain{
Foo: v.Foo,
Bar: v.Bar.Value(),
Baz: v.Baz.Value(),
Qux: v.Qux.Unwrap().Value(),
Quux: sliceund.Map(
conversion.UnwrapElasticSlice(v.Quux),
func(o []option.Option[int]) (out [3]option.Option[int]) {
copy(out[:], o)
return out
},
).Value(),
Corge: conversion.NonNullSlice(conversion.LenNAtLeastSlice(3, conversion.UnwrapElasticSlice(v.Corge))).Value(),
}
}
func (v ExamplePlain) UndRaw() Example {
return Example{
Foo: v.Foo,
Bar: option.Some(v.Bar),
Baz: und.Defined(v.Baz),
Qux: conversion.OptionUnd(true, v.Qux),
Quux: sliceelastic.FromUnd(sliceund.Map(
sliceund.Defined(v.Quux),
func(s [3]option.Option[int]) []option.Option[int] {
return s[:]
},
)),
Corge: sliceelastic.FromUnd(conversion.NullifySlice(sliceund.Defined(v.Corge))),
}
}
さらに、フィールドがUndRaw/UndPlainの循環的変換を実装する際にはそれを呼び出せるようにします。
Validatorのところで説明したように、JSON valueが別のJSON Objectを持ち、それらを表現するGoの型は各部を別々のnamed typeとして定義することはよくあることだからです。
Validatorの例でも使用したDependantに対して、Plainは以下のようになります。
type Dependent struct {
Foo Example
Bar sliceund.Und[Example] `und:"required"`
}
---
type DependentPlain struct {
Foo ExamplePlain
Bar ExamplePlain `und:"required"`
}
func (v Dependent) UndPlain() DependentPlain {
return DependentPlain{
Foo: v.Foo.UndPlain(),
Bar: sliceund.Map(
v.Bar,
func(v Example) ExamplePlain {
vv := v.UndPlain()
return vv
},
).Value(),
}
}
func (v DependentPlain) UndRaw() Dependent {
return Dependent{
Foo: v.Foo.UndRaw(),
Bar: sliceund.Map(
sliceund.Defined(v.Bar),
func(v ExamplePlain) Example {
vv := v.UndRaw()
return vv
},
),
}
}
基本方針
生成されるコードのイメージで説明したものを実現するにはどうするかについて考えます。
やりたいことを書き下すと
- 対象のstruct typeが
und:""struct tagの設定されたund typeのfieldを持つかの判定 - 対象のstruct typeが
UndValidate,UndPlain/UndRawを実装する型――implementor――をfieldに持つかの判定
それらを「生成対象の型」や、matchedと呼びます。
- 上記どちらかの当てはまる場合、型を置き換えた
Patch,Plaintypeの生成 -
ApplyPatch,UndValidate,UndPlainなどのメソッドの生成
これらの処理は1度のcode generatorの実行で複数のpackageをまとめて処理したほうがよいです。
なぜなら、生成対象の型は実行後implementor(UndValidate,UndPlain/UndRawを実装する)になるからです。生成対象の型への依存チェーンが複数のpackageにまたがっていると、単一のpackageしか同時に処理できないコマンド体系では依存関係を考慮した順序で何度も生成を呼び出す必要があります。これはライブラリが丸め込みたい煩雑さです。
生成対象の型をfieldに含むstruct typeを以後dependantと呼びます。
code generatorはexportされていないfieldに対しても処理を行うため、生成対象と同じpackage内に書き出される必要があります。
- 生成対象の型の判定 =>
go/typesで型情報を参照して行います。 -
Patch,Plaintypeの生成 => astのrewriteで行います。- 型の書き換えはfield構造を全く変えず、und typeでfieldをwrapしたり、unwrapしたりするだけのため、astの付け替えでやると都合がよいです。
-
go/types以下で実装される型情報だけを使っても生成できるのですが、この場合コメント情報が消えるようです。- 今回実装するものは元となる型について回っているコメントもそのまま生成されるコードに残したい意図があります。コメントがなくなってフィールドの意図がわからなくなると困るだろうということです。
-
ApplyPatch,UndValidate,UndPlainなどのメソッドの生成 =>fmt.Sprintfで行います- 理由は後述
-
UndRaw/UndPlain実装の判定:go/typesで型情報をたどって行います。-
UndPlain/UndRawはT--(UndPlain)-->T'--(UndRaw)-->Tという循環的な変換を行うためinterfaceで表現できません。 - 型情報をたどって
UndPlainの返り値のUndRawメソッドの返り値が元の型と一致するかをチェックします。
-
- 複数のpackageをまとめて処理する
- => golang.org/x/tools/go/packagesで複数パッケージをまとめて処理します
- => 型の依存関係のgraphを形成することで、複数packageにまたがった依存によって連鎖的に
dependantとなった型の判定を適切に行えるようにします。
ファイルの書き出しは以下でSuffixWriterを定義して、生成対象の型を含むファイルのファイル名を受けて.und_patch.goのようなsuffixを付けたファイルに生成対象の型に紐づいて生成されたものはすべてまとめて書き出します。
こうすることで再生成の際に上書きすることや、まとめて削除するのが容易になります。
処理の流れをざっくり図示すると以下のような感じです

機能の実装
前述の基本方針に従いながら実現したいコードを生成するためにはどのような機能が必要かを述べます。
以下を後続の節で順次説明します。
-
- golang.org/x/tools/go/packagesを使用した複数パッケージからのastおよび型情報の収集
-
- 生成対象の型の検知
- 以下を検知します
-
und:""struct tagを持つund typeのfieldを含むstruct type -
implementorのfieldを含むstruct type
-
- さらに、上記の型を含む型をの検知と、さらにその型を含む型・・・という感じで連鎖的な型(
dependant)の検知
-
- 型依存graphの形成
-
- 連鎖的に検知された型(
dependant)から、生成されることになるはずの型を*types.Namedとして生成する
-
implementorのUndPlain/UndRawによる変換先の型は、型情報から取得可能ですが、dependantはまだ型を書き出していないので、implementor同様の方法では変換先を得られません - ただしcode generatorはどのような型を書き出すことになるのかを知っています。
-
implementorとdependantをほぼ同じように取り扱いたいならば、dependantの変換先も同じフォーマットで得られたほうが良いです。
- 連鎖的に検知された型(
-
- struct tagの編集
-
Patchtypeなどにjson:",omitempty"を追加するときなどに必要です。
-
- import情報の解析/連携
-
- astのrewriteおよび書き出し
1. golang.org/x/tools/go/packagesを使用した複数パッケージからのastおよび型情報の収集
astと型情報の解析はgolang.org/x/tools/go/packagesを用います。
なぜgolang.org/x/tools/go/packagesを用いる必要があるか?
なぜgo/types, go/astがあるのにgolang.org/x/tools/go/packagesを用いる必要があるのかについて先に説明します。
astの素朴な解析はgo/token, go/ast, go/parserを用いることで行えます。
package main
import (
"go/parser"
"go/token"
)
func main() {
fset := token.NewFileSet()
/* *ast.File */file, err := parser.ParseFile(
fset,
"path/to/source/file",
nil,
parser.ParseComments|parser.AllErrors,
)
if err != nil {
// handle error
}
}
さらに型チェックも同様にgo/types, go/importerによって行えます
package main
import (
+ "go/importer"
"go/parser"
"go/token"
+ "go/types"
)
func main() {
fset := token.NewFileSet()
/* *ast.File */file, err := parser.ParseFile(fset, "path/to/source/file", nil, parser.ParseComments|parser.AllErrors)
if err != nil {
// handle error
}
+ conf := &types.Config{
+ Importer: importer.Default(),
+ Sizes: types.SizesFor("gc", "amd64"),
+ }
+ pkg := types.NewPackage(pkgPath, files[0].Name.Name)
+ typeInfo := &types.Info{
+ Types: make(map[ast.Expr]types.TypeAndValue),
+ Defs: make(map[*ast.Ident]types.Object),
+ Uses: make(map[*ast.Ident]types.Object),
+ Implicits: make(map[ast.Node]types.Object),
+ Instances: make(map[*ast.Ident]types.Instance),
+ Scopes: make(map[ast.Node]*types.Scope),
+ Selections: make(map[*ast.SelectorExpr]*types.Selection),
+ }
+ chk := types.NewChecker(conf, fset, pkg, typeInfo)
+ err := chk.Files(file)
+ if err != nil {
+ // handle error
+ }
}
ただし直接使うには少し難しい部分があります。
type checkerは外部モジュールをimportする際に、*types.ConfigのImporterフィールドで受け取ったImporter interfaceの実装を使用しますが、stdのgo/importerで提供されるipmorter実装はgo module awareではないのか、デフォルトではgo getでキャッシュされたモジュールをロードしてくれません。
ではどうするのかというと、importerを自作する必要があります。
-
go list --json --deps=true -- ./package/specifierで依存を含むすべてのソースコードを列挙 - モジュールのdependency graphを作成、末端(=なにもimportしない)から順次ロード
-
Importer実装として、Package pathを与えられたら*types.Packageを返すようにinterfaceを実装する。
golang.org/x/tools/go/packagesによるロード
上記の課題を解決してくれるのがgolang.org/x/tools/go/packagesです。
中身を読む限り、golang.org/x/tools/go/packagesはgo list --json --deps=true -- ./package/specifierによって依存モジュールを列挙、依存関係をDAG化、グラフをdepth-firstの順番でロード、type checkと上記のことを一通りやってくれます。
上で上げたナイーブな実装とは違い、CGOやPGOの考慮やgo list以外のdriverのサポート、package patternが多すぎる際にmax safe cli arg以下になるようにコマンド実行の分割、ロードのconcurrent化、type check失敗時の考慮などしっかり作りこまれています。
golang.org/x/tools/go/packagesでtype checkまで行うコードは以下のようになります
import "golang.org/x/tools/go/packages"
func main() {
cfg := &packages.Config{
Mode: packages.NeedName |
packages.NeedTypes |
packages.NeedSyntax |
packages.NeedTypesInfo |
packages.NeedTypesSizes,
Context: ctx,
Dir: dir,
}
pkgs, err := packages.Load(cfg, "variadic", "package/match", "patterns")
if err != nil {
// handle error
}
}
ずいぶん簡単になりましたね。
packages.Loadで[]*packages.Packageが返され、これの各種フィールドがastや型情報となります。
PackageのPkgPath, Syntax([]*ast.File), TypeInfo(*types.Info)を使いたい場合、上記のようにModeビットフラグを設定します。
NeedTypesSizesフラグもないと*types.Infoの各フィールドがpopulateされません。
後述する理由でロード対象の依存先もロードしておきたいのでpackages.NeedImports|packages.NeedDepsも加えます。
cfg := &packages.Config{
Mode: packages.NeedName |
+ packages.NeedImports |
+ packages.NeedDeps |
packages.NeedTypes |
packages.NeedSyntax |
packages.NeedTypesInfo |
packages.NeedTypesSizes,
Context: ctx,
Dir: dir,
}
2. 生成対象の型の検知
go/typesで定義される型情報を用いて、type specを走査し
-
und:""struct tagのついたund typeのフィールドを持つ型(「生成対象の型」もしくはmatchedtypes) -
UndPlain/UndRawを実装する型
を見つけます。
UndValidateを実装する型もUndPlain/UndRawを実装する型と同じように探すことができますが、こちらも似たような方法でできるので説明されません。
型周りの詳しい話は以下を読むといいかもしれません。
何気に(予定上)Go1.24から導入されるgeneric type aliasesに合わせた更新も入ってます。
type specに対応するtype infoを探す
go/typesで型を探索するには、
- Scope.Lookupを使うか
-
Infoの
Defsフィールドを走査する
のいずれかをします。
Defsから探す場合はキーの型が*ast.Identなのでast情報も同様に必要になります。
今回のケースに限ってはastも探索する前提なのでDefsから探すこととします。
以下みたいな感じです。
var info *types.Info
for _, f := range []*ast.File{...} {
for _, decl := range f.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok {
// func or bad decl
continue
}
if genDecl.Tok != token.TYPE {
// import, constant or variable spec
continue
}
for _, spec := range genDecl.Specs {
ts := spec.(*ast.TypeSpec)
typeInfo /* types.Object */ := info.Defs[ts.Name]
switch ty := typeInfo.Type().(type) {
case *types.Alias:
// alias...
case *types.Named:
// named...
}
}
}
}
type specのidentでDefsを走査した場合、得られるのは名前付き型(*types.Named)もしくはalias(*types.Alias, type A = B)のみのようです。
und struct tagを持つund typeのフィールドを見つける
こうして見つけた型がund typeかつund:"" struct tagがついているかは以下のように探索します。
var st *types.Struct = typeInfo.Type().Underlying().(*types.Struct)
for i := range st.NumFields() {
f := st.Field(i)
undTagValue, ok := reflect.StructTag(st.Tag(i)).Lookup("und")
if ok {
undOpt, err := undtag.ParseOption(undTagValue)
if err != nil {
return err
}
if !isUndType(f.Type()) {
return fmt.Errorf("tagged but not an und type is an error")
}
// found
}
}
Defsから得られたtypes.ObjectのTypeメソッドでtypes.Type interfaceが得られます。前述どおり実際の型はnamedかaliasのみです。
aliasは無視するものとします。
ここで得られているのは名前だけですので、具体的なstruct fieldを探索するためにはそれのunderlying typeをUnderlyingメソッドで取り出します。
Underlyingの用語はGo specのそれと一致しており、つまるところ以下のような感じです。
type Foo struct {Foo string; Bar int}
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// this part is underlying
type Fooのunderlying typeはstruct {Foo string; Bar int}というわけです。
*types.Structはreflect.StructFieldと違ってfieldではなく*types.StructにTagメソッドがあり、それからi番目のフィールドのstruct tagを取得します。
上記のisUndTypeの具体的実装は以下のようになります。
func isUndType(ty types.Type) bool {
named, ok := ty.(*types.Named)
if !ok {
return false
}
obj := named.Obj()
pkg := obj.Pkg()
if pkg == nil {
// 組み込み型などの場合、Pkgからnilが帰ります。
// named typeではerror型がnilを返します。
// types.Objectを受けとるところではPkgのnil checkはしておくほうが無難ですね。
return false
}
name := obj.Name()
pkgPath := pkg.Path()
switch [2]string{pkgPath, name} {
case [2]string{"github.com/ngicks/und/option", "Option"},
[2]string{"github.com/ngicks/und", "Und"},
[2]string{"github.com/ngicks/und/elastic", "Elastic"},
[2]string{"github.com/ngicks/und/sliceund", "Und"},
[2]string{"github.com/ngicks/und/sliceund/elastic", "Elastic"}:
return true
default:
return false
}
}
types.ObjectのNameでunqualified nameが得られ、Pkg().Path()でパッケージのパスが得られるため、これを比較すればよいです。
コメントにある通り、error組み込み型は、組み込み型だからpackageが存在しませんがnamed typeです。なのでPkg()がnilを返します。nilチェックは必須です。
UndPlain/UndRawを実装する型の検知
前述のとおり、code generatorが生成することになるUndPlain/UndRawはT -> T' -> Tの循環的な変換メソッドです。
これらを実装する型を検知し、implementorとして取り扱うこととします。implementorに依存している型も同様にmatchedとして扱うことで、go module間での円滑な連携を可能とします。
GoのinterfaceにはSelf typeを表す方法がないため、UndPlain/UndRawはinterfaceで表現することはできず、型情報を解析して実装をチェックするよりほかありません。
ある型のmethod setをtypesを通じて得るにはtypes.NewMethodSetを用います。
例を下に示します。
type Foo struct {
}
func (f Foo) MethodOnNonPointer() {
//
}
func (f *Foo) MethodOnPointer() {
//
}
---
var fooObj types.Object
mset := types.NewMethodSet(fooObj.Type())
for i, sel := range hiter.AtterAll(mset) {
t.Logf("%d: %s", i, sel.Obj().Name())
// 0: MethodOnNonPointer
}
mset = types.NewMethodSet(types.NewPointer(fooObj.Type()))
for i, sel := range hiter.AtterAll(mset) {
t.Logf("%d: %s", i, sel.Obj().Name())
// 0: MethodOnNonPointer
// 1: MethodOnPointer
}
通常のGoのルールのとおり、pointer typeでなければpointer typeがreceiverのmethodは見えません。
基本的に*types.Namedを受けとってtypes.NewPointerでポインターに包んでからmethod setをとることになります。
method setのAtメソッドでn番目のメソッドを*types.Selectionとして得られます。
これは*types.Signatureなのでtype assertionしてから利用します。
以上よりUndPlain/UndRawを実装しているかは以下のようにチェックできます。
*types.Namedを受けとってtypes.NewPointerで包んでからmethod setを取得し、所望の名前のメソッドを探します。
返り値の型もtypes.NewPointerで包んでからmethod setを取得し、所望の名前のメソッドを探して、それの返り値が最初に入力された型かをチェックします。
*types.NamedのUnderlyingがinterfaceであるときはpointerで包むとmethodが見えなくなるため、それを判別する必要があります。つまりasPointerは以下のようになります。
ここで考慮しなければならないのが、入力されたnamed type tyがinstantiateされていない場合です。instantiateされていない型、つまりtype Foo[T any]のような型からメソッドの返り値をとると、そのtype param TでinstantiateされたFooPlain[T]が返ります。FooPlain[T]のUndRawから返ってくる型はFoo[T]であり、type Foo[T any]という具体的にinstantiateされていないtype paramだけを持つ状態で食い違うため同じ型ではないと判定されます。
そのため元の型tyがTypeParamを持つがTypeArgを持たない(=instantiateされていない)ときはメソッドが返した型でもう1度isConversionMethodImplementorを実行します。
3. 型依存graphの形成
matched type(フィールドにund:"" struct tagがついたund typeを含む型)を探し出し、さらにそれらの型に依存する型(dependant)を依存グラフを上に向けてたどることですべて発見するために、型情報をグラフとします。
やることは以下です
-
*types.Named(名前付き型)の列挙
-
type Foo ...として定義した型の中でtype A = Bというaliasを除いたものです。
-
- 型をnodeとし*types.Namedから*types.Namedへの依存をedgeとして記録。
-
matcherを受けとり、*types.Namedがmatchedであるかを判別-
matcherはund typeやUndValidate、UndRaw/UndPlainのような特別な関数を満たす外部の型にもマッチするようにします。- マッチしたもので、
Loadで得た[]*packages.Packageで直接ロードされたpackage以外で定義された型はexternalとしてマークします。
- マッチしたもので、
-
-
matchedから上へedgeをたどってdependanttypeを辿ることができるようにします。- transitの際、edgeを上にたどるかどうかを決める
edgeFilterを受けとり、例えばchan Aのような依存ではたどらないものとします。
- transitの際、edgeを上にたどるかどうかを決める
*types.Namedの列挙/nodeの記録
*types.Namedの列挙はastおよび型情報の収集: packages.Loadによるast/型情報の取得で説明した通り、[]*packages.PackageのSyntax([]*ast.File)をiterateして見つかった各*ast.TypeSpecのNameでTypesInfo(*types.Info)のDefsを引きます。
型情報にはどのtype specがグルーピングされていたとか、コメントとかは直接現れないため*ast.GenDecl, *ast.TypeSpecでもフィルタリングをかけられるようにします。
matcherにマッチしたとき、nodeのMatchedビットを立てます。
Nodeは以下のように定義されます
edgeの記録
Edgeは以下の通りに定義します。
*types.Namedから*types.Namedへの経路をたどり、map,slice,array,pointer,channelのような無名の型の情報をStackとして記録します。
以下のように順繰りに型をunwrapしながら経路情報を記録します。
各nodeは*types.Namedであるため、Underlyingでtraverseをかけます。
たどり着いたnamed typeがmatcherにマッチしたとき、nodeとしてすでに格納されていないならば外部タイプであるので、externalビットを立てます。
externalとしてマッチした時のみ、type argも記録します。type argの記録時にはnamed typeでないことも許容します。
und.Und[T]のTがUndValidateやUndRaw -> UndPlainのような特定のinterfaceを満たす時、特別なハンドリングを行いたのでtype argの記録が必要でした。
edgeは親子に双方に描きます。グラフをtraverseするときはedgeを子から親に向けてたどりますが、code generatorは子の情報を使うからです。
graphの例とedge filteringについて
例えば、下記のようなコードがあるとき、
type A struct {
Foo und.Und[int] `und:"required"`
}
type B struct {
A A
}
type C struct {
A map[string]A
}
type D struct {
A []A
}
// or even
type E struct {
A map[string]*[3][]chan A
}
各依存関係は以下のようになります。
-
B-{struct}->A -
C-{struct, map}->A -
D-{struct, slice}->A -
E-{struct, map, pointer, array, slice}->A
{struct, map}のような形で、array, chan, map, pointer, slice, structのような無名の型をたどった経路を記録します。これが前述のStackです。
Go1.18からgenericsが導入されたため、親から子への依存はtype argによりばらばらにinstantiateされる可能性がありますが、nodeそのものはinstantiateされてない型の定義そのものです。そのため、child側だけはNodeとTypeをそれぞれ記録する必要があります。

Foo nodeには複数のtype argをもってedgeが書かれることになります。
und.Und[T]をexternalとするグラフを図示すると以下のようになります。

今回生成したいコードはJSONなど外部とのデータのやり取りに用いる型を対象とするため、chanをStackに含むedgeは対象にしません。
そこでedgeのtraverse時にedgeをフィルターする機能を持つものとします。
上記の図ではUndからCのedgeはchanを含むものしかないため、それ以上辿らないものとします。
連鎖的にDもdependantではないという風に取り扱います。Aはmatchするためmatched、Bは連鎖的にdependantとして判定されます。

graphのtraverse
graphのtraversalはmatched、externalを起点にedgeを親に向けてたどります。
edgeの形成はNode間(*types.Nameから*types.Named)のみの評価であるため評価は必ず終わりますが、edgeをたどる際には無限ループが生じうるため、注意が必要です。
例えばTree型は型的に再帰することで木構造を形成することが多いため、この場合nodeが循環します。visit処理はこれらで無限ループに陥らないようなケアが必要です。
type Tree struct {
l, r *Tree
value any
}
そこで、お決まりですがvisited map[*node]boolなマップを用意し、1度visitしたnodeに再度visitすることがないようにします。
4. 連鎖的に検知された型(dependant)から、生成されることになるはずの型を*types.Namedとして生成する
UndRaw/UndPlainを実装する型の、UndPlainで返される型は上記*types.Namedの探索によって行われます。
dependantはUndPlainを実装するものとして取り扱われますが、こちらの場合はコードが生成されていないため上記と同じ*types.Namedを探索しただけでは変換先の型を取り出すことができませんが、implementorと同じように*types.Namedで変換先を渡せると扱いを統一できてよいのでそうします。
そこで、変換前の*types.Namedをベースに変換後の型を生成します。
types.NewNamedでメソッドセットを受けとりますが、これ自体に作成した*types.TypeNameが必要であるため関数分離の都合上callbackを受けとってメソッドセットを作成します。この例では元となった型のUnderlyingをそのままSetUnderlyingに渡しますが、ここに渡す型をinterface、mapなど好きな型に変えることで任意のnamed typeを作ることができます。
具体的な呼び出し例は以下になります。
元の型に+"Plain"をつけた名前で型を作り、メソッドはUndRawだけを持ちます。
このUndRawが参照されることは今回の実装では一度もなかったですが、実験的にtypes.TypeStringでプリントして正しくシグネチャが作成できていることは確認しています。
この辺の処理はtype checkerそのものを参考にしました。
typesのdoc commentを読むだけでは少々分かりにくかったですが、type checkerはinstantiateまでやりますから、全く同じ方法はとっていませんが参考になりました。
5. struct tagの編集
struct tagの編集機能は以下で実装します。
単なるテキスト処理であり、特筆すべきことはないため詳細な説明は省きます。
Goのstdのreflect.StructTag.Lookupを改変してkey-valueのペアに解析できるように変更し、
encoding/json/v2 discussionのexperimental実装のタグ解析部分を参考に、仕様をまねてjson:"name"のname部分はsingle quotation(')でescapeしてもよい、option:valueという形式で値のあるオプション(e.g. format:RFC3339)をとっても良いという形式にしてあります。
6. import情報の解析/連携
生成されるコードはast rewriteによって生成される部分と単なるテキストの書き出しで生成される部分があります。例えば以下のようなコードがあるとき
package foo
import (
"github.com/ngicks/und"
baaaaaar "example.com/foo/bar"
)
type Foo struct {
A und.Und[baaaaaar.Bar] `und:"required"`
}
Plain機能で生成されるコードは以下のようになります。ast上ではbaaaaaarは単なる文字列なので、
type FooPlain struct {
A baaaaaar.Bar
}
となります。単にテキストとして書き出されるメソッド群(UndValidate, UndPlain/UndRaw)でもこのbaaaaaarという名前を用いることで、元のソースからの統一感を損なわないようにします。そのためimportを記録し、PackagePathからident(この場合baaaaaar)を引き出せる情報ストアが必要です。
また、生成されるコードが既存のファイルに存在していなかったimportを追加したいのはよくあると思います。例えばfmt.Errorfを使用するために"fmt"を追加するといったことですね。この場合、既存のファイルに存在していたimportと名前が被ってしまう可能性があります。
普通にGoを書いててもcrypto/randとmath/rand/v2がどちらもrandなので被ってしまいますよね。
さらに、identを指定しないimport specはインポートされるパッケージがpackage clauseで付けた名前になります。つまり、
import (
"github.com/charmbracelet/bubbletea" /* tea */
)
上記はbubbleteaがpackage teaで定義されるため、teaでアクセスできます。
この挙動はspec上実装依存であると述べられており、別のコンパイラが提供された違った挙動をすることもあり得ますが、まあ基本的にこの挙動は保たれるでしょう。
package pathのbase nameとpackage nameが違う場合、linterがpackage nameでimportすべきだと警告を出す場合がありますが、上記のようなpackage pathのbaseのサブストリングである場合は出ないようですね。
goplsの設定で"ui.semanticTokens": trueでsemantic tokensを有効にしてあると、teaの部分が緑色(色はカラースキームによって異なる)で表示されてteaでこのpackageにアクセスできることがわかります。
ということで以下のことを行うimport連携ストアを作成します
-
[]*packages.Packageを引数に、生成対象とその依存先のmoduleのimportをリストする(dependenciesimports) - またcode generatorなどの外部のパッケージが任意にimportを追加できるものとする(
extraimports) - code generatorは、package pathを引数に
*ast.SelectorExprや*dst.SelectorExprを生成できる -
*ast.Fileを引数に、identとpackage pathの関係を洗い出す - 上記の
extraimportsやcode generatorが*ast.SelectorExprのために引き出したpackage pathのうち、*ast.Fileに含まれていなかったものをmissingimportsとして記録しておく -
missingimportsを*dst.FileのImportsやGenDeclsのimport declにappendする
packages.Loadで依存先moduleの解析も行うには、*packages.ConfigのModeビットにpackages.NeedImports|packages.NeedDepsも加えます。
[]*package.Packageの各packageをを列挙するにはpackages.Visitを呼び出します。Visitは[]*package.Packagesをdependency orderかつ重複を排除しながらtraverseする機能を提供します。
適当にラップすればiteratorに変換できます。
[]*package.Packageから解析された型情報をdependencies, code generatorが追加したいimportをextra、*ast.Fileから解析されたident - package pathの関係をidentとして保存しておきます。extraおよびcode generator動作中に問い合わせられたpackage pathのなかでidentに存在しないものはmissingに記録します。
下記のような関数でidentからpackage pathに対応するidentを取り出そうとし、ない場合dependenciesから取り出してmissingに記録します。
identが被った場合に備えて_%dでsuffixしながらマップに追加できるようにします。これによりmath/rand/v2をインポート済みのファイルにcrypto/randを追加しようとすると、import rand_1 "crypto/rand"という風に追加されることになります。
最後に、*dst.Fileにmissingの内容を追加することで、のちのnode単位のast printingでimport declをprintするとき、追加されたimportも出力できるようにします。
7. astのrewriteおよび書き出し
astはgo/ast以下で定義される各型によって表現されます。
すでに言及済みですが、go/parserで定義されるparser.ParseFileによってsource codeを解析して得られます。
得られた*ast.Fileはast.Fprintによって構造をprint可能です。
0 *ast.File {
1 . Package: ./target/foo.go:1:1
2 . Name: *ast.Ident {
3 . . NamePos: ./target/foo.go:1:9
4 . . Name: "target"
5 . }
6 . Decls: []ast.Decl (len = 6) {
7 . . 0: *ast.GenDecl {
8 . . . TokPos: ./target/foo.go:3:1
9 . . . Tok: import
10 . . . Lparen: -
11 . . . Specs: []ast.Spec (len = 1) {
12 . . . . 0: *ast.ImportSpec {
13 . . . . . Path: *ast.BasicLit {
14 . . . . . . ValuePos: ./target/foo.go:3:8
15 . . . . . . Kind: STRING
16 . . . . . . Value: "\"fmt\""
17 . . . . . }
18 . . . . . EndPos: -
19 . . . . }
20 . . . }
21 . . . Rparen: -
22 . . }
...
Goのastはastutil.Applyがあってastの書き換えがしやすいですが、実はast上コメントはバイトオフセットで表現されており、astノードの書き換えを行った時にこのオフセットが更新されないことで出力結果が狂ってしまうという問題があります。
そこでrewriteにはgithub.com/dave/dstを使用します。
The go/ast package wasn't created with source manipulation as an intended use-case. Comments are stored by their byte offset instead of attached to nodes, so re-arranging nodes breaks the output. See this Go issue for more information.
The dst package enables manipulation of a Go syntax tree with high fidelity. Decorations (e.g. comments and line spacing) remain attached to the correct nodes as the tree is modified.
とある通り、このライブラリはまさしくこの問題を解決するために開発されています。
以下のようにすることで変換を行います。
var pkgs []*packages.Package
for _, pkg := range pkgs {
for _, file := range pkg.Syntax {
dec := decorator.NewDecorator(pkg.Fset)
/* *dst.File */ df, err := dec.DecorateFile(file)
if err != nil {
// ...
}
}
}
変換前の*ast.Fileないのast.Nodeから*dst.File内部の変換先を参照するには以下のようにします
var ts *ast.TypeSpec
dts := dec.Dst.Nodes[ts].(*dst.TypeSpec)
astutil.Applyの代替となるdstutil.Applyがあるため、rewriteはastと同じように行えます。
書き換え自体はGo source codeと紐づくast表現の規則を覚えて気合と根性で何とかします。
ast.Fprintでastの構造をprintできるのでソースを解析してprintしてを繰り返して構造を覚えます。
dstutil.Apply(
dts.Type,
func(c *dstutil.Cursor) bool {
node := c.Node()
switch field := node.(type) {
default:
return true
case *dst.Field:
// replace field...
//
// wrapping field type with sliceund.Und
// *unmodified field type* -> sliceund.Und[*unmodified field type*]
c.Replace(&dst.Field{
Names: field.Names,
Type: &dst.IndexExpr{
X: &dst.SelectorExpr{
X: &dst.Ident{
Name: "sliceund",
},
Sel: &dst.Ident{
Name: "Und",
},
},
Index: field.Type,// *unmodified field type*
},
Tag: field.Tag,
Decs: field.Decs,
})
return false
}
},
nil,
)
さらに、書き換えた*dst.Fileを*ast.Fileへ逆変換するには以下のようにします。
res := decorator.NewRestorer()
/* *ast.File */ af, err := res.RestoreFile(df)
if err != nil {
// ...
}
同様にdst.Nodeからast.Nodeを引くことができます。
var dts *dst.TypeSpec
ats := res.Ast.Nodes[dts].(*ast.TypeSpec)
astのNode単位でのprintにはprinter.Fprintを用います。
dstにもdecorator.Fprintがありますが、こちらは*dst.File単位でしかprintできません。
buf := new(bytes.Buffer)
err := printer.Fprint(buf, res.Fset, ats)
ですので、変換前のast.Nodeから、modifyしてrestoreした後のast.Nodeをたどってprintするには以下のようにします。
var originalNode ast.Node
dec := decorator.NewDecorator(fset)
df /* *dst.File */, err := dec.DecorateFile(afile)
if err != nil {
// ...
}
dNode := dec.Dst.Nodes[originalNode]
modify(dNode)
res := decorator.NewRestorer()
_ /* *ast.File */, err := res.RestoreFile(df)
if err != nil {
// ...
}
modifiedAstNode := res.Ast.Nodes[dNode]
var w io.Writer
err := printer.Fprint(w, res.Fset, modifiedAstNode)
if err != nil {
// ...
}
(おまけ)特定のast.Nodeの無視
*packages.ConfigにはParseFileという項目があり、これによってロードの挙動をカスタマイズ可能です。
ParseFile func(fset *token.FileSet, filename string, src []byte) (*ast.File, error)
なにも指定されなければ下記と同等のコードが使用されます
return parser.ParseFile(fset, filename, src, parser.AllErrors|parser.ParseComments)
これを利用し、デバッグ時に限り特定のコメントがついたノードをParse時に無視するものとします。
- デバッグ目的では無視したい
- 今回動作させたいcode generatorは対象のディレクトリにコードを書き込みます。
- 型情報用いるため、ソースコードはパッケージ単位で処理されますが、2度目以降の生成では生成したコードも型チェックの対象に入ってしまい、結果が変わりえてしまいます。
- これによってgeneratorの実装不備がわかりにくくなります。実際できてると思ったらできてなかったというのが何度かおきました。
- 本番では無視したくない
- ユーザーがパッケージ内に生成された型/メソッドに依存するコードを追加したとき、code generatorがそれらのnodeを無視してしまうとでtype check時にエラーが起きます。
無視できるようにするために、生成されるコードの各Declには//codegen:generatedというコメントを必ず付与することとします。
//codegen:で始まるコメントをパーズする機能をParseDirectiveComment(cg *ast.CommentGroup)として定義していることを前提とすると、単純な発想では以下のような実装を用いれば//codegen:generatedというコメントがついたnodeを無視できます。
func ParseFile(fset *token.FileSet, filename string, src []byte) (*ast.File, error) {
f, err := parser.ParseFile(fset, filename, src, p.mode)
if err != nil {
return f, err
}
f.Decls = slices.AppendSeq(
f.Decls[:0],
xiter.Filter(
func(decl ast.Decl) bool {
var (
direction UndDirection
ok bool
err error
)
switch x := decl.(type) {
case *ast.FuncDecl:
direction, ok, err = ParseDirectiveComment(x.Doc)
case *ast.GenDecl:
direction, ok, err = ParseDirectiveComment(x.Doc)
if direction.generated {
return false
}
x.Specs = slices.AppendSeq(
x.Specs[:0],
xiter.Filter(
func(spec ast.Spec) (pass bool) {
var (
direction UndDirection
ok bool
err error
)
switch x := spec.(type) { // IMPORT, CONST, TYPE, or VAR
default:
return true
case *ast.ValueSpec:
direction, ok, err = ParseUndComment(x.Comment)
case *ast.TypeSpec:
direction, ok, err = ParseUndComment(x.Comment)
}
if !ok || err != nil {
// no error at this moment
return true
}
return !direction.generated
},
slices.Values(x.Specs),
),
)
}
if !ok || err != nil {
// no error at this moment
return true
}
return !direction.generated
},
slices.Values(f.Decls),
),
)
return f, err
}
しかし上記のコードは以下の2点において正しくありません
- unused import
- 削除されたノードによってのみ参照されていたimportが存在するとき、unused importが生じます。
-
Goはunused importをcompilation errorとします- specを見る限りunused importやunused variableについての記述がないので、おそらくですが言語仕様ではく実装の制限です。
- commentが取り残される
-
go/astはコメントをバイトオフセットとして取り扱います。 -
Declを削除しても、*ast.File.Commentsにコメントはすべて残っています。
-
そこでさらに、
- importを修正するためにgoimportsと同等の機能を使用してimportを修正してもらいます
- ノード削除時にはそのノードと、アタッチされたコメントのオフセットを記録し、
*ast.File.Commentsがその範囲に収まる場合はそれを削除する機能を加えます。- 単に
Declにアタッチされたコメントを消しただけでは、function bodyやdeclの中でフィールドにアタッチされたコメントが削除されませんので範囲で削除する必要があります。
- 単に
ということですべて盛り込むと以下になります。
-
type tokenRange []token.Posで消したDeclのrangeを記録し、その間にあるコメントすべてを削除します。 - 一旦
printer.Fprintで*ast.Fileをテキストで出力し、 -
"golang.org/x/tools/imports".Processでunused importを削除してもらいます。- これは
goimportsが内部で使っているのと同じ機能です。 - parse前のソースコードが正しくコンパイル可能であるという前提が成立している限り、
Processが新しくインポートを追加することはありません。
- これは
- フォーマット結果をもう一度
parser.ParseFileで解析して結果を返します。
code generatorの実装
最後に具体的な今回実装したかったcode generatorの実装方法に入っていきます。
やりたいことは大まかに二つで
- 入力となる型を受けとって変更し、テキストとして出力
- 入力をreceiverとしたメソッド(Patcher)、生成した型をreceiverとしたメソッド(Validator/Plain)をテキストとして出力
これらに対して、
- 型の書き出し => dstをrewriteして
printer.Fprint- package clause, import specも
printer.Fprintでprintします。
- package clause, import specも
- メソッドの出力 => *bufio.Writer + fmt.Fprintf
を行います。
まずprinter.Fprintによるprintの方法とbufio.Writer+fmt.Fprintによる書き出しの方針と利点などの説明をし、Patch,Validator,Plainの具体的な変換や生成方法について述べてます。
printer.Fprintによるprint
package, importのprint
package clause, import specのprintは以下のようにします。
var (
w io.Writer
af *ast.File
)
_, err := fmt.Fprintf("%s %s\n\n", token.PACKAGE.String(), af.Name.Name)
if err != nil {
// error...
}
for i, dec := range af.Decls {
genDecl, ok := dec.(*ast.GenDecl)
if !ok {
continue
}
if genDecl.Tok != token.IMPORT {
// it's possible that the file has multiple import spec.
// but it always starts with import spec.
break
}
err := printer.Fprint(w, fset, genDecl)
if err != nil {
// error...
}
_, err = io.WriteString(w, "\n")
if err != nil {
// error...
}
}
_, err = io.WriteString(w, "\n")
if err != nil {
// error...
}
// successful
正しく構成されたastならば必ずファイルはimport specから始まりますので、import spec以外の*ast.GenDeclが見つかるまでDeclsをループで回せばよいです。
import decl自体が複数あることは許されているのでそこには注意しましょう。
package foo
import "fmt"
import "crypto/rand"
import "net/http"
// ...
// こういうのもたまに見る
typeのprint
前述した通り型情報を事前にグラフ化してたどりながら生成していきますが、それぞれの*TypeNodeは以下のように、*ast.TypeSpecも収集してあります。
そのため、前述の「original ast.Node -> modified dst.Node -> modified ast.Node」を順繰りに参照し、Fprintすることができます。
ただし、*ast.TypeSpecはtypeキーワードがないので手動で出力する必要があります。typeキーワードがくっついてるのは*ast.GenDeclのほうです。
つまり、下記のような関係です。
type Foo struct {}
//^^^^^^^^^^^^^^^^ GenDecl
// ^^^^^^^^^^^^^ TypeSpec
これは*ast.GenDeclが複数の*ast.TypeSpecをもてることを考えると事情が理解しやすいかもしれません。
type (
Foo struct{}
//^^^^^^^^^^ TypeSpec
Bar struct{}
//^^^^^^^^^^ TypeSpec
Baz struct{}
//^^^^^^^^^^ TypeSpec
)
//^^^^^^^^^^^^^^ GenDecl
ということで、printer.Fprintの前にtypeキーワード、' '(スペース)を出力しておきます。
(上記のatsは*ast.TypeSpec)
*bufio.Writer + fmt.Fprintfによるメソッドの書き出し
メソッドの書き出しには*bufio.Writerとfmt.Fprintfを用います。
var w io.Writer
bufw := bufio.NewWriter(w)
defer bufw.Flush()
printf := func(format string, args ...any) {
fmt.Fprintf(bufw, format, args...)
}
理由は単純で、エラーの発生もバッファーしておけることです。
このことで細かいエラーハンドリングを隠すことができます。
defer内でFlushを呼ぶことで実際の書き出しを行いながらバッファーしたエラーを回収することができます。
func generateFancyMethods(w io.Writer) (err error) {
bufw := bufio.NewWriter(w)
defer func() {
fErr := bufw.Flush()
if err == nil {
err = fErr
}
}()
printf := func(format string, args ...any) {
fmt.Fprintf(bufw, format, args...)
}
printf(
`func (fancy *Fancy) SuperGoodMethodName() string {
return %q + %q + %q
}
`,
"foo", "bar", "baz",
)
// continue printing...
}
上記のbufio.Writerでラップするのはヘルパーを定義して、以後はこちらを使います。
printする際にはfmtのExplicit argument indexesを用いると便利です。
リンク先でも述べられていますが、format stringの中で%[d]verb(dは任意の1-indexed integer)とするとd番目の引数をprintできます。今回作りたいcode generatorはこれだけで事足りてしまいます。
package main
import "fmt"
func main() {
fmt.Printf(
"%[1]s, %[1]s, %[3]s, %[2]s, %[3]s\n",
"foo", "bar", "baz",
)
// foo, foo, baz, bar, baz
}
code generatorを作るとなるとtext/templateかgithub.com/dave/jenniferが思いつくかと思いますが、下記がそれらを使わない理由です。
-
text/templateは煩雑
- 条件分岐によって生成されるコードがかなり変わるため、
text/templateで書ききると煩雑です -
Goでif/elseをたくさん書いて生成する内容が変わるようなケースだと不向きと思います - dockerが--formatオプションでtext/templateを受け付けますが、このようにデータが先行しており、ユーザー入力によって出力を自由に変更できるようにするとき、より価値を発揮すると思います。
- 条件分岐によって生成されるコードがかなり変わるため、
-
github.com/dave/jenniferはimportの連携ができない
-
jenniferは内部的にimportを管理してqualifierを自動的に調節してくれますが、今回のケースのようにimport周りを外部からコントロールしたい、というのは見たところできないようです - 基本的に1ファイルまるごど
jenniferで出力するのが想定なようですので、今回のように複数のやり口を組み合わせるときには不向き、というか想定していないのを感じます。
-
Patcher
実現したいもの#Patcherで述べたものを実装します。
今回生成するものの中でもっとも簡単です。
Patch typeは元の型のフィールドの型がTであるとき、sliceund.Und[T]で置き換え、json:",omitempty"をstruct tagに追加します。
フィールドの型がund typeであるときは、意図的なので何の変換もしないものとします。ただし、option.Optionであるときは特別にsliceund.Und[T]に変換します。
sliceund, sliceund/elasticにはjson:",omitempty"を追加することでundefinedの時json.Marshalでフィールドがスキップされるようにします。undおよびelasticはencoding/json/v2もといgithub.com/go-json-experiment/jsonでMarshal時にスキップできるようにjson:",omitzero"を追加します。
残りのメソッド群も実装していきます。
Go1.18以降追加されたgenericsによりtype paramが存在する型の場合receiverの型表記にもtype paramを表記する必要があります。
type Foo[T any] struct {
// ...
}
func (f Foo[T]) Foo() {}
// [T]がないとコンパイルしない
そのためtype paramは事前に出力しておきます。型情報からやってもastからやってもいいですがここではastから出力します。
実装自体は気合と根性ですね。ここに関しては先に実装イメージを書いてそれを出力できるコードを書いただけ、という感じです。
Validator
Validatorは、und:"" struct tagのついたund type fieldに対してstruct tagに応じたvalidationを行うか、フィールドがimplementorもしくはdependantである場合、実装を呼び出します。
生成したいコードのイメージは実現したいもの#Validatorを再び参照してください。
追加要件
ただし追加の要件として、
-
implementor|dependantはpointer typeでもよいこととします。- 大きなstructはpointerにしたいことは結構あるかと思います。
- フィールドが
map[string][][5]map[int]sliceund.Und[string]のように無名の型で深くネストすることを許します。- つまり、edgeがmap, array, sliceを持つことを許します。
- ここまで極端なことをすることは少ないかと思いますが
[][]Tやmap[string]map[string]Tをフィールドに指定すること自体はそう珍しくないと思います。
- und typeにラップされた
implementor|dependant(sliceund.Und[Implementor])のUndValidateも呼び出すようにします
つまり下記のようにpointer typeの場合、nilチェックを挟むことになります。
type Dependant struct {
// ...
+ FooP *All
// ...
}
func (v Dependent) UndValidate() (err error) {
// ...
+ {
+ if v.FooP != nil {
+ err = v.FooP.UndValidate()
+ }
+ if err != nil {
+ return validate.AppendValidationErrorDot(
+ err,
+ "FooP",
+ )
+ }
+ }
// ...
}
以下のコードでフィールドが
- 無名の型で深くネストした場合
- und typeにラップされた
implementor|dependantの場合にUndValidateも呼び出す
例を示します。
Goにはスコープごとに変数を再定義できる仕様があるためfor-rangeがネストするたび同名の変数を再使用できています。この仕様がなければもう少しcode generatorの実装難易度が上がっていました。
type Implementor struct {
Opt option.Option[string] `und:"required"`
}
type DeeplyNested struct {
A []map[string][5]und.Und[Implementor] `und:"required"`
}
func (v DeeplyNested) UndValidate() (err error) {
{
validator := undtag.UndOptExport{
States: &undtag.StateValidator{
Def: true,
},
}.Into()
v := v.A
for k, v := range v {
for k, v := range v {
for k, v := range v {
if !validator.ValidUnd(v) {
err = fmt.Errorf("%s: value is %s", validator.Describe(), validate.ReportState(v))
}
if err == nil {
err = und.UndValidate(v)
}
if err != nil {
err = validate.AppendValidationErrorIndex(
err,
fmt.Sprintf("%v", k),
)
break
}
}
if err != nil {
err = validate.AppendValidationErrorIndex(
err,
fmt.Sprintf("%v", k),
)
break
}
}
if err != nil {
err = validate.AppendValidationErrorIndex(
err,
fmt.Sprintf("%v", k),
)
break
}
}
if err != nil {
return validate.AppendValidationErrorDot(
err,
"A",
)
}
}
return
}
validate.AppendValidationErrorIndexでフィールドのセレクタをエラー情報にappendします。こうすることでvalidation failed at .A[1][foo][3].Opt: must be defined: value is noneのようなエラーメッセージを表示できるようにして、どのフィールドがvalidation errorになったのかわかるようにします。
実装
前述のとおり、型情報からstruct tagを取得できます
undtag.ParseOptionとして解析機能がexportしてあるのでこのstruct tagの解析自体はこれを呼び出すだけです。
undtag.ParseOptionの解析結果であるundtag.UndOptはinternal packageとしてvendorされたoptionを利用するため、これ自体を外部パッケージが初期化できません。
そのためundtag.UndOptExportを出力してIntoメソッドを呼び出すことでundtag.UndOptを得ます。
map[string][][]Aのように深くネストした型のAを取り出すためのunwrapperを出力します
少しわかりにくいですかね?
今回許すnamed typeへの経路はmap, slice, arrayのみですが、これらすべてはfor k, v := range value {}で処理可能です。
そのため、ネストしたのと同数回for-range loopを行えば深くネストしたnamed typeを取り出せます。
そのため、
func unwrapOne(innerExpr string) string {
return fmt.Sprintf(
`for k, v := range {
%s
}
`,
innerExpr,
)
}
という風にします。
%sに内側のexpr(expression)を渡します。渡されるのはfor-loop expressionかvalidatorの呼び出しです。こうすればいくらでも入れ子状にfor-loopを書き出すことができます。
さらに、
func unwrapOne(innerExpr string) string {
return fmt.Sprintf(
`for k, v := range {
%s
+ if err != nil {
+ err = validate.AppendValidationErrorIndex(
+ err,
+ fmt.Sprintf("%%v", k),
+ )
+ break
+ }
}
`,
innerExpr,
)
}
とエラー時にbreakさせることでエラー発生時に順次innermostのループだけを抜けさせることで、すべてのループでそれぞれvalidate.AppendValidationErrorIndexを呼び出せるようにします。
unwrapperをappendしていく順序と実際に呼び出すべき順序は逆であるのでslices.Backwardで逆順に適用していきます
あとはimplementor|dependantなら呼び出すとか、implementor|dependantがpointer typeならnilチェックをするとかそういった細かい気遣いを加えて完成です。
書いてたときはなかなかしんどかったですがその甲斐あってそこそこきれいにまとまりました。
Plain
Plain変換はこの3つのテーマの中でもっとも複雑です。
生成したいコードのイメージは実現したいもの#Plainを再び参照してください。
追加要件
ただし、Validator同様追加の要件として、
-
implementorはpointer typeでもよいこととします。 - フィールドが
map[string][][5]map[int]sliceund.Und[string]のように深くネストすることを許します。 - und typeにラップされた
implementor|dependant(sliceund.Und[Implementor])も変換されるようにします。- 型の変換(e.g.
sliceund.Und[Implementor]->sliceund.Und[ImplementorPlain]) -
UndPlain/UndRawの呼び出し
- 型の変換(e.g.
Plain typeへのast rewrite
field unwrapper
type DeeplyNested struct {
A []map[string][5]und.Und[Implementor] `und:"required"`
}
上記ではund typeであるund.Undはslice, map, arrayにラップされています。ast rewriteは上記のund.Und[Implementor]を(und:"required"であるから)ImplementorPlainに変更したいわけですから、mapやsliceの部分は一切触れる必要がありません。
そのため、mapやarrayをたどって目的の型のexpressionを取り出します。
前述通り、どのように目的の型がラップされるかは*TypeDependencyEdgeに記録済みですのでこれを利用します。
取り出したdst.Exprそのものに別のexprを代入したくなるケースを考慮して*dst.Exprを返すようにします。
後続の変換メソッド生成処理でunwrapしたdst nodeとwrapされたままのdst nodeが必要なのでここでそれらを記録しておきます。
implementor|dependantのrewrite
und:"" struct tagの付けられていないimplementor|dependant、もしくはund typeにラップされたimplementor|dependantはUndPlainの変換先の型名に取り換えます。
変換された*types.Namedを*dst.SelectorExprに変換して前節でunwrapされたdst.Nodeに代入します。
plainConverterは、implementorとdependantを一緒くたにしてnamed typeからnamed typeへの変換をするための関数で実装は以下のようになります
ややすっきりしない作りですが
-
implementorは型情報をたどって変換先の*types.Namedを取り出し -
dependantは前述のmakeRenamedTypeでUndRawだけを実装する*types.Namedを作成して返します。
und typeのrewrite
上記のfield unwrapperによって取り出されたdst.Exprを書き換えます。
und typeは現状、必ずtype paramを1つ持つので、必ず*dst.IndexExprとなります。
ここから先は面倒で複雑な変換を行います。
例えば、option.Option[T], und.Und[T]のフィールドにund:"required" struct tagがついている場合、Plain typeのフィールドの型はTとなります。
この操作はast(dst)のrewriteで行います。
前述の例、und.Und[T]をstringでinstantiateしたund.Und[string]でastの構造をしめします。

und:"required"がついている場合、stringで置き換えるので、expr = expr.(*ast.IndexExpr).Indexという代入操作をします。

結果としてstringのみが残ります。

例えばほかにもund.Und部分をoption.Optionに書き換えるのならば、図のSelectorExpr部分を任意に置き換えればできますし、und.Und[T]をund.Und[[]T]に置換するのもIndex部分を*ast.ArrayTypeに置き換え、置き換え前のIndexのexprを*ast.ArrayTypeのEltフィールドに代入すればできます。
こういう感じでパターンを網羅していきます。
まず最初にund.Und[T]のTがimplementor|dependantである場合、UndPlainで変換された先に取り換えます。
-
und:""で指定できるのは-
def(=defined) null-
und(=undefined) -
required=defのshorthand -
nullish=null,undのshorthand -
len=Elasticの長さを指定-
len>n,len>=n,len==n,len<n,len<=n
-
-
values=Elasticの各要素の状態を指定-
values:nonnullで各要素はnullになってはならないことを表現できる。
-
-
これに従い、
-
option.Option[T]は-
def && (null || und)なら変更なし -
def:option.Option[T]->T -
null||und->Empty
-
-
und.Und[T]は-
def && null && und-> 変更なし -
def && (null || und):und.Und[T]->option.Option[T] -
null && und->option.Option[Empty] -
def:und.Und[T]->T -
null || und->Empty
-
という風に変換していきます。
null || undのような特殊なケースのためにEmptyというjson.Marshal時にMarshalJSON実装でnullを返す[]struct{}ベースの型を定義し、そちらを使うようにします。
elastic.Elastic[T]の変換はもっとパターンが多くなってややこしいです。
def&&null&&undかつlenオプションがない、もしくは==以外の指定で、さらにvalues:nonnullが指定されていないとき型の変換は必要ないのでreturnします。
そうでない場合、elastic.Elastic[T] -> und.Und[[]option.Option[T]]という変換をかけます。ここから先のパターンは少なくとも必ずこの型には変換されます。
-
len==1の場合、[]option.Option[T]部分はsliceである必要はないのでund.Und[[]T]->und.Und[T]と変換します。 -
len==nの場合、und.Und[[]T]->und.Und[[n]T]に変換します。
さらに、values:nonnullが指定されている場合、und.Und[[]option.Option[T]] -> und.Und[[]T]に変換します。
len==1だった場合はこの時点でund.Und[option.Option[T]]であるので、und.Und[T]に変換します。
最後にdef,null,undの状態に応じた変換をかけます。
-
def && null && und: 変更なし -
def && (null || und):und.Und[T]->option.Option[T] -
null && und: ->option.Option[Empty] -
de:und.Und[T]->T -
null || und: ->Empty
上記すべてを盛り込むと下記のように実装されます
UndPlain/UndRaw method
UndPlainメソッドでRaw -> Plainの変換、UndRawメソッドでPlain -> Rawの相互変換を実装します。
型の変換時と同じく、und typeやimplementorがslice, map, arrayにラップされていることは許されているので、mapやarrayをたどって目的の値までたどり、値とund:"" struct tagに応じた変換処理をかけます。
しかるに、field unwrappingと変換で二つの工程の分けることができます。
field unwrapper
mapやsliceをunwrapして関心のある型にたどり着くための方法について述べます。
型変換時と同様に*TypeDependencyEdgeに型の経路を記録済みですのでこれを利用します。
以下のような型あるとき、
type DeeplyNested struct {
A []map[string][5]und.Und[Implementor] `und:"required"`
}
以下のように、値を順次for-rangeで下っていき、変換となるexpressionを呼び出します。
func(v DeeplyNested) UndRaw() DeeplyNestedPlain {
return DeeplyNestedPlain{
A: func(v []map[string][5]und.Und[Implementor]) []map[string][5]ImplementorPlain {
for k, v := range v {
// ...
var (
k int
v und.Und[Implementor]
)
out[k] = conversion(v)
// ...
}
}(v.A)
}
}
これをcode generatorで生成するには、code genreatorに有利な単純な表現の繰り返しでこれを実現しなければなりません。
単純な発想では以下のような繰り返しになるんですが、
func(v []map[string][]V) []map[string][]VPlain {
out := make([]map[string][]VPlain)
inner := out
for k /* int */, v /* map[string][]V */ :=range v /* []map[string][]V */ {
outer := inner
inner := make(map[string][]VPlain, len(v))
for k /* string */, v /* []V */ := range v {
outer := inner
inner := make([]VPlain, len(v))
for k, v := range v{
// この繰り返し?
}
outer[k] = inner
}
outer[k] = inner
}
return out
}
これではarrayが経路に含まれるとarrayのコピーによってunused writeが生じます。
// ...
outer := inner
inner := [5]T
for k, v := range v{
//
}
outer[k] = inner
// unused write to array index t16 + 1:int unusedwrite(default)
// ...
そこで、outerの定義はinnerへのアドレスをとることとします。
// ...
outer := &inner
inner := [5]T
for k, v := range v{
//
}
(*outer)[k] = inner
// ...
表現を初期化部、中間経路、終端と三つに分けてそれぞれをfunc(expr string) stringとします。
// 初期化部
func(expr string) string {
return fmt.Sprintf(
`func (v %s) %s {
out := %s
inner := out
%s
return out
}(%s)`,
input /* map[string]V */,
output/* map[string]VPlain */,
initializer(toExpr, s[0].Kind) /* make(map[string]VPlain, len(v)) */,
expr /* 中間経路(終端) */,
fieldExpr /* v.Aなど */,
)
}
// 中間経路
func(s string) string {
return fmt.Sprintf(
`for k, v := range v {
outer := &inner
inner := %s
%s
(*outer)[k] = inner
}`,
initializerExpr/* make([]T, len(v))など */, s/* 中間経路(終端) */,
)
}
// 終端(この%sがV -> VPlainの変換expression)
func(s string) string {
return fmt.Sprintf(
`for k, v := range v {
inner[k] = %s
}`,
s,
)
}
wrappers []func(string) stringを定義し、これらを順次詰め込みます。[初期化, 経路, 経路, ..., 終端]という順列でappendすることとし、
expr := wrappee("v")
for _, wrapper := range slices.Backward(wrappers) {
expr = wrapper(expr)
}
という風に、slices.Backwardで逆順で適用すればよいということになります。
ad hocな即時間数を毎度書くため、フィールドの変換前(Raw)、変換後(Plain)の型をそれぞれ明示的に示す必要があり、さらにmake(T, len(v))を毎回呼ぶために経路上の中間となる型の表現もすべて書き出す必要があります。
前述のとおり経路の情報はすでに保存済みであるので、それを利用した以下の関数を定義します。
これによりmap[string][]V -> []V -> Vという感じで順次unwrapすることができます。ast.Exprはprinter.Fprintでnode単位でprint可能ですので、printした結果をテキストとして前述の関数群に渡します。
全部を組み合わせて以下のようにunwrapFieldAlongPathを定義します。
conversion
型の変換時と同様に型とund:"" strcut tagの内容に基づいて変換する関数を定義します。
コードの生成量を減らすためにgithub.com/ngicks/und側で変換のためのランタイムを提供します。
これらのコードはundモジュール自体が使うことは一切ありません。そういったものをそこに定義するのはそれはそれで邪道に思いますが、生成されたコードが依存するランタイムを減らせてばらばらにバージョン管理しなくていいのが明確なメリットとなります。
変換自体は型の変換で説明したことをコード的に行うのみです。
-
und.Und[T] -> T:(und.Und[T]{}).Value() -
und.Und[T] -> option.Option[T]:(und.Und[T]{}).Unwrap().Value()
逆変換は
-
T -> und.Und[T]:und.Defined(t) -
option.Option[T] -> und.Und[T]:conversion.OptionUnd(false, opt)
こういう感じです。
-
len==1の時[]T->Tの変換が行われますが、この時変換メソッドは生成の都合で[]T->[1]T->Tというステップで行う決断を下しました。 -
[]T->[n]Tへの変換はad hocな即時間数を毎回定義します- 全く同じ関数を何度も定義することになりますが、内容が同じであればコンパイラがいい感じに1つの関数に減らしてくれるでしょうから気にしません。
- 逆にいうとそういった最適化に協力するために即時関数を生成する際にはなるだけ変数をキャプチャしないようにします。
-
UndRaw/UndPlainを呼び出すには上記のundモジュールのconversionパッケージの協力を得ずに、即時間数を生成します- genericsだとtype constraintの都合上receiverがポインタであっても、ノンポインタであってもよいとすることが難しいためです。
-
implementorはUndRaw/UndPlainをpointer receiverの上に実装してもよいですし、implementor type Aがあるときはstrcutフィールド上でfieldName *Aであってもよくなります。
以下のように定義されます。
Raw -> Plain
Plain -> Raw
Raw ↔ Plain変換部の呼び出し。
UndRawとUndPlainの生成は意外にも互いにほとんど同じ処理で上記のRaw -> PlainとPlain -> Rawの各部を取り換える以外はほとんど共通です。
github.com/spf13/cobraを利用した実行ファイル化
github.com/spf13/cobraを利用して実装した機能をサブコマンドで呼び出せる実行ファイルにまとめます。
go run github.com/ngicks/go-codegen/codegen@latest undgen plain -v --ignore-generated --dir ./path/to/target --pkg ./...
という感じで呼び出せます(と書いといてなんですがこのモジュール外から呼び出したことないです!)
以下の4つのファイルでまとめます。undgenというサブコマンドの更にサブコマンドでpatch/validator/plainを呼び出せます。
cobraを使うと複数のコマンドを簡単にまとめられて助かります。
生成結果
生成サンプル用の型と結果は以下に格納されます。
おわりに
筆者がここ数年ずっとやりたいと思いながらできていなかった、astと型情報をメタデータとするcode generatorの実装をようやくできるようになりました。
これを作り出したきかっけは業務でpartial jsonを使ったpatchを行うと都合のいい場面が出たからなんですが、例によって例のごとく、その時はその場限りな方法で解決してしまったため、今回作ったものを使う機会は逃してしまっています。
さて今後についてですが
undgenについては現状の実装から大きく変わることはないと思いますが、いくつかの変更を予測しています。
- リファクタ: もう少しまとめられそうなコードが重複しているので整理しなおします。
-
und:"und"がついたときのplain typeの対応するフィールドを*Tにする-
T | nullはoption.Option[T]で表現できますが、T | undefinedはjson:",omitmepty"のついた*Tである必要があるためです。 - そうしなければ、
json.Marshalなどで出力する際にはRawに一度変換しなおさなければフィールドがnullで出力されてしますため、少し不便ですね。 -
Plainだけを使っても運用が通用したほうが便利ではあると思うためそうなるように検証を重ねていこうかなと思っています。
-
- さらなるオプションの追加
- type-suffixオプション: 現状、生成される型は元の型名+
Patch|Plainの名前がつきます。これが固定だと少し具合が悪いかなと思います。 - denylistオプション: また、今は
validator,plainは//codegen:ignoreというコメントがついていない型はすべてtype nodeとして列挙されます。- これはこのcode genreatorが複数のパッケージを同時に処理することを前提とするため、cli引数からallowlist/denylistを受けとるのが煩雑であるためです。
- もう少し見直してdenylistを受けとれるようにしたほうが良いかなあと思っています。
- type-suffixオプション: 現状、生成される型は元の型名+
さらに、今回作ったものを通じて型情報の操作に習熟したのでもっと違うものも作れるようになりました。今後はそちらも作って行くことになるかと思います。
- wrapper: struct fieldにinterfaceを含む型に対して、そのinterfaceを実装するようにメソッドを生成します。
- すべての挙動はそのフィールドのinterface実装に移譲するが、引数やinterfaceかからの返り値を加工したいときに使うcode generatorです。
- 例1: afero.Fsをラップして、引数がfs.ValidPathを満たすように変換する
- 例2:
afero.Fsをラップして、引数と返り値をすべて記録し、テストに使う。 - メソッドが多いinterfaceのラッパーを定義するのがしんどかったので、カスタマイズ性を犠牲にせずに楽に生成できる仕組みを整えておきたいと筆者は常々思っています。
- cloner: 型に対して
Cloneメソッドを生成してdeep-cloneを可能とします- いくつかのOSS実装を試したことがあるんですが、例えば
*map[K]Vというフィールドを含むstructに対して生成するとpointerであることが想定されていなくて生成されたコードがコンパイルできなかったりします。 - 今回実装したcode genreatorの処理のほとんどがdeep clonerの生成に用いることができるためじゃあ作ればよくないかと思っています。
- 型がcopy-by-assignなのか検知する機能以外もうほとんど実装終わってる気がするんですよ。
- noCopy型の検知とかもですね
- field unwrapperのところはもうほとんどdeep cloneです。
- 型がcopy-by-assignなのか検知する機能以外もうほとんど実装終わってる気がするんですよ。
-
undgenでさぼったtype paramの追跡が必須なのでそこが少々課題ですが
- いくつかのOSS実装を試したことがあるんですが、例えば
- option: よくあるfunctional optionパターンを実装するのですが、unexported fieldそれぞれがoption interfaceを満たす型として生成します。
- 例えば
type A struct{foo string}があるとき、type Option interface { apply(a *A) Option }が定義され、 -
type fooOption string,func (o fooOption) apply(a *A) Option { prev := a.foo; a.foo = string(o); return fooOption(prev) }という風に実装します - よくある
type Option func(a *A)と違って比較できます。- 仕様上の制限で関数はnilとしか比較できない。
- 型として定義することで、fieldの型がcomparableならcomparableのままにできます。
- すべてのフィールドに対して個別に型を定義するのは手間すぎてやる気が起きなかったんですが、code generatorを整備しておけば現実的に可能ですね。
- 例えば
- なんとかpostfix系: code generatorの結果を受けてさらにfixするとか置き換えるとかする
- oapi-codegen-postfix: #970でも指摘されていますがoneOfを指定するとmarshalがおかしくなります。これをfixする。
- 理由は単純で
type BにMarshalJSON実装をしているとき、type A Bで定義したAをjson.Encoderに渡しているから起きています。type A Bはmethod setを継承していないためBのMarshalJSONが呼び出されないため必ず{}が出力されます。 - 今(
v2.4.1)確認しても修正されていなかったのでまだやる価値はある。
- 理由は単純で
- oapi-codegen-postfix: #970でも指摘されていますがoneOfを指定するとmarshalがおかしくなります。これをfixする。
- ident-mover: ファイル単位、exportされたident単位でパッケージに入っていたものを別のパッケージに移動させる
- リファクタ(?)の中でも頻繁に困るのは元は同じパッケージで定義していたものを別のパッケージに切り出す時の書き換えです
- 現在進行形でリファクタで苦労しています。
-
GoLandにはこういったものが最初から同梱されてるんですかね?
型情報とdst-rewriteを活用すれば別ファイルに書き出さないタイプのcode generator、つまりリファクタツールでもなんでも作れちゃいますね
Discussion