Go 代码生成实战——用 go generate 消灭重复代码的正确姿势
Go 代码生成实战——用 go generate 消灭重复代码的正确姿势
适读人群:想提升 Go 开发效率、减少模板代码的工程师 | 阅读时长:约15分钟 | 核心价值:go generate 的实际使用场景、常用代码生成工具以及自定义生成器的编写
从一次"手抖"事故说起
那是一个晚上8点,我要上线一个新功能,需要给 User 结构体加一个新字段 PhoneNumber。
在 Java 时代,这是 Lombok 一个注解的事。但那时候我们有一套基于 map[string]interface{} 的表单验证和 JSON 映射代码,每次加字段都要手动更新:
User结构体里加字段 ✓validateUser函数里加验证规则 ✓userToMap函数里加字段映射mapToUser函数里加反向映射- 测试代码里的测试数据
我加了1、2,然后直接上线了,忘了3和4。
结果新字段传进来的数据在序列化时丢了,导致一批用户的手机号没有保存。不是特别严重的事故,但处理了很久。
那次之后我就在想:这种"加字段要在 N 个地方同步修改"的模式,不就是代码生成应该解决的问题吗?
go generate 是什么
go generate 是 Go 内置的代码生成工具调度器。你在源码里写一行注释:
//go:generate some-tool -args...然后在这个文件所在目录运行 go generate,Go 就会执行这个命令。它本身不做任何代码生成,只是一个调度框架,实际生成工作由你指定的工具完成。
常见的代码生成工具:
stringer:为枚举类型生成String()方法mockgen:生成接口的 mock 实现protoc:从 Protobuf 生成 Go 代码ent:从 schema 定义生成数据库操作代码wire:生成依赖注入代码- 自定义工具:处理你特定项目需求
踩坑实录
坑一:生成的文件被意外提交进了 git 导致冲突
现象: 两个同事同时修改了 schema,各自运行了 go generate,生成的文件有 diff,merge 时产生冲突。
原因: 生成文件没有被特殊对待。
解法: 在每个生成文件的开头加 // Code generated by xxx; DO NOT EDIT. 注释,这是 Go 的约定——有这个注释的文件:
go vet会跳过- golangci-lint 默认跳过
- 代码审查者知道不用 review 这个文件
还要建立规范:生成文件提交到 git(不 gitignore),但发生冲突时,直接以一方为准然后重新生成。
坑二:go generate 在 CI 里没有运行,导致生成代码和定义不一致
现象: 本地运行正常,CI 构建失败,报 undefined 错误。
原因: 某人修改了接口定义,忘了运行 go generate 重新生成 mock,也没提交生成的文件。
解法: CI 里加一步检查:运行 go generate ./... 然后用 git diff --exit-code 检查是否有文件变化,有变化说明生成代码不是最新的。
坑三:生成器依赖的工具版本不固定,不同机器生成结果不同
现象: 本地生成的文件和 CI 生成的文件有细微差别(注释格式不同),每次提交都有 diff。
解法: 把生成工具作为项目依赖管理,用 tools.go 文件固定版本:
// tools.go
//go:build tools
package tools
import (
_ "github.com/golang/mock/mockgen"
_ "golang.org/x/tools/cmd/stringer"
)这样 go.mod 会记录这些工具的版本,确保所有人用相同版本。
实战:为接口自动生成 mock
这是最常用的代码生成场景。以 UserRepository 为例:
// repository/user.go
package repository
//go:generate mockgen -source=user.go -destination=../mocks/user_repository_mock.go -package=mocks
// UserRepository 用户数据访问接口
type UserRepository interface {
FindByID(ctx context.Context, id string) (*User, error)
FindByEmail(ctx context.Context, email string) (*User, error)
Create(ctx context.Context, user *User) error
Update(ctx context.Context, user *User) error
Delete(ctx context.Context, id string) error
}运行 go generate ./repository/...,自动生成 mocks/user_repository_mock.go,在测试里用:
func TestUserService_GetUser(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
mockRepo := mocks.NewMockUserRepository(ctrl)
// 设置期望
mockRepo.EXPECT().
FindByID(gomock.Any(), "user-123").
Return(&User{ID: "user-123", Name: "老张"}, nil)
svc := NewUserService(mockRepo)
user, err := svc.GetUser(context.Background(), "user-123")
assert.NoError(t, err)
assert.Equal(t, "老张", user.Name)
}实战:自定义代码生成器
当标准工具满足不了需求时,可以写自己的生成器。Go 的 text/template + go/ast 包是写生成器的基础工具。
下面是一个简单的例子:为带有特定注解的结构体自动生成 Validate() 方法。
// 这是生成器程序(cmd/gen-validator/main.go)
package main
import (
"bytes"
"flag"
"fmt"
"go/ast"
"go/parser"
"go/token"
"os"
"text/template"
)
// 生成的代码模板
var validatorTemplate = template.Must(template.New("validator").Parse(`// Code generated by gen-validator; DO NOT EDIT.
package {{.Package}}
import "fmt"
{{range .Types}}
// Validate 验证 {{.Name}} 的字段合法性
func (v *{{.Name}}) Validate() error {
{{range .Fields}}
{{if .Required}}
if v.{{.Name}} == "" {
return fmt.Errorf("{{.JSONName}} is required")
}
{{end}}
{{if .MaxLen}}
if len(v.{{.Name}}) > {{.MaxLen}} {
return fmt.Errorf("{{.JSONName}} too long (max {{.MaxLen}})")
}
{{end}}
{{end}}
return nil
}
{{end}}
`))
// Field 字段信息
type Field struct {
Name string
JSONName string
Required bool
MaxLen int
}
// TypeInfo 结构体信息
type TypeInfo struct {
Name string
Fields []Field
}
// TemplateData 模板数据
type TemplateData struct {
Package string
Types []TypeInfo
}
func main() {
inputFile := flag.String("input", "", "input file")
outputFile := flag.String("output", "", "output file")
flag.Parse()
// 解析 Go 源文件
fset := token.NewFileSet()
f, err := parser.ParseFile(fset, *inputFile, nil, parser.ParseComments)
if err != nil {
fmt.Fprintf(os.Stderr, "parse error: %v\n", err)
os.Exit(1)
}
data := TemplateData{
Package: f.Name.Name,
}
// 遍历 AST,找到带有 +validate 注解的结构体
ast.Inspect(f, func(n ast.Node) bool {
ts, ok := n.(*ast.TypeSpec)
if !ok {
return true
}
st, ok := ts.Type.(*ast.StructType)
if !ok {
return true
}
// 检查是否有生成注解(这里简化,实际可以用 struct tag)
typeInfo := TypeInfo{Name: ts.Name.Name}
for _, field := range st.Fields.List {
if len(field.Names) == 0 {
continue
}
f := Field{Name: field.Names[0].Name}
if field.Tag != nil {
// 解析 struct tag,获取 validate 规则
// 简化示例
f.Required = true
f.JSONName = f.Name
}
typeInfo.Fields = append(typeInfo.Fields, f)
}
data.Types = append(data.Types, typeInfo)
return true
})
// 渲染模板
var buf bytes.Buffer
if err := validatorTemplate.Execute(&buf, data); err != nil {
fmt.Fprintf(os.Stderr, "template error: %v\n", err)
os.Exit(1)
}
// 写入输出文件
if err := os.WriteFile(*outputFile, buf.Bytes(), 0644); err != nil {
fmt.Fprintf(os.Stderr, "write error: %v\n", err)
os.Exit(1)
}
fmt.Printf("generated %s\n", *outputFile)
}Go vs Java:Lombok vs go generate
Java 的 Lombok 用注解处理器在编译期生成代码(getter/setter/builder/equals/hashCode),开发体验极好,写几个注解就能省几十行代码。
Go 的 go generate 需要显式运行,生成的文件你能看到,也需要提交进 git。这比 Lombok 多了一步,但生成的代码更透明——你可以读它、修改它(虽然通常不推荐)、debug 它。
Lombok 生成的代码在 IDE 里有时会有奇怪的行为(debugger 跳转、反编译器显示),Go generate 没有这个问题,生成的就是普通的 Go 代码。
如果让我选,对于"减少模板代码"这个目的,两者各有优劣,但 Go 的方案更透明,长期维护成本更低。
