Bootstrap

让代码说话:如何把版本信息注入到代码中

在服务端开发中,手动维护版本信息往往既费力又不准确,因为迭代很快,很多情况下都是只更新了代码,而忘记了更新版本,很难保证一致性。

我们可以使用版本控制系统提供的信息——比如:Tag name、Commit id、Revision id等——作为软件版本,在部署阶段自动注入到代码里。这样既能保证信息真实可回溯,也避免了手动操作的麻烦。

本文介绍了如何在C/C++/Go/Rust/Python中整合上述信息的方法:

获取版本信息

如果你是通过 Tag 上线,而且代码运行在 Docker 容器里,那我们可以把 Tag 名定义为 Dockerfile 的 ARG 参数,在构建镜像时通过 -build-arg 传入:

# Dockerfile

ARG CODEGITTAG=default_version

# using ${CODEGITTAG}

> docker build --build-arg CODEGITTAG=v.2021012801 ...

如果你的代码没有容器化,可以用下面的命令从git中读取。

获取Tag名

> git checkout v1.0.1
....
> git describe --tags
v1.0.1

获取当前分支的commit id

如果你不使用Tag进行上线,那可以用Commit ID作为版本号:

> git rev-parse --short HEAD
23cc251

PS. 这里只演示如何在git中获取版本信息,如果你使用其他版本控制系统,请查阅对应的文档

在得到版本信息之后,我们看下如何在不同的语言中进行注入:

C/C++

在 C/C++ 中,需要结合CMake把版本信息编译进 binary 内,共分为2个步骤:

使用 EXECUTE_PROCESS() 获取git版本

在CMakeLists.txt中增加以下代码:

EXECUTE_PROCESS(
    COMMAND git rev-parse --short HEAD
    OUTPUT_VARIABLE MAGE_VERSION_GIT_HEAD_VERSION
    OUTPUT_STRIP_TRAILING_WHITESPACE
    ERROR_VARIABLE GET_GIT_VERSION_FAILED
)
IF(GET_GIT_VERSION_FAILED)
    MESSAGE(FATAL_ERROR ${GET_GIT_VERSION_FAILED})
ELSE(GET_GIT_VERSION_FAILED)
    MESSAGE("-- Current Git Commit ID: ${MAGE_VERSION_GIT_HEAD_VERSION}")
ENDIF(GET_GIT_VERSION_FAILED)

EXECUTE_PROCESS() 用到的参数:

  • COMMAND ...,这里是获取 Commit ID,也可以获取 Tag Name。

  • OUTPUT_VARIABLE ..., 保存命令输出,即我们需要的版本信息。

  • OUTPUT_STRIP_TRAILING_WHITESPACE, 因为输出时会带上一个换行,会导致生成的代码格式不正确,所以我们用这个参数把它给抹掉。

  • ERROR_VARIABLE ..., 保存执行失败的错误输出。

  • 最后5行是检查错误,并把版本信息打印出来以便于Debug。

结果输出:

PS. EXECUTE_PROCESS功能非常强大,这里只用到了几个基本的参数,详细介绍请参照

在实践中还有一个问题需要解决:有时候,我们不一定在源码目录下进行构建,而是在另一个目录中,比如代码在 src/your-git-src-directory,而构建在 build/your-build-directory。可是build目录并不是一个 git 文件夹,如果我们运行cmake,会得到如下错误提示:

这时,我们只要设置环境变量GIT_DIR就可以解决这个问题了,如下第一行:

SET(ENV{GIT_DIR} ${PROJECT_SOURCE_DIR}/.git) # <-----
EXECUTE_PROCESS(
    COMMAND git rev-parse --short HEAD
    OUTPUT_VARIABLE MAGE_VERSION_GIT_HEAD_VERSION
    OUTPUT_STRIP_TRAILING_WHITESPACE
    ERROR_VARIABLE GET_GIT_VERSION_FAILED
)
IF(GET_GIT_VERSION_FAILED)
    MESSAGE(FATAL_ERROR ${GET_GIT_VERSION_FAILED})
ELSE(GET_GIT_VERSION_FAILED)
    MESSAGE("-- Current Git Commit ID: ${MAGE_VERSION_GIT_HEAD_VERSION}")
ENDIF(GET_GIT_VERSION_FAILED)

PS. GIT_DIR对大部分 git 命令都生效~

加上这行后,再次运行,结果正常:

使用 CONFIGURE_FILE生成版本头文件

在源码目录增加一个 config.h.cmake 的文件:

#define MAGE_VERSION_MAJOR @MAGE_VERSION_MAJOR@
#define MAGE_VERSION_MINOR @MAGE_VERSION_MINOR@
#define MAGE_VERSION_PATCH @MAGE_VERSION_PATCH@
#define MAGE_VERSION_GIT_HEAD_VERSION "@MAGE_VERSION_GIT_HEAD_VERSION@"

其中major、minor和patch是传统的软件版本规范,如果你的项目里不需要的话,可以删除掉,如果保留就需要在CMake中赋值。除了这3个之外,就是最重要的 "@MAGE_VERSION_GIT_HEAD_VERSION@",即我们从git里获取的版本信息,它要和EXECUTE_PROCESS()指令的OUTPUT_VARIABLE的变量名保持一致,注意我们用双引号把它括起来当作一个字符串使用。

增加以下代码生成头文件:

configure_file("src/config.h.cmake" "config.h")

运行 cmake,在当前目录下(准确的说是 cmake 的 目录)就生成了config.h 文件:

#define MAGE_VERSION_MAJOR 2
#define MAGE_VERSION_MINOR 0
#define MAGE_VERSION_PATCH 0
#define MAGE_VERSION_GIT_HEAD_VERSION "305fdd9"

我们可以在代码里包含这个文件,并进行相关的操作。

Go

在 Go 语言中注入版本信息比较简单,使用 go build 的 ldflags 参数就可以直接给代码里的Variable进行赋值!请看下面的测试项目:

core.go 文件中定义了一个全局 Variable(CodeVersion)和3个helper function:

// core.go

package core

import (
	"fmt"
	"runtime"
)

var (
	CodeVersion = ""
)

func RuntimeVersion() string {
	return fmt.Sprintf("%s %s/%s", runtime.Version(), runtime.GOOS, runtime.GOARCH)
}

func CodeBaseVersion() string {
	return CodeVersion
}

func Version() string {
	return fmt.Sprintf("%s; %s", CodeBaseVersion(), RuntimeVersion())
}

接着是main.go:

// main.go

package main

import (
	"flag"
	"fmt"

	"github.com/zhujun1980/goversion/core"
)

func main() {
	var ver = flag.Bool("version", false, "show version")
	flag.Parse()

	if *ver {
		fmt.Printf("%s\n", core.Version())
		return
	}
}

编译 goversion 并把版本信息写进去:

> go build -ldflags="-X 'github.com/zhujun1980/goversion/core.CodeVersion=v.2021012901'"

这里假设你已经获得到了代码的版本信息,如果没有,可以从命令行直接调用git命令:

> go build -ldflags="-X 'github.com/zhujun1980/goversion/core.CodeVersion=$(git describe --tags)'"

ldflags 的 -X 参数根据 package 路径直接对变量进行赋值,注意是全路径。执行结果:

我们还可以把构建时间写进去:

// core.go

package core

import (
	"fmt"
	"runtime"
)

var (
	CodeVersion = ""
	BuildTime   = ""
)

func RuntimeVersion() string {
	return fmt.Sprintf("%s %s/%s", runtime.Version(), runtime.GOOS, runtime.GOARCH)
}

func CodeBaseVersion() string {
	return CodeVersion
}

func Version() string {
	return fmt.Sprintf("%s; %s; %s", CodeBaseVersion(), RuntimeVersion(), BuildTime)
}

在编译的时候如法炮制——把时间传进去:

go build -ldflags="-X 'github.com/zhujun1980/goversion/core.CodeVersion=v.2021012902' -X 'github.com/zhujun1980/goversion/core.BuildTime=$(date -R)'"

运行程序:

用这个方法我们可以把任何信息都写进去:服务器IP,用户ID,当地天气,生辰八字、当天黄历 ;-)

Rust

在 Rust 里面,需要使用 Cargo 的 build script 来生成文件,Cargo 是 Rust 自带的构建系统,build script 一般用来编译非 Rust 第三方代码(比如C++),它在编译正式源码之前被调用,也可以用来生成代码。在Cargo的文档中提供了一个例子,我们把它修改一下来实现我们的需求:

// build.rs

use rustc_version::version;
use std::env;
use std::format;
use std::fs;
use std::path::Path;

fn main() {
    let out_dir = env::var_os("OUT_DIR").unwrap();
    let dest_path = Path::new(&out_dir).join("version.rs");
    fs::write(
        &dest_path,
        format!(
            "pub fn version() -> &'static str {{
            \"{}; {} {}/{}\"
            }}
            ",
            match env::var("CODE_BASE_VERSION") {
                Ok(val) => val,
                Err(_) => "".to_string(),
            },
            version().unwrap(),
            match env::var("CARGO_CFG_TARGET_OS") {
                Ok(val) => val,
                Err(_) => "".to_string(),
            },
            match env::var("CARGO_CFG_TARGET_ARCH") {
                Ok(val) => val,
                Err(_) => "".to_string(),
            }
        ),
    )
    .unwrap();
    println!("cargo:rerun-if-changed=build.rs");
}

# main.rs

include!(concat!(env!("OUT_DIR"), "/version.rs"));

fn main() {
    println!("{}", version());
}

main.rs 很简单,只引入了 version.rs 文件并打印调用结果;主要功能在 build.rs 文件:生成 version.rs 文件,返回我们的代码版本,Rust 编译器的版本,操作系统、CPU架构等信息。

注意为了能获取 Rust 编译器的版本,需要在Cargo.toml增加构建依赖:

# Cargo.toml

[build-dependencies]
rustc_version = "0.3.3"

编译这个程序,同时设置 CODE_BASE_VERSION 环境变量为代码版本:

> CODE_BASE_VERSION=v.2021013001 cargo build

成功之后会在 OUT_DIR 目录生成一个 version.rs 文件:

// version.rs

pub fn version() -> &'static str {
            "v.2021013001; 1.48.0 macos/x86_64"
            }

运行程序:

关于 Cargo build script 更详细的介绍,请参见它的文档。

Python等解释型语言

在Python这样的解释型语言中,因为没有编译的过程,所以要在上线部署时完成版本信息注入,比如在构建镜像时。

我们建立一个如下的代码模版:

# version.py

import platform


def codebase_version():
    return "CODE_BASE_VERSION"


def py_version():
    return "{}{} {}/{}".format(platform.python_implementation(),
                               platform.python_version(),
                               platform.system(),
                               platform.machine())


def version():
    return "{}; {}".format(codebase_version(), py_version())


if __name__ == "__main__":
    print(version())

函数 codebase_version() 中的 CODE_BASE_VERSION 是需要被替换的版本信息,在构建时,使用 sed 命令进行替换:

> CODE_BASE_VERSION=v.2021013101
> sed -i -e 's|CODE_BASE_VERSION|'$CODE_BASE_VERSION'|g' version.py

CODE_BASE_VERSION 会被替换为 v.2021013101,注意:CODE_BASE_VERSION 中不能包含 "|",否则会和 sed 的正则分隔符冲突。

运行程序:

显示版本信息

除了在命令行中显示版本信息,我们还可以在服务接口中返回它。比如在返回内容中增加一个version字段等:

总结

本文介绍了如何在不同语言中,把版本信息注入到代码的自动化方法,从而避免了手动维护版本的繁琐,并提高了准确性。版本是代码的一个“属性”,推而广之,我们还可以把更多类似的“属性”在服务中体现出来,比如编程语言、服务器IP、运行时版本、核心代码的时间统计等等。它们不是业务数据,而是关于代码或服务本身的数据,它让代码自己“说话”,把当前最真实的情况反映给开发者,也为后续一些自动化的操作提供了可能。

希望本文能对你有所帮助,有什么想法和意见可以留言告诉我~

封面图,Photo by on

关注个人公众号 WhatHowWhy 获得及时更新