go条件编译
条件编译按字面解释就是满足某个条件的情况下编译某个文件。这里有两个问题:
- 如何设定条件?
- 如何给定条件?
再回答这两个问题之前,我们先来看一个传统的hello world例子。
示例
新建Go项目:
|–helloworld
|------main1.go
|------main2.go
main1.go内容如下:
// +build hello
package main
func main() {
println("hello")
}
main2.go内容如下:
// +build world
package main
func main() {
println("world")
}
别忘了执行go mod init
.
这个项目有两个mian
函数,我们通过条件编译让它正常运行起来。
如果想打印“hello”,我们可以执行go build -tags=hello
;如果想打印“world”,则执行go build -tags=world
。
如果想直接看到结果,可以直接执行go run -tags=hello .
或者go run -tags=world .
,使用go run
时,我们需要编译当前目录,而不是某个具体的go文件。
很好,我们已经成功用条件编译搞定了两个main
函数。这里有以下两点需要注意:
+build
和注释之间的空格不是必须的,但是必须放在文件开头+build
之后必须有空行,否则会被编译器当做普通注释
以上是通过编译标签来实现条件编译,下面我们来看另一种方式。
新建go项目:
|–helloworld2
|------main_windows.go
|------main_linux.go
main_windows.go内容如下:
package main
func main() {
println("hello windows")
}
mian_linux.go内容如下:
package main
func main() {
println("hello linux")
}
在Windows系统上执行go run .
你会看到"hello windows"的输出,在linux系统上则看到的是"hello linux"。
以上两个例子就是go条件编译的两种方式,所谓的条件就是字符串匹配。看完了例子我们再来回答文章开头的两个问题。
第一个问题
第一个问题,如何设定条件。我们可以通过+build
方式设置编译标签或者给文件名加后缀。
后缀规则
文件名后缀规则比较简单,有两条规则:
文件名_GOOS.go
文件名_GOOS_GOARCH.go
GOOS
表示操作系统,比如windows、linux等;GOARCH
表示平台比如amd64、386等。关于支持的操作系统和平台,在src/go/build/syslist.go
文件中有详细定义。
const goosList = "aix android darwin dragonfly freebsd hurd illumos ios js linux nacl netbsd openbsd plan9 solaris windows zos "
const goarchList = "386 amd64 amd64p32 arm armbe arm64 arm64be ppc64 ppc64le mips mipsle mips64 mips64le mips64p32 mips64p32le ppc riscv riscv64 s390 s390x sparc sparc64 wasm "
编译标签
编译标签支持多个,规则主要是"与"和"或"的规则。
- 源文件中,多行编译标签表示与的关系,也及时必须同时有。
// +build hello // +build world
- 源文件中,同一行的多个编译标签,逗号分隔表示与,空格分隔表示或,"或"是任意有一个匹配就行。
// +build hello,world
// +build hello world
- 源文件中,标签前加
!
表示非,就是不能有。-tags
也有这个规则,它表示的是没有这个标签。//+build !hello
go build -tags=!hello
- 对于
-tags
,多个标签既可以用逗号分隔,也可以用空格分隔,但它们都表示与的关系。早期go版本用空格分隔,后来改成了用逗号分隔,但空格依然可以识别。至于原因,我猜测是因为用空格分隔的多个tag必须用双引号包裹,go build -tags="hello world"
,否则空格后的tag会被当做包名编译,因为go build
也是用空格分隔参数的,改用逗号分隔就不会有这个问题,也不用加双引号,go build -tags=hello,world
。
第二个问题
第二个问题,如何给定条件。我们可以通过-tags
给编译器传递编译参数,或者由运行时隐含提供GOOS
和GOARCH
参数。在src/runtime/internal/sys
目录下,有很多zgoarch_*.go
和zgoos_*.go
的文件。在他们的文件开头都有+build
指定编译标签,根据不同的操作系统和平台,选择不同的文件编译,比如windows系统的zgoos_windows.go
长下面这样。
// +build windows
package sys
const GOOS = `windows`
const GoosAix = 0
const GoosAndroid = 0
const GoosDarwin = 0
const GoosDragonfly = 0
const GoosFreebsd = 0
const GoosHurd = 0
const GoosIllumos = 0
const GoosIos = 0
const GoosJs = 0
const GoosLinux = 0
const GoosNacl = 0
const GoosNetbsd = 0
const GoosOpenbsd = 0
const GoosPlan9 = 0
const GoosSolaris = 0
const GoosWindows = 1
const GoosZos = 0
这里需要注意,自定义的编译标签如果不是与系统相关的,尽量不要和源码中的编译标签重复,否则测试的时候会出错。比如在windows环境下指定-tags=linux
会报GOOS
重复定义的错误。
条件编译的实现
纸上得来终觉浅,绝知此事要躬行。前面我们通过实例了解了条件编译的规则,现在让我们从源代码的角度看看为什么是这样。这部分代码在src/gp/build/build.go
中。
这部分代码有亿点多,我们先找一个切入点goodOSArchFile
函数,源码如下:
// goodOSArchFile returns false if the name contains a $GOOS or $GOARCH
// suffix which does not match the current system.
// The recognized name formats are:
//
// name_$(GOOS).*
// name_$(GOARCH).*
// name_$(GOOS)_$(GOARCH).*
// name_$(GOOS)_test.*
// name_$(GOARCH)_test.*
// name_$(GOOS)_$(GOARCH)_test.*
//
// Exceptions:
// if GOOS=android, then files with GOOS=linux are also matched.
// if GOOS=illumos, then files with GOOS=solaris are also matched.
// if GOOS=ios, then files with GOOS=darwin are also matched.
func (ctxt *Context) goodOSArchFile(name string, allTags map[string]bool) bool {
if dot := strings.Index(name, "."); dot != -1 {
name = name[:dot]
}
// Before Go 1.4, a file called "linux.go" would be equivalent to having a
// build tag "linux" in that file. For Go 1.4 and beyond, we require this
// auto-tagging to apply only to files with a non-empty prefix, so
// "foo_linux.go" is tagged but "linux.go" is not. This allows new operating
// systems, such as android, to arrive without breaking existing code with
// innocuous source code in "android.go". The easiest fix: cut everything
// in the name before the initial _.
i := strings.Index(name, "_")
if i < 0 {
return true
}
name = name[i:] // ignore everything before first _
l := strings.Split(name, "_")
if n := len(l); n > 0 && l[n-1] == "test" {
l = l[:n-1]
}
n := len(l)
if n >= 2 && knownOS[l[n-2]] && knownArch[l[n-1]] {
return ctxt.match(l[n-1], allTags) && ctxt.match(l[n-2], allTags)
}
if n >= 1 && (knownOS[l[n-1]] || knownArch[l[n-1]]) {
return ctxt.match(l[n-1], allTags)
}
return true
}
注释我就不翻译了[懒.jpg],大概意思就是说如果文件名有GOOS
或GOARCH
后缀但是有和当前系统不匹配,就返回false
。中间还说了,分隔后缀的下划线并不是一开始就有的,是1.4版本加入的。
这个名字就很有意思,“好的操作系统/平台文件”,翻译过来就是问你是不是良民,代入感很强。
函数首先拿到不带扩展名的文件名,然后拿到第一个下划线之后的内容,也就是后缀,再将后缀按下划线分割成切片。接着讲最后的test
去掉,因为go测试文件是以_test
结尾的。最后根据切片长度分配判断GOOS
和GOARCH
是否匹配。
如果长度为2,那么第一个是GOOS
,第二个是GOARCH
;如果长度是1,那么即可能是GOOS
,也可能是GOARCH
。这和我们前面的认知是一致的。
让我们看看
konwnOS
和knownArch
是什么东东。var knownOS = make(map[string]bool) var knownArch = make(map[string]bool) func init() { for _, v := range strings.Fields(goosList) { knownOS[v] = true } for _, v := range strings.Fields(goarchList) { knownArch[v] = true } }
strings.Fields
是将字符串按空格切分成切片。goosList
和goarchList
不知道你还有没印象,没印象赶紧往前翻!
所以konwnOS
和knownArch
其实就是go支持的操作系统和平台,如果你在文件名后缀中给了一个go不认识的系统或平台,那么该文件是会参与编译的。
回到match
函数之前,让我们先看看Context
,源码如下:
// 编译上下文
type Context struct {
GOARCH string // 目标平台
GOOS string // 目标操作系统
GOROOT string // Go root
GOPATH string // Go path
// Dir是调用者的工作目录, 如果是空字符串则使用进程当前目录。在module模式下,它用来定位main模块。
// 如果Dir不是空, 传递给Import和ImportDir的目录必须是决定路径。
Dir string
CgoEnabled bool // 是否包含cgo文件
UseAllFiles bool // 使用全部文件不管是否有+build和文件名后缀,也就是不使用条件编译
Compiler string // compiler to assume when computing target paths
// build和release标签指定了处理+build行时应该满足的编译约束.
// 客户端创建一个新的上下文可能会创建自定义BuildTags(默认为空), 但自定义
// ReleaseTags(默认为当前版本兼容的go发行版本)通常会报错.
// 默认的构建上下文没有设置BuildTags.
// 另外, 编译约束将GOARCH和GOOS视为必须满足的标签.
// ReleaseTags的最后一个元素被认为是当前发行版本.
BuildTags []string
ReleaseTags []string
// InstallSuffix指定了安装目录的后缀, 默认为空. 想区分开编译输出可以设置InstallSuffix.
// 例如, 使用竞争检测时, go命令会使用 InstallSuffix = "race" 参数, 因此在Linux/386
// 系统上, 包会写入"linux_386_race"目录, 而不是"linux_386".
InstallSuffix string
// Import默认使用操作系统的文件系统调用读取目录和文件. 为了从其他源读取,
// 调用者可以设置下面(此行一下任何一个)的函数. 他们都有默认实现,使用本地文件系统.
// 所以客户端仅需要设置需要改变的那个函数即可
// JoinPath将路径片段组合成一个完整路径.
// 如果JoinPath是nil, Import使用filepath.Join.
JoinPath func(elem ...string) string
// SplitPathList将路径集合切分成独立路径切片.
// 如果SplitPathList是nil, Import使用filepath.SplitList.
SplitPathList func(list string) []string
// IsAbsPath判断路径是否是绝对路径.
// 如果IsAbsPath是nil, Import使用filepath.IsAbs.
IsAbsPath func(path string) bool
// IsDir判断路径名是否是目录.
// 如果IsDir是nil, Import调用os.Stat并使用结果的IsDir方法.
IsDir func(path string) bool
// HasSubdir从词法上判断 dir 是否是 root 的(间接)子目录. 它不会检查 dir 是否存在.
// 如果是子目录, HasSubdir返回 dir 相对于 root 的相对路径.
// 如果HasSubdir是nil, Import使用基于filepath.EvalSymlinks的实现.
HasSubdir func(root, dir string) (rel string, ok bool)
// ReadDir返回一个按文件名排序的fs.FileInfo切片
// 如果ReadDir是nil, Import使用ioutil.ReadDir.
ReadDir func(dir string) ([]fs.FileInfo, error)
// OpenFile以读取的方式打开一个文件(不是目录).
// 如果OpenFile是nil, Import使用os.Open.
OpenFile func(path string) (io.ReadCloser, error)
}
Context
其实是编译上下文,包含一些编译相关的信息。
让我们回来看match
函数。
// match判断文件名是否符合以下条件之一:
// $GOOS
// $GOARCH
// cgo (if cgo is enabled)
// !cgo (if cgo is disabled)
// ctxt.Compiler
// !ctxt.Compiler
// tag (if tag is listed in ctxt.BuildTags or ctxt.ReleaseTags)
// !tag (if tag is not listed in ctxt.BuildTags or ctxt.ReleaseTags)
// a comma-separated list of any of these
//
func (ctxt *Context) match(name string, allTags map[string]bool) bool {
if name == "" {
if allTags != nil {
allTags[name] = true
}
return false
}
// 处理逗号分隔的字符串,前面提到的逗号表示"与"逻辑正是体现在这里
if i := strings.Index(name, ","); i >= 0 {
ok1 := ctxt.match(name[:i], allTags)
ok2 := ctxt.match(name[i+1:], allTags)
return ok1 && ok2
}
if strings.HasPrefix(name, "!!") { // 语法错误
return false
}
if strings.HasPrefix(name, "!") { // "非"逻辑
return len(name) > 1 && !ctxt.match(name[1:], allTags)
}
if allTags != nil {
allTags[name] = true
}
// 标签必须是字母, 数字, 下划线或英文句点.
// 不同于Go标识符, 全为数字是可以的(例如: "386").
for _, c := range name {
if !unicode.IsLetter(c) && !unicode.IsDigit(c) && c != '_' && c != '.' {
return false
}
}
// special tags
if ctxt.CgoEnabled && name == "cgo" {
return true
}
if name == ctxt.GOOS || name == ctxt.GOARCH || name == ctxt.Compiler {
return true
}
if ctxt.GOOS == "android" && name == "linux" {
return true
}
if ctxt.GOOS == "illumos" && name == "solaris" {
return true
}
if ctxt.GOOS == "ios" && name == "darwin" {
return true
}
// other tags
for _, tag := range ctxt.BuildTags {
if tag == name {
return true
}
}
for _, tag := range ctxt.ReleaseTags {
if tag == name {
return true
}
}
return false
}
这段代码其实不算难懂,name
是逗号分隔的标签,match
会递归处理每个标签,将他们写入allTags
并返回匹配结果。前面提到的+build
后的编译标签通过逗号分隔表示"与"逻辑正是在match
函数中实现的。所有格式正确的标签都会写入allTags
,它其实记录的是有哪些标签,为什么要记录呢,是因为要实现!
表示不包含这个规则。前面提到的文件名后缀匹配和编译标签匹配都在match
函数中实现。
首先它会对标签的合法性做判断,然后是特殊标签的匹配,这里就包括文件后缀名判断,最后是编译标签和release标签的匹配。
到此我们明白了文件名后缀形式的条件编译是如何实现的,那么编译标签是如何做的呢?其实可以通过反向搜索match
函数的调用地方就能找到了。在shouldBuild
这个函数中。
// shouldBuild判断是否可以使用该文件,
// 规则是编译指令(// go:build)后面必须跟一个空行(避免和Go包文档注释混淆),
// 以 '// +build' 开头的行也视为编译指令.
//
// The file is accepted only if each such line lists something
// matching the file. 例如:
//
// // +build windows linux
//
// 标记文件仅在Windows和Linux上可用.
//
// 对于每个tag, shouldBuild都会设置allTags[tag] = true.
//
// shouldBuild判断该文件是否应该被编译和是否有 //go:binary-only-package 命令
func (ctxt *Context) shouldBuild(content []byte, allTags map[string]bool) (shouldBuild, binaryOnly bool, err error) {
// Pass 1. 找到后面紧跟空行的编译指令, 包括 //go:build 指令.
content, goBuild, sawBinaryOnly, err := parseFileHeader(content)
if err != nil {
return false, false, err
}
// Pass 2. 处理所有 +build 行.
p := content //编译指令
shouldBuild = true
sawBuild := false
for len(p) > 0 {
line := p
if i := bytes.IndexByte(line, '\n'); i >= 0 {
line, p = line[:i], p[i+1:] //取首行
} else {
p = p[len(p):] //结束循环
}
line = bytes.TrimSpace(line) //当前行
//判断是否以 // 开头
if !bytes.HasPrefix(line, bSlashSlash) {
continue
}
line = bytes.TrimSpace(line[len(bSlashSlash):])
//判断是否以 + 开头
if len(line) > 0 && line[0] == '+' {
// 按空格切分
f := strings.Fields(string(line))
//判断是否以 +build 开头
if f[0] == "+build" {
sawBuild = true
ok := false
//遍历标签
for _, tok := range f[1:] {
if ctxt.match(tok, allTags) {
ok = true
}
}
if !ok {
shouldBuild = false
}
}
}
}
if goBuild != nil && !sawBuild {
return false, false, errGoBuildWithoutBuild
}
return shouldBuild, sawBinaryOnly, nil
}
shouldBuild
函数也不算复杂,首先通过parseFileHeader
函数拿到编译指令,后面的for
循环是核心逻辑。for
循环会遍历每一行进行判断,首先判断是否是//
开头,然后判断是否是+
开头,然后按空格切分,判断是否是+build
开头,最后对每个标签(组)判断是否匹配-tags
指定的标签。
让我们再次回过头来看看前面提到的一些规则。
前面我们说,//
和+build
之间的空格有没有都可以,因为shouldBuild
函数执行了TrimSpace
去掉了空格。
前面还有//+build
指定的标签,
分隔表示与,空格分隔表示或,从shouldBuild
函数也能找到答案。标签按空格分割后,遍历标签的过程中,有任何一个标签匹配上,后面的if
逻辑就不会走,最终shouldBuild
为true
。而,
表示的与逻辑则是在match
函数中实现。这里我们还能看出一点://+build
中,或的优先级要高于与的优先级。当既有逗号分隔又有空格分隔时,正确的理解应该是编译标签会按空格分组,对于每个组的各个编译标签,必须全部匹配才算匹配;而有任何一个组匹配上,那么整个编译标签就算匹配。
除了看源代码,还可以看看build_test.go
中的测试代码,因为可以看到参数是什么,对于理解源代码还是有帮助的。
最后来个表情包吧,毕竟没有表情包的文章是没有灵魂的。