go generate 完全指南
爭做團隊核心程序員,關注「幽鬼」
開發人員有很強的自動化重複性任務的傾向,這也適用於編寫代碼。因此,元編程(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/<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 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
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/YBGppDhhBMorqkSzvufHlA