提示:本节课最终代码为:feature/s09。
应用发布之后,经常会遇到以下 2 类问题:
应用现网出 Bug,需要查看代码进行排障,但是不知道线上应用具体用的哪个版本(可能发布系统有记录某个服务器的应用版本,但服务器上的二进制文件有被替换的可能);
用户想要升级应用,但却不知道当前应用是什么版本,需不需要升级。
以上这 2 大类问题,如何解决呢?业界标准的做法是给应用添加版本号功能,通过执行 -v
或 --version
参数,应用能够输出自己的版本号。注意,这里的版本号需要能够定位到具体的代码快照(例如具体的 Git Commit ID)。
本节课,我们就来展示如何给应用添加版本号功能。
为什么要给应用添加版本功能?
试想这么一个最常遇到的场景:线上的应用出现异常,通过查看日志,发现这么一条报错信息:
{"level":"error","timestamp":"2022-11-25 09:05:58.608","caller":"token/token.go:40","message":"Failed to sign jwt token"}
然后你进入源码仓库,打开 token/token.go
文件,定位到 82 行,一看傻眼了,第 82 行是一行函数声明,并不是输出这条日志的代码。我们无法根据日志报错信息精准定位到报错的代码段,会给我们排障带来很大的困难。这时候,我们最想知道的就是当前报错程序的具体代码快照,最好精确到 Git Commit 进行唯一定位。
解决上述问题的最好方法,就是给二进制程序添加版本号功能。例如:通过 --version
打印出本次构建的 Git Commit ID,并附带一些其他的编译信息,以协助我们还原构建上下文。应用程序版本号,具有以下功能:
对内,可以精准定位到构建的代码仓库快照,方便我们走读代码,发现错误;
对外,能够知道用户使用的是哪个版本的应用,方便功能定位、问题反馈和软件更新;
方便程序实现自动检测更新功能;
通过版本号,也能够给使用者传递额外的信息,例如:用户能够知道当前软件开发的阶段和当前版本的状态:
v2.0.0
说明这是一个基于v1.x.x
升级之后的版本,该版本经过v1
版本的打磨,从功能和稳定性上都会有比较大的提升(通过版本号,使用者能发现的隐藏的信息);1.0.0-alpha
说明当前应用正处在内测阶段(alpha),一般不对外发布,功能不全,Bug 也可能会比较多(通过版本号传递给使用者的信息)。
所以,给项目添加版本号,可以说是一个 Go 应用标准和必需功能。那么如何给应用添加版本号呢?主要有 2 大类的工作需要去做:
指定版本号规范;
给应用程序添加
-v
/--version
参数以输出版本信息。
提示:业界通常使用
-v
/--version
命令行选项来输出版本信息,我们开发 Go 项目时,需要遵循业界常用的开发方式、使用习惯,以减少应用的理解成本。
版本号规范
之前我们学习过,业界有多种版本号规范,目前用的最多的是 语义化版本 2.0.0 规范。
SemVer 版本规范格式
SemVer 规范格式为: [name]x.y.z-[state+buildmetadata]
,例如:v2.1.5
、v1.2.3-alpha.1+001
。每一部分代表的含义如下:
name
:可选段,一般为v
,表示 Version。SemVer 2.0.0 规范中name
不会出现,但一般会加上,以明确表示这是一个版本号;x
(主版本号,MAJOR):在做了不兼容的 API 修改时递增;y
(次版本号,MINOR):在做了向下兼容的功能性新增及修改时递增。一般偶数为稳定版本,奇数为开发版本;z
(修订号,PATCH):在做了向下兼容的问题修正时递增;state
:可选段,用来表示当前软件的状态。例如:alpha
表示 alpha 版,即内测版。state 标识可参考 附录A:版本状态段规则;buildmetadata
:可选段,用来标识某次编译信息,一般是编译器在编译过程中自动生成的。
提示:
[x]
代表其内的内容(x
)是可选的。
提示:
0.Y.Z
表示当前软件处于研发阶段,软件并不稳定;1.0.0
表示当前软件为初始的稳定版,后续的更新都基于此版本。
SemVer 版本规范内容可参考 附录D:SemVer 版本规范内容, 更详细的可以参考:semver.org/lang/zh-CN/…
如何添加版本号?
在实际开发中,当开发完一个 miniblog 特性后,会编译 miniblog 二进制文件并发布到生产环境,很多时候为了定位问题和出于安全目的(确认发的是正确的版本),我们需要知道当前 miniblog 的版本,以及一些编译信息,例如编译时 Go 的版本、Git 目录是否干净,以及基于哪个 git commmit 来编译的。在一个编译好的可执行程序中,我们通常可以用类似 ./appname -v
的方式来获取版本信息。
我们可以将这些信息写在配置文件中,程序运行时从配置文件中取得这些信息进行显示。但是在部署程序时,除了二进制文件,还需要额外的配置文件,不是很方便,而且配置文件可能会被篡改。或者将这些信息写入代码中,这样不需要额外的配置,但要在每次编译时修改代码文件,也比较麻烦。
Go 官方提供了一种更好的方式:通过 -ldflags -X importpath.name=value
来给程序自动添加版本信息。
提示:在实际开发中,绝大部分都是用 Git 来做源码版本管理的,所以 miniblog 的版本功能也基于 Git。
如何实现 Go 应用版本功能?
先来看下,miniblog 的版本功能,执行:
$ _output/miniblog --version
gitCommit: 93864ffc831f5565b85b274639eb6e816a3f1632
gitTreeState: dirty
buildDate: 2022-11-25T05:54:24Z
goVersion: go1.19
compiler: gc
platform: linux/amd64
那么我们要如何实现上述的版本功能呢?经过简单的思考,我们初步设想有以下 2 种思路:
将版本信息保存在一个叫
version.txt
文件中,version.txt
随 miniblog 二进制文件一起发布,通过这个绑定的文件来查看二进制的版本信息;将版本信息,硬编码在 miniblog 代码中,通过执行
miniblog --version
来输出版本信息。
上述 2 种方案,都能实现应用的版本功能,但存在诸多问题。
第 1 种:要额外维护一个 version.txt
文件,而且 version.txt
还很容易就被篡改了;
第 2 种:每次发布,都要修改源码中的版本信息,步骤繁琐,还容易漏改,导致不可预知的问题。
所以,上述 2 种 方案都不可行。那么如何实现呢?通过 Google how to add version to golang app
,我们很容易搜到一堆实现:
查看文章内容,我们知道这些文章都是用 go build -ldflags='-X main.Version=v1.0.0'
这种方式来实现的。聪明的你,一定会得出一个结论:我们可以通过 -ldflags
给 Go 应用添加版本功能,而且这也是一种业界标准实现!
那么接下来,我们就继续通过 Google 学习了 go build -ldflags="-X main.Version=v1.0.0"
所执行操作的含义:go build
时,通过指定 -ldflags
选项,将 v1.0.0
赋值给 main
包中的 Version
变量中。之后,程序内可以通过打印 Version
变量的值,来输出版本号(v1.0.0
)。
参数详解:
go build
的 -ldflags
可以将指定的参数传递给 go 的链接器( go tool ``link
),格式为:-ldflags '[pattern=]arg list'
,例如:-X importpath.name=value
。
运行 go tool link -h
:
$ go tool link
usage: link [options] main.o
...
-X definition
add string value definition of the form importpath.name=value
...
可以看到 -X importpath.name=value
告诉 Go 链接器将 value
赋值给 importpath
包中的 name
变量。注意:name
一定要是个 string
类型的变量,否则编译器会报以下错误:
# command-line-arguments
main.name: cannot set with -X: not a var of type string (type.main.name)
所以,使用 go build -ldflags "-X importpath.name=value"
可以实现给应用程序添加版本号的功能。
这里,我们根据思路编写一个示例代码并测试:
package main
import (
"flag"
"fmt"
)
var (
// GitVersion 是语义化的版本号.
GitVersion = "v0.0.0-master+$Format:%h$"
// BuildDate 是 ISO8601 格式的构建时间, $(date -u +'%Y-%m-%dT%H:%M:%SZ') 命令的输出.
BuildDate = "1970-01-01T00:00:00Z"
)
func main() {
version := flag.Bool("version", false, "Print version info.")
flag.Parse()
if *version {
fmt.Println("GitVersion", GitVersion)
fmt.Println("BuildDate", BuildDate)
}
fmt.Println("ok")
}
编译并运行:
$ go build -ldflags "-X main.GitVersion=v1.0.0 -X main.BuildDate=$(date +%F)" -o version main.go
$ ./version -version
GitVersion v1.0.0
BuildDate 2022-11-25
ok
可以看到我们通过命令行,成功给 version
二进制添加了版本功能,通过 -version
即可输出我们期望的版本信息。
给 miniblog 添加版本功能
可通过以下几步给 miniblog
添加版本功能:
创建一个
version
包用来保存版本信息,并供其他包调用以输出版本信息;miniblog
主程序添加--version
选项;添加执行
miniblog --version
时打印版本信息的逻辑。
创建一个 version
包
创建一个 pkg/version/version.go
文件,内容如下:
package version
import (
"encoding/json"
"fmt"
"runtime"
"github.com/gosuri/uitable"
)
var (
// GitVersion 是语义化的版本号.
GitVersion = "v0.0.0-master+$Format:%h$"
// BuildDate 是 ISO8601 格式的构建时间, $(date -u +'%Y-%m-%dT%H:%M:%SZ') 命令的输出.
BuildDate = "1970-01-01T00:00:00Z"
// GitCommit 是 Git 的 SHA1 值,$(git rev-parse HEAD) 命令的输出.
GitCommit = "$Format:%H$"
// GitTreeState 代表构建时 Git 仓库的状态,可能的值有:clean, dirty.
GitTreeState = ""
)
// Info 包含了版本信息.
type Info struct {
GitVersion string `json:"gitVersion"`
GitCommit string `json:"gitCommit"`
GitTreeState string `json:"gitTreeState"`
BuildDate string `json:"buildDate"`
GoVersion string `json:"goVersion"`
Compiler string `json:"compiler"`
Platform string `json:"platform"`
}
// String 返回人性化的版本信息字符串.
func (info Info) String() string {
if s, err := info.Text(); err == nil {
return string(s)
}
return info.GitVersion
}
// ToJSON 以 JSON 格式返回版本信息.
func (info Info) ToJSON() string {
s, _ := json.Marshal(info)
return string(s)
}
// Text 将版本信息编码为 UTF-8 格式的文本,并返回.
func (info Info) Text() ([]byte, error) {
table := uitable.New()
table.RightAlign(0)
table.MaxColWidth = 80
table.Separator = " "
table.AddRow("gitVersion:", info.GitVersion)
table.AddRow("gitCommit:", info.GitCommit)
table.AddRow("gitTreeState:", info.GitTreeState)
table.AddRow("buildDate:", info.BuildDate)
table.AddRow("goVersion:", info.GoVersion)
table.AddRow("compiler:", info.Compiler)
table.AddRow("platform:", info.Platform)
return table.Bytes(), nil
}
// Get 返回详尽的代码库版本信息,用来标明二进制文件由哪个版本的代码构建.
func Get() Info {
// 以下变量通常由 -ldflags 进行设置
return Info{
GitVersion: GitVersion,
GitCommit: GitCommit,
GitTreeState: GitTreeState,
BuildDate: BuildDate,
GoVersion: runtime.Version(),
Compiler: runtime.Compiler,
Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
}
}
我们将 version
包放在根目录下的 pkg
目录下,是想分享给第三方开发者。所以,我们需要确保 version
包功能独立、完善、稳定。
上述代码中,我们定义了一个 Info
结构体,用来统一保存版本信息:
type Info struct {
GitVersion string `json:"gitVersion"`
GitCommit string `json:"gitCommit"`
GitTreeState string `json:"gitTreeState"`
BuildDate string `json:"buildDate"`
GoVersion string `json:"goVersion"`
Compiler string `json:"compiler"`
Platform string `json:"platform"`
}
从 Info
结构体的字段中,我们可以发现,version
包展示了比较详细的构建信息,包括版本号。根据字段名,你应该能知道每一个字段表示的意思。
另外,我们还实现了以下方法,来展示不同格式的版本信息:
Get
方法:返回详尽的代码库版本信息;String
方法:以友好的可读的格式展示构建信息;ToJSON
方法:以 JSON 格式展示版本信息;Text
方法:将版本信息编码为 UTF-8 格式的文本。
将版本信息注入到 version
包中
接下来,我们可以通过 -ldflags -X "importpath.name=value"
构建参数将版本信息注入到 version
包中。
通过 Makefile 来构建程序,所以我们需要将版本信息、构建参数等在 Makefile 中实现:
## 指定应用使用的 version 包,会通过 `-ldflags -X` 向该包中指定的变量注入值
VERSION_PACKAGE=github.com/marmotedu/miniblog/pkg/version
## 定义 VERSION 语义化版本号
ifeq ($(origin VERSION), undefined)
VERSION := $(shell git describe --tags --always --match='v*')
endif
## 检查代码仓库是否是 dirty(默认dirty)
GIT_TREE_STATE:="dirty"
ifeq (, $(shell git status --porcelain 2>/dev/null))
GIT_TREE_STATE="clean"
endif
GIT_COMMIT:=$(shell git rev-parse HEAD)
GO_LDFLAGS += \
-X $(VERSION_PACKAGE).GitVersion=$(VERSION) \
-X $(VERSION_PACKAGE).GitCommit=$(GIT_COMMIT) \
-X $(VERSION_PACKAGE).GitTreeState=$(GIT_TREE_STATE) \
-X $(VERSION_PACKAGE).BuildDate=$(shell date -u +'%Y-%m-%dT%H:%M:%SZ')
上述 Makefile 不难理解:
使用
git describe --tags --always --match='v*'
命令获取版本号;使用
date -u +'%Y-%m-%dT%H:%M:%SZ'
命令获取构建时间;使用
git rev-parse HEAD
获取构建时的 Commit ID;
git describe --tags --always --match='v*'
参数介绍:
--tags
:使用所有的标签,而不是只使用带注释的标签(annotated tag)。git tag <tagname>
生成一个不带注释的标签,git tag -a <tagname> -m '<message>'
生成一个带注释的标签;--always
:如果仓库没有可用的标签,那么使用 commit 缩写来替代标签;--match <pattern>
:只考虑与给定模式相匹配的标签。
那么另外 3 个编译信息:GoVersion
、Compiler
、Platform
如何获取呢?我们可以使用标准库 runtime
包在 Get
方法调用时动态获取,例如:
func Get() Info {
// 以下变量通常由 -ldflags 进行设置
return Info{
GitVersion: GitVersion,
GitCommit: GitCommit,
GitTreeState: GitTreeState,
BuildDate: BuildDate,
GoVersion: runtime.Version(),
Compiler: runtime.Compiler,
Platform: fmt.Sprintf( "%s/%s" , runtime.GOOS, runtime.GOARCH),
}
}
最后,我们还需要将 -ldflags
参数名及其值追加到 go build
命令行参数中(见:Makefile#L44):
@go build -v -ldflags "$(GO_LDFLAGS)" -o $(OUTPUT_DIR)/miniblog $(ROOT_DIR)/cmd/miniblog/main.go
miniblog
主程序添加 --version
选项
通过步骤 2,在编译 miniblog
之后,我们已经将需要的版本信息注入到了 version
包中,接下来,我们需要再 miniblog
主程序中调用 version
包并打印版本信息。
编辑 internal/miniblog/miniblog.go
文件,在其中添加以下行(见:miniblog.go#L71):
// 添加 --version 标志
verflag.AddFlags(cmd.PersistentFlags())
verflag.AddFlags
函数实现如下:
// AddFlags 在任意 FlagSet 上注册这个包的标志,这样它们指向与全局标志相同的值.
func AddFlags(fs *pflag.FlagSet) {
fs.AddFlag(pflag.Lookup(versionFlagName))
}
通过以上代码,我们在 miniblog
命令行中添加了 --version
参数:
$ _output/miniblog -h
...
--version version[= true ] Print version information and quit.
添加执行 miniblog --version
时打印版本信息的逻辑
最后,我们就要实现执行 miniblog --version
打印版本信息的功能。
在 RunE
方法中添加以下行(见 miniblog.go#L39):
verflag.PrintAndExitIfRequested()
verflag.PrintAndExitIfRequested
函数实现如下:
func PrintAndExitIfRequested() {
if *versionFlag == VersionRaw {
fmt.Printf("%#v\n", version.Get())
os.Exit(0)
} else if *versionFlag == VersionTrue {
fmt.Printf("%s\n", version.Get())
os.Exit(0)
}
}
通过上面的代码,当我们执行 miniblog --version
命令后,--version
命令行选项的值会被赋值给 verflag
包的 versionFlag
变量。
接下来,会运行 PrintAndExitIfRequested
函数,该函数会根据 versionFlag
的值,调用 version
包获取版本信息,输出不同格式的版本信息并退出程序。
编译并测试
开发完成的最终完整代码为:feature/s09,编译代码、运行以获取应用的版本信息:
$ make
$ _output/miniblog --version -c configs/miniblog.yaml
gitVersion: f9bcff9
gitCommit: f9bcff93cc300b8e00de1789d89eb5ffd2b13dfe
gitTreeState: clean
buildDate: 2022-12-12T11:59:45Z
goVersion: go1.19.4
compiler: gc
platform: linux/amd64
$ _output/miniblog --version=raw -c configs/miniblog.yaml
version.Info{GitVersion:"f9bcff9", GitCommit:"f9bcff93cc300b8e00de1789d89eb5ffd2b13dfe", GitTreeState:"clean", BuildDate:"2022-12-12T11:59:45Z", GoVersion:"go1.19.4", Compiler:"gc", Platform:"linux/amd64"}
可以看到,我们的 miniblog
程序根据 --version
的值输出了不同格式的、内容详尽的版本信息。有了这些版本信息,我们可以精确地定位当前应用所使用的代码以及编译环境,为日后的排障打好坚实的基础。
小结
为了日后排障能够精准定位到应用所使用的代码快照,我们需要给应用添加版本功能,并且应用的版本号要遵循 SemVer 规范。业界标准的添加版本号的方法是通过 go build -ldflags "-X importpath.name=value"
来添加的,本节课的最后,给大家一步一步展示了 miniblog
是如何添加版本功能的。
这里想说的是 miniblog
实现版本的方法,参考了 kubernetes 项目的实现方法,其实现思路、输出的版本信息,都是非常优雅的,你可以将本节课的实现方法,作为应用版本功能的最佳实践方法。
这里也给大家留个小作业:请大家走读 verflag
包的代码,弄明白在执行 miniblog --version
命令时,--version
命令行选项的值是怎么赋值给 versionFlag
的?