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) 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。