go条件编译

go条件编译

条件编译按字面解释就是满足某个条件的情况下编译某个文件。这里有两个问题:

  1. 如何设定条件?
  2. 如何给定条件?

再回答这两个问题之前,我们先来看一个传统的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方式设置编译标签或者给文件名加后缀。

后缀规则

文件名后缀规则比较简单,有两条规则:

  1. 文件名_GOOS.go
  2. 文件名_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给编译器传递编译参数,或者由运行时隐含提供GOOSGOARCH参数。在src/runtime/internal/sys目录下,有很多zgoarch_*.gozgoos_*.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],大概意思就是说如果文件名有GOOSGOARCH后缀但是有和当前系统不匹配,就返回false。中间还说了,分隔后缀的下划线并不是一开始就有的,是1.4版本加入的。

这个名字就很有意思,“好的操作系统/平台文件”,翻译过来就是问你是不是良民,代入感很强。

函数首先拿到不带扩展名的文件名,然后拿到第一个下划线之后的内容,也就是后缀,再将后缀按下划线分割成切片。接着讲最后的test去掉,因为go测试文件是以_test结尾的。最后根据切片长度分配判断GOOSGOARCH是否匹配。

如果长度为2,那么第一个是GOOS,第二个是GOARCH;如果长度是1,那么即可能是GOOS,也可能是GOARCH。这和我们前面的认知是一致的。

让我们看看konwnOSknownArch是什么东东。

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是将字符串按空格切分成切片。goosListgoarchList不知道你还有没印象,没印象赶紧往前翻!
所以konwnOSknownArch其实就是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逻辑就不会走,最终shouldBuildtrue。而,表示的与逻辑则是在match函数中实现。这里我们还能看出一点://+build中,的优先级要高于的优先级。当既有逗号分隔又有空格分隔时,正确的理解应该是编译标签会按空格分组,对于每个组的各个编译标签,必须全部匹配才算匹配;而有任何一个组匹配上,那么整个编译标签就算匹配。

除了看源代码,还可以看看build_test.go中的测试代码,因为可以看到参数是什么,对于理解源代码还是有帮助的。


最后来个表情包吧,毕竟没有表情包的文章是没有灵魂的。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值