Go 代码生成实战——go generate、模板、代码生成器的工程化实践
Go 代码生成实战——go generate、模板、代码生成器的工程化实践
适读人群:有一定 Go 经验、希望减少重复代码的工程师 | 阅读时长:约16分钟 | 核心价值:用代码生成消灭样板代码,提升工程效率
给第 100 个 repository 手写 CRUD 的那个下午
我在一个中台项目里,每新增一个领域模型,就要手写一套"标准"代码:接口定义、mock 实现、DTO 结构体转换、gRPC handler 的样板。
每次大概需要花一个小时写这些东西,而且都是复制粘贴改名字,写着写着就容易出错——字段名写错了、某个方法忘了实现……
那天下午,我加完第 8 个模型,打开第 9 个准备开始的时候,停住了,心想:这不对,程序员不应该做这种事。
于是我花了两天时间,用 Go 的 text/template 和 go generate 搭了一个代码生成器,从此新加一个模型只需要写一个 YAML 描述文件,运行一条命令,所有样板代码自动生成。
今天把这套方案拆解给大家看。
go generate 的基本工作原理
go generate 不是什么魔法,它只做一件事:扫描 Go 源文件里的 //go:generate 注释,然后执行注释里的命令。
// 在任意 .go 文件里写这样的注释
//go:generate go run ./cmd/generator -input ./schema/user.yaml -output ./internal/user
// 然后在项目根目录运行
// go generate ./...这就是全部了。go generate 只是一个"命令执行器",真正的代码生成逻辑在你自己写的 generator 程序里。
用 text/template 生成 Go 代码
package generator
import (
"bytes"
"fmt"
"go/format"
"os"
"text/template"
"strings"
)
// FieldDef 描述一个字段
type FieldDef struct {
Name string
Type string
Tag string
Comment string
}
// ModelDef 描述一个数据模型
type ModelDef struct {
Package string
ModelName string
TableName string
Fields []FieldDef
HasSoftDelete bool
GenerateAt string
}
// 代码模板
const repositoryTemplate = `// Code generated by go-generator. DO NOT EDIT.
// Generated at: {{ .GenerateAt }}
package {{ .Package }}
import (
"context"
"database/sql"
"fmt"
"time"
)
// {{ .ModelName }} 数据模型
type {{ .ModelName }} struct {
{{- range .Fields }}
{{ .Name }} {{ .Type }} ` + "`" + `{{ .Tag }}` + "`" + ` // {{ .Comment }}
{{- end }}
{{- if .HasSoftDelete }}
DeletedAt *time.Time ` + "`" + `db:"deleted_at" json:"deleted_at,omitempty"` + "`" + `
{{- end }}
}
// {{ .ModelName }}Repository 数据访问层接口
type {{ .ModelName }}Repository interface {
Get(ctx context.Context, id string) (*{{ .ModelName }}, error)
Create(ctx context.Context, model *{{ .ModelName }}) error
Update(ctx context.Context, model *{{ .ModelName }}) error
Delete(ctx context.Context, id string) error
List(ctx context.Context, filter {{ .ModelName }}Filter) ([]*{{ .ModelName }}, int64, error)
}
// {{ .ModelName }}Filter 查询过滤条件
type {{ .ModelName }}Filter struct {
Page int
PageSize int
OrderBy string
}
// {{ .ModelName }}RepositoryImpl 数据访问层实现
type {{ .ModelName }}RepositoryImpl struct {
db *sql.DB
}
func New{{ .ModelName }}Repository(db *sql.DB) {{ .ModelName }}Repository {
return &{{ .ModelName }}RepositoryImpl{db: db}
}
func (r *{{ .ModelName }}RepositoryImpl) Get(ctx context.Context, id string) (*{{ .ModelName }}, error) {
query := "SELECT * FROM {{ .TableName }} WHERE id = ?{{ if .HasSoftDelete }} AND deleted_at IS NULL{{ end }}"
row := r.db.QueryRowContext(ctx, query, id)
model := &{{ .ModelName }}{}
// TODO: scan fields
_ = row
return model, nil
}
func (r *{{ .ModelName }}RepositoryImpl) Create(ctx context.Context, model *{{ .ModelName }}) error {
query := fmt.Sprintf("INSERT INTO {{ .TableName }} (%s) VALUES (%s)",
"{{ range $i, $f := .Fields }}{{ if $i }},{{ end }}{{ $f.Name }}{{ end }}",
"{{ range $i, $f := .Fields }}{{ if $i }},{{ end }}?{{ end }}",
)
_, err := r.db.ExecContext(ctx, query)
return err
}
func (r *{{ .ModelName }}RepositoryImpl) Update(ctx context.Context, model *{{ .ModelName }}) error {
// TODO: implement
return nil
}
func (r *{{ .ModelName }}RepositoryImpl) Delete(ctx context.Context, id string) error {
{{- if .HasSoftDelete }}
_, err := r.db.ExecContext(ctx,
"UPDATE {{ .TableName }} SET deleted_at = ? WHERE id = ?",
time.Now(), id,
)
return err
{{- else }}
_, err := r.db.ExecContext(ctx, "DELETE FROM {{ .TableName }} WHERE id = ?", id)
return err
{{- end }}
}
func (r *{{ .ModelName }}RepositoryImpl) List(ctx context.Context, filter {{ .ModelName }}Filter) ([]*{{ .ModelName }}, int64, error) {
// TODO: implement with pagination
return nil, 0, nil
}
`
// MockTemplate 生成 mock 实现
const mockTemplate = `// Code generated by go-generator. DO NOT EDIT.
package mock
import (
"context"
"sync"
)
// Mock{{ .ModelName }}Repository 是 {{ .ModelName }}Repository 的 mock 实现
type Mock{{ .ModelName }}Repository struct {
mu sync.RWMutex
store map[string]*{{ .Package }}.{{ .ModelName }}
// 可以注入错误,用于测试错误分支
GetErr error
CreateErr error
UpdateErr error
DeleteErr error
}
func NewMock{{ .ModelName }}Repository() *Mock{{ .ModelName }}Repository {
return &Mock{{ .ModelName }}Repository{
store: make(map[string]*{{ .Package }}.{{ .ModelName }}),
}
}
func (m *Mock{{ .ModelName }}Repository) Get(ctx context.Context, id string) (*{{ .Package }}.{{ .ModelName }}, error) {
if m.GetErr != nil {
return nil, m.GetErr
}
m.mu.RLock()
defer m.mu.RUnlock()
if v, ok := m.store[id]; ok {
return v, nil
}
return nil, fmt.Errorf("%s not found: %s", "{{ .ModelName }}", id)
}
func (m *Mock{{ .ModelName }}Repository) Create(ctx context.Context, model *{{ .Package }}.{{ .ModelName }}) error {
if m.CreateErr != nil {
return m.CreateErr
}
m.mu.Lock()
defer m.mu.Unlock()
m.store[model.ID] = model
return nil
}
func (m *Mock{{ .ModelName }}Repository) Update(ctx context.Context, model *{{ .Package }}.{{ .ModelName }}) error {
if m.UpdateErr != nil {
return m.UpdateErr
}
m.mu.Lock()
defer m.mu.Unlock()
m.store[model.ID] = model
return nil
}
func (m *Mock{{ .ModelName }}Repository) Delete(ctx context.Context, id string) error {
if m.DeleteErr != nil {
return m.DeleteErr
}
m.mu.Lock()
defer m.mu.Unlock()
delete(m.store, id)
return nil
}
func (m *Mock{{ .ModelName }}Repository) List(ctx context.Context, filter {{ .Package }}.{{ .ModelName }}Filter) ([]*{{ .Package }}.{{ .ModelName }}, int64, error) {
m.mu.RLock()
defer m.mu.RUnlock()
var result []*{{ .Package }}.{{ .ModelName }}
for _, v := range m.store {
result = append(result, v)
}
return result, int64(len(result)), nil
}
`
// Generator 代码生成器
type Generator struct {
repoTmpl *template.Template
mockTmpl *template.Template
}
func NewGenerator() (*Generator, error) {
funcMap := template.FuncMap{
"lower": strings.ToLower,
"upper": strings.ToUpper,
"snake": toSnakeCase,
}
repoTmpl, err := template.New("repository").Funcs(funcMap).Parse(repositoryTemplate)
if err != nil {
return nil, fmt.Errorf("解析 repository 模板失败: %w", err)
}
mockTmpl, err := template.New("mock").Funcs(funcMap).Parse(mockTemplate)
if err != nil {
return nil, fmt.Errorf("解析 mock 模板失败: %w", err)
}
return &Generator{repoTmpl: repoTmpl, mockTmpl: mockTmpl}, nil
}
func (g *Generator) Generate(model ModelDef, outputDir string) error {
// 生成 repository
if err := g.generateFile(g.repoTmpl, model, outputDir+"/"+strings.ToLower(model.ModelName)+"_repository.go"); err != nil {
return err
}
// 生成 mock
if err := g.generateFile(g.mockTmpl, model, outputDir+"/mock/"+strings.ToLower(model.ModelName)+"_mock.go"); err != nil {
return err
}
return nil
}
func (g *Generator) generateFile(tmpl *template.Template, data interface{}, outputPath string) error {
var buf bytes.Buffer
if err := tmpl.Execute(&buf, data); err != nil {
return fmt.Errorf("执行模板失败: %w", err)
}
// 用 go/format 格式化生成的代码
formatted, err := format.Source(buf.Bytes())
if err != nil {
// 格式化失败时把原始内容写出来,方便调试
os.WriteFile(outputPath+".broken", buf.Bytes(), 0644)
return fmt.Errorf("格式化代码失败: %w\n原始内容已写入 %s.broken", err, outputPath)
}
return os.WriteFile(outputPath, formatted, 0644)
}
func toSnakeCase(s string) string {
var result strings.Builder
for i, r := range s {
if i > 0 && r >= 'A' && r <= 'Z' {
result.WriteByte('_')
}
result.WriteRune(r)
}
return strings.ToLower(result.String())
}完整的 generator main 程序
// cmd/generator/main.go
package main
import (
"flag"
"fmt"
"os"
"time"
"gopkg.in/yaml.v3"
"your-project/generator"
)
type SchemaFile struct {
Package string `yaml:"package"`
Models []generator.ModelDef `yaml:"models"`
}
func main() {
input := flag.String("input", "", "schema YAML 文件路径")
output := flag.String("output", "", "输出目录")
flag.Parse()
if *input == "" || *output == "" {
fmt.Fprintln(os.Stderr, "必须指定 -input 和 -output")
os.Exit(1)
}
// 读取 schema 文件
data, err := os.ReadFile(*input)
if err != nil {
fmt.Fprintf(os.Stderr, "读取 schema 文件失败: %v\n", err)
os.Exit(1)
}
var schema SchemaFile
if err := yaml.Unmarshal(data, &schema); err != nil {
fmt.Fprintf(os.Stderr, "解析 schema 文件失败: %v\n", err)
os.Exit(1)
}
gen, err := generator.NewGenerator()
if err != nil {
fmt.Fprintf(os.Stderr, "初始化生成器失败: %v\n", err)
os.Exit(1)
}
for i := range schema.Models {
schema.Models[i].Package = schema.Package
schema.Models[i].GenerateAt = time.Now().Format(time.RFC3339)
if err := gen.Generate(schema.Models[i], *output); err != nil {
fmt.Fprintf(os.Stderr, "生成 %s 失败: %v\n", schema.Models[i].ModelName, err)
os.Exit(1)
}
fmt.Printf("✓ 生成 %s\n", schema.Models[i].ModelName)
}
}Schema YAML 文件示例:
# schema/user.yaml
package: user
models:
- modelName: User
tableName: users
hasSoftDelete: true
fields:
- name: ID
type: string
tag: 'db:"id" json:"id"'
comment: 用户 ID
- name: Username
type: string
tag: 'db:"username" json:"username"'
comment: 用户名
- name: Email
type: string
tag: 'db:"email" json:"email"'
comment: 邮箱
- name: CreatedAt
type: time.Time
tag: 'db:"created_at" json:"created_at"'
comment: 创建时间在 Go 文件里添加 generate 指令:
// internal/user/generate.go
package user
//go:generate go run ../../cmd/generator -input ../../schema/user.yaml -output .运行 go generate ./... 即可。
三个踩坑实录
坑一:生成的代码格式不对,影响 CI 检查
现象:生成的代码不符合 gofmt 格式,CI 里的 gofmt -l . 检查失败,把整个流水线拦住了。
原因:模板里的缩进是手写的,很难保持完全规范。
解法:生成代码之后必须调用 go/format.Source() 格式化,这样不管模板里的格式多乱,输出结果都是标准 gofmt 格式。
坑二:生成的代码和手写代码冲突
现象:某个生成文件里的方法需要自定义逻辑,我直接在生成文件里改了,下次重新生成把改动覆盖了。
原因:把生成文件当成普通文件编辑了。
解法:
- 生成文件开头一定要加
// Code generated ... DO NOT EDIT.注释,IDE 会提示不要编辑 - 需要自定义的部分用另一个文件(不被生成器覆盖的文件)来写,利用 Go 的同一个 package 可以分多个文件的特性
坑三:模板语法错误排查困难
现象:模板 Execute 报错,但错误信息只说"在第 37 行有错误",根本不知道是什么问题。
原因:Go 的 text/template 错误信息不够友好,特别是嵌套模板里的错误。
解法:
- 把模板拆小,每个模板只生成一部分,逐步排查
- 先用简单数据跑一遍模板,确认基本可以工作,再填入真实数据
- 格式化失败时把未格式化的原始内容写到
.broken文件,直接看哪里的 Go 语法不对
Java 对比
Java 里代码生成最常见的是 Lombok(注解处理器)和 MyBatis Generator。Lombok 非常强大,@Data、@Builder、@Slf4j 一行注解搞定一大堆样板代码。
Go 没有注解处理器这个机制,但有 go generate + 工具链这条路,两者思路不同:Java 是在编译时生成,Go 是在生成阶段(pre-compile)显式生成。Go 的方式更透明——你能看到生成的文件,能 grep、能 debug,不像 Lombok 那样"魔法感"很强但难以追踪。
小结
Go 代码生成的核心要点:
go generate是命令执行器:真正的逻辑在你自己写的程序里- 生成后必须
go/format.Source()格式化:保证输出符合 gofmt 标准 - 生成文件加
DO NOT EDIT注释:防止被意外修改 - 把可变逻辑放在非生成文件里:生成文件只放纯样板代码
- yaml/json 描述 + 模板 = 强大的代码生成器:这是最常见的组合
