logo

Go Generate 完全指南

Published on

开发者总是倾向于自动化重复性任务,这同样适用于编写代码。因此,元编程(metaprogramming) 一直是开发和研究的热门领域,可以追溯到 1960 年代的 Lisp。元编程中特别有用的一个方面是代码生成,即编写能够生成其他程序或自身部分的程序。支持宏的语言内置了这种能力;其他语言则通过扩展现有特性来支持这一点(例如 C++ 模板元编程)。

虽然 Go 没有宏或其他形式的元编程,但作为一门实用的语言,它通过官方工具链支持代码生成。

go generate 命令早在 Go 1.4 就已引入,此后在 Go 生态系统中被广泛使用。Go 项目本身在数十个地方都依赖于 go generate;我将在后面简要概述这些用例。

基础知识

让我们从一些术语开始。go generate 的工作方式是在三个主要角色之间进行协调:

  1. 生成器(Generator): 是由 go generate 调用的程序或脚本。在任何给定的项目中,可以调用多个生成器,单个生成器也可以被多次调用等。

  2. 魔法注释(Magic comments): 是在 .go 文件中以特殊方式格式化的注释,用于指定要调用哪个生成器以及如何调用。任何在行首以 //go:generate 开头的注释都符合条件。

  3. go generate: 是 Go 工具,它读取 Go 源文件,查找并解析魔法注释,然后按照指定运行生成器。

非常重要的是要强调,以上就是 Go 为代码生成提供的全部自动化内容。对于其他任何事情,开发者都可以自由使用任何适合他们的工作流程。例如,go generate 应该始终由开发者手动运行;它永远不会自动调用(比如作为 go build 的一部分)。此外,由于我们通常将二进制文件发送给用户或执行环境,所以大家都理解 go generate 仅在开发期间运行(可能就在运行 go build 之前);Go 程序的用户不应该知道代码的哪些部分是生成的以及如何生成。

这也适用于shipping模块;go-generate不会运行导入包的生成器。因此,当一个项目发布时,无论生成的代码是它的一部分,都应该检入并与其余代码一起分发。

一个简单的例子

通过实践来学习是最好的;为此,我创建了几个简单的 Go 项目来帮助说明这篇文章中解释的主题。第一个是 samplegentool,这是一个基本的 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。这是一个包含 3 个文件的示例 Go 模块,分为两个包:

$ tree
.
├── anotherfile.go
├── go.mod
├── mymod.go
└── mypack
    └── mypack.go

这些文件的内容是填充物;重要的是其中的 go:generate 魔法注释。让我们看一下 mypack/mypack.go 中的示例:

//go:generate samplegentool arg1 "multiword arg"

我们看到它调用了带有一些参数的 samplegentool。要使这个调用工作,samplegentool 应该能在 PATH 中找到。这可以通过在 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 会在每个包含魔法注释的文件中被调用;由于我们使用 ./... 模式运行 go generate,这包括子目录。这对于在各个地方都有多个生成器的大型项目来说非常方便。

输出中有很多有趣的内容;让我们逐行分析:

  • cwd 报告了 samplegentool 被调用时的工作目录。这总是找到带有魔法注释的文件的目录;这是由 go generate 保证的,让生成器知道它在目录树中的位置。
  • os.Args 报告传递给生成器的命令行参数。如上面的输出所示,这包括标志以及由引号括起的多字参数。
  • 然后打印出传递给生成器的环境变量;完整说明请参见官方文档。这里最有趣的环境变量是 GOFILE,它指定了找到魔法注释的文件名(这个路径相对于工作目录),以及 GOPACKAGE,它告诉生成器这个文件属于哪个包。

生成器能做什么?

现在我们对生成器如何被 go generate 调用有了很好的理解,它们能做什么呢?事实上,它们可以做任何我们想做的事。确实如此。毕竟,生成器只是计算机程序。如前所述,生成的文件通常也会检入到源代码中,所以生成器可能只需要很少运行。在许多项目中,开发者不会像我在上面的例子中那样从根目录运行 go generate ./...;相反,他们只会根据需要在特定目录中运行特定的生成器。

在下一节中,我将深入介绍一个非常流行的生成器 - stringer 工具。在此期间,这里列举了 Go 项目本身使用生成器的一些任务(这不是完整列表;所有用途都可以通过在 Go 源码树中搜索 go:generate 找到):

  • gob 包使用生成器来生成用于编码/解码数据的重复性辅助函数。
  • math/bits 包使用生成器为其提供的一些位运算生成快速查找表。
  • 几个加密包使用生成器来生成哈希函数洗牌模式和某些操作的重复性汇编代码。
  • 一些加密包还使用生成器从特定 HTTP URL 获取证书。显然,这些不需要经常运行...
  • net/http 使用生成器来生成各种 HTTP 常量。
  • 在 Go 运行时的源代码中有几个有趣的生成器用途,如为各种任务生成汇编代码、数学运算的查找表等。
  • Go 编译器实现使用生成器为 IR 节点生成重复性类型和方法。

此外,在标准库中至少有两个地方使用生成器来实现类似泛型的功能,其中几乎重复的代码是从具有不同类型的现有代码生成的。一个地方是 sort 包,另一个是 suffixarray 包。

生成器深入探讨:stringer

stringer 是 Go 项目中最常用的生成器之一,它可以自动为类型创建 String() 方法,使其实现 fmt.Stringer 接口。它最常用于为枚举生成文本表示。

让我们看一个来自标准库(math.big 包)的例子;具体来说,是 RoundingMode 类型,它的定义如下:

type RoundingMode byte

const (
    ToNearestEven RoundingMode = iota
    ToNearestAway
    ToZero
    AwayFromZero
    ToNegativeInf
    ToPositiveInf
)

至少在 Go 1.18 之前,这是一个惯用的 Go 枚举;要使这些枚举值的名称可打印,我们需要为这个类型实现一个 String() 方法,该方法将是一种 switch 语句,列举每个值及其字符串表示。这是非常重复性的工作,这就是为什么使用 stringer 工具的原因。

我在一个小的示例模块中复制了RoundingMode类型及其值,这样我们就可以更容易地对生成器进行实验。让我们在文件中添加适当的魔术注释:

//go:generate stringer -type=RoundingMode

我们稍后会讨论 stringer 接受的标志。首先确保安装它:

$ go install golang.org/x/tools/cmd/stringer@latest

现在我们可以运行 go generate;由于在示例项目中包含魔法注释的文件位于子包中,我将从模块根目录运行:

$ go generate ./...

如果一切设置正确,此命令将成功完成,没有任何标准输出。检查项目内容,你会发现生成了一个名为 roundingmode_string.go 的文件,内容如下:

// Code generated by "stringer -type=RoundingMode"; DO NOT EDIT.

package float

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[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 工具有多种代码生成策略,取决于它所调用的枚举值的性质。我们的情况是最简单的一种,具有"单个连续运行"的值。如果值形成多个连续运行,stringer 将生成稍微不同的代码,如果值根本不形成运行,则会生成另一个版本。

首先,_RoundingMode_name 常量用于在单个连续字符串中有效保存所有字符串表示。_RoundingMode_index 作为此字符串的查找表;例如,让我们看看 ToZero,它的值为 2。_RoundingMode_index[2] 是 26,所以代码将在索引 26 处索引到 _RoundingMode_name,这将引导我们到 ToZero 部分(结束是下一个索引,在这种情况下是 32)。

String() 中的代码还有一个后备方案,以防添加了更多枚举值但未重新运行 stringer 工具。在这种情况下,生成的值将是 RoundingMode(N),其中 N 是数值。

在Go的工具链中,没有任何机制保证生成的代码与源代码保持同步;运行代码生成器完全是开发者的责任,因此提供一些额外的防护机制是非常有用的。

关于func _()中的奇怪代码

首先,注意到这个函数实际上什么都没有编译出来:它既没有返回值,也没有副作用,并且不会被调用。这个函数的目的在于作为一个编译时保护机制。当原始的枚举发生了不兼容的更改,而开发者忘记重新运行go generate时,这段代码会作为最后一道防线。

具体来说,它的作用是防止现有的枚举值被修改。在这种情况下,如果没有重新运行go generate,虽然String()方法可能仍然可以调用,但会返回完全错误的值。而编译时保护机制会尝试捕获这种情况,通过触发超出数组边界的编译错误来强制开发者发现问题。

关于stringer工具的工作原理,让我们从阅读它的帮助信息开始:

$ stringer -help
Usage of stringer:
  stringer [flags] -type T [directory]
  stringer [flags] -type T files... # Must be a single package
For more information, see:
  https://pkg.go.dev/golang.org/x/tools/cmd/stringer
Flags:
  -linecomment
      use line comment text as printed text when present
  -output string
      output file name; default srcdir/<type>_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()方法。在一个真实的代码库中,可能会有多个定义的类型,但我们通常只希望为特定类型生成这些方法。

在这个例子中,我们没有指定-output参数,因此默认生成的文件名为roundingmode_string.go

细心的读者可能会注意到,当我们调用stringer时,并没有显式指定输入文件。快速查看stringer的源码发现,它也不会使用GOFILE环境变量。那么,它是如何知道应该分析哪些文件的呢?实际上,stringer使用golang.org/x/tools/go/packages来加载当前工作目录中的整个包。换句话说,无论带有魔法注释的文件在哪个位置,stringer默认会分析整个包。这非常合理,因为Go语言中,文件只是代码的容器,而包才是工具链真正关心的输入单位。例如,常量定义并不一定非要与类型声明位于同一个文件中。

源码生成器和构建标签

到目前为止,我们假设生成器在运行 go generate 时位于 PATH 中,但情况可能并非总是如此。

考虑一个非常常见的场景,你的模块有自己的生成器,它只对这个特定模块有用。当有人在该模块上工作时,你希望他们能够克隆代码,运行 go generatego build 等。但是,如果魔法注释假设生成器总是在 PATH 中,这将不起作用,除非在运行 go generate 之前构建生成器并正确指向它们。

在 Go 中,解决方案很简单,因为 go run 非常适合运行只是模块树中某处的 .go 文件的生成器。这是一个带有魔法注释的包文件示例:

package mypack

//go:generate go run gen.go arg1 arg2

func PackFunc() string {
    return "insourcegenerator/mypack.PackFunc"
}

注意这里如何调用生成器:使用 go run gen.go。这意味着 go generate 将期望在包含魔法注释的文件的同一目录中找到 gen.gogen.go 的内容是:

//go:build ignore

package main

import (
    "fmt"
    "os"
)

func main() {
    // ... same main() as the simple example at the top of the post
}

这只是一个小型 Go 程序(在 package main 中)。唯一值得注意的是 //go:build 约束,它告诉 Go 工具链在构建项目时忽略此文件。确实,gen.go 不是包的一部分;它本身就在 package main 中,并且旨在与 go generate 一起运行,而不是编译到包中。

标准库中有许多使用 go run 调用的小程序作为生成器的例子。

典型的模式是涉及 3 个文件进行代码生成,这些文件都位于同一目录/包中:

  1. 源文件:包含包的一些代码,以及使用 go run 调用生成器的魔法注释。
  2. 生成器:一个带有 package main 的单个 .go 文件;此生成器由源文件中的魔法注释中的 go run 调用以生成生成的文件。生成器 .go 文件通常会有 //go:build ignore 约束,以将其从包本身的构建中排除。
  3. 生成的文件:由生成器生成;在某些约定中,它会与源文件同名,但后面跟着 _gen(如 pack.go --> pack_gen.go);或者它可能是某种前缀(如 gen)。生成的文件中的代码与源文件中的代码在同一个包中。在许多情况下,生成的文件包含一些作为未导出符号的实现细节;源文件可以在其代码中引用这些,因为两个文件在同一个包中。

当然,这些都不是工具强制要求的 - 它只是描述了一个常见的约定;特定项目可以以不同的方式设置(例如,一个生成器为多个包生成代码)。

高级特性

本节讨论 go generate 的一些高级或较少使用的特性。

-command 标志

此标志让我们可以为 go:generate 行定义别名;如果某个生成器是一个多词命令,我们想为多次调用缩短它,这可能会很有用。

原始动机可能是通过以下方式将 go tool yacc 缩短为 yacc

//go:generate -command yacc go tool yacc

之后就可以用这个 4 个字母的名称而不是三个单词多次调用 yacc

有趣的是,go tool yacc 在 1.8 版本中从 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 环境变量

在自动传递给生成器的环境变量中,有一个特别突出 - DOLLAR。它是做什么的?为什么要为一个字符专门设置一个环境变量?在 Go 源代码树中没有使用这个环境变量。

DOLLAR 的起源可以追溯到 Rob Pike 的一个提交。正如变更描述所说,这里的动机是在不需要复杂的 shell 转义的情况下将 $ 字符传递给生成器。如果 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"}