目录

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 拼代码字符串,但这种方式有几个致命问题:

  1. 脆弱:缩进、括号、换行全靠自己手动控制,稍不注意就会生成语法错误的代码。
  2. 难以扩展:当结构体字段变多、逻辑变复杂时,字符串拼接的代码会迅速变得不可维护。
  3. 无法校验:字符串拼接没有任何语法层面的保障,生成的代码对不对只能靠运行时才能发现。

而通过 AST 的方式,源码解析本身就保证了结构的正确性,再配合 text/template 的模板引擎,生成的代码格式可控、逻辑清晰、易于维护。


二、Go 标准库中的 AST 工具链

Go 标准库提供了四个核心包来处理 AST,它们各司其职:

包名 作用
go/token 定义词法标记(token)的类型和位置信息,是整个工具链的基础
go/parser .go 源文件或代码字符串解析成 AST
go/ast 定义 AST 的所有节点类型,比如 ast.Fileast.FuncDeclast.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/parsergo/ast,我们成功地把源代码中的结构体信息全部提取了出来。接下来就可以用这些信息来生成目标代码了。


四、结合 text/template 自动生成 DAO 代码

提取到结构体信息之后,真正的"代码生成"环节就要靠 Go 的模板引擎 text/template 来完成了。

整体思路分三步:

  1. 用 AST 解析源文件,提取结构体名称和字段列表。
  2. 定义代码模板,用 Go template 语法编写目标代码的骨架。
  3. 将提取到的数据填入模板,输出最终的 .go 文件。

下面是一个完整的实战案例。假设我们有一个 model.go 文件:

package model

type Person struct {
    Id   uint64 `json:"id" gorm:"primaryKey"`
    Name string `json:"name"`
    Age  int    `json:"age"`
}

我们希望自动生成对应的 DAO 代码,包含 CreateGetByIdUpdateDelete 四个方法。

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 结构体对应的 CreateGetByIdUpdateDelete 四个方法。如果你在 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 处理指针类型和切片类型

在实际项目中,结构体字段的类型不一定都是简单的 intstring,还可能是 *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 的思路,为结构体生成高性能的 MarshalJSONUnmarshalJSON 方法。
  • 生成接口 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/templatehtml/template 会自动对输出进行 HTML 转义,这会导致生成的代码中出现不必要的转义字符。只有在生成 HTML 内容时才需要用 html/template


八、总结

这篇文章带你走了一遍 Go AST 代码自动生成的完整流程:

  1. 理解 AST:它是编译器对源代码的结构化表示,比字符串操作更可靠。
  2. 认识工具链go/parser 负责解析、go/ast 定义结构、go/printer 负责输出、go/token 提供基础设施。
  3. 解析源文件:通过遍历 AST 节点,精确提取结构体名称、字段名、字段类型和标签。
  4. 模板生成代码:用 text/template 编写代码骨架,将解析到的数据填入模板,生成目标文件。
  5. 进阶用法:结合 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/