提示:本节课最终代码为:feature/s09

应用发布之后,经常会遇到以下 2 类问题:

  1. 应用现网出 Bug,需要查看代码进行排障,但是不知道线上应用具体用的哪个版本(可能发布系统有记录某个服务器的应用版本,但服务器上的二进制文件有被替换的可能);

  2. 用户想要升级应用,但却不知道当前应用是什么版本,需不需要升级。

以上这 2 大类问题,如何解决呢?业界标准的做法是给应用添加版本号功能,通过执行 -v--version 参数,应用能够输出自己的版本号。注意,这里的版本号需要能够定位到具体的代码快照(例如具体的 Git Commit ID)。

本节课,我们就来展示如何给应用添加版本号功能。

为什么要给应用添加版本功能?

试想这么一个最常遇到的场景:线上的应用出现异常,通过查看日志,发现这么一条报错信息:

  1. {"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 大类的工作需要去做:

  1. 指定版本号规范;

  2. 给应用程序添加 -v / --version 参数以输出版本信息。

提示:业界通常使用 -v / --version 命令行选项来输出版本信息,我们开发 Go 项目时,需要遵循业界常用的开发方式、使用习惯,以减少应用的理解成本。

版本号规范

之前我们学习过,业界有多种版本号规范,目前用的最多的是 语义化版本 2.0.0 规范。

SemVer 版本规范格式

SemVer 规范格式为: [name]x.y.z-[state+buildmetadata],例如:v2.1.5v1.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)是可选的。

8.基础功能:如何给应用添加版本信息,方便排查问题? - 图1

提示: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 的版本功能,执行:

  1. $ _output/miniblog --version
  2. gitCommit: 93864ffc831f5565b85b274639eb6e816a3f1632
  3. gitTreeState: dirty
  4. buildDate: 2022-11-25T05:54:24Z
  5. goVersion: go1.19
  6. compiler: gc
  7. platform: linux/amd64

那么我们要如何实现上述的版本功能呢?经过简单的思考,我们初步设想有以下 2 种思路:

  1. 将版本信息保存在一个叫 version.txt 文件中,version.txt 随 miniblog 二进制文件一起发布,通过这个绑定的文件来查看二进制的版本信息;

  2. 将版本信息,硬编码在 miniblog 代码中,通过执行 miniblog --version 来输出版本信息。

上述 2 种方案,都能实现应用的版本功能,但存在诸多问题。

第 1 种:要额外维护一个 version.txt 文件,而且 version.txt 还很容易就被篡改了;

第 2 种:每次发布,都要修改源码中的版本信息,步骤繁琐,还容易漏改,导致不可预知的问题。

所以,上述 2 种 方案都不可行。那么如何实现呢?通过 Google how to add version to golang app,我们很容易搜到一堆实现:

8.基础功能:如何给应用添加版本信息,方便排查问题? - 图2

查看文章内容,我们知道这些文章都是用 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

  1. $ go tool link
  2. usage: link [options] main.o
  3. ...
  4. -X definition
  5. add string value definition of the form importpath.name=value
  6. ...

可以看到 -X importpath.name=value 告诉 Go 链接器将 value 赋值给 importpath 包中的 name 变量。注意:name 一定要是个 string 类型的变量,否则编译器会报以下错误:

  1. # command-line-arguments
  2. main.name: cannot set with -X: not a var of type string (type.main.name)

所以,使用 go build -ldflags "-X importpath.name=value" 可以实现给应用程序添加版本号的功能。

这里,我们根据思路编写一个示例代码并测试:

  1. package main
  2. import (
  3. "flag"
  4. "fmt"
  5. )
  6. var (
  7. // GitVersion 是语义化的版本号.
  8. GitVersion = "v0.0.0-master+$Format:%h$"
  9. // BuildDate 是 ISO8601 格式的构建时间, $(date -u +'%Y-%m-%dT%H:%M:%SZ') 命令的输出.
  10. BuildDate = "1970-01-01T00:00:00Z"
  11. )
  12. func main() {
  13. version := flag.Bool("version", false, "Print version info.")
  14. flag.Parse()
  15. if *version {
  16. fmt.Println("GitVersion", GitVersion)
  17. fmt.Println("BuildDate", BuildDate)
  18. }
  19. fmt.Println("ok")
  20. }

编译并运行:

  1. $ go build -ldflags "-X main.GitVersion=v1.0.0 -X main.BuildDate=$(date +%F)" -o version main.go
  2. $ ./version -version
  3. GitVersion v1.0.0
  4. BuildDate 2022-11-25
  5. ok

可以看到我们通过命令行,成功给 version 二进制添加了版本功能,通过 -version 即可输出我们期望的版本信息。

给 miniblog 添加版本功能

可通过以下几步给 miniblog 添加版本功能:

  1. 创建一个 version 包用来保存版本信息,并供其他包调用以输出版本信息;

  2. miniblog 主程序添加 --version 选项;

  3. 添加执行 miniblog --version 时打印版本信息的逻辑。

创建一个 version

创建一个 pkg/version/version.go 文件,内容如下:

  1. package version
  2. import (
  3. "encoding/json"
  4. "fmt"
  5. "runtime"
  6. "github.com/gosuri/uitable"
  7. )
  8. var (
  9. // GitVersion 是语义化的版本号.
  10. GitVersion = "v0.0.0-master+$Format:%h$"
  11. // BuildDate 是 ISO8601 格式的构建时间, $(date -u +'%Y-%m-%dT%H:%M:%SZ') 命令的输出.
  12. BuildDate = "1970-01-01T00:00:00Z"
  13. // GitCommit 是 Git 的 SHA1 值,$(git rev-parse HEAD) 命令的输出.
  14. GitCommit = "$Format:%H$"
  15. // GitTreeState 代表构建时 Git 仓库的状态,可能的值有:clean, dirty.
  16. GitTreeState = ""
  17. )
  18. // Info 包含了版本信息.
  19. type Info struct {
  20. GitVersion string `json:"gitVersion"`
  21. GitCommit string `json:"gitCommit"`
  22. GitTreeState string `json:"gitTreeState"`
  23. BuildDate string `json:"buildDate"`
  24. GoVersion string `json:"goVersion"`
  25. Compiler string `json:"compiler"`
  26. Platform string `json:"platform"`
  27. }
  28. // String 返回人性化的版本信息字符串.
  29. func (info Info) String() string {
  30. if s, err := info.Text(); err == nil {
  31. return string(s)
  32. }
  33. return info.GitVersion
  34. }
  35. // ToJSON 以 JSON 格式返回版本信息.
  36. func (info Info) ToJSON() string {
  37. s, _ := json.Marshal(info)
  38. return string(s)
  39. }
  40. // Text 将版本信息编码为 UTF-8 格式的文本,并返回.
  41. func (info Info) Text() ([]byte, error) {
  42. table := uitable.New()
  43. table.RightAlign(0)
  44. table.MaxColWidth = 80
  45. table.Separator = " "
  46. table.AddRow("gitVersion:", info.GitVersion)
  47. table.AddRow("gitCommit:", info.GitCommit)
  48. table.AddRow("gitTreeState:", info.GitTreeState)
  49. table.AddRow("buildDate:", info.BuildDate)
  50. table.AddRow("goVersion:", info.GoVersion)
  51. table.AddRow("compiler:", info.Compiler)
  52. table.AddRow("platform:", info.Platform)
  53. return table.Bytes(), nil
  54. }
  55. // Get 返回详尽的代码库版本信息,用来标明二进制文件由哪个版本的代码构建.
  56. func Get() Info {
  57. // 以下变量通常由 -ldflags 进行设置
  58. return Info{
  59. GitVersion: GitVersion,
  60. GitCommit: GitCommit,
  61. GitTreeState: GitTreeState,
  62. BuildDate: BuildDate,
  63. GoVersion: runtime.Version(),
  64. Compiler: runtime.Compiler,
  65. Platform: fmt.Sprintf("%s/%s", runtime.GOOS, runtime.GOARCH),
  66. }
  67. }

我们将 version 包放在根目录下的 pkg 目录下,是想分享给第三方开发者。所以,我们需要确保 version 包功能独立、完善、稳定。

上述代码中,我们定义了一个 Info 结构体,用来统一保存版本信息:

  1. type Info struct {
  2. GitVersion string `json:"gitVersion"`
  3. GitCommit string `json:"gitCommit"`
  4. GitTreeState string `json:"gitTreeState"`
  5. BuildDate string `json:"buildDate"`
  6. GoVersion string `json:"goVersion"`
  7. Compiler string `json:"compiler"`
  8. Platform string `json:"platform"`
  9. }

Info 结构体的字段中,我们可以发现,version 包展示了比较详细的构建信息,包括版本号。根据字段名,你应该能知道每一个字段表示的意思。

另外,我们还实现了以下方法,来展示不同格式的版本信息:

  • Get 方法:返回详尽的代码库版本信息;

  • String 方法:以友好的可读的格式展示构建信息;

  • ToJSON 方法:以 JSON 格式展示版本信息;

  • Text 方法:将版本信息编码为 UTF-8 格式的文本。

将版本信息注入到 version 包中

接下来,我们可以通过 -ldflags -X "importpath.name=value" 构建参数将版本信息注入到 version 包中。

通过 Makefile 来构建程序,所以我们需要将版本信息、构建参数等在 Makefile 中实现:

  1. ## 指定应用使用的 version 包,会通过 `-ldflags -X` 向该包中指定的变量注入值
  2. VERSION_PACKAGE=github.com/marmotedu/miniblog/pkg/version
  3. ## 定义 VERSION 语义化版本号
  4. ifeq ($(origin VERSION), undefined)
  5. VERSION := $(shell git describe --tags --always --match='v*')
  6. endif
  7. ## 检查代码仓库是否是 dirty(默认dirty)
  8. GIT_TREE_STATE:="dirty"
  9. ifeq (, $(shell git status --porcelain 2>/dev/null))
  10. GIT_TREE_STATE="clean"
  11. endif
  12. GIT_COMMIT:=$(shell git rev-parse HEAD)
  13. GO_LDFLAGS += \
  14. -X $(VERSION_PACKAGE).GitVersion=$(VERSION) \
  15. -X $(VERSION_PACKAGE).GitCommit=$(GIT_COMMIT) \
  16. -X $(VERSION_PACKAGE).GitTreeState=$(GIT_TREE_STATE) \
  17. -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 个编译信息:GoVersionCompilerPlatform 如何获取呢?我们可以使用标准库 runtime 包在 Get 方法调用时动态获取,例如:

  1. func Get() Info {
  2. // 以下变量通常由 -ldflags 进行设置
  3. return Info{
  4. GitVersion: GitVersion,
  5. GitCommit: GitCommit,
  6. GitTreeState: GitTreeState,
  7. BuildDate: BuildDate,
  8. GoVersion: runtime.Version(),
  9. Compiler: runtime.Compiler,
  10. Platform: fmt.Sprintf( "%s/%s" , runtime.GOOS, runtime.GOARCH),
  11. }
  12. }

最后,我们还需要将 -ldflags 参数名及其值追加到 go build 命令行参数中(见:Makefile#L44):

  1. @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):

  1. // 添加 --version 标志
  2. verflag.AddFlags(cmd.PersistentFlags())

verflag.AddFlags 函数实现如下:

  1. // AddFlags 在任意 FlagSet 上注册这个包的标志,这样它们指向与全局标志相同的值.
  2. func AddFlags(fs *pflag.FlagSet) {
  3. fs.AddFlag(pflag.Lookup(versionFlagName))
  4. }

通过以上代码,我们在 miniblog 命令行中添加了 --version 参数:

  1. $ _output/miniblog -h
  2. ...
  3. --version version[= true ] Print version information and quit.

添加执行 miniblog --version 时打印版本信息的逻辑

最后,我们就要实现执行 miniblog --version 打印版本信息的功能。

RunE 方法中添加以下行(见 miniblog.go#L39):

  1. verflag.PrintAndExitIfRequested()

verflag.PrintAndExitIfRequested 函数实现如下:

  1. func PrintAndExitIfRequested() {
  2. if *versionFlag == VersionRaw {
  3. fmt.Printf("%#v\n", version.Get())
  4. os.Exit(0)
  5. } else if *versionFlag == VersionTrue {
  6. fmt.Printf("%s\n", version.Get())
  7. os.Exit(0)
  8. }
  9. }

通过上面的代码,当我们执行 miniblog --version 命令后,--version 命令行选项的值会被赋值给 verflag 包的 versionFlag 变量。

接下来,会运行 PrintAndExitIfRequested 函数,该函数会根据 versionFlag 的值,调用 version 包获取版本信息,输出不同格式的版本信息并退出程序。

编译并测试

开发完成的最终完整代码为:feature/s09,编译代码、运行以获取应用的版本信息:

  1. $ make
  2. $ _output/miniblog --version -c configs/miniblog.yaml
  3. gitVersion: f9bcff9
  4. gitCommit: f9bcff93cc300b8e00de1789d89eb5ffd2b13dfe
  5. gitTreeState: clean
  6. buildDate: 2022-12-12T11:59:45Z
  7. goVersion: go1.19.4
  8. compiler: gc
  9. platform: linux/amd64
  10. $ _output/miniblog --version=raw -c configs/miniblog.yaml
  11. 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 的?