《Go 语言高级编程》9.3 自研代码生成器与 go/ast

现成的生成器解决不了所有问题,总有需要自己造一个的时候。本节用 go/ast 从零写一个 getter 生成器,实测它的完整流程,讲清 go/ast 与 go/types 的分工,并如实记录一个真实踩坑——注释挂在 GenDecl 上而不是 TypeSpec 上,导致第一版生成器静默输出空文件。

9.3 自研代码生成器与 go/ast

9.1 和 9.2 用的都是现成工具。但现实里总会出现「现成工具差一点」的情况:你的领域需要一种特定的样板代码,stringer 不管、mockgen 不管、sqlc 也不管。这时候唯一的出路是自己写一个生成器。

好消息是 Go 把这件事的门槛降得很低——标准库自带 go/ast、go/parser、go/format,你不需要引入任何第三方依赖就能解析并生成 Go 代码。

本节要回答的问题是:怎么用 go/ast 从零写一个生成器,它的边界在哪。结论先行:自研生成器的最小骨架是 go/parser(读源文件)+ go/ast(遍历语法树)+ go/format(格式化输出);go/ast 只提供语法信息,不提供类型信息,需要类型解析时要搭配 go/types;本节实测中踩到一个真实坑——在顶层 type 声明上方的注释属于 GenDecl,不是 TypeSpec,第一版生成器因此静默输出空文件。

9.3.1 什么时候需要自研生成器

判断标准很简单:「这段代码的正确性是否由一个单一事实来源决定,且现有工具不覆盖」。

适合自研不适合自研
从 YAML/JSON 配置生成常量与枚举已经有成熟工具的场景
从接口生成「方法转发」样板一次性脚本
从标记注释生成领域方法逻辑复杂到无法从事实来源推导
从数据库 schema 生成领域结构体需要人做判断的代码

自研生成器的价值不在「省了几行字」,而在**「人写会错、机器写不会错」**。比如给一个结构体的每个导出字段生成一个 GetX()——人写一遍不会错,写第十遍必错,而且容易漏字段。

9.3.2 三种解析路线

要写生成器,第一件事是决定「怎么读输入」。三条路线:

路线手段能力成本
文本解析正则 / 行扫描只能看字符串低但脆
语法解析go/parser + go/ast看到语法结构中
类型解析go/types + go/ast看到完整类型信息高

文本解析(用正则找 type X struct)看似简单,但会被注释、字符串字面量、嵌套结构骗到——任何格式化变化都可能让它失效。语法解析是自研生成器的主流选择,也是本节的主题。类型解析在语法基础上再做类型推导,能回答「这个字段的类型到底是什么」,代价是要做完整的类型检查。

9.3.3 go/ast 的核心概念

go/ast 把 Go 源码表示成一棵树,几个关键节点:

节点类型对应源码例子
*ast.File一个源文件整个 model.go
*ast.GenDecl顶层声明type X struct {...} 这一整条声明
*ast.TypeSpec类型声明本身X struct{...}
*ast.StructType结构体类型struct{ ... }
*ast.Field字段Name string
*ast.Ident标识符Name、string

理解 GenDecl 与 TypeSpec 的层级是本节的关键——后面那个坑就出在这里。一条 type X struct{...} 声明,外层是 GenDecl,里层才是 TypeSpec。注释挂在哪一层,取决于它写在哪里。

9.3.4 写一个 getter 生成器(实测)

目标:扫描一个 Go 文件,对每个带 //gen:getter 注释的结构体,为它的每个导出字段生成一个 GetX() 方法。

输入 model.go:

package main

//gen:getter
type User struct {
	ID   int64
	Name string
	Age  int
}

生成器 main.go 的骨架:

package main

import (
	"bytes"
	"flag"
	"fmt"
	"go/ast"
	"go/format"
	"go/parser"
	"go/token"
	"os"
	"strings"
)

func main() {
	src := flag.String("src", "", "输入 Go 文件")
	out := flag.String("out", "", "输出文件")
	flag.Parse()

	fset := token.NewFileSet()
	f, err := parser.ParseFile(fset, *src, nil, parser.ParseComments)
	must(err)

	var buf bytes.Buffer
	fmt.Fprintf(&buf, "// Code generated by astgen. DO NOT EDIT.\npackage %s\n\n", f.Name.Name)

	// 遍历顶层声明:注释挂在 GenDecl 上,不是 TypeSpec 上
	for _, decl := range f.Decls {
		gd, ok := decl.(*ast.GenDecl)
		if !ok || gd.Tok != token.TYPE || !hasGenTag(gd.Doc) {
			continue
		}
		for _, spec := range gd.Specs {
			ts := spec.(*ast.TypeSpec)
			st, ok := ts.Type.(*ast.StructType)
			if !ok {
				continue
			}
			for _, field := range st.Fields.List {
				for _, name := range field.Names {
					if !name.IsExported() {
						continue
					}
					fmt.Fprintf(&buf, "func (x *%s) Get%s() %s { return x.%s }\n\n",
						ts.Name.Name, name.Name, exprString(field.Type), name.Name)
				}
			}
		}
	}

	srcBytes, err := format.Source(buf.Bytes())
	must(err)
	must(os.WriteFile(*out, srcBytes, 0o644))
	fmt.Printf("生成 %s (%d 字节)\n", *out, len(srcBytes))
}

三个辅助函数——错误处理、判断注释里有没有标记,以及把类型表达式渲染成字符串:

func must(err error) {
	if err != nil {
		panic(err)
	}
}

func hasGenTag(doc *ast.CommentGroup) bool {
	if doc == nil {
		return false
	}
	for _, c := range doc.List {
		if strings.Contains(c.Text, "gen:getter") {
			return true
		}
	}
	return false
}

func exprString(e ast.Expr) string {
	var b strings.Builder
	_ = format.Node(&b, token.NewFileSet(), e)
	return b.String()
}

运行并查看结果:

$ GOTOOLCHAIN=go1.27.0 go run . -src model.go -out user_getters.go
生成 user_getters.go (198 字节)
// Code generated by astgen. DO NOT EDIT.
package main

func (x *User) GetID() int64 { return x.ID }

func (x *User) GetName() string { return x.Name }

func (x *User) GetAge() int { return x.Age }

三个导出字段各生成了一个 getter,字段名、类型都正确。验证格式:

$ gofmt -l user_getters.go
(无输出,说明已符合 gofmt)

9.3.5 踩坑实测:注释挂在 GenDecl 上

这个坑值得单独写出来,因为它的失败方式是静默的——不报错,只是什么都不生成。

第一版生成器用 ast.Inspect 遍历,并检查 ts.Doc(即 TypeSpec 的注释):

ast.Inspect(f, func(n ast.Node) bool {
	ts, ok := n.(*ast.TypeSpec)
	if !ok {
		return true
	}
	if !hasGenTag(ts.Doc) { // 问题在这里
		return true
	}
	// ... 生成
})

运行结果:

$ GOTOOLCHAIN=go1.27.0 go run . -src model.go -out user_getters.go
生成 user_getters.go (55 字节)

只有 55 字节——文件里只有一个 package 声明,一个 getter 都没有:

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

根因是 go/ast 的注释归属规则:对于单条 type X struct{...} 声明,上方的注释属于外层的 GenDecl.Doc;只有当类型写在 type ( ... ) 分组声明里时,注释才可能落在 TypeSpec.Doc 上。

修正方法是改成遍历 f.Decls,检查 gd.Doc(就是 9.3.4 里最终版的写法)。修正后 55 字节变成 198 字节。

教训:写 go/ast 生成器时,「生成物为空」和「生成物不对」都要当 bug 处理。一个可靠的生成器应该在「一个都没匹配到」时主动报错或警告,而不是安静地写出一个空文件——否则你会以为生成器没跑,其实是它跑了但什么都没找到。

9.3.6 生成物必须过 go/format

生成器最容易犯的错是输出一堆对齐错乱的代码。解法是在生成器内部调用 go/format,而不是生成后再手动跑 gofmt:

srcBytes, err := format.Source(buf.Bytes())
must(err)

format.Source 对语法正确但格式不对的源码返回格式化结果;对语法错误的源码返回错误。这顺带成了一个免费的正确性检查——如果模板生成了非法的 Go,format.Source 会直接报错,而不是把坏代码写进文件。

配合 gofmt -l 就能形成一条 CI 防线:

$ gofmt -l user_getters.go   # 输出为空才算通过

9.3.7 go/ast 的边界:只给语法,不给类型

这是自研生成器最需要理解的边界。go/ast 看到的是语法,不是类型。举个例子,下面两个字段在 go/ast 里长得几乎一样:

type A struct {
	T time.Time   // time 是 import 的包
}

type B struct {
	T Time        // Time 是本地的类型
}

在 go/ast 层面,time.Time 是一个 *ast.SelectorExpr,Time 是一个 *ast.Ident——但 ast 不知道 time 到底指向哪个包,也不知道 Time 到底是什么类型。它甚至不知道 time 是导入的包还是一个变量名。

要回答「这个字段的类型到底是什么」,必须做类型解析,也就是 go/types。

9.3.8 用 go/types 补上类型信息

go/types 在 go/ast 之上做完整的类型检查,产出类型信息。它需要三样东西:文件集、AST 文件、以及一个 types.Importer(用来解析 import)。

它比 go/ast 重得多,因为要真正解析所有依赖包。工程实践里有两个选择:

方案库特点
只用语法go/ast轻、快、够用于「结构体 + 标记注释」场景
需要类型go/types + golang.org/x/tools/go/packages重、慢、但能回答类型问题

golang.org/x/tools/go/packages 是社区事实标准——它封装了 go/types 的加载细节,go tool 能像 go build 一样解析一个包。注意:9.2 里 ent 生成失败的报错(package "context" without types)正是出在这一层——类型加载器与新版 Go 工具链不兼容。自研生成器如果要用 go/types,就要为这类兼容性问题留出排查时间。

一条实用的经验:优先只用 go/ast。绝大多数「生成样板代码」的需求(getter、常量、枚举、转发方法)都只需要语法信息;只有当你要做「基于字段类型的智能生成」时,才值得付出 go/types 的代价。

9.3.9 自研生成器的工程化清单

把一个自研生成器接入工程,检查这几项:

  • 生成命令写进 //go:generate,而不是散在 Makefile 里
  • 生成器本身是一个独立的 main 包,可 go run 也可 go tool
  • 生成器版本用 go get -tool 锁定(9.1.5)
  • 生成物头部有 // Code generated ... DO NOT EDIT.
  • 生成器内部调用 format.Source
  • 「零匹配」时主动报错,而不是静默写空文件
  • CI 里跑 go generate ./... && git diff --exit-code
  • 生成物有对应的测试

到这里,第 9 章把代码生成的三种形态讲完了:现成的单方法生成器(stringer)、现成的重型生成器(mockgen/sqlc/ent)、以及自研生成器(go/ast)。它们共同指向同一个思想——把运行期的工作挪到编译期,把重复的劳动交给机器。下一章我们转向另一个「模型层面」的话题:结构化并发。

阅读导航:上一节:9.2 mockgen 与 sqlc/ent · 下一节:10.1 errgroup 与 semaphore(x/sync) 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练