开发人员有很强的自动化重复性任务的倾向,这也适用于编写代码。因此,元编程(metaprogramming)的主题是一个开发和研究的热门领域,可以追溯到 1960 年代的 Lisp。元编程中一个特别有用的领域是代码生成(code-generation)。支持宏的语言内置了此功能;其他语言扩展了现有功能以支持这一点(例如 C++模板元编程[1])。
虽然 Go 没有宏或其他形式的元编程,但它是一种实用语言,它包含官方工具链支持的代码生成。
自从 Go 1.4[2] 引入 go generate 命令后,它一直广泛应用于 Go 生态系统。Go 项目本身在很多地方都依赖于 go generate;我将在后面的帖子中快速概述这些用例。
01 基础知识
让我们从一些术语开始。go generate 工作方式主要由三个参与者之间协调进行的:
- Generator:是由 go generate 调用的程序或脚本。在任何给定的项目中,可以调用多个生成器,可以多次调用单个生成器等。
- Magic comments:是 .go 文件中以特殊方式格式化的注释,用于指定调用哪个生成器以及如何调用。任何以文本 //go:generate 行开头的注释都是合法的。
- go generate : 是 Go 工具,它读取 Go 源文件、查找和解析 magic comments 并运行指定的生成器。
需要强调的是,以上是 Go 为代码生成提供的自动化的全部范围。对于其他任何事情,开发人员可以自由使用适合他们的任何工作流程。例如,go generate 应该始终由开发人员手动运行;它永远不会自动调用(比如不会作为 go build 的一部分)。此外,由于我们通常使用 Go 将二进制文件发送给用户或执行环境,因此很容易理解 go generate 仅在开发期间运行(可能就在运行 go build 之前);Go 程序的用户不会知道哪部分代码是生成的以及如何生成的。(实际上,很多时候会在生成的文件开头加上注释,这是生成的,请别手动修改。)
这也适用于生成 module;go generate 不会运行导入包的生成器。因此,当一个项目发布时,生成的代码应该与其余代码一起 checked 和分发。
02 一个简单的例子
学习最好是动手做;为此,我创建了几个简单的 Go 项目,它们将帮助我说明这篇文章中解释的主题。第一个是samplegentool[3],一个基本的 Go 工具,用于模拟生成器。
这是它的完整源代码:
- package main
-
- import (
- "fmt"
- "os"
- )
-
- func main() {
- fmt.Printf("Running %s go on %s\n", os.Args[0], os.Getenv("GOFILE"))
-
- cwd, err := os.Getwd()
- if err != nil {
- panic(err)
- }
- fmt.Printf(" cwd = %s\n", cwd)
- fmt.Printf(" os.Args = %#v\n", os.Args)
-
- for _, ev := range []string{"GOARCH", "GOOS", "GOFILE", "GOLINE", "GOPACKAGE", "DOLLAR"} {
- fmt.Println(" ", ev, "=", os.Getenv(ev))
- }
- }
这个工具不读任何代码,也不写任何代码;它所做的只是报告它是如何被调用的。我们很快就会了解细节。首先我们看另一个项目 - mymod[4]。这是一个示例 Go 模块,包含 3 个文件,分为两个包:
- $ tree
- .
- ├── anotherfile.go
- ├── go.mod
- ├── mymod.go
- └── mypack
- └── mypack.go
这些文件的内容只是填充物;重要的是 go:generate 这个神奇的注释。让我们以mypack/mypack.go 中的那个为例:
- //go:generate samplegentool arg1 "multiword arg"
我们看到它调用带有一些参数的 samplegentool。为了使这个调用起作用,应该在 PATH 的某个地方能找到 samplegentool。这可以通过在 samplegentool项目运行 go build 来完成,以生成二进制,然后设置 PATH。现在,如果我们在 mymod 项目的根目录中运行 go generate ./...,我们将看到如下内容:
- $ go generate ./...
- Running samplegentool go on anotherfile.go
- cwd = /tmp/mymod
- os.Args = []string{"samplegentool", "arg1", "arg2", "arg3", "arg4"}
- GOARCH = amd64
- GOOS = linux
- GOFILE = anotherfile.go
- GOLINE = 1
- GOPACKAGE = mymod
- DOLLAR = $
- Running samplegentool go on mymod.go
- cwd = /tmp/mymod
- os.Args = []string{"samplegentool", "arg1", "arg2", "-flag"}
- GOARCH = amd64
- GOOS = linux
- GOFILE = mymod.go
- GOLINE = 3
- GOPACKAGE = mymod
- DOLLAR = $
- Running samplegentool go on mypack.go
- cwd = /tmp/mymod/mypack
- os.Args = []string{"samplegentool", "arg1", "multiword arg"}
- GOARCH = amd64
- GOOS = linux
- GOFILE = mypack.go
- GOLINE = 3
- GOPACKAGE = mypack
- DOLLAR = $
首先,注意 samplegentool 在它出现在 magic comment 中的每个文件上被调用;这包括子目录,因为我们 使用 ./... 模式运行 go generate。这对于在不同地方有很多生成器的大型项目来说真的很方便。
输出中有很多有趣的东西;让我们一行一行地剖析它:
- cwd 报告调用 samplegentool 的工作目录。这始终是找到带有 magic 注释的文件的目录;这由 go generate 保证,并让生成器知道它在目录树中的位置。
- os.Args 报告传递给生成器的命令行参数。正如上面的输出所示,这包括 flag 以及用引号括起来的多词参数。
- 传递给生成器的环境变量被打印出来;有关这些的完整解释,请参阅 官方文档[5]。这里最有趣的环境变量是 GOFILE ,它指向在其中找到 magic 注释的文件名(此路径是相对于工作目录的),而 GOPACKAGE 告诉生成器,此文件属于哪个包。
03 generators(生成器) 能做什么?
现在我们已经很好地了解了 go generate 是如何调用生成器的,那么它们能做什么呢?事实上他们可以做任何我们想做的事情。毕竟,生成器是计算机程序。如前所述,生成的文件通常也会放入到源代码中,因此生成器可能只需要很少次运行。在许多项目中,开发人员不会像我在上面的示例中那样从根运行 go generate ./...;相反,他们只会根据需要在特定目录中运行特定的生成器。
在下一节中,我将深入介绍一个非常流行的生成器 — stringer工具。同时,以下是 Go 项目本身使用生成器执行的一些任务(这不是完整列表;所有用途都可以通过在 Go 源代码树中 grepping go:generate 找到):
- gob 包使用生成器生成重复的辅助函数用于编码/解码数据。
- math/bits 包使用生成器为其提供的某些位操作生成快速查找表。
- 个别 crypto 包使用生成器为某些操作生成散列函数混洗模式和重复的汇编代码。
- 某些 crypto 包还使用生成器从特定的 HTTP URL 获取证书。显然,这些不是为了经常运行而设计的...
- net/http 使用生成器来生成各种 HTTP 常量。
- Go 运行时的源代码中有几个有趣的生成器,例如为各种任务生成汇编代码,为数学运算生成查找表等。
- Go 编译器实现使用生成器为 IR 节点生成重复的类型和方法。
此外,标准库中至少有两个地方使用生成器来实现类似泛型的功能,其中几乎重复的代码是从不同类型的现有代码中生成的,比如 sort 和 suffixarray 包。
04 深挖生成器 stringer
Go 项目中最常用的生成器之一是stringer[6] — 一种自动为类型创建 String() 方法的工具,以便它们实现 fmt.Stringer 接口。它最常用于为枚举生成文本表示。
我们看标准库math.big 包中的一个例子;具体来说是 RoundingMode[7] 类型,其定义如下:
- type RoundingMode byte
-
- const (
- ToNearestEven RoundingMode = iota
- ToNearestAway
- ToZero
- AwayFromZero
- ToNegativeInf
- ToPositiveInf
- )
至少在 Go 1.18 之前,这是一个惯用的 Go 枚举;为了使这些枚举值的名称可打印,我们需要为这种类型实现一个 String() 方法,这会使用 switch 语句,枚举每个值及其字符串表示。这是一项非常重复的工作,stringer 工具正好派上用场。
我在一个小示例模块中[8]复制了 RoundingMode 类型及其值, 以便我们可以更轻松地试验生成器。让我们在文件中添加适当的 magic 注释:
- //go:generate stringer -type=RoundingMode
我们将快速讨论 stringer 接受的 flag。确保先安装了它:
- $ go install golang.org/x/tools/cmd/stringer@latest
现在我们可以运行 go generate;因为在示例项目中,带有 magic 注释的文件位于一个子包中,所以我将从模块根目录运行它:
- $ go generate ./...
如果一切设置正确,此命令成功完成后不会有任何输出。查看项目内容,会发现生成了一个名为roundingmode_string.go 的文件,内容如下:
- // Code generated by "stringer -type=RoundingMode"; DO NOT EDIT.package floatimport "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[ToNearestEven-0] _ = x[ToNearestAway-1] _ = x[ToZero-2] _ = x[AwayFromZero-3] _ = x[ToNegativeInf-4] _ = x[ToPositiveInf-5]}const _RoundingMode_name = "ToNearestEvenToNearestAwayToZeroAwayFromZeroToNegativeInfToPositiveInf"var _RoundingMode_index = [...]uint8{0, 13, 26, 32, 44, 57, 70}func (i RoundingMode) String() string { if i >= RoundingMode(len(_RoundingMode_index)-1) { return "RoundingMode(" + strconv.FormatInt(int64(i), 10) + ")" } return _RoundingMode_name[_RoundingMode_index[i]:_RoundingMode_index[i+1]]}
工具 stringer 拥有多个代码生成策略,取决于调用它的枚举值的性质。我们的案例是最简单的案例,其中包含“单次连续运行(single consecutive run)”的值。如果这些值形成多个连续运行,stringer 将生成稍微不同的代码,如果这些值根本不形成运行,则生成另一个版本。为了娱乐和讲解,详细研究 stringer 的来源;在这里,让我们关注当前使用的策略。
首先,_RoundingMode_name 常量用于有效地将所有字符串表示形式保存在单个连续字符串中。_RoundingMode_index 用作此字符串的查找表;例如 ToZero 值为 2。_RoundingMode_index[2] 是 26,所以该代码将索引_RoundingMode_name在索引 26 中,这使我们的ToZero部(端是下一个索引,32 在这种情况下) . 因此,代码将索引到索引 26 处的 _RoundingMode_name,这将引导我们找到 ToZero 部分。
String() 中的代码有一个回调函数,以防添加更多枚举值但未重新运行 stringer 工具。在这种情况下,产生的值将是 RoundingMode(N),其中 N 是数值。
这个回调很有用,因为 Go 工具链中没有任何内容可以保证生成的代码与源代码保持同步;如前所述,运行生成器完全是开发人员的责任。
但是 func _() 中的奇怪代码呢?首先,请注意它实际上什么也没有编译:该函数不返回任何内容,没有副作用并且不会被调用。这个函数的目的是作为 编译守卫;如果原始 enum 以与生成的代码根本不兼容的方式发生变化,并且开发人员忘记重新运行 go generate,则这是一种额外的安全性。具体来说,它将防止现有的枚举值被修改。在这种情况下,除非重新运行 go generate,否则 String() 方法可能会成功,但会产生完全错误的值。编译守卫试图通过使代码无法编译越界数组查找来捕获这种情况。
现在让我们谈谈 stringer 的工作原理;首先,阅读它的 -help 是有指导意义的:
- $ stringer -helpUsage of stringer: stringer [flags] -type T [directory] stringer [flags] -type T files... # Must be a single packageFor more information, see: https://pkg.go.dev/golang.org/x/tools/cmd/stringerFlags: -linecomment use line comment text as printed text when present -output string output file name; default srcdir/
_string.go -tags string comma-separated list of build tags to apply -trimprefix prefix trim the prefix from the generated constant names -type string comma-separated list of type names; must be set
我们已经使用 -type 参数告诉 stringer 为哪种类型生成 String() 方法。在现实的代码库中,人们可能希望在其中定义了多种类型的包上调用该工具;在这种情况下,我们可能希望stringer 只为特定类型生成 String() 方法。
我们没有指定 -output flag,所以使用默认值;在这种情况下,生成的文件名为 roundingmode_string.go。
眼尖的读者会注意到,当我们调用 stringer 时,我们没有指定它应该用作输入的文件。快速浏览该工具的源代码会发现它也不使用 GOFILE 环境变量。那么它如何知道要分析哪些文件呢?事实证明,stringer 使用 golang.org/x/tools/go/packages 从其当前工作目录(你还记得,这是包含 magic 注释的文件所在的目录)加载整个包。这意味着无论魔术(magic)注释在哪个文件中,stringer 默认情况下会分析整个包。如果你仔细考虑一下,这是有道理的,谁说常量必须与类型声明在同一个文件中?在 Go 中,文件只是一个方便的代码容器;包是工具关心的真正输入单位。
05 源码生成器和构建 tags
到目前为止,我们假设生成器在 go generate 运行时位于 PATH 中的某个位置,但情况并非总是如此。
考虑一个非常常见的场景,你的模块有自己的生成器,它只对这个特定的模块有用。当有人对模块进行黑客攻击时,他们能够克隆代码,运行 go generate 和 go build 等。但是,如果魔术注释假定生成器始终位于 PATH 中,则除非在运行 go generate 之前构建并正确指向生成器,否则这将无法工作。
Go 中的解决方案很简单,因为 go run 是运行生成器的完美搭配,这些生成器只是模块树中某处的 .go 文件。这里有[9]一个简单的例子。这是一个带有神奇注释的包文件:
- package mypack//go:generate go run gen.go arg1 arg2func PackFunc() string { return "insourcegenerator/mypack.PackFunc"}
请注意此处如何调用生成器:使用 go run gen.go。这意味着 go generate 将期望在与包含魔术注释的文件相同的目录中找到 gen.go。gen.go 的内容是:
- //go:build ignorepackage mainimport ( "fmt" "os")func main() { // ... same main() as the simple example at the top of the post}
它只是一个小的 Go 程序(在包 main 中)。唯一需要注意的是 //go:build 约束,它告诉 Go 工具链在构建项目时忽略这个文件。事实上,gen.go 不是包的一部分;它位于 main 包中,旨在与 go generate 一起运行,而不是编译到包中。
标准库中有许多小程序的示例,这些小程序旨在通过作为生成器的 go run 调用。
典型的模式是代码生成涉及 3 个文件,它们都共存于同一个目录/包中:
- 源文件包含一些包的代码,以及一条神奇的注释,用于调用带有 go run 的生成器。
- generator,它是一个单一的包含 package main 的 .go 文件; 该生成器由源文件中的魔术注释中的 go run 调用以生成生成的文件。生成器 .go 文件通常会有一个 //go:build ignore 约束,以将其从包本身的构建中排除。
- generated file 由 generator 生成; 在某些约定中,它与源文件具有相同的名称,但后跟_gen(如 pack.go --> pack_gen.go);或者它可能是某种前缀(如 gen)。生成文件中的代码与源文件中的代码在同一个包中。在许多情况下,生成的文件包含一些未导出符号的实现细节;源文件可以在其代码中引用这一点,因为这两个文件位于同一个包中。
当然,这些都不是工具所要求的——它只是描述了一个通用的约定;特定的项目可以以不同的方式设置(例如,一个生成器为多个包生成代码)。
06 高级功能
本节讨论 go generate 的一些高级或较少使用的功能。
-command 标志
这个 flag 让我们为 go:generate 行定义别名;如果某些生成器是一个多字命令,我们想为多次调用缩短它,这可能会很有用。
最初的动机可能是将 go tool yacc 缩短为 yacc :
- //go:generate -command yacc go tool yacc
之后 yacc 可以只用这个 4 个字母的名字而不是三个词来调用多次。
有趣的是,go tool yacc在 1.8 中[10]从核心 Go 工具链中删除了,而且我在主 Go 存储库(除了测试go generate本身)或x/tools模块中都没有发现 -command 的任何用法 。
-run 标志
该标志用于 go generate 命令本身,用于选择要运行的生成器。回想一下我们在同一个项目中调用了 3 次 samplegentool 的简单示例 。我们只能选择其中之一来使用 -run 标志运行:
- $ go generate -run multi ./...Running samplegentool go on mypack.go cwd = /tmp/mymod/mypack os.Args = []string{"samplegentool", "arg1", "multiword arg"} GOARCH = amd64 GOOS = linux GOFILE = mypack.go GOLINE = 3 GOPACKAGE = mypack DOLLAR = $
这对于调试应该是显而易见的:在具有多个生成器的大型项目中,我们通常只想运行一个子集以进行调试/快速编辑这样的循环目的。
DOLLAR
在自动神奇地传递给生成器的环境变量( env var )中,有一个脱颖而出 —— DOLLAR。它是做什么用的?为什么要将 env var 专用于一个字符?在 Go 源代码树中没有使用这个 env var。
DOLLAR的起源可以追溯到Rob Pike 的这个提交[11]。正如更改描述所说,这里的动机是将 $ 字符传递到生成器中,而无需复杂的shell escaping[12]。如果 go generate 调用 shell 脚本或将正则表达式作为参数的东西,这很有用。
可以使用我们的 samplegentool 生成器观察 DOLLAR 的效果。如果我们将其中一个神奇的注释更改为:
- //go:generate samplegentool arg1 $somevar
生成器报告其参数为
- os.Args = []string{"samplegentool", "arg1", ""}
这是因为 $somevar 被 shell 解释为引用 somevar 变量,该变量不存在,因此其默认值为空。相反,我们可以如下使用 DOLLAR:
- //go:generate samplegentool arg1 ${DOLLAR}somevar
然后生成器报告:
- os.Args = []string{"samplegentool", "arg1", "$somevar"}
原文链接:https://eli.thegreenplace.net/2021/a-comprehensive-guide-to-go-generate/
参考资料
[1]C++模板元编程: https://en.wikipedia.org/wiki/Template_metaprogramming
[2]Go 1.4: https://go.dev/blog/generate
[3]samplegentool: https://github.com/eliben/code-for-blog/tree/master/2021/go-generate-guide/samplegentool
[4]mymod: https://github.com/eliben/code-for-blog/tree/master/2021/go-generate-guide/mymod
[5]官方文档: https://pkg.go.dev/cmd/go#hdr-Generate_Go_files_by_processing_source
[6]stringer: https://pkg.go.dev/golang.org/x/tools/cmd/stringer
[7]RoundingMode: https://pkg.go.dev/math/big#RoundingMode
[8]小示例模块中: https://github.com/eliben/code-for-blog/tree/master/2021/go-generate-guide/stringerusage
[9]这里有: https://github.com/eliben/code-for-blog/tree/master/2021/go-generate-guide/insourcegenerator
[10]在 1.8 中: https://tip.golang.org/doc/go1.8#tool_yacc
[11]Rob Pike 的这个提交: https://go-review.googlesource.com/c/go/+/8091/
[12]shell escaping: http://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#Quoting