Xavier's Blog

做更美好的自己

Go 条件编译

golang

golang支持两种条件编译的实现方式

编译标签(build tags)

在源代码的开头第一行指定需要编译的平台,比如要获取磁盘大小,linux和win 平台的方法是不一样的。 注意 编译条件的注释和package 语句之间一定要隔一行。不然无法识别编译条件,编译条件支持“非”逻辑,比如某个文件在非 windows 环境下编译,可以写作

// +build !wiondows
// +build windows

package diskinfo // 注意 编译条件的注释和package 语句之间一定要隔一行。不然无法识别编译条件
// +build linux

// 编译条件支持“非逻辑” +build linux 可以用 +build !windows 替换
package registerservice

在源代码里添加标注,通常称之为编译标签( build tag),编译标签是在尽量靠近源代码文件顶部的地方用注释的方式添加

go build在构建一个包的时候会读取这个包里的每个源文件并且分析编译便签,这些标签决定了这个源文件是否参与本次编译

编译标签添加的规则(附上原文):

    a build tag is evaluated as the OR of space-separated options
    each option evaluates as the AND of its comma-separated terms
    each term is an alphanumeric word or, preceded by !, its negation
  • 编译标签由空格分隔的编译选项(options)以”或”的逻辑关系组成
  • 每个编译选项由逗号分隔的条件项以逻辑”与”的关系组成
  • 每个条件项的名字用字母+数字表示,在前面加!表示否定的意思
  • 不同tag域之间用空格区分,他们是OR关系
  • 同一tag域之内不同的tag用都好区分,他们是AND关系
  • 每一个tag都由字母和数字构成,!开头表示条件“非”

例子(编译标签要放在源文件顶部)

// +build darwin freebsd netbsd openbsd

这个将会让这个源文件只能在支持kqueue的BSD系统里编译

一个源文件里可以有多个编译标签,多个编译标签之间是逻辑”与”的关系

// +build linux darwin
// +build 386

这个将限制此源文件只能在 linux/386或者darwin/386平台下编译. 除了添加系统相关的tag,还可以自由添加自定义tag达到其它目的。 编译方法: 只需要在go build指令后用-tags指定编译条件即可

go build -tags mytag1 mytag2

注意:刚开始使用编译标签经常会犯下面这个错误

// +build !linux
package mypkg // wrong

这个例子里的编译标签和包的声明之间没有用空行隔开,这样编译标签会被当做包声明的注释而不是编译标签从而被忽略掉

下面这个是正确的标签的书写方式,标签的结尾添加一个空行这样标签就不会当做其他声明的注释

// +build !linux

package mypkg // correct

用go vet命令也可以检测到这个缺少空行的错误,初期可以用这个命令来避免缺少空行的错误

% go vet mypkg
mypkg.go:1: +build comment appears too late in file
exit status 1

作为参考,下面的例子将licence声明,编译标签和包声明放在一起,请大家注意分辨

% head headspin.go

// Copyright 2013 Way out enterprises. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.

// +build someos someotheros thirdos,!amd64

// Package headspin implements calculates numbers so large
// they will make your head spin.
package headspin

编译方法:

只需要在go build指令后用-tags指定编译条件即可

go build -tags linux

文件后缀

这个方法通过改变文件名的后缀来提供条件编译,这种方案比编译标签要简单,go/build可以在不读取源文件的情况下就可以决定哪些文件不需要参与编译。文件命名约定可以在go/build 包里找到详细的说明,简单来说如果你的源文件包含后缀:_GOOS.go,那么这个源文件只会在这个平台下编译,_GOARCH.go也是如此。这两个后缀可以结合在一起使用,但是要注意顺序:_GOOS_GOARCH.go, 不能反过来用:_GOARCH_GOOS.go. 例子如下:

mypkg_freebsd_arm.go // only builds on freebsd/arm systems
mypkg_plan9.go       // only builds on plan9

编译标签和文件后缀的选择

具有$GOOS.go后缀的go文件在编译的时候会根据当前平台来判断是否将该文件导入并编译;同样适用于处理器架构判断$GOARCH.go。

两者可以结合起来使用,形式为:$GOOS$GOARCH.go

文件名还是需要一个前缀,没有前缀的话,会导致编译时刻忽略这个文件。

编译标签和文件后缀的功能上有重叠,例如一个文件名:mypkg_linux.go包含了// +build linux将会出现冗余

通常情况下,如果源文件与平台或者cpu架构完全匹配,那么用文件后缀,例如:

mypkg_linux.go         // only builds on linux systems
mypkg_windows_amd64.go // only builds on windows 64bit platforms

文件名必须提供,如果只由后缀的文件名会被编译器忽略,比如:

_linux.go
_freebsd_386.go

这两个文件会被编译器忽略,因为以下划线开头的文件都会被忽略

相反,如果满足以下任何条件,那么使用编译标签:

  • 这个源文件可以在超过一个平台或者超过一个cpu架构下可以使用
  • 需要去除指定平台
  • 有一些自定义的编译条件

Golang中使用交叉编译

我们知道golang一份代码可以编译出在不同系统和cpu架构运行的二进制文件。go也提供了很多环境变量,我们可以设置环境变量的值,来编译不同目标平台。

GOOS 目标平台, GOARCH目标架构

# 编译目标平台linux 64位
GOOS=linux GOARCH=amd64 go build main.go

# 编译目标平台windows 64位
GOOS=windows GOARCH=amd64 go build main.go

常用的GOOS和GOARCH 可以使用下面命令查看

go tool dist list

golang使用条件编译

golang中有两种使用条件编译的方式,一种是通过文件名的 命名规则,另一种则是注释,一种特别的 标签注释,通过这种注释,golang编译器可以在编译时识别要编译的文件或者代码段。

1、通过命名规则

  • _GOOS
  • _GOARCH
  • _GOOS_GOARCH (示例:source_windows_amd64.go),其中GOOS和GOARCH分别代表任何已知的操作系统和体系结构值(也就是环境变量GOOS和GOARCH的值),符合命名规则的文件会按照隐式约束构建。

注意下命名的顺序。_GOOS_GOARCH是可以的,但是_GOARCH_GOOS不行,也就是说GOOS必须在GOARCH之前。

比如我们自定义config_linux_amd64.go 那么就会在linux平台,64位架构的cpu下编译。

2、标签注释 条件编译,标签注释格式以 // +build 开头,比如官网例子:

// +build linux,cgo darwin,cgo

注意编译标签注释 如果不是写在源码文件的第一行的话,需要上下空一行,与正常的注释和代码隔开,不然的话,编译器会忽略,无法识别。

编译标签注释之间也会有逻辑运算,对应关系如下

空格 OR 逗号 AND 感叹号 NOT 换行 AND

按照官网的例子来说明下

// +build linux,386 darwin,!cgo

对应的逻辑运算:

(linux AND 386) OR (darwin AND (NOT cgo)) // +build linux darwin // +build 386

对应的逻辑运算:

(linux OR darwin) AND 386

对应的条件可以有如下值:

操作系统, 值可以通过 runtime.GOOS 获取,比如 linux CPU架构, 值可以通过 runtime.GOARCH 获取 , 比如 amd64 编译器,如 gc, gccgo 是否开启Cgo, cgo 语言版本, Go版本如 go1.1,…,go1.12 自定义标签, 任意标签,可以是发布版本号,开发版本等等, 比如生产环境prod 实际应用

在实际开发中,一般正常的商业项目都会区分开发环境、测试环境、灰度验证环境、正式环境。那么这么多环境,数据源,redis,日志级别等的配置一般也不一样。如何保证在不同的环境使用不同的配置呢。

1、 启动时 指定配置文件

编译后的可执行文件在目标环境执行时,可以通过指定参数的方式来确定执行环境,读取的配置文件。

假设编译后的执行文件名为 server, $exec_path为可执行文件所在路径,比如/usr/local

开发调试环境

$exec_path/server debug

线上正式环境

$exec_path/server prod

如果使用这种方式就需要在代码里判断传递的参数,然后使用对应的配置。编写文件server.go如下

package main

import (
    "fmt"
    "os"
)

func main()  {
    serverMode := os.Args[1]
    switch serverMode {
    case "debug":
        // 加载调试模式配置
        fmt.Printf("传递模式为%s,加载调试模式配置\r\n", serverMode)
    case "prod":
        // 加载正式环境配置
        fmt.Printf("传递模式为%s,加载正式环境配置\r\n", serverMode)
    default:
        panic("启动模式错误")
    }
}

编译启动

编译

go build server.go

执行

./server debug #输出如下 传递模式为debug,加载调试模式配置

2、 通过 环境变量

设置环境变量(linux环境)

export SERVER_MODE=‘debug’

在代码里读取变量,然后可以按照读取的值加载不同的配置。代码处理与1中传递启动参数类似。

os.Getenv(“SERVER_MODE”)

3、 编译时 使用ldflags

编写config.go内容如下

package main

import "fmt"

var mode string

func main()  {
    fmt.Println("mode value is:", mode)
}

编译运行

编译

go build -ldflags ‘-X main.mode=prod’ config.go

运行

./config

输出如下

mode value is: prod

既然编译时可以确定mode的值,那么想要根据mode加载不同的配置,那么就轻而易举的解决了。

4、 使用条件编译 分别编写config_prod.go 和 config_dev.go 分别代表生产环境和开发环境的配置。 项目布局如下

. ├── config │ ├── config_dev.go │ └── config_prod.go └── main.go

config_prod.go

//+build prod

package config

var String = "this is prod mode"
var String2 = "prod test"

func Config() string {
    return String2
}

config_dev.go

//+build dev

package config

var String1 = "this is debug mode"
var String2 = "debug test"

func Config() string {
    return String2
}

main.go

package main

import (
    "config"
    "fmt"
)

func main()  {
    fmt.Println("this build is: ", config.String1, config.String2, config.Config())
}

编译运行

编译(-o 指定编译后生成的可执行文件名)

go build -tags prod -o main

运行

./main

输出结果

this build is: this is prod mode prod test prod test

可见使用这种方式也达到了不同环境使用不同配置的目的。实际项目开发中,根据项目规划需要来选择。

编译标签 (build tag)

在源码文件顶部添加注释,来决定文件是否参与编译

// +build

golang

说明:

以空格分开表示AND
以逗号分开表示OR
!表示NOT

标签可以指定为以下内容:

操作系统,环境变量中GOOS的值,如:linux、darwin、windows等等。
操作系统的架构,环境变量中GOARCH的值,如:arch64、x86、i386等等。
使用的编译器,gc或者gccgo。
是否开启CGO,cgo。
golang版本号: 比如Go Version 1.1为go1.1,Go Version 1.12版本为go1.12,以此类推。
其它自定义标签,通过go build -tags指定的值。

例如,编译条件为(linux AND 386) OR (darwin AND (NOT cgo))

// +build linux,386 darwin,!cgo

golang

另外一个文件可以有多个编译约束,比如条件为(linux OR darwin) AND amd64

// +build linux darwin // +build amd64

golang

也可以将一个文件从编译中排除,使用ignore标签。

// +build ignore

golang

注意:// +build的下一行必须是空行。

// +build linux

// main package comment package main

golang

下面的写法不会识别为build tag,而会解析为包注释:

// +build linux package main

golang 文件后缀

除了编译标签,编译器也会根据文件后缀来自动选择编译文件,格式如下:

$filename$GOOS.go $filename$GOARCH.go $filename$GOOS$GOARCH.go

bash

$filename: 源文件名称。
$GOOS: 表示操作系统,从环境变量中获取。
$GOARCH: 表示系统架构,从环境变量中获取。

如在项目中有tcp.go和tcp_linux_x86.go 两个文件,执行:

GOOS=linux GOARCH=x86 go build

bash

将选择 tcp_linux_x86.go进行编译,而执行:

GOOS=linux GOARCH=x86 go build

bash

选择tcp.go进行编译。 利用ldflags在编译过程中为变量赋值

本节为附加说明,不属于条件编译的范畴。有时我们需要在编译过程中为变量赋值,此时可以利用ldflags参数完成。ldflags是go build的一个参数,使用方式如下:

go build -ldflags “-w -s -X main.Version=${VERSION} -X github.com/demo/version.BuildNo=${BUILD_NO}”

bash

参数说明:

-w 删除DWARF信息:编译出来的程序无法用gdb进行调试。

-s 删除符号表:panic的stack trace没有文件名/行号信息,等价于C/C++程序被strip。

-X 替换包中的变量的值。

加上-w -s可以有效减少编译出来地程序的大小,但不利于进行调试和日志追踪。

今天继续关于Go开发经验的分享,这次的主题是关于Go的交叉编译和条件编译,伴随着我对自己打不过、惹不起的壕同事小张还有运维们的碎碎念。

交叉编译

交叉编译是用来在一个平台上生成另一个平台的可执行程序。比如我工作开发时用的Mac,系统内核是darwin,小张用的是外星人,系统内核是windows (小张明显比我有钱,我的Mac是公司发的,人家的外星人是为打游戏自己买的)。

那么假如我编写的代码依赖了系统底层平台或处理器架构特性的Go包时,比如说我上周在文章《Go服务迁到K8s后老抽风重启? 记一次完整的线上问题解决过程》里写的,为了把Go运行时的panic错误重定向到日志文件,我用了syscall.Dup2这个函数把标准错误原来的文件描述符替换成了自己指定的日志文件的描述符。syscall.Dup2Go语言在类Unix系统,X86_64架构下才有的函数库,在Mac系统上、各种服务器环境上编译都没有问题,但是唯独在像小张这样不用办公电脑的土豪们用的Windows系统上编译不过去。

所以在上篇文章说的那个为了追踪在Kubernetes上服务老重启的问题,用syscall.Dup2重定向标准输出的解决方案是有副作用的,我贴一下之前这个功能的代码。

func RewriteStderrFile() error {
   if runtime.GOOS == "windows" {
      return nil
   }
   ......
    file, err := os.OpenFile(stdErrFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
    if err != nil {
      fmt.Println(err)
        return err
    }
    
    if err = syscall.Dup2(int(file.Fd()), int(os.Stderr.Fd())); err != nil {
        fmt.Println(err)
        return err
    }
    ......
    return nil
}

天真的用了一个runtime.GOOS == “windows”的判断,我还想着能在代码里根据内核的不同执行不同的代码,但是Go的软件包是先编译成可执行文件再执行的,这个判断根本没啥用。所以在Windows系统下编译项目的时候,因为没有syscall.Dup2直接就失败了……。

我这不就是典型的动态语言的思维吗,之前还写文章跟别人讲《如何避免用动态语言的思维写Go代码》……这次打自己脸打的实在有点疼。

虽然项目这个更新已经上线了,但是土壕小张和运维我都惹不起,迫于无奈我就看了看Go官方的标准库到底是怎么兼容多平台的。

网管瞎吐槽:我真觉得像在Kubernetes收集服务的错误日志这事儿该运维想办法干……,从公司基础设施建设层面,统一化收集所有rpc服务的错误日志,这样所有服务的代码都不用改,比我自己在项目里加代码不强吗?你们觉得我说的对不对(是不是能少干活…)。

条件编译

我发现在go的每个内置库里都有很多以不停系统名结尾的文件。下面是Goos[1]内置库源代码的部分截图:

img

在有些文件里还有类似下面这样的注释:

// +build aix darwin dragonfly freebsd js,wasm linux netbsd openbsd solaris

package os
...

看了些资料后才知道,他们是用于Go软件包的条件编译[2]的,条件编译的意思就是通过某种方式来指示编译器编译特定代码。

Go不支持宏,不可以像c语言那样使用#define来控制是否包含平台相关的特定代码。作为替代,Go使用构建标签(build tags)和代码文件的命名约定来支持Go软件包的条件编译。

构建标签

构建标签就是上面我说的源代码里的注释:

// +build aix darwin dragonfly freebsd js,wasm linux netbsd openbsd solaris

package os
...

需要注意的是,构建标签必须在代码文件里位于package声明的上方,并且后跟一个空行。

Go编译一个包时,它会分析包内的每个源码文件并查找构建标签。标签决定了这个源码文件是否被编译。

构建标签遵循以下三个原则:

  1. 空格隔开的选项是或(OR)的关系
  2. 逗号隔开的选项是与(AND)的关系
  3. 每个选项由字母和数字组成。如果前面加上!,则表示反义
// +build darwin freebsd netbsd openbsd

上面的例子,表示这个源码文件只会在支持kqueueBSD系统中被编译。

一个源码文件可以包含多个构建标签。构建规则是每个独立规则的逻辑与关系。如下例子表示该文件将在linux/386darwin/386平台才会被编译 。

// +build linux darwin
// +build 386

用逻辑表达式表示就是:(linux OR darwin) AND 386。

文件名后缀

第二种条件编译的方法是通过源码文件的文件名实现的。这种方案比构造标签方案更简单。

go/build包的文档有关于命名约定的描述。简单来说,如果文件名包含_Missing superscript or subscript argumentGOARCH.go。这两种后缀可以组合起来,但要保证顺序,正确的格式是_Missing superscript or subscript argumentGOARCH.go,错误的格式是_Missing superscript or subscript argumentGOOS.go。

以下是文件名后缀的一些例子:

mypkg_freebsd_arm.go // 只在 freebsd/arm 系统编译
mypkg_plan9.go       // 只在 plan9 编译
mypkg_darwin.go      // 只在macos 系统编译

源码文件光有后缀是不行的,比如如下文件名:

_linux.go
_freebsd_386.go

即使是在Linux或FreeBSD系统,这两个文件也会被忽略,原因是go/build包会忽略所有文件名以._开始的文件。

使用构建标签还是文件名后缀

构建标签和文件名后缀在功能上是重叠的。比如,一个名为mypkg_linux.go的文件,再包含构建标签// +build linux会显得多余。

通常来说,当只有一个特定平台或体系需要指定时,我们选择文件名后缀的方式。比如:

mypkg_linux.go         // 只在 linux 系统编译
mypkg_windows_amd64.go // 只在 windows amd 64位 平台编译

相反,如果你的文件需要指定给多个平台或体系架构使用,或者你需要排除某个特定平台时,我们选择构建标签的方式。比如:

// 在所有类unix平台编译
// +build darwin dragonfly freebsd linux netbsd openbsd

// 在非Windows平台编译
// +build !windows

实践应用

应用环境,我就说下是怎么解决文章开头说的问题让小张大佬平复心情的吧……。

设置条件编译

首先我像下面这样,在包里建了两个源码文件,用来分别存放在Windows系统和非Windows系统下使用的RewriteStderrFile函数:

project
|
└───pkg1
│   │----rewrite_err_unix.go
│   │----rewrite_err_windows.go

因为我们的项目在那几个大佬电脑的Windows系统上编译和运行的时候都是开发阶段,其他测试上线之类的环境都是Linux系统,所以我懒癌发作,直接写了个空函数,毕竟只要能编译运行小张就不会太难为我了。

//rewrite_err_windows.go
package pkg1

func RewriteStdErrLog(topic string) error {
 return nil
}

对于要在服务器上和Mac电脑上编译的源码,跟之前的差不多,只是增加了构建标签:

//+build darwin linux

package pkg1

......

func RewritePanicsToFile(topic string) error {
    ......
    file, err := os.OpenFile(stdErrFile, os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
    if err != nil {
      fmt.Println(err)
        return err
    }
   if err = syscall.Dup2(int(errFileHandler.Fd()), int(os.Stderr.Fd())); err != nil {
      return err
   }
    ......
  
   return nil
}

执行交叉编译

交叉编译的执行就非常简单了,在编译时给go build命令设置OSARCH参数即可:

比如在Mac 下编译 Windows 64位可执行程序,用:

CGO_ENABLED=0 GOOS=windows GOARCH=amd64 go build main.go

在Mac系统执行完上面的命令就会编译生成软件包在Windows系统上的可执行文件(.exe文件)

如果是Windows 下编译 Mac 64位可执行程序,用:

SET CGO_ENABLED=0
SET GOOS=darwin
SET GOARCH=amd64
go build main.go

总结

事实上,除了用于.go的Go源码文件,构建标签和文件名后缀这些条件编译规则可以作用于任何go tool可以编译的源码文件,包括.c.s文件。Go标准库中,尤其是runtimesyscallosnet包中包含了大量这种例子。咱们一定要去看看,多学习,尤其是身边有像小张这样又壕又凶的队友的同学们,一定把今天我说的这些都学会……。