9.1 go generate 与 stringer
8.3 的结尾留下了一句话:把运行期的工作挪到编译期。代码生成就是这句话的工程实现。
它的思想很朴素:与其在运行时用 reflect 去发现「这个类型有哪些字段、有哪些常量」,不如在编译前用程序把这些信息读出来,直接生成成 Go 代码。运行期就没有反射成本了——因为它已经被「编译期的反射」取代。
本节要回答的问题是:
go generate怎么工作、stringer 生成的到底是什么、如何把生成器的版本锁死。结论先行:go generate不是编译器特性,只是「按注释里的命令逐条执行」;stringer 为一个枚举类型生成String()方法,内部用「名字拼接 + 索引表」实现,并带一个「常量变了就编译报错」的自检技巧;生成器版本用go.mod的tool指令锁定(实测golang.org/x/tools v0.51.0),避免「我本地生成的和你不一样」。
9.1.1 go generate 是什么(以及不是什么)
go generate 的设计目标只有一个:给工具一个标准的触发入口。它做的事极其简单——扫描 Go 源文件里以 //go:generate 开头的注释,把注释后面的命令当作 shell 命令执行。
//go:generate go tool stringer -type=Status
三条必须记住的纪律:
//go:generate与//之间不能有空格。写成// go:generate不会被识别——这是最常见的「命令没跑」的原因。go generate不分析代码。它不知道你的命令对不对、工具装没装,只是「照着注释执行」。命令写错,它不会提前警告。- 默认不递归。
go generate只扫当前目录,要写go generate ./...才会扫子目录。
还有一点是设计上的刻意取舍:go generate 与 go build 是分开的两步,go build 不会自动触发它。这是为了避免构建过程有副作用(生成文件、联网、改仓库)。CI 里通常是先 go generate ./...,再 go build。
9.1.2 stringer 实测:从枚举到 String()
准备一个枚举类型,并在它上方写上 go:generate:
package task
//go:generate go tool stringer -type=Status
type Status int
const (
StatusTodo Status = iota
StatusInProgress
StatusDone
)
先安装 stringer,并把它记录进 go.mod 的 tool 指令:
$ GOTOOLCHAIN=go1.27.0 GOPROXY=https://goproxy.cn,direct \
go get -tool golang.org/x/tools/cmd/stringer@latest
go: added golang.org/x/tools v0.51.0
go get -tool 是 Go 1.24 起支持的新用法:把命令行工具当作模块依赖来管理。安装后用 go tool 可以列出它:
$ GOTOOLCHAIN=go1.27.0 go tool | grep stringer
stringer (golang.org/x/tools/cmd/stringer)
然后执行生成:
$ GOTOOLCHAIN=go1.27.0 go generate ./task/
$ ls task/
status_string.go status.go
生成了 status_string.go。注意这里没有输出任何日志——go generate 成功时通常是静默的,靠退出码判断成败(echo $? 为 0)。
9.1.3 生成物长什么样
打开 status_string.go:
// Code generated by "stringer -type=Status"; DO NOT EDIT.
package task
import "strconv"
func _() {
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
var x [1]struct{}
_ = x[StatusTodo-0]
_ = x[StatusInProgress-1]
_ = x[StatusDone-2]
}
const _Status_name = "StatusTodoStatusInProgressStatusDone"
var _Status_index = [...]uint8{0, 10, 26, 36}
func (i Status) String() string {
idx := int(i) - 0
if i < 0 || idx >= len(_Status_index)-1 {
return "Status(" + strconv.FormatInt(int64(i), 10) + ")"
}
return _Status_name[_Status_index[idx]:_Status_index[idx+1]]
}
这段代码有三个值得学习的设计:
(1)自检技巧:var x [1]struct{} + _ = x[StatusTodo-0]。
这是一个精妙的「常量变了就编译失败」的机关。x 是长度 1 的数组,合法的索引只有 0。x[StatusTodo-0] 当 StatusTodo == 0 时合法;如果哪天有人改了常量值让 StatusTodo != 0,这行就变成越界索引,编译直接报错。它在提醒你:「常量变了,重跑 stringer」。用编译器当守卫,零运行时成本。
(2)名字拼接 + 索引表。_Status_name 是把所有名字首尾相接的一整段字符串,_Status_index 记录每个名字的起止下标。String() 只做一次切片,不分配、不循环——这是为什么 stringer 生成的 String() 几乎和直接返回常量一样快。
(3)越界兜底。if i < 0 || idx >= len(_Status_index)-1 处理了「值不在枚举范围内」的情况,返回 Status(42) 这种形式,而不是 panic。
验证生成物合规:
$ gofmt -l task/status_string.go
(无输出,说明已符合 gofmt)
gofmt -l 无输出就是「格式正确」。把这一行写进 CI 能挡住绝大多数「模板改崩了」的情况。
生成物默认用常量名作为字符串(StatusTodo),但很多时候你希望日志里显示的是中文或更友好的文案。stringer 支持用 -linecomment 读取常量后的行注释:
const (
StatusTodo Status = iota // 待处理
StatusInProgress // 进行中
StatusDone // 已完成
)
$ GOTOOLCHAIN=go1.27.0 go tool stringer -type=Status -linecomment
这样 _Status_name 就会变成 "待处理进行中已完成",而 String() 的实现完全不变。用注释作为文案来源,等于把「显示文案」也纳入了单一事实来源——改文案只需改一处注释,重跑生成器。
stringer 的几个常用选项:
| 选项 | 作用 |
|---|---|
-type=Status | 指定要处理的类型(可逗号分隔多个) |
-linecomment | 用行注释作为字符串值 |
-output=status_string.go | 指定输出文件名 |
-trimprefix=Status | 去掉名字里的前缀(Todo 而非 StatusTodo) |
9.1.4 给生成物写测试
生成物是代码,就该有测试。测 String() 最简单的方式是穷举枚举值:
func TestStatusString(t *testing.T) {
cases := map[Status]string{
StatusTodo: "StatusTodo",
StatusInProgress: "StatusInProgress",
StatusDone: "StatusDone",
}
for st, want := range cases {
if got := st.String(); got != want {
t.Errorf("%d: got %q want %q", st, got, want)
}
}
}
还要测越界值——这是生成物里那段 if i < 0 || idx >= ... 兜底逻辑的验证点:
if got := Status(99).String(); got != "Status(99)" {
t.Errorf("out of range: got %q", got)
}
枚举测试的价值在于「新增枚举值时逼你更新测试」:当你给枚举加一个 StatusArchived,这个 map 测试不会自动失败,但结合覆盖率工具就能看出新分支没被覆盖。更严格的做法是用 -type 之外的生成器同时生成一份「全部取值列表」,让测试遍历它。
9.1.5 版本锁定:go.mod 的 tool 指令
生成器最大的协作痛点是版本漂移:你用 stringer 1.0 生成,同事用 2.0 生成,git diff 里全是无关差异。解决办法就是刚才的 go get -tool。生成的 go.mod:
module strdemo
go 1.27.0
tool golang.org/x/tools/cmd/stringer
require (
golang.org/x/mod v0.41.0 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/tools v0.51.0 // indirect
)
tool 指令记录的是工具的模块路径(只有一个工具时写成单行 tool <path>,多个时会变成 tool ( ... ) 块)。关键在于:工具版本由 go.mod 决定,和普通依赖走同一套最小版本选择(MVS)。同事 clone 下来直接 go generate ./...,用的就是同一个 golang.org/x/tools v0.51.0,不需要各自 go install。
9.1.6 生成物的三行防御
把 go generate 接进工程,推荐三道防线:
| 防线 | 命令 | 挡住什么 |
|---|---|---|
| 格式 | gofmt -l | 生成物不符合规范 |
| 一致性 | go generate ./... && git diff --exit-code | 生成物与生成器不一致 |
| 正确性 | go vet ./... | 生成物有低级错误 |
第二道最有价值:它保证「仓库里的生成物永远等于生成器当前该产出的内容」。如果有人手改了生成物却没重跑生成器,CI 会因为 git diff 非空而失败。
9.1.7 生成物要不要提交
这是团队必须达成一致的问题:
| 方案 | 优点 | 缺点 | 适用 |
|---|---|---|---|
| 提交生成物 | CI 不用装生成器;diff 能看到变化 | 仓库变大;review 噪音 | 生成器依赖重 |
| 不提交(CI 生成) | 仓库干净;单一事实来源清晰 | 每次构建都要跑;本地易忘 | 生成器轻、已锁版本 |
| 提交 + CI 校验一致 | 兼顾两者 | 需要一条 diff 检查 | 推荐 |
推荐第三种,理由就是上一节的第二道防线:提交生成物,同时用 git diff --exit-code 保证它和生成器同步。
9.1.8 什么时候不该用 stringer
stringer 只解决一个问题:枚举的 String()。它不解决 Parse()(字符串转枚举),也不生成 MarshalJSON。如果需求超出这个范围,有两条路:
- 多个生成器分工:
stringer管String(),另写一个生成器管Parse()。但要注意——一个类型只能有一个生成器管同名方法,否则会method already declared(见 9.1.9)。 - 自己写生成器:用
text/template+go/format写一个,这是 9.3 的主题。
9.1.9 常见坑速查
| 现象 | 原因 | 修法 |
|---|---|---|
//go:generate 不执行 | // 后有空格 | 写成 //go:generate |
| 子目录生成器没跑 | 漏了 ./... | go generate ./... |
method String already declared | 两个生成器抢同一个方法 | 一个类型一个生成器 |
| 同事生成的和我不同 | 工具版本不一致 | 用 go get -tool 锁定 |
| CI 上找不到工具 | 只在本机 go install | 改用 go tool + tool 指令 |
| 生成物格式乱 | 生成器没调 format.Source | 在生成器内部格式化 |
go generate + stringer 是代码生成的入门形态:一个注释、一个工具、一份生成物。下一节我们把视野扩大到测试与数据访问层——mockgen、sqlc、ent 这些「重型生成器」如何嵌入工程。
阅读导航:上一节:8.3 unsafe/reflect 边界与 Pointer 规则 · 下一节:9.2 mockgen 与 sqlc/ent 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。