Go 包構建:專家也未必瞭解的文件選擇細節
在 Go 語言開發中,包(package)是代碼組織的基本單位 [1],也是基本的構建單元。Go 編譯器會將每個包構建成一個目標文件 (.a),然後通過鏈接器將這些目標文件鏈接在一起,形成最終的可執行程序。
儘管 Go 包的構建過程看似簡單,但實際上蘊含着許多值得深入瞭解的細節。例如,當我們執行 go build 命令時,Go 編譯器是如何選擇需要編譯的源文件的?你可能會回答:“不就是通過文件名中的 ARCH 和 OS 標識以及構建約束(build constraints)來選擇的嗎?” 雖然你的答案並沒有錯,但如果我進一步提出以下問題,你是否還能給出確切的答案呢?
假設一個 Go 源文件使用瞭如下的構建約束:
//go:build unix
package foo
// ... ...
在執行 GOOS=android go build 時,這個文件是否會被編譯?如果執行的是 GOOS=aix go build 呢?而 “unix” 究竟包含了哪些操作系統?
再進一步,當一個源文件的文件名中包含 ARCH 和操作系統標識,並且文件內容中也使用了構建約束時,Go 編譯器會如何處理這些信息的優先級?
即使是經驗豐富的 Go 專家,對於上述在包構建過程中涉及的文件選擇細節,可能也只能給出模糊的答案。
在實際開發中,我們常常需要針對不同操作系統和架構編寫特定的代碼,這意味着靈活性與複雜性並存。Go 的構建約束和文件名約定雖然爲我們提供了靈活性,但也帶來了額外的複雜性。理解這些規則不僅有助於優化構建過程,還能有效避免潛在的錯誤和不必要的麻煩。
在這篇文章中,我將與大家探討 Go 包構建過程中源文件選擇的細節,包括文件名中 ARCH 和 os 標識約定和構建約束的作用,以及二者的優先級處理問題。希望通過這些內容,幫助開發者更好地掌握 Go 語言的構建機制,從而提高開發效率。
爲了更好地說明 Go 包構建時的文件選擇邏輯,我們先從 Go 包構建的一些 “表象” 說起。
注:在本文中,我們將使用 Go 1.17[2] 引入的新版 build constraints 寫法://go:build ,之前的 // +build aix darwin dragonfly freebsd js,wasm ... 寫法已經不再被推薦使用。如果你想對舊版 build constraints 寫法有一個全面瞭解以便與新寫法對比,推薦閱讀我的《Go 語言精進之路:從新手到高手的編程思想、方法和技巧》第 2 冊 [3]。
- 表象
在 Go 工程中,通常一個目錄對應一個 Go 包,每個 Go 包下可以存在多個以. go 爲後綴的 Go 源文件,這些源文件只能具有唯一的包名(測試源文件除外),以標準庫 fmt 包爲例,它的目錄下的源文件列表如下 (以 Go 1.23.0[4] 源碼爲例):
$ls $GOROOT/src/fmt
doc.go export_test.go print.go stringer_example_test.go
errors.go fmt_test.go scan.go stringer_test.go
errors_test.go format.go scan_test.go
example_test.go gostringer_example_test.go state_test.go
在這些文件中,哪些最終進入到了 fmt 包的目標文件 (fmt.a) 中呢?貼心的 Go 工具鏈爲我們提供了查看方法:
$go list -f '{{.GoFiles}}' fmt
[doc.go errors.go format.go print.go scan.go]
對於獨立於目標 ARCH 和 OS 的 fmt 包來說,其 Go 源文件的選擇似乎要簡單一些。我們看到,除了包測試文件 (xxx_test.go),其他文件都被編譯到了最終的 fmt 包中。
我們再來看一個與目標 ARCH 和 OS 相關性較高的 net 包。除去子目錄,這個包目錄下的 Go 源文件數量大約有 220 多個,但在 macOS/amd64 下通過 go list 查看最終進入 net 包目標文件的文件,大約只有幾十個:
$go list -f '{{.GoFiles}}' net
[addrselect.go cgo_darwin.go cgo_unix.go cgo_unix_syscall.go conf.go dial.go dnsclient.go dnsclient_unix.go dnsconfig.go dnsconfig_unix.go error_posix.go error_unix.go fd_posix.go fd_unix.go file.go file_unix.go hook.go hook_unix.go hosts.go interface.go interface_bsd.go interface_darwin.go ip.go iprawsock.go iprawsock_posix.go ipsock.go ipsock_posix.go lookup.go lookup_unix.go mac.go mptcpsock_stub.go net.go netcgo_off.go netgo_off.go nss.go parse.go pipe.go port.go port_unix.go rawconn.go rlimit_unix.go sendfile_unix_alt.go sock_bsd.go sock_posix.go sockaddr_posix.go sockopt_bsd.go sockopt_posix.go sockoptip_bsdvar.go sockoptip_posix.go splice_stub.go sys_cloexec.go tcpsock.go tcpsock_posix.go tcpsock_unix.go tcpsockopt_darwin.go tcpsockopt_posix.go udpsock.go udpsock_posix.go unixsock.go unixsock_posix.go unixsock_readmsg_cloexec.go writev_unix.go]
接下來,我們跳出 Go 標準庫,來看一個自定義的示例:
$tree -F buildconstraints/demo1
buildconstraints/demo1
├── foo/
│ ├── f1_android.go
│ ├── f2_linux.go
│ └── f3_darwin.go
└── go.mod
// buildconstraints/demo1/foo/f1_android.go
//go:build linux
package foo
func F1() {
}
// buildconstraints/demo1/foo/f2_linux.go
//go:build android
package foo
func F2() {
}
// buildconstraints/demo1/foo/f3_darwin.go
//go:build android
package foo
func F3() {
}
在 GOOS=android 下構建 buildconstraints/demo1/foo 這個包,哪些文件會被選出來呢,看下面輸出結果:
$GOOS=android go list -f '{{.GoFiles}}' github.com/bigwhite/demo1/foo
[f1_android.go f2_linux.go]
如果說前兩個示例還好理解,那這第三個示例很可能會讓很多開發者覺得有些 “發矇”。別急,上面三個示例都是表象,接下來,我們就來仔細探索一下 Go 構建時的文件選擇機制。
- 文件選擇機制
Go 包構建時選擇源文件的機制還是蠻繁瑣的,我們需要從源碼入手梳理出其主要邏輯,在 Go 1.23 版本中,Go 包構建過程源文件選擇邏輯的代碼位於 $GOROOT/src/go/build/build.go 中,這個源文件有 2k 多行,不過不用擔心,我這裏會替你把主要調用邏輯梳理爲下圖:
函數 Import 調用 Default.Import 去獲取包的詳細信息,信息用 build.Package 結構表示:
// $GOROOT/src/go/build/build.go
// A Package describes the Go package found in a directory.
type Package struct {
Dir string // directory containing package sources
Name string // package name
ImportComment string // path in import comment on package statement
Doc string // documentation synopsis
ImportPath string // import path of package ("" if unknown)
Root string // root of Go tree where this package lives
SrcRoot string // package source root directory ("" if unknown)
PkgRoot string // package install root directory ("" if unknown)
PkgTargetRoot string // architecture dependent install root directory ("" if unknown)
BinDir string // command install directory ("" if unknown)
Goroot bool // package found in Go root
PkgObj string // installed .a file
AllTags []string // tags that can influence file selection in this directory
ConflictDir string // this directory shadows Dir in $GOPATH
BinaryOnly bool // cannot be rebuilt from source (has //go:binary-only-package comment)
// Source files
GoFiles []string // .go source files (excluding CgoFiles, TestGoFiles, XTestGoFiles)
... ...
其中的 GoFiles 就是參與 Go 包編譯的源文件列表。
Default 是默認的上下文信息,包括構建所需的默認 goenv 中幾個環境變量,比如 GOARCH、GOOS 等的值:
// Default is the default Context for builds.
// It uses the GOARCH, GOOS, GOROOT, and GOPATH environment variables
// if set, or else the compiled code's GOARCH, GOOS, and GOROOT.
var Default Context = defaultContext()
Context 的 Import 方法代碼行數很多,對於要了解文件選擇細節的我們來說,其中最重要的調用是 Context 的 matchFile 方法。
matchFile 正是那個用於確定某個 Go 源文件是否應該被選入最終包文件中的方法。它內部的邏輯可以分爲兩個主要步驟。
第一步是調用 Context 的 goodOSArchFile 方法對 Go 源文件的名字進行判定,goodOSArchFile 方法的判定也有兩個子步驟:
- 判斷名字中的 OS 和 ARCH 是否在 Go 支持的 OS 和 ARCH 列表中
當前 Go 支持的 OS 和 ARCH 在 syslist.go 文件中有定義:
// $GOROOT/src/go/build/syslist.go
// knownArch is the list of past, present, and future known GOARCH values.
// Do not remove from this list, as it is used for filename matching.
var knownArch = map[string]bool{
"386": true,
"amd64": true,
"amd64p32": true,
"arm": true,
"armbe": true,
"arm64": true,
"arm64be": true,
"loong64": true,
"mips": true,
"mipsle": true,
"mips64": true,
"mips64le": true,
"mips64p32": true,
"mips64p32le": true,
"ppc": true,
"ppc64": true,
"ppc64le": true,
"riscv": true,
"riscv64": true,
"s390": true,
"s390x": true,
"sparc": true,
"sparc64": true,
"wasm": true,
}
// knownOS is the list of past, present, and future known GOOS values.
// Do not remove from this list, as it is used for filename matching.
// If you add an entry to this list, look at unixOS, below.
var knownOS = map[string]bool{
"aix": true,
"android": true,
"darwin": true,
"dragonfly": true,
"freebsd": true,
"hurd": true,
"illumos": true,
"ios": true,
"js": true,
"linux": true,
"nacl": true,
"netbsd": true,
"openbsd": true,
"plan9": true,
"solaris": true,
"wasip1": true,
"windows": true,
"zos": true,
}
我們也可以通過下面命令查看:
$go tool dist list
aix/ppc64
android/386
android/amd64
android/arm
android/arm64
darwin/amd64
darwin/arm64
dragonfly/amd64
freebsd/386
freebsd/amd64
freebsd/arm
freebsd/arm64
freebsd/riscv64
illumos/amd64
ios/amd64
ios/arm64
js/wasm
linux/386
linux/amd64
linux/arm
linux/arm64
linux/loong64
linux/mips
linux/mips64
linux/mips64le
linux/mipsle
linux/ppc64
linux/ppc64le
linux/riscv64
linux/s390x
netbsd/386
netbsd/amd64
netbsd/arm
netbsd/arm64
openbsd/386
openbsd/amd64
openbsd/arm
openbsd/arm64
openbsd/ppc64
openbsd/riscv64
plan9/386
plan9/amd64
plan9/arm
solaris/amd64
wasip1/wasm
windows/386
windows/amd64
windows/arm
windows/arm64
注:像 sock_bsd.go、sock_posix.go 這樣的 Go 源文件,雖然它們的文件名中包含 posix、bsd 等字樣,但這些文件實際上只是普通的 Go 源文件。其文件名本身並不會影響 Go 包在構建時選擇文件的結果。
- 調用 matchTag 來判定該 Go 源文件名字中的 OS 和 ARCH 是否與當前上下文信息中的 OS 和 ARCH 匹配
Go 支持的源文件名組成格式如下:
// name_$(GOOS).*
// name_$(GOARCH).*
// name_$(GOOS)_$(GOARCH).*
// name_$(GOOS)_test.*
// name_$(GOARCH)_test.*
// name_$(GOOS)_$(GOARCH)_test.*
不過這裏有三個例外,即:
如果上下文中的 GOOS=android,那麼文件名字中 OS 值爲 linux 的 Go 源文件也算是匹配的;
如果上下文中的 GOOS=illumos,那麼文件名字中 OS 值爲 solaris 的 Go 源文件也算是匹配的;
如果上下文中的 GOOS=ios,那麼文件名字中 OS 值爲 darwin 的 Go 源文件也算是匹配的。
還有一個特殊處理,那就是當文件名字中 OS 值爲 unix 時,該源文件可以匹配以下上下文中 GOOS 的值:
// $GOROOT/src/go/build/syslist.go
// unixOS is the set of GOOS values matched by the "unix" build tag.
// This is not used for filename matching.
// This list also appears in cmd/dist/build.go and
// cmd/go/internal/imports/build.go.
var unixOS = map[string]bool{
"aix": true,
"android": true,
"darwin": true,
"dragonfly": true,
"freebsd": true,
"hurd": true,
"illumos": true,
"ios": true,
"linux": true,
"netbsd": true,
"openbsd": true,
"solaris": true,
}
這裏面列出 os 都是所謂的 “類 Unix” 操作系統。
如果 goodOSArchFile 方法返回文件名匹配成功,那麼第二步就是調用 Context 的 shouldBuild 方法對 Go 源文件中的 build constraints 進行判定,這個判定過程也是調用 matchTag 完成的,因此規則與上面對 matchTag 的說明一致。如果判定 match 成功,那麼該源文件將會被 Go 編譯器編譯到最終的 Go 包目標文件中去。
下面我們結合文章第一節 “表象” 中的那個自定義示例來判定一下爲何最終會輸出那個結果。
- 示例分析
在 buildconstraints/demo1/foo 包目錄中,一共有三個 Go 源文件:
$tree -F foo
foo
├── f1_android.go
├── f2_linux.go
└── f3_darwin.go
注意:當前我的系統爲 darwin/amd64,但我們使用了 GOOS=android 的環境變量。我們順着上一節梳理出來的文件選擇判定的主邏輯,對着三個文件逐一過一遍。
- f1_android.go
首先用 goodOSArchFile 判定文件名是否匹配。當 GOOS=android 時,文件名中的 os 爲 android,文件名匹配成功,
然後用 shouldBuild 判定文件中的 build constraints 是否匹配。該文件的約束爲 linux,在上面 matchTag 的三個例外規則裏提到過,當 GOOS=android 時,如果 build constraints 是 linux,是可以匹配的。
因此,f1_android.go 將出現在最終編譯文件列表中。
- f2_linux.go
首先用 goodOSArchFile 判定文件名是否匹配。當 GOOS=android 時,文件名中的 os 爲 linux,linux 顯然在 go 支持的 os 列表中,並且根據 matchTag 的例外規則,當 GOOS=android 時,文件名中的 os 爲 linux 時是可以匹配的。
然後用 shouldBuild 判定文件中的 build constraints 是否匹配。該文件的約束爲 android,與 GOOS 相同,可以匹配。
因此,f2_linux.go 將出現在最終編譯文件列表中。
- f3_darwin.go
首先用 goodOSArchFile 判定文件名是否匹配。當 GOOS=android 時,文件名中的 os 爲 darwin,雖然 darwin 在 go 支持的 os 列表中,但 darwin 與 GOOS=android 並不匹配,因此在 goodOSArchFile 這步中,f3_darwin.go 就被 “淘汰” 掉了!即便 f3_darwin.go 中的 build constraints 爲 android。
因此,f3_darwin.go 不會出現在最終編譯文件列表中。
如果再增加一個源文件 f4_unix.go,其內容爲:
//go:build android
func F4() {
}
這個 f4_unix.go 是否會出現在最終的包編譯文件列表中呢?這個作爲思考題留給大家了,也歡迎你在評論區留言,說說你的思考結果。
- 小結
在 Go 語言的開發過程中,包的構建是核心環節之一,而源文件的選擇則是構建過程中一個複雜且關鍵的細節。本文深入探討了 Go 編譯器在執行 go build 命令時,如何根據文件名中的架構(ARCH)和操作系統(OS)標識,以及構建約束(build constraints),來選擇需要編譯的源文件。
通過具體示例,本文展示了不同文件名和構建約束如何影響最終的編譯結果,並揭示了 Go 編譯器處理這些信息的優先級。理解這些內部機制不僅能幫助開發者優化構建過程,還能有效避免潛在的錯誤。希望本文的分析能夠給大家帶去幫助。
注:限於篇幅,本文僅針對包編譯文件選擇最複雜的部分進行的探索,而像 ReleaseTags(比如: go1.21 等)、cgo、_test.go 後綴等比較明顯的約束並未涉及,同時對於新版 build constraints 的運算符組合也未提及,感興趣的童鞋可以參考 go build constraints[5] 官方文檔查閱。
本文涉及的源碼可以在這裏 [6] 下載。
- 參考資料
-
Go build constraints[7] - https://pkg.go.dev/cmd/go#hdr-Build_constraints
-
proposal: cmd/go: allow && and || operators and parentheses in build tags[8] - https://github.com/golang/go/issues/25348
-
Bug-resistant build constraints — Draft Design[9] - https://go.googlesource.com/proposal/+/master/design/draft-gobuild.md
-
cmd/go: continue conversion to bug-resistant //go:build constraints[10] - https://github.com/golang/go/issues/41184
-
Go 1.17 release notes[11] - https://go.dev/doc/go1.17
-
cmd/go: provide build tags for architecture environment variables[12] - https://github.com/golang/go/issues/45454
參考資料
[1]
包(package)是代碼組織的基本單位: https://tonybai.com/2023/06/18/go-package-design-guide/
[2]
Go 1.17: https://tonybai.com/2021/08/17/some-changes-in-go-1-17
[3]
《Go 語言精進之路:從新手到高手的編程思想、方法和技巧》第 2 冊: https://book.douban.com/subject/35720729/
[4]
Go 1.23.0: https://tonybai.com/2024/08/19/some-changes-in-go-1-23/
[5]
go build constraints: https://pkg.go.dev/cmd/go#hdr-Build_constraints
[6]
這裏: https://github.com/bigwhite/experiments/tree/master/buildconstraints
[7]
Go build constraints: https://pkg.go.dev/cmd/go#hdr-Build_constraints
[8]
proposal: cmd/go: allow && and || operators and parentheses in build tags: https://github.com/golang/go/issues/25348
[9]
Bug-resistant build constraints — Draft Design: https://go.googlesource.com/proposal/+/master/design/draft-gobuild.md
[10]
cmd/go: continue conversion to bug-resistant //go:build constraints: https://github.com/golang/go/issues/41184
[11]
Go 1.17 release notes: https://go.dev/doc/go1.17
[12]
cmd/go: provide build tags for architecture environment variables: https://github.com/golang/go/issues/45454
Gopher Daily(Gopher 每日新聞) - https://gopherdaily.tonybai.com
我的聯繫方式:
-
微博 (暫不可用):https://weibo.com/bigwhite20xx
-
微博 2:https://weibo.com/u/6484441286
-
博客:tonybai.com
-
github: https://github.com/bigwhite
-
Gopher Daily 歸檔 - https://github.com/bigwhite/gopherdaily
-
Gopher Daily Feed 訂閱 - https://gopherdaily.tonybai.com/feed
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/xeukmYI6pL9XrTD3Ik_7UQ