Go AST 代码自动生成实战:用抽象语法树告别重复编码
写 Go 项目的时候,你有没有遇到过这样的场景——一堆结构体需要手写 MarshalJSON、手写 CRUD 方法、手写 mock 实现?重复的体力活不仅浪费时间,还特别容易手抖写错。
其实,Go 标准库自带了一套相当强大的 AST(抽象语法树)工具链,配合 text/template,可以实现读取源码 → 提取结构体信息 → 自动输出目标代码这一整套流程。
这篇文章就带你从零搞懂这件事。读完之后,你完全可以自己写一个代码生成器,把那些枯燥的重复工作交给程序去干。
一、AST 到底是什么
AST,全称 Abstract Syntax Tree,翻译过来就是抽象语法树。
通俗地说,Go 编译器在编译代码之前,会先对源代码做一次"结构化拆解"。它会把你写的每一行代码——包名声明、import 语句、函数定义、结构体字段——全部拆解成一棵树形结构。这棵树就是 AST。
举个例子,当编译器看到这段代码:
package main
func Add(a, b int) int {
return a + b
}它会在内部构建出类似这样的树:
File
├── Package: main
└── FuncDecl: Add
├── Params: a int, b int
├── Results: int
└── Body
└── ReturnStmt: a + b每一个节点都对应源代码中的一个语法元素。我们要做的事情,就是通过 Go 标准库把这棵树读出来,然后从中提取需要的信息(比如结构体名称、字段名、字段类型、标签等),最后用模板引擎拼出目标代码。
为什么不直接用字符串拼接
你当然可以用 fmt.Sprintf 拼代码字符串,但这种方式有几个致命问题:
- 脆弱:缩进、括号、换行全靠自己手动控制,稍不注意就会生成语法错误的代码。
- 难以扩展:当结构体字段变多、逻辑变复杂时,字符串拼接的代码会迅速变得不可维护。
- 无法校验:字符串拼接没有任何语法层面的保障,生成的代码对不对只能靠运行时才能发现。
而通过 AST 的方式,源码解析本身就保证了结构的正确性,再配合 text/template 的模板引擎,生成的代码格式可控、逻辑清晰、易于维护。
二、Go 标准库中的 AST 工具链
Go 标准库提供了四个核心包来处理 AST,它们各司其职:
| 包名 | 作用 |
|---|---|
go/token |
定义词法标记(token)的类型和位置信息,是整个工具链的基础 |
go/parser |
将 .go 源文件或代码字符串解析成 AST |
go/ast |
定义 AST 的所有节点类型,比如 ast.File、ast.FuncDecl、ast.StructType 等 |
go/printer |
把 AST 重新输出为格式化的 Go 源代码 |
它们之间的关系可以用一句话概括:go/parser 把代码变成 AST,go/ast 描述 AST 长什么样,go/printer 把 AST 变回代码,go/token 则在底层提供位置和标记信息。
三、用标准库解析一个 Go 文件
下面用一段完整的代码,演示如何用标准库解析一个 .go 文件并打印出其中所有结构体的名称和字段信息:
package main
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
)
func main() {
src := `
package model
type User struct {
Id uint64 ` + "`" + `json:"id" gorm:"primaryKey"` + "`" + `
Name string ` + "`" + `json:"name"` + "`" + `
Age int ` + "`" + `json:"age"` + "`" + `
}
`
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, "", src, parser.ParseComments)
if err != nil {
fmt.Println("解析失败:", err)
return
}
// 遍历所有顶层声明
for _, decl := range f.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok {
continue
}
for _, spec := range genDecl.Specs {
typeSpec, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
structType, ok := typeSpec.Type.(*ast.StructType)
if !ok {
continue
}
fmt.Printf("结构体: %s\n", typeSpec.Name.Name)
for _, field := range structType.Fields.List {
fieldName := field.Names[0].Name
fieldType := fmt.Sprintf("%s", field.Type)
// 获取实际类型名称
switch t := field.Type.(type) {
case *ast.Ident:
fieldType = t.Name
case *ast.SelectorExpr:
fieldType = fmt.Sprintf("%s.%s", t.X, t.Sel)
}
tag := ""
if field.Tag != nil {
tag = field.Tag.Value
}
fmt.Printf(" 字段: %-10s 类型: %-10s 标签: %s\n", fieldName, fieldType, tag)
}
}
}
}运行这段代码,输出如下:
结构体: User
字段: Id 类型: uint64 标签: `json:"id" gorm:"primaryKey"`
字段: Name 类型: string 标签: `json:"name"`
字段: Age 类型: int 标签: `json:"age"`可以看到,通过 go/parser 和 go/ast,我们成功地把源代码中的结构体信息全部提取了出来。接下来就可以用这些信息来生成目标代码了。
四、结合 text/template 自动生成 DAO 代码
提取到结构体信息之后,真正的"代码生成"环节就要靠 Go 的模板引擎 text/template 来完成了。
整体思路分三步:
- 用 AST 解析源文件,提取结构体名称和字段列表。
- 定义代码模板,用 Go template 语法编写目标代码的骨架。
- 将提取到的数据填入模板,输出最终的
.go文件。
下面是一个完整的实战案例。假设我们有一个 model.go 文件:
package model
type Person struct {
Id uint64 `json:"id" gorm:"primaryKey"`
Name string `json:"name"`
Age int `json:"age"`
}我们希望自动生成对应的 DAO 代码,包含 Create、GetById、Update、Delete 四个方法。
4.1 定义数据结构
首先,我们需要定义用来承载解析结果的数据结构:
// FieldInfo 保存单个字段的信息
type FieldInfo struct {
Name string
Type string
Tag string
}
// StructInfo 保存结构体的完整信息
type StructInfo struct {
Name string
Fields []FieldInfo
}4.2 编写 AST 解析函数
func ParseStructs(filePath string) ([]StructInfo, error) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments)
if err != nil {
return nil, fmt.Errorf("解析文件失败: %w", err)
}
var structs []StructInfo
for _, decl := range f.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok {
continue
}
for _, spec := range genDecl.Specs {
typeSpec, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
structType, ok := typeSpec.Type.(*ast.StructType)
if !ok {
continue
}
info := StructInfo{Name: typeSpec.Name.Name}
for _, field := range structType.Fields.List {
if len(field.Names) == 0 {
continue // 跳过匿名字段
}
fi := FieldInfo{Name: field.Names[0].Name}
switch t := field.Type.(type) {
case *ast.Ident:
fi.Type = t.Name
case *ast.SelectorExpr:
fi.Type = fmt.Sprintf("%s.%s", t.X, t.Sel)
default:
fi.Type = fmt.Sprintf("%v", field.Type)
}
if field.Tag != nil {
fi.Tag = field.Tag.Value
}
info.Fields = append(info.Fields, fi)
}
structs = append(structs, info)
}
}
return structs, nil
}4.3 定义代码模板
这是整个生成器最核心的部分。我们使用 Go 的 text/template 语法来编写 DAO 代码的模板:
const daoTemplate = `// Code generated by ast-gen. DO NOT EDIT.
package dao
import (
"gorm.io/gorm"
"your_project/model"
)
{{range .}}
// Create{{.Name}} 创建一条 {{.Name}} 记录
func Create{{.Name}}(db *gorm.DB, item *model.{{.Name}}) error {
return db.Create(item).Error
}
// Get{{.Name}}ById 根据 ID 查询 {{.Name}}
func Get{{.Name}}ById(db *gorm.DB, id uint64) (*model.{{.Name}}, error) {
var result model.{{.Name}}
err := db.Where("id = ?", id).First(&result).Error
if err != nil {
return nil, err
}
return &result, nil
}
// Update{{.Name}} 更新 {{.Name}} 记录
func Update{{.Name}}(db *gorm.DB, item *model.{{.Name}}) error {
return db.Save(item).Error
}
// Delete{{.Name}}ById 根据 ID 删除 {{.Name}}
func Delete{{.Name}}ById(db *gorm.DB, id uint64) error {
return db.Where("id = ?", id).Delete(&model.{{.Name}}{}).Error
}
{{end}}
`4.4 组合起来:完整的生成器
package main
import (
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"text/template"
)
type FieldInfo struct {
Name string
Type string
Tag string
}
type StructInfo struct {
Name string
Fields []FieldInfo
}
func ParseStructs(filePath string) ([]StructInfo, error) {
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, filePath, nil, parser.ParseComments)
if err != nil {
return nil, fmt.Errorf("解析文件失败: %w", err)
}
var structs []StructInfo
for _, decl := range f.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok {
continue
}
for _, spec := range genDecl.Specs {
typeSpec, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
structType, ok := typeSpec.Type.(*ast.StructType)
if !ok {
continue
}
info := StructInfo{Name: typeSpec.Name.Name}
for _, field := range structType.Fields.List {
if len(field.Names) == 0 {
continue
}
fi := FieldInfo{Name: field.Names[0].Name}
switch t := field.Type.(type) {
case *ast.Ident:
fi.Type = t.Name
case *ast.SelectorExpr:
fi.Type = fmt.Sprintf("%s.%s", t.X, t.Sel)
default:
fi.Type = fmt.Sprintf("%v", field.Type)
}
if field.Tag != nil {
fi.Tag = field.Tag.Value
}
info.Fields = append(info.Fields, fi)
}
structs = append(structs, info)
}
}
return structs, nil
}
const daoTemplate = `// Code generated by ast-gen. DO NOT EDIT.
package dao
import (
"gorm.io/gorm"
"your_project/model"
)
{{range .}}
// Create{{.Name}} 创建一条 {{.Name}} 记录
func Create{{.Name}}(db *gorm.DB, item *model.{{.Name}}) error {
return db.Create(item).Error
}
// Get{{.Name}}ById 根据 ID 查询 {{.Name}}
func Get{{.Name}}ById(db *gorm.DB, id uint64) (*model.{{.Name}}, error) {
var result model.{{.Name}}
err := db.Where("id = ?", id).First(&result).Error
if err != nil {
return nil, err
}
return &result, nil
}
// Update{{.Name}} 更新 {{.Name}} 记录
func Update{{.Name}}(db *gorm.DB, item *model.{{.Name}}) error {
return db.Save(item).Error
}
// Delete{{.Name}}ById 根据 ID 删除 {{.Name}}
func Delete{{.Name}}ById(db *gorm.DB, id uint64) error {
return db.Where("id = ?", id).Delete(&model.{{.Name}}{}).Error
}
{{end}}
`
func main() {
// 解析源文件
structs, err := ParseStructs("./model.go")
if err != nil {
fmt.Println(err)
return
}
// 创建输出文件
outFile, err := os.Create("./dao_gen.go")
if err != nil {
fmt.Println("创建输出文件失败:", err)
return
}
defer outFile.Close()
// 解析并执行模板
tmpl, err := template.New("dao").Parse(daoTemplate)
if err != nil {
fmt.Println("解析模板失败:", err)
return
}
if err := tmpl.Execute(outFile, structs); err != nil {
fmt.Println("执行模板失败:", err)
return
}
fmt.Println("DAO 代码生成完成: dao_gen.go")
}运行之后,会在当前目录下生成一个 dao_gen.go 文件,里面包含了 Person 结构体对应的 Create、GetById、Update、Delete 四个方法。如果你在 model.go 里再加几个结构体,重新运行生成器,对应的 DAO 方法也会一并生成。
五、进阶技巧:让生成器更实用
上面的例子已经能跑通了,但在实际项目中,你可能还需要处理一些额外的场景。
5.1 自动格式化生成的代码
生成的代码可能存在缩进不规范的问题。Go 标准库提供了 go/format 包,可以对生成的代码做自动格式化,效果和执行 gofmt 一样:
import "go/format"
// 格式化生成的代码
formatted, err := format.Source(buf.Bytes())
if err != nil {
fmt.Println("格式化失败:", err)
return
}
os.WriteFile("./dao_gen.go", formatted, 0644)5.2 结合 go generate 使用
Go 有一个内置的代码生成机制:go generate。你只需要在源文件中加一行注释:
//go:generate go run gen/main.go然后在项目根目录执行 go generate ./...,Go 工具链就会自动运行你的代码生成器。这样做的好处是把代码生成纳入了标准的构建流程,团队成员拉取代码后只需要一条命令就能重新生成所有自动代码。
5.3 处理指针类型和切片类型
在实际项目中,结构体字段的类型不一定都是简单的 int、string,还可能是 *time.Time、[]byte 这样的复合类型。你需要在 AST 解析函数中增加对 *ast.StarExpr(指针类型)和 *ast.ArrayType(数组 / 切片类型)的处理:
switch t := field.Type.(type) {
case *ast.Ident:
fi.Type = t.Name
case *ast.SelectorExpr:
fi.Type = fmt.Sprintf("%s.%s", t.X, t.Sel)
case *ast.StarExpr:
// 指针类型,如 *time.Time
fi.Type = "*" + fmt.Sprintf("%v", t.X)
case *ast.ArrayType:
// 切片类型,如 []byte
fi.Type = "[]" + fmt.Sprintf("%v", t.Elt)
}六、实际应用场景
掌握了 AST + template 这套组合拳之后,你能做的事情远不止生成 DAO 代码。以下是一些在真实项目中非常常见的应用:
- 生成 JSON 序列化 / 反序列化方法:类似
easyjson的思路,为结构体生成高性能的MarshalJSON和UnmarshalJSON方法。 - 生成接口 mock 实现:读取接口定义,自动生成对应的 mock 结构体,方便单元测试。
- 生成 API 路由注册代码:解析带有特定注释标记的 handler 函数,自动生成路由注册逻辑。
- 生成数据校验代码:根据结构体标签中的校验规则(如
validate:"required,min=1"),自动生成参数校验函数。 - 生成 Protocol Buffers 或 gRPC 相关代码:虽然 protoc 已经提供了代码生成功能,但有些项目需要在此基础上做二次生成。
社区里也有不少优秀的代码生成工具就是基于这套原理实现的,比如 stringer(为枚举类型生成 String() 方法)和 mockgen(为接口生成 mock 实现)。
七、常见问题
Q1:AST 解析和正则表达式提取有什么区别?
正则表达式是基于文本模式匹配,它不理解代码的语法结构。比如你用正则提取结构体字段,很容易被注释中的代码、多行字符串等情况干扰。而 AST 是编译器级别的解析,它对代码的理解是准确的、结构化的,不会出现误匹配的问题。
Q2:生成的代码需要手动格式化吗?
不需要。你可以在生成器中使用 go/format 包来自动格式化输出代码,效果等同于执行 gofmt。这样生成的代码风格和手写代码完全一致。
Q3:AST 能解析不完整的代码片段吗?
可以。parser.ParseFile 支持传入 parser.AllErrors 模式,即使代码有语法错误也会尽力解析。另外,parser.ParseExpr 可以单独解析一个表达式,不需要完整的文件结构。
Q4:代码生成器应该在什么时候运行?
推荐结合 go generate 使用。在需要生成代码的源文件中添加 //go:generate 指令,然后执行 go generate ./... 即可。这样做可以把代码生成纳入标准的构建流程,避免遗漏。
Q5:生成的代码是否应该提交到版本控制?
这取决于团队约定。常见的做法有两种:一种是将生成的代码提交到 Git,这样其他人拉取代码后不需要额外操作;另一种是在 .gitignore 中忽略生成的文件,通过 CI/CD 流程中执行 go generate 来保证代码是最新的。两种方式各有优劣,核心原则是保证团队每个人拿到的代码都是一致的。
Q6:text/template 和 html/template 应该用哪个?
生成 Go 源代码应该用 text/template。html/template 会自动对输出进行 HTML 转义,这会导致生成的代码中出现不必要的转义字符。只有在生成 HTML 内容时才需要用 html/template。
八、总结
这篇文章带你走了一遍 Go AST 代码自动生成的完整流程:
- 理解 AST:它是编译器对源代码的结构化表示,比字符串操作更可靠。
- 认识工具链:
go/parser负责解析、go/ast定义结构、go/printer负责输出、go/token提供基础设施。 - 解析源文件:通过遍历 AST 节点,精确提取结构体名称、字段名、字段类型和标签。
- 模板生成代码:用
text/template编写代码骨架,将解析到的数据填入模板,生成目标文件。 - 进阶用法:结合
go/format自动格式化、集成go generate构建流程、处理复杂类型。
当你需要为大量结构体生成 CRUD 方法、序列化代码、mock 实现或校验逻辑时,与其一个个手写,不如花半小时搭一个代码生成器。一次投入,长期受益。
如果大家对 Go AST 代码自动生成还有什么疑问,或者在实际项目中遇到了什么棘手的场景,欢迎在评论区留言交流,我们一起探讨解决方案~~~
版权声明
未经授权,禁止转载本文章。
如需转载请保留原文链接并注明出处。即视为默认获得授权。
未保留原文链接未注明出处或删除链接将视为侵权,必追究法律责任!
本文原文链接: https://fiveyoboy.com/articles/go-ast-auto-code-generation/
备用原文链接: https://blog.fiveyoboy.com/articles/go-ast-auto-code-generation/