Go Generate 完全指南,你會(huì)嗎?
開發(fā)人員有很強(qiáng)的自動(dòng)化重復(fù)性任務(wù)的傾向,這也適用于編寫代碼。因此,元編程(metaprogramming)的主題是一個(gè)開發(fā)和研究的熱門領(lǐng)域,可以追溯到 1960 年代的 Lisp。元編程中一個(gè)特別有用的領(lǐng)域是代碼生成(code-generation)。支持宏的語言內(nèi)置了此功能;其他語言擴(kuò)展了現(xiàn)有功能以支持這一點(diǎn)(例如 C++模板元編程[1])。
雖然 Go 沒有宏或其他形式的元編程,但它是一種實(shí)用語言,它包含官方工具鏈支持的代碼生成。
自從 Go 1.4[2] 引入 go generate 命令后,它一直廣泛應(yīng)用于 Go 生態(tài)系統(tǒng)。Go 項(xiàng)目本身在很多地方都依賴于 go generate;我將在后面的帖子中快速概述這些用例。
01 基礎(chǔ)知識(shí)
讓我們從一些術(shù)語開始。go generate 工作方式主要由三個(gè)參與者之間協(xié)調(diào)進(jìn)行的:
- Generator:是由 go generate 調(diào)用的程序或腳本。在任何給定的項(xiàng)目中,可以調(diào)用多個(gè)生成器,可以多次調(diào)用單個(gè)生成器等。
- Magic comments:是 .go 文件中以特殊方式格式化的注釋,用于指定調(diào)用哪個(gè)生成器以及如何調(diào)用。任何以文本 //go:generate 行開頭的注釋都是合法的。
- go generate : 是 Go 工具,它讀取 Go 源文件、查找和解析 magic comments 并運(yùn)行指定的生成器。
需要強(qiáng)調(diào)的是,以上是 Go 為代碼生成提供的自動(dòng)化的全部范圍。對(duì)于其他任何事情,開發(fā)人員可以自由使用適合他們的任何工作流程。例如,go generate 應(yīng)該始終由開發(fā)人員手動(dòng)運(yùn)行;它永遠(yuǎn)不會(huì)自動(dòng)調(diào)用(比如不會(huì)作為 go build 的一部分)。此外,由于我們通常使用 Go 將二進(jìn)制文件發(fā)送給用戶或執(zhí)行環(huán)境,因此很容易理解 go generate 僅在開發(fā)期間運(yùn)行(可能就在運(yùn)行 go build 之前);Go 程序的用戶不會(huì)知道哪部分代碼是生成的以及如何生成的。(實(shí)際上,很多時(shí)候會(huì)在生成的文件開頭加上注釋,這是生成的,請(qǐng)別手動(dòng)修改。)
這也適用于生成 module;go generate 不會(huì)運(yùn)行導(dǎo)入包的生成器。因此,當(dāng)一個(gè)項(xiàng)目發(fā)布時(shí),生成的代碼應(yīng)該與其余代碼一起 checked 和分發(fā)。
02 一個(gè)簡(jiǎn)單的例子
學(xué)習(xí)最好是動(dòng)手做;為此,我創(chuàng)建了幾個(gè)簡(jiǎn)單的 Go 項(xiàng)目,它們將幫助我說明這篇文章中解釋的主題。第一個(gè)是samplegentool[3],一個(gè)基本的 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))
- }
- }
這個(gè)工具不讀任何代碼,也不寫任何代碼;它所做的只是報(bào)告它是如何被調(diào)用的。我們很快就會(huì)了解細(xì)節(jié)。首先我們看另一個(gè)項(xiàng)目 - mymod[4]。這是一個(gè)示例 Go 模塊,包含 3 個(gè)文件,分為兩個(gè)包:
- $ tree
- .
- ├── anotherfile.go
- ├── go.mod
- ├── mymod.go
- └── mypack
- └── mypack.go
這些文件的內(nèi)容只是填充物;重要的是 go:generate 這個(gè)神奇的注釋。讓我們以mypack/mypack.go 中的那個(gè)為例:
- //go:generate samplegentool arg1 "multiword arg"
我們看到它調(diào)用帶有一些參數(shù)的 samplegentool。為了使這個(gè)調(diào)用起作用,應(yīng)該在 PATH 的某個(gè)地方能找到 samplegentool。這可以通過在 samplegentool項(xiàng)目運(yùn)行 go build 來完成,以生成二進(jìn)制,然后設(shè)置 PATH?,F(xiàn)在,如果我們?cè)? mymod 項(xiàng)目的根目錄中運(yùn)行 go generate ./...,我們將看到如下內(nèi)容:
- $ 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 在它出現(xiàn)在 magic comment 中的每個(gè)文件上被調(diào)用;這包括子目錄,因?yàn)槲覀?使用 ./... 模式運(yùn)行 go generate。這對(duì)于在不同地方有很多生成器的大型項(xiàng)目來說真的很方便。
輸出中有很多有趣的東西;讓我們一行一行地剖析它:
- cwd 報(bào)告調(diào)用 samplegentool 的工作目錄。這始終是找到帶有 magic 注釋的文件的目錄;這由 go generate 保證,并讓生成器知道它在目錄樹中的位置。
- os.Args 報(bào)告?zhèn)鬟f給生成器的命令行參數(shù)。正如上面的輸出所示,這包括 flag 以及用引號(hào)括起來的多詞參數(shù)。
- 傳遞給生成器的環(huán)境變量被打印出來;有關(guān)這些的完整解釋,請(qǐng)參閱 官方文檔[5]。這里最有趣的環(huán)境變量是 GOFILE ,它指向在其中找到 magic 注釋的文件名(此路徑是相對(duì)于工作目錄的),而 GOPACKAGE 告訴生成器,此文件屬于哪個(gè)包。
03 generators(生成器) 能做什么?
現(xiàn)在我們已經(jīng)很好地了解了 go generate 是如何調(diào)用生成器的,那么它們能做什么呢?事實(shí)上他們可以做任何我們想做的事情。畢竟,生成器是計(jì)算機(jī)程序。如前所述,生成的文件通常也會(huì)放入到源代碼中,因此生成器可能只需要很少次運(yùn)行。在許多項(xiàng)目中,開發(fā)人員不會(huì)像我在上面的示例中那樣從根運(yùn)行 go generate ./...;相反,他們只會(huì)根據(jù)需要在特定目錄中運(yùn)行特定的生成器。
在下一節(jié)中,我將深入介紹一個(gè)非常流行的生成器 — stringer工具。同時(shí),以下是 Go 項(xiàng)目本身使用生成器執(zhí)行的一些任務(wù)(這不是完整列表;所有用途都可以通過在 Go 源代碼樹中 grepping go:generate 找到):
- gob 包使用生成器生成重復(fù)的輔助函數(shù)用于編碼/解碼數(shù)據(jù)。
- math/bits 包使用生成器為其提供的某些位操作生成快速查找表。
- 個(gè)別 crypto 包使用生成器為某些操作生成散列函數(shù)混洗模式和重復(fù)的匯編代碼。
- 某些 crypto 包還使用生成器從特定的 HTTP URL 獲取證書。顯然,這些不是為了經(jīng)常運(yùn)行而設(shè)計(jì)的...
- net/http 使用生成器來生成各種 HTTP 常量。
- Go 運(yùn)行時(shí)的源代碼中有幾個(gè)有趣的生成器,例如為各種任務(wù)生成匯編代碼,為數(shù)學(xué)運(yùn)算生成查找表等。
- Go 編譯器實(shí)現(xiàn)使用生成器為 IR 節(jié)點(diǎn)生成重復(fù)的類型和方法。
此外,標(biāo)準(zhǔn)庫中至少有兩個(gè)地方使用生成器來實(shí)現(xiàn)類似泛型的功能,其中幾乎重復(fù)的代碼是從不同類型的現(xiàn)有代碼中生成的,比如 sort 和 suffixarray 包。
04 深挖生成器 stringer
Go 項(xiàng)目中最常用的生成器之一是stringer[6] — 一種自動(dòng)為類型創(chuàng)建 String() 方法的工具,以便它們實(shí)現(xiàn) fmt.Stringer 接口。它最常用于為枚舉生成文本表示。
我們看標(biāo)準(zhǔn)庫math.big 包中的一個(gè)例子;具體來說是 RoundingMode[7] 類型,其定義如下:
- type RoundingMode byte
- const (
- ToNearestEven RoundingMode = iota
- ToNearestAway
- ToZero
- AwayFromZero
- ToNegativeInf
- ToPositiveInf
- )
至少在 Go 1.18 之前,這是一個(gè)慣用的 Go 枚舉;為了使這些枚舉值的名稱可打印,我們需要為這種類型實(shí)現(xiàn)一個(gè) String() 方法,這會(huì)使用 switch 語句,枚舉每個(gè)值及其字符串表示。這是一項(xiàng)非常重復(fù)的工作,stringer 工具正好派上用場(chǎng)。
我在一個(gè)小示例模塊中[8]復(fù)制了 RoundingMode 類型及其值, 以便我們可以更輕松地試驗(yàn)生成器。讓我們?cè)谖募刑砑舆m當(dāng)?shù)?magic 注釋:
- //go:generate stringer -type=RoundingMode
我們將快速討論 stringer 接受的 flag。確保先安裝了它:
- $ go install golang.org/x/tools/cmd/stringer@latest
現(xiàn)在我們可以運(yùn)行 go generate;因?yàn)樵谑纠?xiàng)目中,帶有 magic 注釋的文件位于一個(gè)子包中,所以我將從模塊根目錄運(yùn)行它:
- $ go generate ./...
如果一切設(shè)置正確,此命令成功完成后不會(huì)有任何輸出。查看項(xiàng)目?jī)?nèi)容,會(huì)發(fā)現(xiàn)生成了一個(gè)名為roundingmode_string.go 的文件,內(nèi)容如下:
- // 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 擁有多個(gè)代碼生成策略,取決于調(diào)用它的枚舉值的性質(zhì)。我們的案例是最簡(jiǎn)單的案例,其中包含“單次連續(xù)運(yùn)行(single consecutive run)”的值。如果這些值形成多個(gè)連續(xù)運(yùn)行,stringer 將生成稍微不同的代碼,如果這些值根本不形成運(yùn)行,則生成另一個(gè)版本。為了娛樂和講解,詳細(xì)研究 stringer 的來源;在這里,讓我們關(guān)注當(dāng)前使用的策略。
首先,_RoundingMode_name 常量用于有效地將所有字符串表示形式保存在單個(gè)連續(xù)字符串中。_RoundingMode_index 用作此字符串的查找表;例如 ToZero 值為 2。_RoundingMode_index[2] 是 26,所以該代碼將索引_RoundingMode_name在索引 26 中,這使我們的ToZero部(端是下一個(gè)索引,32 在這種情況下) . 因此,代碼將索引到索引 26 處的 _RoundingMode_name,這將引導(dǎo)我們找到 ToZero 部分。
String() 中的代碼有一個(gè)回調(diào)函數(shù),以防添加更多枚舉值但未重新運(yùn)行 stringer 工具。在這種情況下,產(chǎn)生的值將是 RoundingMode(N),其中 N 是數(shù)值。
這個(gè)回調(diào)很有用,因?yàn)?Go 工具鏈中沒有任何內(nèi)容可以保證生成的代碼與源代碼保持同步;如前所述,運(yùn)行生成器完全是開發(fā)人員的責(zé)任。
但是 func _() 中的奇怪代碼呢?首先,請(qǐng)注意它實(shí)際上什么也沒有編譯:該函數(shù)不返回任何內(nèi)容,沒有副作用并且不會(huì)被調(diào)用。這個(gè)函數(shù)的目的是作為 編譯守衛(wèi);如果原始 enum 以與生成的代碼根本不兼容的方式發(fā)生變化,并且開發(fā)人員忘記重新運(yùn)行 go generate,則這是一種額外的安全性。具體來說,它將防止現(xiàn)有的枚舉值被修改。在這種情況下,除非重新運(yùn)行 go generate,否則 String() 方法可能會(huì)成功,但會(huì)產(chǎn)生完全錯(cuò)誤的值。編譯守衛(wèi)試圖通過使代碼無法編譯越界數(shù)組查找來捕獲這種情況。
現(xiàn)在讓我們談?wù)?stringer 的工作原理;首先,閱讀它的 -help 是有指導(dǎo)意義的:
- $ 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
我們已經(jīng)使用 -type 參數(shù)告訴 stringer 為哪種類型生成 String() 方法。在現(xiàn)實(shí)的代碼庫中,人們可能希望在其中定義了多種類型的包上調(diào)用該工具;在這種情況下,我們可能希望stringer 只為特定類型生成 String() 方法。
我們沒有指定 -output flag,所以使用默認(rèn)值;在這種情況下,生成的文件名為 roundingmode_string.go。
眼尖的讀者會(huì)注意到,當(dāng)我們調(diào)用 stringer 時(shí),我們沒有指定它應(yīng)該用作輸入的文件??焖贋g覽該工具的源代碼會(huì)發(fā)現(xiàn)它也不使用 GOFILE 環(huán)境變量。那么它如何知道要分析哪些文件呢?事實(shí)證明,stringer 使用 golang.org/x/tools/go/packages 從其當(dāng)前工作目錄(你還記得,這是包含 magic 注釋的文件所在的目錄)加載整個(gè)包。這意味著無論魔術(shù)(magic)注釋在哪個(gè)文件中,stringer 默認(rèn)情況下會(huì)分析整個(gè)包。如果你仔細(xì)考慮一下,這是有道理的,誰說常量必須與類型聲明在同一個(gè)文件中?在 Go 中,文件只是一個(gè)方便的代碼容器;包是工具關(guān)心的真正輸入單位。
05 源碼生成器和構(gòu)建 tags
到目前為止,我們假設(shè)生成器在 go generate 運(yùn)行時(shí)位于 PATH 中的某個(gè)位置,但情況并非總是如此。
考慮一個(gè)非常常見的場(chǎng)景,你的模塊有自己的生成器,它只對(duì)這個(gè)特定的模塊有用。當(dāng)有人對(duì)模塊進(jìn)行黑客攻擊時(shí),他們能夠克隆代碼,運(yùn)行 go generate 和 go build 等。但是,如果魔術(shù)注釋假定生成器始終位于 PATH 中,則除非在運(yùn)行 go generate 之前構(gòu)建并正確指向生成器,否則這將無法工作。
Go 中的解決方案很簡(jiǎn)單,因?yàn)?go run 是運(yùn)行生成器的完美搭配,這些生成器只是模塊樹中某處的 .go 文件。這里有[9]一個(gè)簡(jiǎn)單的例子。這是一個(gè)帶有神奇注釋的包文件:
- package mypack//go:generate go run gen.go arg1 arg2func PackFunc() string { return "insourcegenerator/mypack.PackFunc"}
請(qǐng)注意此處如何調(diào)用生成器:使用 go run gen.go。這意味著 go generate 將期望在與包含魔術(shù)注釋的文件相同的目錄中找到 gen.go。gen.go 的內(nèi)容是:
- //go:build ignorepackage mainimport ( "fmt" "os")func main() { // ... same main() as the simple example at the top of the post}
它只是一個(gè)小的 Go 程序(在包 main 中)。唯一需要注意的是 //go:build 約束,它告訴 Go 工具鏈在構(gòu)建項(xiàng)目時(shí)忽略這個(gè)文件。事實(shí)上,gen.go 不是包的一部分;它位于 main 包中,旨在與 go generate 一起運(yùn)行,而不是編譯到包中。
標(biāo)準(zhǔn)庫中有許多小程序的示例,這些小程序旨在通過作為生成器的 go run 調(diào)用。
典型的模式是代碼生成涉及 3 個(gè)文件,它們都共存于同一個(gè)目錄/包中:
- 源文件包含一些包的代碼,以及一條神奇的注釋,用于調(diào)用帶有 go run 的生成器。
- generator,它是一個(gè)單一的包含 package main 的 .go 文件; 該生成器由源文件中的魔術(shù)注釋中的 go run 調(diào)用以生成生成的文件。生成器 .go 文件通常會(huì)有一個(gè) //go:build ignore 約束,以將其從包本身的構(gòu)建中排除。
- generated file 由 generator 生成; 在某些約定中,它與源文件具有相同的名稱,但后跟_gen(如 pack.go --> pack_gen.go);或者它可能是某種前綴(如 gen)。生成文件中的代碼與源文件中的代碼在同一個(gè)包中。在許多情況下,生成的文件包含一些未導(dǎo)出符號(hào)的實(shí)現(xiàn)細(xì)節(jié);源文件可以在其代碼中引用這一點(diǎn),因?yàn)檫@兩個(gè)文件位于同一個(gè)包中。
當(dāng)然,這些都不是工具所要求的——它只是描述了一個(gè)通用的約定;特定的項(xiàng)目可以以不同的方式設(shè)置(例如,一個(gè)生成器為多個(gè)包生成代碼)。
06 高級(jí)功能
本節(jié)討論 go generate 的一些高級(jí)或較少使用的功能。
-command 標(biāo)志
這個(gè) flag 讓我們?yōu)?go:generate 行定義別名;如果某些生成器是一個(gè)多字命令,我們想為多次調(diào)用縮短它,這可能會(huì)很有用。
最初的動(dòng)機(jī)可能是將 go tool yacc 縮短為 yacc :
- //go:generate -command yacc go tool yacc
之后 yacc 可以只用這個(gè) 4 個(gè)字母的名字而不是三個(gè)詞來調(diào)用多次。
有趣的是,go tool yacc在 1.8 中[10]從核心 Go 工具鏈中刪除了,而且我在主 Go 存儲(chǔ)庫(除了測(cè)試go generate本身)或x/tools模塊中都沒有發(fā)現(xiàn) -command 的任何用法 。
-run 標(biāo)志
該標(biāo)志用于 go generate 命令本身,用于選擇要運(yùn)行的生成器?;叵胍幌挛覀?cè)谕粋€(gè)項(xiàng)目中調(diào)用了 3 次 samplegentool 的簡(jiǎn)單示例 。我們只能選擇其中之一來使用 -run 標(biāo)志運(yùn)行:
- $ 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 = $
這對(duì)于調(diào)試應(yīng)該是顯而易見的:在具有多個(gè)生成器的大型項(xiàng)目中,我們通常只想運(yùn)行一個(gè)子集以進(jìn)行調(diào)試/快速編輯這樣的循環(huán)目的。
DOLLAR
在自動(dòng)神奇地傳遞給生成器的環(huán)境變量( env var )中,有一個(gè)脫穎而出 —— DOLLAR。它是做什么用的?為什么要將 env var 專用于一個(gè)字符?在 Go 源代碼樹中沒有使用這個(gè) env var。
DOLLAR的起源可以追溯到Rob Pike 的這個(gè)提交[11]。正如更改描述所說,這里的動(dòng)機(jī)是將 $ 字符傳遞到生成器中,而無需復(fù)雜的shell escaping[12]。如果 go generate 調(diào)用 shell 腳本或?qū)⒄齽t表達(dá)式作為參數(shù)的東西,這很有用。
可以使用我們的 samplegentool 生成器觀察 DOLLAR 的效果。如果我們將其中一個(gè)神奇的注釋更改為:
- //go:generate samplegentool arg1 $somevar
生成器報(bào)告其參數(shù)為
- os.Args = []string{"samplegentool", "arg1", ""}
這是因?yàn)?$somevar 被 shell 解釋為引用 somevar 變量,該變量不存在,因此其默認(rèn)值為空。相反,我們可以如下使用 DOLLAR:
- //go:generate samplegentool arg1 ${DOLLAR}somevar
然后生成器報(bào)告:
- 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 的這個(gè)提交: https://go-review.googlesource.com/c/go/+/8091/
[12]shell escaping: http://www.gnu.org/savannah-checkouts/gnu/bash/manual/bash.html#Quoting