查看原文
其他

go generate 完全指南

程序员ug 幽鬼 2022-09-08

争做团队核心程序员,关注「幽鬼

开发人员有很强的自动化重复性任务的倾向,这也适用于编写代码。因此,元编程(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 节点生成重复的类型和方法。

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

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/<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() 方法。在现实的代码库中,人们可能希望在其中定义了多种类型的包上调用该工具;在这种情况下,我们可能希望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 generatego 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.gogen.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




往期推荐


欢迎关注「幽鬼」,像她一样做团队的核心。



您可能也对以下帖子感兴趣

文章有问题?点此查看未经处理的缓存