在开发过程中经常需要在编译期做一些处理,此时经常的方案一般是用宏或者其他预定义的功能。
在cpp,dart和rust中分别提供了不同程度的能力。
比如从环境中拿到一些变量的值,作为变量或者按照条件编译。
- 像把git提交的id和时间写入到程序和help或者version文本中。
- 或者根据环境中的变量来改变编译流程。
以获取git提交信息作为例子,总结一下三种语言提供的能力有何不同。
git
先说说如何获取提交id这类的信息吧。
一般说来关心的git信息只有三种:
- Commit hash,或者叫revision,是指每次提交对应的id hash,这玩意可以完全记录下来,也可以记录前面七个字母。
- 大概七个字母即可,似乎八个也是正确格式,但是github上显示的短hash是七个字母,这里就也以七个字母为准。
- Commit time,提交时间,什么时候提交的。
- Tag,也就是当前或者最近一次
git tag
打上的标签,这个是软件的版本。- 其实还有一点,如果分发不在tag上的提交的话,建议再加上一点额外的信息:记录一下当前提交比最新的tag多几个提交,有点类似patch version的感觉。
- 举个例子:比如昨天提交完,打了一个tag叫
v1.2
,今天又提交了一次,那么这次提交比昨天的tag多了一次提交,如果今天的这个提交要作为nightly版本发布的话,最好提供类似v1.2+1
或者v1.2-今天的commit-id
。可以一眼看出多了多少提交。 - 否则的话,虽然有git hash,也会让人迷惑:明明是一样的tag版本,怎么提交id还不一样呢。
- 当然,如果没有nightly版本,所有发布的版本都是正好在tag上,可以不做这个。
那么如何获取各项信息呢?
短的git hash
运行:git --no-pager show --oneline --format=%h -s HEAD
输出:29d6fc0
由于高版本的git在输出时会使用pager(就是可以翻页,然后退出的功能,类似less命令),有可能导致在脚本或者构建过程中使用git命令的时候获取不到正确的输出,因此加上--no-pager
保证stdout里的信息的想要的。
然后format呢类似date命令的format,详细定义见man手册。
长的git hash
运行:git --no-pager show --oneline --format=%H -s HEAD
输出:29d6fc099e3042331f8076115752d409ed70b067
git提交时间
运行:git --no-pager show --oneline --format=%cd --date=format:"%F %T %z" -s HEAD
输出:2023-09-07 05:04:03 +0800
这里的时间格式就更像date的了,可以自定义,还是详见man手册。
最近的git tag
运行:git describe --abbrev=0 --tags
输出:v1.5.0
注意如果没有tag,stdout会为空,stderr有错误信息“致命错误:没有发现名称,无法描述任何东西。”,这种情况需要单独处理,比如把版本号手动设置成v0.0.0
。
距离最近的git tag有几次提交:git log --pretty=oneline TAG_ID...COMMIT_ID | wc -l | xargs expr 1 +
略复杂一点,TAG_ID
是之前获得的当前最近的tag的名字,比如v1.6.0
,COMMIT_ID
是之前获得的当前提交的commit hash,比如29d6fc0
然后这个git命令会把从TAG_ID
到COMMIT_ID
的所有提交打印出来,每个提交占一行,不包括TAG_ID
所在的那一次提交。
最后再用wc -l
统计一下行数,就可以作为到TAG_ID的距离
。
那么最后为什么还要用expr
加上1呢?其实是喜好问题,如果加上1,当TAG_ID
和COMMIT_ID
实际是一次提交的时候,获取到的距离是1,最终可以打印类似v1.5.0-release1
。不加expr
的话最终是v1.5.0-release0
。我个人更喜欢这个修订号从1开始,于是加上1了,不加也完全可以。
因为各种包管理的包版本,在这个小的修订号上都是从1开始。
这部分的获取方式不唯一,git提供的命令太多了,选一个喜欢的就好。
有个地方忘了说,如果环境中git版本很低,不支持pager,那加了--no-pager
反而会报错,不认识这个参数嘛。但是那种情况很少见,因此不考虑了。
至少我记得ubuntu1804上都支持--no-pager
。
CPP
直接用宏即可,比如make的时候添加参数-DMY_MACRO=1
后源码中会多出一个叫MY_MACRO
的值为1的宏。
然后……随便写个例子。
1
2
3
4
5
| #if MY_MACRO
#include"my_header1.h"
#else
#include"my_header2.h"
#endif
|
里面用#if
和#ifdef
其实都可以,看想用宏表达什么意思吧,是类似int的数值还是bool一样“有或者咩有”。
好吧,这个不是今天的重点,重点是如何自动地把这些git信息随着开发自动更新,手动加宏也太丑了。
这个时候还是要请出没有人喜欢但是又不得不用的cmake。
cmake内可以执行命令,可以获取执行的命令的输出,还可以加宏,完全满足需求。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
| # Get git info
find_package(Git QUIET)
if (GIT_FOUND)
set(GIT_COMMIT_TIME "")
set(GIT_COMMIT "")
set(GIT_COMMIT_LONG "")
# GIT_BRANCH = $$system(git rev-parse --abbrev-ref HEAD)
execute_process(
#COMMAND ${GIT_EXECUTABLE} --no-pager log -1 --pretty=format:%cd --date=format:"%F %T %z"
COMMAND ${GIT_EXECUTABLE} --no-pager show --oneline --format=%cd --date=format:"%F %T %z" -s HEAD
OUTPUT_VARIABLE GIT_COMMIT_TIME
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
)
execute_process(
COMMAND ${GIT_EXECUTABLE} --no-pager show --oneline --format=%h -s HEAD
OUTPUT_VARIABLE GIT_COMMIT
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
)
execute_process(
COMMAND ${GIT_EXECUTABLE} --no-pager show --oneline --format=%H -s HEAD
OUTPUT_VARIABLE GIT_COMMIT_LONG
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
)
execute_process(
COMMAND ${GIT_EXECUTABLE} describe --abbrev=0 --tags
OUTPUT_VARIABLE GIT_VERSION_TAG
OUTPUT_STRIP_TRAILING_WHITESPACE
ERROR_QUIET
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
)
execute_process(
COMMAND ${GIT_EXECUTABLE} log --pretty=oneline ${GIT_VERSION_TAG}...${GIT_COMMIT}
COMMAND wc -l
COMMAND xargs expr 1 +
OUTPUT_VARIABLE GIT_RELEASE_COUNT
OUTPUT_STRIP_TRAILING_WHITESPACE
# ERROR_QUIET
WORKING_DIRECTORY ${CMAKE_CURRENT_SOURCE_DIR}
)
add_definitions(-DAPP_COMMIT_TIME=${GIT_COMMIT_TIME})
add_definitions(-DAPP_COMMIT="${GIT_COMMIT}")
add_definitions(-DAPP_COMMIT_LONG="${GIT_COMMIT_LONG}")
add_definitions(-DAPP_VERSION_TAG="${GIT_VERSION_TAG}")
add_definitions(-DAPP_RELEASE_COUNT="${GIT_RELEASE_COUNT}")
message(STATUS "Git commit time:" ${GIT_COMMIT_TIME})
message(STATUS "Git commit:" ${GIT_COMMIT})
message(STATUS "Git commit full name:" ${GIT_COMMIT_LONG})
message(STATUS "Git version tag:" ${GIT_VERSION_TAG})
message(STATUS "Git release count:" ${GIT_RELEASE_COUNT})
else ()
message(WARNING "Git not found")
endif ()
|
使用的git命令就是刚才说的命令。不同点在于最后的管道要分开写成三个COMMAND。
上述配置把git的commit hash以及提交时间定义成宏,之后在源码里直接使用APP_COMMIT_TIME
等宏即可。
需要注意的是,这玩意是在cmake里的,也就是说如果只make,不跑cmake,这些宏的定义不会更新。
并且,在提交之后,需要重新cmake一次,以生成最新的信息,不然宏的值还是老的。
Dart
dart虽然是个易上手的尽量简单的语言,也提供了类似宏的功能,叫dart define。
dart define不是某个包,是语言内置的功能,在fluter run
或者flutter build
的时候,加上参数--dart-define=MY_MACRO="MACRO_VALUE"
以后,在源码里通过String.fromEnvironment("MY_MACRO")
可以获取到值MACRO_VALUE。
有一个地方可能会引起疑惑,因为String.fromEnvironment
显然是运行期执行的代码,那么我运行的时候如果手动设置环境变量MY_MACRO=MACRO_VALUE
的话,岂不是把编译时候的覆盖掉了?
其实不然,如果编译期指定了MY_MACRO
的值,那么编译的时候String.fromEnvironment
可以获取到,会被编译器优化成写死的值,不需要担心运行时被覆盖。
但是直接在命令里加dart define也非常麻烦,那就写个脚本吧。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
| #!/bin/bash
set -ex
COMMAND=""
APP_VERSION=""
GIT_COMMIT_TIME=$(git --no-pager show --oneline --format=%cd --date=format:"%F %T %z" -s HEAD)
GIT_COMMIT_ID=$(git --no-pager show --oneline --format=%h -s HEAD)
GIT_COMMIT_ID_LONG=$(git --no-pager show --oneline --format=%H -s HEAD)
FLUTTER_VERSION=$(flutter --version | sed -n 's/Flutter \([0-9\.]*\).*/\1/p')
DART_VERSION=$(dart --version | sed -n 's/Dart SDK version: \([0-9\.]*\).*/\1/p')
if [ -f ../pubspec.yaml ];then
PUBSPEC_FILE="../pubspec.yaml"
elif [ -f pubspec.yaml ];then
PUBSPEC_FILE="pubspec.yaml"
fi
APP_VERSION=$(cat ${PUBSPEC_FILE} | sed -n 's/version: \([0-9\.\+]*\).*/\1/p')
COMMAND="flutter $*"
${COMMAND} \
--dart-define=GIT_COMMIT_TIME="${GIT_COMMIT_TIME}" \
--dart-define=GIT_COMMIT_ID="${GIT_COMMIT_ID}" \
--dart-define=GIT_COMMIT_ID_LONG="${GIT_COMMIT_ID_LONG}" \
--dart-define=FLUTTER_VERSION="${FLUTTER_VERSION}" \
--dart-define=DART_VERSION="${DART_VERSION}" \
--dart-define=APP_VERSION="${APP_VERSION}" \
|
这里不光获取到了git提交的信息,还把flutter和dart的信息也加进去了。
用的时候只需要:
1
2
3
4
5
6
| Text(
String.fromEnvironment(
'GIT_COMMIT_ID',
defaultValue: 'unknown',
),
)
|
非常方便,而且安全。
但是!
但是dart的这个define没法做条件编译。
虽然有网上的文章说可以条件编译,比如:
1
2
| import "xxx.dart"
if yyy "yyy.dart";
|
类似这个,但是只能区分web平台和原生平台,无法区分是windows还是linux还是android。
这算个毛线条件编译,如果有条件编译的话,做package的时候就不需要分成好几个package 比如xxx_windows,xxx_linux了。
分开太丑了,而且不好调试,也不好维护,有传言说dart会支持元编程,但是现在还不行,等等吧。
另外如果是windows平台,还需要写powershell完成这个功能。
太折磨了,就一个区分平台,有时候明明只有一点区别却非要分成多个package,我现在在做的一个原生的插件就因为这个很难同时在win和linux上编译,仅仅因为ffi的时候参数类型有区别。
windows是utf16,其他平台是utf8。
Rust
Rust的方案在我看来是上述两个方案的折中吧,既是原生支持的功能,也需要跑一些命令比较麻烦。
在Rust里,这部分可以通过build.rs这个功能完成,在里面跑一些命令,获取输出,保存成变量,然后代码里引入,使用。
build.rs:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
| use std::env;
use std::ffi::OsStr;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command;
#[allow(clippy::too_many_lines)]
fn main() {
let out_dir = env::var("OUT_DIR").unwrap();
let git_commit_time = run_command(
"git",
[
"--no-pager",
"show",
"--oneline",
"--format=%cd",
"--date=format:%F",
"-s",
"HEAD",
],
)
.0;
let git_commit_time_long = run_command(
"git",
[
"--no-pager",
"show",
"--oneline",
"--format=%cd",
"--date=format:%F %T %z",
"-s",
"HEAD",
],
)
.0;
let git_commit_revision_long = run_command(
"git",
[
"--no-pager",
"show",
"--oneline",
"--format=%H",
"-s",
"HEAD",
],
)
.0;
let git_commit_revision = run_command(
"git",
[
"--no-pager",
"show",
"--oneline",
"--format=%h",
"-s",
"HEAD",
],
)
.0;
let git_tag_data = run_command("git", ["describe", "--abbrev=0", "--tags"]).0;
let git_tag = if git_tag_data.is_empty() {
String::from("0.0.0")
} else {
String::from_utf8(git_tag_data).unwrap()
};
let mut data = String::new();
data.push_str(make_trimmed_str_var_from_bytes("GIT_COMMIT_TIME", git_commit_time).as_str());
data.push_str(
make_trimmed_str_var_from_bytes("GIT_COMMIT_TIME_LONG", git_commit_time_long).as_str(),
);
data.push_str(
make_trimmed_str_var_from_bytes("GIT_COMMIT_REVISION", git_commit_revision).as_str(),
);
data.push_str(
make_trimmed_str_var_from_bytes("GIT_COMMIT_REVISION_LONG", git_commit_revision_long)
.as_str(),
);
data.push_str(make_trimmed_str_var("GIT_TAG_VERSION", &git_tag).as_str());
let dst_path = PathBuf::from(out_dir).join("constants.generated.rs");
generate_file(dst_path, data);
}
fn generate_file<P: AsRef<Path>, D: AsRef<[u8]>>(path: P, data: D) {
let mut f = OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(path)
.unwrap();
f.write_all(data.as_ref()).unwrap();
}
fn run_command<S, I>(command: &str, args: I) -> (Vec<u8>, Vec<u8>)
where
S: AsRef<OsStr>,
I: IntoIterator<Item = S>,
{
let cmd_output = Command::new(command).args(args).output().unwrap();
let cmd_stdout = cmd_output.stdout;
let cmd_stderr = cmd_output.stderr;
(cmd_stdout, cmd_stderr)
}
fn make_trimmed_str_var(name: &str, value: &str) -> String {
format!("pub const {name}: &'static str = \"{value}\";\n",)
}
fn make_trimmed_str_var_from_bytes(name: &str, value: Vec<u8>) -> String {
let v = format!(
"pub const {name}: &'static str = \"{}\";\n",
String::from_utf8(value).unwrap().trim()
);
v
}
|
因为重复的代码太多,还稍微封装了一下。
处理得比较粗糙吧,如果任何一处报错了就直接panic,但是无所谓,按说就不应该panic的。
每次build的时候,会把生成的代码放到target/debug/build/app_name-hash/out/constants.generated.rs
。
生成类似如下的代码:
1
2
3
4
5
| pub const GIT_COMMIT_TIME: &'static str = "2023-09-20";
pub const GIT_COMMIT_TIME_LONG: &'static str = "2023-09-20 17:30:39 +0800";
pub const GIT_COMMIT_REVISION: &'static str = "856d8bc";
pub const GIT_COMMIT_REVISION_LONG: &'static str = "856d8bce9cef73cf134414384319c3bf57dad35c";
pub const GIT_TAG_VERSION: &'static str = "0.0.0";
|
不需要手动设置OUT_DIR
的值,也不要设置。
然后这样去用:
1
2
3
4
5
6
7
8
9
10
11
| mod constants {
#![allow(dead_code)]
include!(concat!(env!("OUT_DIR"), "/constants.generated.rs"));
}
use self::constants::{GIT_COMMIT_REVISION, GIT_COMMIT_TIME, GIT_TAG_VERSION};
lazy_static! {
static ref VERSION: String =
format!("{GIT_TAG_VERSION}+{GIT_COMMIT_REVISION} {GIT_COMMIT_TIME}");
}
|
定义一个当前模块的子模块,叫constants,名字其实随便,也不用担心重名因为是直接引用的内部的变量。
然后引入需要用的那些常量,GIT_COMMIT_TIME
等,然后就随便用了。这里因为需要static周期的变量,用了lazy_static。
怎么说呢,这个做法还比较优雅吧,也不算丑。
我这里测试,不管是intellij-rust还是rust-analyzer,在打开项目以后,编制索引的时候会把这些变量编出来,这样也不会报错找不到定义。
感觉完爆之前在flatpak设置rust代理
里提到的*.in,因为那种方案是把这些变量放到里meson.build里,ide感知不到。
总结
三种语言各有优劣吧。
- cpp的用cmake,宏用起来简单,好配置,功能也强,大多数人也熟悉,但是注意编之前先跑cmake刷新变量的值。
- dart的就更简单了,跑脚本甚至手动加都行,也足够安全,但是没法条件编译,功能很弱,跨平台的话还需要写不同的脚本。
- rust的功能很强,和ide集成也和cpp/cmake无异,也可以条件编译,甚至集成外部的代码,只是写起来麻烦一些。