Go 代码生成的三层认知:从忍住不用到自己造轮子

代码生成最难的不是学会用,是知道什么时候不用。5信号决策框架帮你30秒判断该不该generate,Schema驱动模式教你用对,AST工具链给你自己造的最短路径。

封面

我见过太多项目的 Makefile 里有一行 go generate ./...。跑一次 40 秒,生成的代码没人看得懂,但没人敢删。

每次有新同事入职,看到项目里散落着 30 个 //go:generate 指令,第一反应是恐惧——“这些是什么?动了会不会炸?”

这不是代码生成的问题。这是判断力的问题。

Go 社区教你怎么用代码生成的文章已经够多了。教你 go generate 语法,教你写 text/template,教你用 stringer。但很少有人告诉你:什么时候不该用。

我花了两年时间,把自己项目里的代码生成指令从 30 个砍到 10 个。这个过程中我明白了一件事——代码生成是一种能力,但判断"什么时候不用"是更高级的能力。

这篇文章的重点不是生成器怎么写——而是什么时候该写、什么时候不该写。它帮你建立三层认知:

  1. 克制:什么时候该忍住不 generate
  2. 原则:该用时,正确的打开方式是什么
  3. 能力:真需要自己造时,最短路径是什么

一、克制——什么时候不该用代码生成

一个真实的砍 codegen 经历

两年前我的项目里有 30 个 //go:generate 指令。分布在十几个包里,用途五花八门:

  • mockgen 生成 mock 接口
  • 自写模板生成类型安全的集合
  • easyjson 生成 JSON 序列化代码
  • 内部 RPC 框架的 client stub

CI 跑一次 go generate ./... 要 40 秒。偶尔还因为团队成员本地 mockgen 版本不一致,生成代码有 diff,PR 卡在 lint 阶段。

Go 1.18 发布后我做了一次系统性清理。不是冲动式删除,而是逐个审视:这个 generate 指令解决的问题,现在有没有更好的方案?

最终结论:砍掉了 20 个,剩下 10 个。CI 时间从 40 秒降到 12 秒。新人看到的 generate 指令少了,每一个都有明确的存在理由。

砍掉的那 20 个有什么共同点?它们解决的问题,泛型或者手写都能更好地解决。

泛型能替代的,就不该 generate

泛型vs代码生成

最典型的例子是类型安全的容器。泛型之前,想要一个 IntSet、一个 StringSet、一个 UserIDSet,你有三种选择:

  1. interface{} 做通用容器——丢掉类型安全
  2. 为每种类型手写一份——代码膨胀
  3. 用代码生成,写一个模板为每种类型生成一份——工程复杂度上升

Go 1.18 之后?一个泛型 Set[T comparable] 搞定一切:

type Set[T comparable] struct {
    m map[T]struct{}
}

func NewSet[T comparable]() *Set[T] {
    return &Set[T]{m: make(map[T]struct{})}
}

func (s *Set[T]) Add(v T)           { s.m[v] = struct{}{} }
func (s *Set[T]) Contains(v T) bool { _, ok := s.m[v]; return ok }
// Remove, Len... 接口一致,不赘述

10 行代码。支持所有 comparable 类型。编译时类型安全。改一处,所有使用方自动更新。免去了 CI generate、版本锁定、生成代码 drift 等所有额外成本。

代码生成方案呢?我简单算一笔账:

维度 泛型方案 代码生成方案
初始代码量 ~15 行 ~80 行(模板+生成器)
新增类型成本 0 行 +1 行 generate 指令
修改接口 改 1 处 改模板 + 重新 generate 所有文件
CI 额外步骤 go generate + diff 检查
可读性 高(代码就在那) 中(要看模板才能理解生成逻辑)

判定很简单:如果你的需求是"同一接口用于多种类型"——泛型完胜,codegen 是 overengineering。

类似的还有:泛型之前用 codegen 生成的 sort 接口实现、sync.Pool 的类型安全包装、channel 的 fan-in/fan-out 工具函数。这些在 Go 1.18 之后全部可以用泛型替代。

但这不意味着代码生成没用了。泛型解决的是"同一算法用于不同类型"这一类问题。

还有一大类问题,泛型解决不了——比如"根据类型的字段列表生成不同结构的代码"。ORM 要为每张表生成不同的 struct,protobuf 要为每个 message 生成不同的序列化逻辑。这类场景,代码生成仍然不可替代。

5 信号判定法:该不该 generate?

砍了 20 个、留了 10 个之后,我归纳出一个判定框架。当你犹豫"这个场景要不要写生成器"时,数一下这 5 个信号:

信号 问题 Yes 的例子 No 的例子
1 类型集合是否开放的? 用户可以定义新的 protobuf message 内部固定的 3 种缓存类型
2 每种类型是否需要不同结构的代码? ORM 为每张表生成不同字段的 struct Set 对所有类型逻辑相同
3 是否在做反射替代 用 codegen 替代 encoding/json 的反射 只是避免手打,无性能收益
4 是否有明确的 schema .proto 文件、OpenAPI spec、SQL DDL “大概是这个格式”
5 团队有人能维护生成器? 有人写过 AST 工具或了解模板引擎 全员靠拷贝粘贴

规则:满足 ≥ 3 个信号 → 值得用代码生成。满足 ≤ 2 个 → 大概率是 overengineering。

5信号决策流程

这不是算分公式——信号 4(有明确 schema)在实践中权重最高。如果没有 schema,即使其他四个信号都满足,也要慎重。回头看那 20 个被砍掉的,≤ 2 是一个清晰的分界线。

留下的 10 个 generate 指令,我逐个检查过,每个至少满足 3 个信号。砍掉的 20 个呢?大多只满足 1-2 个——而那 1-2 个往往只是"写起来方便"或"以前就有,没人敢动"。

这个框架不是死规则。信号 5(团队维护能力)是一个容易被忽视的隐性成本:你今天写了一个精巧的生成器,半年后你离职了,新来的同事看不懂模板语法,生成器 panic 了没人能修——这时候 codegen 的成本远高于手写。

一个容易犯的错误:用 codegen 解决组织问题

还有一种更隐蔽的误用:团队内部约定"每个服务都要实现 Health Check 接口",有人写了个生成器来"自动给每个服务加 Health Check"。

表面上看满足了信号 2(不同结构的代码)和信号 4(有 schema)。但实际上,这个"schema"是人为创造的——Health Check 的实现逻辑各服务差异很大(有的查数据库、有的查 Redis、有的只返回 OK),生成器要么生成一个空壳让人手动填充,要么生成一个万能版本但大部分服务用不上。

本质问题:这不是代码生成能解决的问题,这是接口设计和团队规范的问题。生成器掩盖了真实问题——应该讨论的是"Health Check 到底应该怎么设计",而不是"怎么让每个服务都有一个"。

判断依据:如果你的生成器产出的代码,90% 以上的使用者都需要手动修改——那它不是在"生成",只是在"占位"。占位不需要生成器,一个 cp template.go my_service.go 就够了。

用错工具

二、原则——该用时,正确的打开方式

散落的模板为什么会失控

通过了 5 信号判定,确认了代码生成确实有价值。但"用"和"用对"是两回事。

混乱vs有序

项目里常见的失控模式是这样的:5 个 text/template 文件,分散在不同包的 internal/gen/ 目录里。每个模板的格式不同——有的用 {{.FieldName}},有的用自定义函数;生成逻辑不同——有的用 go run gen.go,有的用独立的二进制工具;版本管理也不同——有的提交生成代码,有的只提交模板。

新人来了第一天就懵:改了 api.proto 要跑哪个 generate?改了数据库 schema 要跑哪个 generate?它们之间有依赖关系吗?跑的顺序重要吗?

根因在于缺少 single source of truth。每个模板自成一派,没有统一的设计原则把它们串起来。

Schema 驱动:成功项目的共同模式

你可能在 API 设计中已经熟悉 contract-first 思维。这里我要论证的是:同样的原则应用到代码生成时,schema 的角色不只是接口定义——它是生成器的输入规范,也是多方向一致性的保证源。

看看 Go 生态里最成功的代码生成工具,它们都把这个原则用到了极致:

工具 Schema 是什么 生成什么
protobuf/buf .proto 文件 Go 类型 + gRPC 客户端 + HTTP 网关 + 文档
ent Go 代码(ent schema DSL) 类型安全 ORM + 迁移脚本
wire Provider 函数声明 完整的依赖注入初始化代码
oapi-codegen OpenAPI spec(YAML) HTTP 客户端 + 服务端 + 类型定义
sqlc SQL 查询文件 类型安全的数据库访问代码

它们的共同模式——Schema 驱动开发

Schema(唯一真实来源)
    ├─→ 方向 A:生成 Go 类型定义
    ├─→ 方向 B:生成客户端代码
    ├─→ 方向 C:生成文档
    └─→ 方向 D:生成测试桩

代码生成的真正价值在于一处修改,多方向自动更新,保证一致性。“少写重复代码"只是副产品。

Schema驱动模式

protobuf 是这个模式的教科书案例:你改了 .proto 文件中某个 message 的字段,重新 buf generate 一次,Go 类型定义、gRPC 客户端方法签名、REST 网关的路由绑定、Swagger 文档——全部自动更新。不存在"改了接口定义忘了改客户端"的问题。

可以把 schema 理解为合同——上游定义约束,下游必须遵守。改了合同,所有下游自动按新合同重新生成。散落模板则像各自签的私人协议,互相不通气。

这就解释了为什么有些项目的代码生成越来越混乱:缺少 schema 这一层抽象。模板直接从源代码或配置文件中提取信息,每个模板自己定义"输入是什么格式”。时间一长,输入格式漂移了,模板之间的假设开始冲突。

一个快速自检

当你决定用代码生成时,问自己一个问题:我的 schema 是什么?

  • 能指出一个明确的 schema 文件(.proto、.sql、OpenAPI yaml、Go struct 定义)→ 方向正确
  • schema 散落在多个文件里 → 先整理,把 schema 收敛到一个位置
  • 根本说不清什么是 schema → 停。先想清楚"什么是唯一真实来源",再谈生成

K8s 的 code-generator 是大规模 Schema 驱动的极致案例。API Group 中的 Go type 定义是 schema,一次 generate-groups.sh 产出四类代码:typed client、lister、informer、deepcopy。每个 CRD 自动生成数千行样板代码(视 API 复杂度,通常在 2000-5000 行之间)。没有人手写这些代码——手写不仅低效,更重要的是无法保证与 type 定义的一致性

Schema 驱动的核心价值在于保证正确性,省力气只是顺带的。

反面案例:从散落模板到 Schema 驱动的改造

一个典型的改造思路。假设你的项目有这样的代码生成现状:

  • internal/api/gen.go:用 text/template 从 Go struct 标签生成路由注册代码
  • internal/client/gen.go:用另一个模板从同一批 struct 生成 HTTP 客户端
  • internal/docs/gen.go:又一个模板生成 API 文档

三个模板各自解析 struct,各自定义"哪些标签有什么含义"。某天你给某个字段加了个新标签,路由代码更新了,但客户端和文档没有同步更新——因为那两个模板不认识这个新标签。

Schema 驱动的改造思路:

  1. 收敛 schema:把 API 定义抽成一个独立的 .yaml 文件(或 OpenAPI spec)
  2. 统一解析:写一个 schema parser,输出结构化的中间表示
  3. 多方向生成:路由、客户端、文档三个生成器共用同一份中间表示

改造后,加新字段只改 schema 文件一处。三个方向的生成代码自动保持一致——不需要你记住"改了 schema 要跑哪三个 generate",因为它们共享同一个入口。

这个改造的投入不小——要花一两天重构现有的三个生成器。但回报是:之后每次 API 变更的维护成本从"改三个地方+祈祷没漏"变成"改一处+跑一次 generate"。项目越大,这个投资越值。

改造前后对比

三、能力——自己造生成器的最短路径

什么时候需要自己造

前两层讲的是"用别人的工具"。但总有一些场景,现成工具覆盖不到:

  • 你的内部 RPC 框架有自己的 IDL 格式
  • 你想为所有 error code 自动生成文档页面
  • 你想为 enum 类型自动生成类型安全的 JSON marshal 逻辑
  • 你想扫描代码中的特定 annotation 注释来生成路由注册代码

这时候就需要自己造生成器。好消息是,Go 标准库给了你一套完整的元编程工具链。

Go 的元编程三件套

元编程三件套

Go 没有 Rust 那样的宏系统,没有 Java 那样的 annotation processor。但标准库里有一组强大的代码分析工具:

职责 一句话理解
go/parser + go/ast 解析源代码为语法树,以结构化节点表示 Go 代码 “阅读理解”——把代码变成可遍历的数据结构
go/types 在语法树上做类型检查,推断表达式类型 “逻辑推理”——搞清楚每个表达式是什么类型
go/format 把语法树或代码字符串格式化为标准 Go 风格 “排版美化”——等价于 gofmt

三件套的工作流:

源文件(.go)
    ↓ go/parser.ParseFile()
语法树(ast.File)
    ↓ 遍历 ast.Decls,提取类型/字段/常量信息
数据结构(你定义的中间表示)
    ↓ text/template 渲染
代码字符串([]byte)
    ↓ go/format.Source()
生成文件(.go)  ← 风格统一,可直接编译

go/types 是可选的——简单场景只用 go/ast 就够。但如果你需要知道"这个变量是什么类型"、“这个接口有哪些实现者”,就需要 go/types 做类型推断。

最小 demo:自动生成 String() 方法

来看一个完整的例子。假设你有一组 enum 常量:

type Status int

const (
    StatusPending   Status = iota
    StatusActive
    StatusCompleted
    StatusCancelled
)

你想自动生成 String() 方法,让 fmt.Println(StatusActive) 输出 "StatusActive" 而不是 "1"

这就是官方 stringer 工具的简化版。核心流程四步,完整实现不到 80 行:

// 1. 解析:一行代码把源文件变成语法树
fset := token.NewFileSet()
f, _ := parser.ParseFile(fset, "types.go", nil, parser.ParseComments)

// 2. 提取:遍历 AST,找到 const 组中的类型和值
for _, decl := range f.Decls {
    genDecl, ok := decl.(*ast.GenDecl)
    if !ok || genDecl.Tok != token.CONST { continue }
    // genDecl.Specs 里就是每个常量——类型名、名字、值都能拿到
}

// 3. 渲染:用 template 把数据填充到代码骨架
var tmpl = template.Must(template.New("").Parse(`
// Code generated by enum_gen; DO NOT EDIT.
func (v {{.TypeName}}) String() string {
    switch v {
    {{- range .Values}}
    case {{.Name}}: return "{{.Name}}"
    {{- end}}
    default: return fmt.Sprintf("{{.TypeName}}(%d)", int(v))
    }
}`))

// 4. 格式化:format.Source 确保输出通过 gofmt
formatted, _ := format.Source(buf.Bytes())
os.WriteFile("types_string.go", formatted, 0644)

这里的关键认知,比代码本身更重要:

  • AST 节点类型是确定的*ast.GenDecl 对应 const/var/type/import*ast.FuncDecl 对应函数。你不需要正则匹配源代码——AST 已经帮你结构化了
  • format.Source 是你的安全网:模板里缩进写得再乱,输出也是标准格式
  • 80 行搞定一个生成器:解析、提取、渲染,核心概念就这三个

工程实践:四个必须做的事

工程实践四件事

写完生成器只是开始。要让它在团队中可靠运行而不变成定时炸弹,你需要做好四件事:

1. 版本锁定——生成器本身也需要版本管理

//go:generate go run github.com/your/generator@v1.2.3 ./...

@version 锁定生成器版本。版本不一致是 codegen 项目最常见的摩擦源——“本地 mockgen 是 v1.6,CI 上装的是 v1.5"导致生成代码有无意义的 diff。团队越大,这个问题越严重。

注意go run pkg@version 会启动独立的模块上下文,不使用当前项目的 go.sum。如果你的生成器需要 import 当前项目的类型(如 mockgen、wire),应改用 tools.go + go.mod 管理:

//go:build tools
package tools
import _ "github.com/golang/mock/mockgen"

然后通过 go run github.com/golang/mock/mockgen(不带 @version)运行,依赖版本由 go.mod 统一管理。

2. CI 一致性校验——让机器帮你兜底

steps:
  - run: go generate ./...
  - run: git add -N . && git diff --exit-code
    # 有 diff = 有人改了 schema 但忘了重新 generate
    # git add -N 确保新增的未跟踪文件也被检测到

做法是:生成代码提交到版本控制。CI 每次构建重新 generate,然后检查有没有 diff。有 diff 就说明两种情况之一:有人改了 schema 但忘了跑 generate,或者有人手动改了生成文件(不应该手动改)。

这比"CI 里现场 generate 但不提交"更好——因为提交了生成代码,code review 能看到接口变化的全貌,不只是 schema 的变化。

3. 执行时机——隐含依赖是最常踩的坑

这是我踩过的最痛的坑:go generate 指令之间有隐含依赖。

具体场景:A 包的 generate 产出 types.go,B 包的 generate 要 import A 包的类型。如果 CI 里先跑了 B 再跑 A,B 的 generate 会编译失败——因为 A 的新类型还没生成。

go generate ./... 的执行顺序是按文件系统遍历顺序,不是依赖顺序。大多数时候碰巧没事,但项目大了之后迟早会撞到这个坑。

我的解决方案:在 Makefile 里显式声明顺序,不依赖隐式遍历。

.PHONY: generate
generate:
	go generate ./internal/schema/...   # 先:类型定义生成
	go generate ./internal/client/...   # 后:依赖类型的客户端生成
	go generate ./internal/mock/...     # 最后:mock 依赖前两者的接口

看起来"多此一举”?等你在 CI 上调了两天"偶尔失败的 generate"之后,你会感谢这几行 Makefile。

4. 标准头注释——让工具链认识你的生成文件

生成的文件第一行必须包含:

// Code generated by your-tool; DO NOT EDIT.

这不是客气话。Go 工具链依赖这行注释识别生成文件:go vet 会跳过某些检查,golangci-lint 默认不扫描生成文件,IDE 会标灰显示。少了这行,你的生成文件会被 linter 报一堆"问题",白白浪费 code review 时间。

什么时候用 go/types

前面的 enum stringer demo 只用了 go/ast——纯语法层面就够了,因为我们只需要常量名字和所属类型名。

但有些场景,光看语法不够。比如你想生成"所有实现了 io.Reader 接口的类型的列表"——这需要类型信息。go/ast 不知道一个 struct 是否实现了某个接口,只有 go/types 才能做这个判断。

经验法则:

  • 只需要名字、字段列表、常量值 → go/ast 够用
  • 需要"这个类型是否实现了某接口"“这个表达式的类型是什么” → 要引入 go/types
  • 需要跨文件、跨包分析 → 必须用 golang.org/x/tools/go/packages 加载完整类型信息

大多数自定义生成器只需要 go/ast。不要过早引入 go/types——它需要加载完整的类型检查器,构建速度会变慢,复杂度也会上升。先用最简方案解决问题,真的不够再升级。

AST工具链流程

决策速查表

回到开头的问题——面对一个"要不要用代码生成"的决策,这张表帮你 30 秒内做出判断:

你的场景 建议 判断依据
同一接口用于多种类型 泛型 类型参数化就够了,codegen 是 overengineering
为外部 schema 生成 Go 代码 codegen schema 驱动,保证一致性
替代运行时反射提升性能 codegen 编译时确定 > 运行时推断
只是想少写几行重复代码 手写 投入产出不成比例,生成器维护成本被低估
有 schema + 需要多方向产出 codegen 这是代码生成的甜蜜点
团队没人能维护生成器 手写 生成器坏了没人修,比手写更贵
枚举类型需要 String()/Marshal codegen 经典场景,用 stringer 或自写
内部 RPC 框架需要生成 stub codegen 满足信号 1/2/3/4

代码生成是一种强大的工具。protobuf、ent、wire 这些项目证明了它在正确场景下的巨大价值。但工具的价值从来不在于"会用"——在于知道什么时候用、什么时候不用

一个三人团队花三天写了一个生成器,解决了一个手写半小时就能搞定的问题,然后花半年维护这个生成器。这种故事每天都在发生。

下次你看到 Makefile 里那行 go generate ./... 时,先问自己那 5 个问题。30 秒,就能避免半年的维护成本。


附录:实验代码

本文 2 组实验的代码已开源:

GitHub:zhiyulab-evidence/go-code-generation

  • enum-generator/:第三章 AST enum 生成器完整实现(enum_gen.go + 测试类型定义)
  • generics-vs-codegen/:第一章泛型 vs codegen Set 实现对比

每个子目录都有独立的源码文件,可直接 go run 复现。

原文发布于 止语Lab


关于止语Lab

一个工程师的深度技术笔记。

不写入门教程,不追热点。只写那些真正折腾过、想通了的东西。

了解更多 →