📊

[Go]ast(dst)と型情報からコードを生成する(partial-json patcher etc)

2024/11/22に公開

EDIT: 2024/11/27

急いで書いたら読みにくかったので大幅改修。
差分(見なくていい): https://github.com/ngicks/zenn-article/pull/1

ast(dst)と型情報からコードを生成する(partial-json patcher etc)

こんにちは

この記事ではGoのast(dst)と型情報を用いたコードジェネレーターの実装を例にしながらポイントや考慮すべきことをまとめます。
似たような感じでコードジェネレーターを作りたい人や、go/astgo/types以下で実装される型や関数の使い方がわからなくてとっかかりがつかめない人(かつての私)が進みやすくなるかもしれないことを目指しています。

Overview

プログラムを書いていると時たま、コードジェネレーターの吐いたコードの結果を受けてさらにコードを編集したいときがあります。例としてはgithub.com/oapi-codegen/oapi-codegenが生成したコードの特定パス以下(e.g. /config)のrequest bodyをパスごとに保存できる簡単なconfig storeを作ったりなどですね。
Goのソースコードを引数にコードジェネレーターを作成する際、単にテキストファイルとしてソースコードを解析してもよいのですが、astや型情報を用いることができたほうが改行やコメントその他で意味論的に違いのないソースコードを違いなく処理できるため、その観点からはできるならそうしたほうがいいと言えます。

そこで本記事では以下のようなことをします。

  • astと型情報の解析
  • astの書き換え
  • 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/expxiterパッケージが存在しているわけではありません。

基本的にはある程度実践的に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
)

実現したいもの

具体的にどういったものを実装するかについて述べます

下記は以前書いたものですが

https://zenn.dev/ngicks/articles/go-json-undefined-or-null-slice

この記事の中で作成したgithub.com/ngicks/undで定義される型をstruct fieldに指定すると

をそれぞれ表現することができます。(ただしjson:",omitempty"を必要とする)

記事内で課題感を述べましたが、Goでstructを定義し、そのfieldでT | null | undefinedを表現し分けることは普通にはできません。null | undefinedを表現し分ける方法が普通にはないからです。sliceelastic.ElasticElasticsearchに格納することができるJSONのフィールドを((T|null)[][]などのネストしたArray以外)表現しきるためにあります。

本記事ではこれらを用いて以下を実現するコードを生成するコードジェネレーターを実装します。

  • Patcher
    • Partial JSONを受けとってデータの部分的更新(Patch)を行うことができるようにする
  • Validator
  • Plain
    • sliceund.Und[T], sliceelastic.Elastic[T]など値をT[]TのようなPlainなものに置き換えた型を生成する
    • 元の型(Raw)と相互に変換できるようにする。

生成されるコードのイメージ

まずどういったコードを生成したら目標が実現できるかを思い描き、そこから具体的に何を実装すべきかについて考えます。

この記事で一番話したかったのは機能の実装のところなんですが、たてつけ上説明しないと意味不明なのでここでどういったコードを生成するか紹介します。
興味なかったらとばしてください。

Patcher

Patcherが実現したいのはPartial JSONを受けとって元となるデータ構造にパッチを当てられるようにすることです。

Partial JSONとここで呼んでいるのは各fieldがT | undefinedで表現できるJSON Objectやそれを含むJSON Valueのことです。
Patchの対象がfieldName *Tを持つとき、Patchの対応するfieldはnullundefinedを表現し分ける必要があり、前述のとおりそのために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"を必要とするのでない場合付け足します。
    • 付け足す、というのがキモです。元からあったjson structタグはなるだけそのままにする必要があります。
  • FromValueで元となった型からpatchへ変換、入力patchとMergeでマージ、ToValueで元となった型に逆変換することでパッチの挙動を実現します。
  • Mergegithub.com/ngicks/undの機能をふんだんに使ってOrをとることで実現します。

元の型->パッチ型な変換は元の型にメソッドとして実現するか、NewFooBarPatchという関数で実現するかしたほうがよかったかもしれませんが、下記理由でしないこととします。

  • なるだけ元の型には何も追加したくないので、メソッドの追加もしたくありません。
    • 追加すると名前被りのリスクがあります。
    • リスク回避のために自然に感じられないPrefixをつけて被りにくくするとかがありえますが、これを避けたいわけです
  • NewFooBarPatch的な関数も同様で名前被りのリスクがあります。

Validator

sliceund.UndT | null | undefinedを表現できるがゆえ、入力されるJSONなどの対応するfieldが存在しない(undefinedである)ことを検知することができます。
T | nullであってもよいが、undefinedではいけないというケースにおいてnull | undefinedを分けて表現できることが強みとなります。
(fieldに必ずnullを指定させることでtypoを検知するというプラクティスもあり得ます。)
特にsliceelastic.Elasticはとれる状態がT | null | undefined | (T | null)[]ととにかく多いです。Elasticsearchからすると[]nullundefinedはどれも同じフィールドが存在しないという意味になりますが、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.AppendValidationErrorDotvalidate.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.UndT | null | undefinedを表現できますが、これはI/Oなどを通じて得られた外部からのデータをうまく取り扱うことを目的としています。
前述のValidatorを実施したり、undefined時のフォールバック用のデフォルト値をPatcherなどで適用した後はもっとGoの「普通の」型のようなものに変換して処理できたほうが便利です。

そこで、Plainund:"" 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で固定であるときに、Tunwrapした型を生成し、そちらから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, Plain typeの生成
  • 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, Plain typeの生成 => astのrewriteで行います。
    • 型の書き換えはfield構造を全く変えず、und typeでfieldをwrapしたり、unwrapしたりするだけのため、astの付け替えでやると都合がよいです。
    • go/types以下で実装される型情報だけを使っても生成できるのですが、この場合コメント情報が消えるようです。
      • 今回実装するものは元となる型について回っているコメントもそのまま生成されるコードに残したい意図があります。コメントがなくなってフィールドの意図がわからなくなると困るだろうということです。
  • ApplyPatch,UndValidate,UndPlainなどのメソッドの生成 => fmt.Sprintfで行います
    • 理由は後述
  • UndRaw/UndPlain実装の判定: go/typesで型情報をたどって行います。
    • UndPlain/UndRawT --(UndPlain)--> T' --(UndRaw)--> Tという循環的な変換を行うためinterfaceで表現できません。
    • 型情報をたどってUndPlainの返り値のUndRawメソッドの返り値が元の型と一致するかをチェックします。
  • 複数のpackageをまとめて処理する
    • => golang.org/x/tools/go/packagesで複数パッケージをまとめて処理します
    • => 型の依存関係のgraphを形成することで、複数packageにまたがった依存によって連鎖的にdependantとなった型の判定を適切に行えるようにします。

ファイルの書き出しは以下でSuffixWriterを定義して、生成対象の型を含むファイルのファイル名を受けて.und_patch.goのようなsuffixを付けたファイルに生成対象の型に紐づいて生成されたものはすべてまとめて書き出します。
こうすることで再生成の際に上書きすることや、まとめて削除するのが容易になります。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/suffixwriter/writer.go

処理の流れをざっくり図示すると以下のような感じです

機能の実装

前述の基本方針に従いながら実現したいコードを生成するためにはどのような機能が必要かを述べます。

以下を後続の節で順次説明します。

    1. golang.org/x/tools/go/packagesを使用した複数パッケージからのastおよび型情報の収集
    1. 生成対象の型の検知
    • 以下を検知します
      • und:"" struct tagを持つund typeのfieldを含むstruct type
      • implementorのfieldを含むstruct type
    • さらに、上記の型を含む型をの検知と、さらにその型を含む型・・・という感じで連鎖的な型(dependant)の検知
    1. 型依存graphの形成
    1. 連鎖的に検知された型(dependant)から、生成されることになるはずの型を*types.Namedとして生成する
    • implementorUndPlain/UndRawによる変換先の型は、型情報から取得可能ですが、dependantはまだ型を書き出していないので、implementor同様の方法では変換先を得られません
    • ただしcode generatorはどのような型を書き出すことになるのかを知っています。
    • implementordependantをほぼ同じように取り扱いたいならば、dependantの変換先も同じフォーマットで得られたほうが良いです。
    1. struct tagの編集
    • Patch typeなどにjson:",omitempty"を追加するときなどに必要です。
    1. import情報の解析/連携
    1. 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.ConfigImporterフィールドで受け取った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/packagesgo 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や型情報となります。
PackagePkgPath, 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のフィールドを持つ型(「生成対象の型」もしくはmatched types)
  • UndPlain/UndRawを実装する型

を見つけます。

UndValidateを実装する型もUndPlain/UndRawを実装する型と同じように探すことができますが、こちらも似たような方法でできるので説明されません。

型周りの詳しい話は以下を読むといいかもしれません。

https://github.com/golang/example/tree/master/gotypes

何気に(予定上)Go1.24から導入されるgeneric type aliasesに合わせた更新も入ってます。

type specに対応するtype infoを探す

go/typesで型を探索するには、

のいずれかをします。

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.ObjectTypeメソッドでtypes.Type interfaceが得られます。前述どおり実際の型はnamedかaliasのみです。
aliasは無視するものとします。
ここで得られているのは名前だけですので、具体的なstruct fieldを探索するためにはそれのunderlying typeUnderlyingメソッドで取り出します。

Underlyingの用語はGo specのそれと一致しており、つまるところ以下のような感じです。

type Foo struct {Foo string; Bar int}
//       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//       this part is underlying

type Fooのunderlying typeはstruct {Foo string; Bar int}というわけです。

*types.Structreflect.StructFieldと違ってfieldではなく*types.StructTagメソッドがあり、それから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.ObjectNameでunqualified nameが得られ、Pkg().Path()でパッケージのパスが得られるため、これを比較すればよいです。
コメントにある通り、error組み込み型は、組み込み型だからpackageが存在しませんがnamed typeです。なのでPkg()がnilを返します。nilチェックは必須です。

UndPlain/UndRawを実装する型の検知

前述のとおり、code generatorが生成することになるUndPlain/UndRawT -> 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を実装しているかは以下のようにチェックできます。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/consts_und.go#L50-L53

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/matcher/method_checker.go#L40-L74

*types.Namedを受けとってtypes.NewPointerで包んでからmethod setを取得し、所望の名前のメソッドを探します。
返り値の型もtypes.NewPointerで包んでからmethod setを取得し、所望の名前のメソッドを探して、それの返り値が最初に入力された型かをチェックします。

*types.NamedUnderlyingがinterfaceであるときはpointerで包むとmethodが見えなくなるため、それを判別する必要があります。つまりasPointerは以下のようになります。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/matcher/matcher.go#L22-L33

ここで考慮しなければならないのが、入力された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だけを持つ状態で食い違うため同じ型ではないと判定されます。
そのため元の型tyTypeParamを持つが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.Namedmatchedであるかを判別
    • matcherはund typeやUndValidateUndRaw/UndPlainのような特別な関数を満たす外部の型にもマッチするようにします。
      • マッチしたもので、Loadで得た[]*packages.Packageで直接ロードされたpackage以外で定義された型はexternalとしてマークします。
  • matchedから上へedgeをたどってdependant typeを辿ることができるようにします。
    • transitの際、edgeを上にたどるかどうかを決めるedgeFilterを受けとり、例えばchan Aのような依存ではたどらないものとします。

*types.Namedの列挙/nodeの記録

*types.Namedの列挙はastおよび型情報の収集: packages.Loadによるast/型情報の取得で説明した通り、[]*packages.PackageSyntax([]*ast.File)をiterateして見つかった各*ast.TypeSpecのNameでTypesInfo(*types.Info)のDefsを引きます。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/typegraph/type_graph.go#L240-L299

型情報にはどのtype specがグルーピングされていたとか、コメントとかは直接現れないため*ast.GenDecl, *ast.TypeSpecでもフィルタリングをかけられるようにします。
matcherにマッチしたとき、nodeのMatchedビットを立てます。

Nodeは以下のように定義されます

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/typegraph/type_graph.go#L58-L70

edgeの記録

Edgeは以下の通りに定義します。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/typegraph/type_graph.go#L92-L101

*types.Namedから*types.Namedへの経路をたどり、map,slice,array,pointer,channelのような無名の型の情報をStackとして記録します。
以下のように順繰りに型をunwrapしながら経路情報を記録します。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/typegraph/type_graph.go#L420-L463

node*types.Namedであるため、Underlyingでtraverseをかけます。
たどり着いたnamed typeがmatcherにマッチしたとき、nodeとしてすでに格納されていないならば外部タイプであるので、externalビットを立てます。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/typegraph/type_graph.go#L325-L376

externalとしてマッチした時のみ、type argも記録します。type argの記録時にはnamed typeでないことも許容します。
und.Und[T]TUndValidateUndRaw -> UndPlainのような特定のinterfaceを満たす時、特別なハンドリングを行いたのでtype argの記録が必要でした。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/typegraph/type_graph.go#L378-L418

edgeは親子に双方に描きます。グラフをtraverseするときはedgeを子から親に向けてたどりますが、code generatorは子の情報を使うからです。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/typegraph/type_graph.go#L465-L491

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など外部とのデータのやり取りに用いる型を対象とするため、chanStackに含むedgeは対象にしません。
そこでedgeのtraverse時にedgeをフィルターする機能を持つものとします。

上記の図ではUndからCedgechanを含むものしかないため、それ以上辿らないものとします。
連鎖的にDdependantではないという風に取り扱います。AはmatchするためmatchedBは連鎖的にdependantとして判定されます。

graphのtraverse

graphのtraversalはmatchedexternalを起点にedgeを親に向けてたどります。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/typegraph/type_graph.go#L505-L535

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することがないようにします。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/typegraph/type_graph.go#L536-L566

4. 連鎖的に検知された型(dependant)から、生成されることになるはずの型を*types.Namedとして生成する

UndRaw/UndPlainを実装する型の、UndPlainで返される型は上記*types.Namedの探索によって行われます。

dependantUndPlainを実装するものとして取り扱われますが、こちらの場合はコードが生成されていないため上記と同じ*types.Namedを探索しただけでは変換先の型を取り出すことができませんが、implementorと同じように*types.Namedで変換先を渡せると扱いを統一できてよいのでそうします。

そこで、変換前の*types.Namedをベースに変換後の型を生成します。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_common.go#L111-L129

types.NewNamedでメソッドセットを受けとりますが、これ自体に作成した*types.TypeNameが必要であるため関数分離の都合上callbackを受けとってメソッドセットを作成します。この例では元となった型のUnderlyingをそのままSetUnderlyingに渡しますが、ここに渡す型をinterfacemapなど好きな型に変えることで任意のnamed typeを作ることができます。

具体的な呼び出し例は以下になります。
元の型に+"Plain"をつけた名前で型を作り、メソッドはUndRawだけを持ちます。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_plain.go#L157-L189

このUndRawが参照されることは今回の実装では一度もなかったですが、実験的にtypes.TypeStringでプリントして正しくシグネチャが作成できていることは確認しています。

この辺の処理はtype checkerそのものを参考にしました。
typesのdoc commentを読むだけでは少々分かりにくかったですが、type checkerはinstantiateまでやりますから、全く同じ方法はとっていませんが参考になりました。

5. struct tagの編集

struct tagの編集機能は以下で実装します。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/structtag/tag.go

単なるテキスト処理であり、特筆すべきことはないため詳細な説明は省きます。

Goのstdのreflect.StructTag.Lookupを改変してkey-valueのペアに解析できるように変更し、
encoding/json/v2 discussionexperimental実装のタグ解析部分を参考に、仕様をまねて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/randmath/rand/v2がどちらもrandなので被ってしまいますよね。

さらに、identを指定しないimport specはインポートされるパッケージがpackage clauseで付けた名前になります。つまり、

import (
    "github.com/charmbracelet/bubbletea" /* tea */
)

上記はbubbleteapackage teaで定義されるため、teaでアクセスできます。

https://github.com/charmbracelet/bubbletea/blob/1feb60b44b74d9a3a7dc54b90ffbecc8ffd6b40d/tea.go#L10

この挙動は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をリストする(dependencies imports)
  • またcode generatorなどの外部のパッケージが任意にimportを追加できるものとする(extra imports)
  • code generatorは、package pathを引数に*ast.SelectorExpr*dst.SelectorExprを生成できる
  • *ast.Fileを引数に、identpackage pathの関係を洗い出す
  • 上記のextra importsやcode generatorが*ast.SelectorExprのために引き出したpackage pathのうち、*ast.Fileに含まれていなかったものをmissing importsとして記録しておく
  • missing importsを*dst.FileImportsGenDeclsのimport declにappendする

packages.Loadで依存先moduleの解析も行うには、*packages.ConfigModeビットにpackages.NeedImports|packages.NeedDepsも加えます。

[]*package.Packageの各packageをを列挙するにはpackages.Visitを呼び出します。Visit[]*package.Packagesをdependency orderかつ重複を排除しながらtraverseする機能を提供します。
適当にラップすればiteratorに変換できます。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/imports/parser.go#L92-L102

[]*package.Packageから解析された型情報をdependencies, code generatorが追加したいimportをextra*ast.Fileから解析されたident - package pathの関係をidentとして保存しておきます。extraおよびcode generator動作中に問い合わせられたpackage pathのなかでidentに存在しないものはmissingに記録します。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/imports/parser.go#L111-L119

下記のような関数でidentからpackage pathに対応するidentを取り出そうとし、ない場合dependenciesから取り出してmissingに記録します。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/imports/parser.go#L282

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/imports/parser.go#L298

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/imports/parser.go#L313

identが被った場合に備えて_%dでsuffixしながらマップに追加できるようにします。これによりmath/rand/v2をインポート済みのファイルにcrypto/randを追加しようとすると、import rand_1 "crypto/rand"という風に追加されることになります。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/imports/parser.go#L212-L229

最後に、*dst.Filemissingの内容を追加することで、のちのnode単位のast printingでimport declをprintするとき、追加されたimportも出力できるようにします。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/imports/parser.go#L339-L396

7. astのrewriteおよび書き出し

astはgo/ast以下で定義される各型によって表現されます。

すでに言及済みですが、go/parserで定義されるparser.ParseFileによってsource codeを解析して得られます。

得られた*ast.Fileast.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の中でフィールドにアタッチされたコメントが削除されませんので範囲で削除する必要があります。

ということですべて盛り込むと以下になります。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/codegen/parser.go#L18-L158

  • 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します。
  • メソッドの出力 => *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も収集してあります。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/typegraph/type_graph.go#L58-L70

そのため、前述の「original ast.Node -> modified dst.Node -> modified ast.Node」を順繰りに参照し、Fprintすることができます。

ただし、*ast.TypeSpectypeキーワードがないので手動で出力する必要があります。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キーワード、' '(スペース)を出力しておきます。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_plain.go#L111-L114

(上記のats*ast.TypeSpec)

*bufio.Writer + fmt.Fprintfによるメソッドの書き出し

メソッドの書き出しには*bufio.Writerfmt.Fprintfを用います。

var w io.Writer
bufw := bufio.NewWriter(w)
defer bufw.Flush()
printf := func(format string, args ...any) {
    fmt.Fprintf(bufw, format, args...)
}

理由は単純で、エラーの発生もバッファーしておけることです。

https://github.com/golang/go/blob/go1.23.3/src/bufio/bufio.go#L673-L690

https://github.com/golang/go/blob/go1.23.3/src/bufio/bufio.go#L632-L635

このことで細かいエラーハンドリングを隠すことができます。
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でラップするのはヘルパーを定義して、以後はこちらを使います。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_common.go#L73-L80

printする際にはfmtのExplicit argument indexesを用いると便利です。
リンク先でも述べられていますが、format stringの中で%[d]verb(dは任意の1-indexed integer)とするとd番目の引数をprintできます。今回作りたいcode generatorはこれだけで事足りてしまいます。

playground

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/templategithub.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]に変換します。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_patcher.go#L183-L265

sliceund, sliceund/elasticにはjson:",omitempty"を追加することでundefinedの時json.Marshalでフィールドがスキップされるようにします。undおよびelasticencoding/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から出力します。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_patcher.go#L306-L320

実装自体は気合と根性ですね。ここに関しては先に実装イメージを書いてそれを出力できるコードを書いただけ、という感じです。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_patcher.go#L336-L430

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_patcher.go#L432-L521

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_patcher.go#L523-L606

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_patcher.go#L608-L643

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を持つことを許します。
    • ここまで極端なことをすることは少ないかと思いますが[][]Tmap[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を取得できます

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_validator.go#L224

undtag.ParseOptionとして解析機能がexportしてあるのでこのstruct tagの解析自体はこれを呼び出すだけです。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_validator.go#L233-L239

undtag.ParseOptionの解析結果であるundtag.UndOptはinternal packageとしてvendorされたoptionを利用するため、これ自体を外部パッケージが初期化できません。
そのためundtag.UndOptExportを出力してIntoメソッドを呼び出すことでundtag.UndOptを得ます。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_validator.go#L344-L393

map[string][][]Aのように深くネストした型のAを取り出すためのunwrapperを出力します

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_validator.go#L149-L174

少しわかりにくいですかね?
今回許す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で逆順に適用していきます

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_validator.go#L320-L324

あとはimplementor|dependantなら呼び出すとか、implementor|dependantがpointer typeならnilチェックをするとかそういった細かい気遣いを加えて完成です。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_validator.go#L104-L342

書いてたときはなかなかしんどかったですがその甲斐あってそこそこきれいにまとまりました。

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の呼び出し

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に記録済みですのでこれを利用します。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_plain_type.go#L35-L50

取り出したdst.Exprそのものに別のexprを代入したくなるケースを考慮して*dst.Exprを返すようにします。

後続の変換メソッド生成処理でunwrapしたdst nodeとwrapされたままのdst nodeが必要なのでここでそれらを記録しておきます。

implementor|dependantのrewrite

und:"" struct tagの付けられていないimplementor|dependant、もしくはund typeにラップされたimplementor|dependantUndPlainの変換先の型名に取り換えます。

変換された*types.Named*dst.SelectorExprに変換して前節でunwrapされたdst.Nodeに代入します。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_plain_type.go#L130-L175

plainConverterは、implementordependantを一緒くたにしてnamed typeからnamed typeへの変換をするための関数で実装は以下のようになります

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_plain.go#L150-L191

ややすっきりしない作りですが

  • implementorは型情報をたどって変換先の*types.Namedを取り出し
  • dependantは前述のmakeRenamedTypeUndRawだけを実装する*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.ArrayTypeEltフィールドに代入すればできます。

こういう感じでパターンを網羅していきます。

まず最初にund.Und[T]Timplementor|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

上記すべてを盛り込むと下記のように実装されます

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_plain_type.go#L185-L325

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))を毎回呼ぶために経路上の中間となる型の表現もすべて書き出す必要があります。
前述のとおり経路の情報はすでに保存済みであるので、それを利用した以下の関数を定義します。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_plain_method.go#L26-L34

これによりmap[string][]V -> []V -> Vという感じで順次unwrapすることができます。ast.Exprprinter.Fprintでnode単位でprint可能ですので、printした結果をテキストとして前述の関数群に渡します。

全部を組み合わせて以下のようにunwrapFieldAlongPathを定義します。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_plain_method.go#L36-L110

conversion

型の変換時と同様に型とund:"" strcut tagの内容に基づいて変換する関数を定義します。

コードの生成量を減らすためにgithub.com/ngicks/und側で変換のためのランタイムを提供します。

https://github.com/ngicks/und/blob/67d88238795b9e837e9bfce9aeaf839dc4084899/conversion/conversion.go
https://github.com/ngicks/und/blob/67d88238795b9e837e9bfce9aeaf839dc4084899/conversion/back_conversion.go

これらのコードは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がポインタであっても、ノンポインタであってもよいとすることが難しいためです。
    • implementorUndRaw/UndPlainをpointer receiverの上に実装してもよいですし、implementor type Aがあるときはstrcutフィールド上でfieldName *Aであってもよくなります。

以下のように定義されます。

Raw -> Plain
https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_plain_to_plain.go

Plain -> Raw
https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_plain_to_raw.go

Raw ↔ Plain変換部の呼び出し。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/generator/undgen/gen_plain_method.go#L112-L481

UndRawUndPlainの生成は意外にも互いにほとんど同じ処理で上記のRaw -> PlainPlain -> 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を使うと複数のコマンドを簡単にまとめられて助かります。

https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/cmd/undgen.go
https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/cmd/undgen_patch.go
https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/cmd/undgen_plain.go
https://github.com/ngicks/go-codegen/blob/8a51f001909b00eca13d37660103a98cdfa945ee/codegen/cmd/undgen_validator.go

生成結果

生成サンプル用の型と結果は以下に格納されます。

https://github.com/ngicks/go-codegen/tree/2a35a98a9c52910efb646ac714b307bd9a43710a/codegen/undgen/internal/testtargets

おわりに

筆者がここ数年ずっとやりたいと思いながらできていなかった、astと型情報をメタデータとするcode generatorの実装をようやくできるようになりました。

これを作り出したきかっけは業務でpartial jsonを使ったpatchを行うと都合のいい場面が出たからなんですが、例によって例のごとく、その時はその場限りな方法で解決してしまったため、今回作ったものを使う機会は逃してしまっています。

さて今後についてですが

undgenについては現状の実装から大きく変わることはないと思いますが、いくつかの変更を予測しています。

  • リファクタ: もう少しまとめられそうなコードが重複しているので整理しなおします。
  • und:"und"がついたときのplain typeの対応するフィールドを*Tにする
    • T | nulloption.Option[T]で表現できますが、T | undefinedjson:",omitmepty"のついた*Tである必要があるためです。
    • そうしなければ、json.Marshalなどで出力する際にはRawに一度変換しなおさなければフィールドがnullで出力されてしますため、少し不便ですね。
    • Plainだけを使っても運用が通用したほうが便利ではあると思うためそうなるように検証を重ねていこうかなと思っています。
  • さらなるオプションの追加
    • type-suffixオプション: 現状、生成される型は元の型名+Patch|Plainの名前がつきます。これが固定だと少し具合が悪いかなと思います。
    • denylistオプション: また、今はvalidator,plain//codegen:ignoreというコメントがついていない型はすべてtype nodeとして列挙されます。
      • これはこのcode genreatorが複数のパッケージを同時に処理することを前提とするため、cli引数からallowlist/denylistを受けとるのが煩雑であるためです。
      • もう少し見直してdenylistを受けとれるようにしたほうが良いかなあと思っています。

さらに、今回作ったものを通じて型情報の操作に習熟したのでもっと違うものも作れるようになりました。今後はそちらも作って行くことになるかと思います。

  • 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です。
    • undgenでさぼったtype paramの追跡が必須なのでそこが少々課題ですが
  • 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 BMarshalJSON実装をしているとき、type A Bで定義したAjson.Encoderに渡しているから起きています。type A Bはmethod setを継承していないためBMarshalJSONが呼び出されないため必ず{}が出力されます。
      • 今(v2.4.1)確認しても修正されていなかったのでまだやる価値はある。
  • ident-mover: ファイル単位、exportされたident単位でパッケージに入っていたものを別のパッケージに移動させる
    • リファクタ(?)の中でも頻繁に困るのは元は同じパッケージで定義していたものを別のパッケージに切り出す時の書き換えです
    • 現在進行形でリファクタで苦労しています。
    • GoLandにはこういったものが最初から同梱されてるんですかね?

型情報とdst-rewriteを活用すれば別ファイルに書き出さないタイプのcode generator、つまりリファクタツールでもなんでも作れちゃいますね

GitHubで編集を提案

Discussion