《Go 语言高级编程》9.1 go generate 与 stringer

上一章说「最好的选择是把运行期的反射挪到编译期」,代码生成就是那条路。本节实测 go generate 的工作方式、用 go.mod 的 tool 指令锁定 stringer 版本、生成物的内部结构,并讲清 go generate 的三条纪律与生成物到底该不该提交。

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

三条必须记住的纪律:

  1. //go:generate 与 // 之间不能有空格。写成 // go:generate 不会被识别——这是最常见的「命令没跑」的原因。
  2. go generate 不分析代码。它不知道你的命令对不对、工具装没装,只是「照着注释执行」。命令写错,它不会提前警告。
  3. 默认不递归。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 。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 《Go 语言编程实战》目录
  2. 《Go 语言编程实战》18.3 上线、观测与迭代
  3. 《Go 语言编程实战》18.2 故障演练