记在一个遗留 C++ 项目上改造版本号。
背景
C++ 加 QT5 的历史遗留项目。彻底重写不实际,很多坑要重新踩。原作者找不着人,连问都没人可以问。只能重构梳理之后 fix bug 先凑合用。
琐碎的重构不值一提,唯独版本号的改造,可以记录一下,给以后参考。
版本号更新
之前的版本号是写死在代码里的。想起来改一下,有时就忘了。
这样会有一些弊端:
首先是增加记忆负担。
修改版本号动作很小。但开发的粒度跟发布的粒度并不一致。提交的是最小可运行粒度。发布则还要考虑业务和运营。一次发布往往包含多个提交。提交的时候,未必能预计到发布的时机并修改版本号。
相对可行的办法,是每次发布完,马上改掉版本号,作为下次发布的预备。
但这样提交历史里,会有大量版本号的提交,稀释了真正有用的提交。
何况即使这样,还是无法跟踪两次正式发布之间的测试版本。
一般完整的版本号,会同时包含 供用户关注的、粒度较大的『发布版本号』,和 内部跟踪的、粒度更小的『内部版本号』。git 的 commit hash (SHA-1) 就是一个天然的内部版本号,帮我们把程序唯一对应到一个提交。有些时候还要加上系统平台、架构或编译器的信息。可一旦加上这些信息,上面的问题就更严重了。
要么不记得,要么改不对,还污染历史。
这些矛盾的根源,在于 版本号主要作用于 测试、发布、运维,帮我们跟踪代码版本、构建信息,其内容和代码逻辑本身几乎是正交的,是在代码提交完之后才真正确定下来的;却为了后续的跟踪,偏偏需要内置在程序里 。
在代码里修改版本号,就成了当预言家,要预测版本什么时候发布,能不能通过测试,能不能正常发布,注定吃力不讨好。
如果版本号里还需要包含 commit hash,在提交前就预测并写入代码,则几乎是不可能的任务。信息摘要有雪崩效应,hash 由提交的所有信息共同确定;但是在这个提交里,却要先把 hash 写入。这就陷入了 鸡-蛋悖论。
编译期确定
所以合理的做法是,代码里预留这些信息的位置和相关的代码,值等到代码提交后的某一刻再决定。一般来说,这个时刻就是编译的时候。
对于 Go 而言,就是在编译时,给 linker 传递 -ldflags "-X '<包名>.<变量名>=<字符串>'"
,就可以给代码里对应的(string)变量赋值。
举例说,如果在 main.go
里这样写
|
|
编译时加上 -ldflags "-X 'main.version=v1.2.3'"
,那么我们最后运行程序得到的输出就是 v1.2.3
。
更具体地,我一般会在 Makefile
里这样写:
|
|
主版本号通过命令行通过环境变量 TAG
传入,其它的信息则通过 shell 自动获取。
C++ 和 QT5 下的方案
我需要在 C++ 里做到类似的事情。
特别强调,我本来用 C++ 就用得很浅,再加上已经有十年不怎么写,下面的内容仅仅对我而言值得记录一笔,对 C++ 高手来说可能完全不值一提。
-D 参数
项目用的是 g++ 编译器,可以很容易查到,最接近的做法,是给编译器传递 -D<宏>[=值]
来定义宏。
举例说,-DDEBUG_LOG
相当于 #define DEBUG_LOG
;-DVERSION=v1.2.3
则相当于 #define VERSION v1.2.3
。注意 -D
和后续内容中间没有任何间隔或者符号。
时间关系,我没有去确认其它编译器是否也有这个参数。
qmake 和 .pro
然而我们并不会手动调用 g++
,甚至都不会手写 Makefile
。项目下确实是有一个 Makefile
,但它是 qmake
自动生成的,任何修改都会在后续被覆盖。qmake
从 <项目名>.pro
生成 Makefile
。
结合现有的 .pro
文件和网上的信息,很容易确定 DEFINES
的值,最终会变成 Makefile
里的 -D
参数。
于是我在 .pro
加了一行
|
|
(暂时版本号里不想添加太多东西,只传入了 commit hash;甚至完整的 hash 都略嫌太长,只保留了前 10 位。)
迫不及待地开始编译,得到的 Makefile
里却是这样的
|
|
以空格为分隔符,每一段都成了一个参数……
不过这个很好解决,加个引号就行
|
|
引号也可以加在别的地方,只要把空格放在引号里,保证不会断开就行
|
|
无论是哪一种,最后得到的 Makefile
里都是
|
|
有效信息已经从 qmake
和 .pro
传到了 make
和 Makefile
手里。接下来 make
执行时,会执行 $(shell ...)
里的内容,并将结果原地展开。那么从 make
传递给 g++
的时候,-DCOMMIT_HASH=
后面就已经是具体的值了。
相当于 #define COMMIT_HASH 具体的hash
宏不是字符串
宏有了,我们终于来到代码部分。
由于宏是外部定义的,代码里没有,直接用会报错。只是不同 IDE 具体报的错不同。(因为 QtCreator 不好用,我在 VS Code 里写代码,QtCreator 只用来编译。)
即使不考虑这些报错,也可能存在后续忘了传对应参数的情况,从而导致代码有不可预料的结果。最好的办法还是加上 #ifdef
|
|
加了之后,VS Code 不再报错了(其实是因为它认为这个宏没有定义,直接忽略了这段代码)。但 QtCreator 很聪明地意识到还有问题,报了一个 expected expression
(这里需要一个表达式)。而如果改为将 COMMIT_HASH
当做字符串参数用,会提示少了参数。
究其原因,是 COMMIT_HASH
只是一个宏,而且是一个外部定义的宏,不是字符串。
外部定义
到这里,对 C++ 已经很生疏的我有点懵,然后就开始胡乱地尝试。(接下来的病急乱投医让各位见笑了)
如果改为 version = "COMMIT_HASH";
,倒是不报错了,但是 version
得到的,也确实只有一个 "COMMIT_HASH"
的字符串。这是因为
宏参数不会在字符串常量内部替换(parameters are not replaced inside string constants)。
宏是预处理器负责处理的,早在开始编译之前就已经被替换。如果在 .pro
里再额外加了引号,试图让宏的值带上引号呢:
|
|
相当于 #define COMMIT_HASH "具体的hash"
。然而没有用,仍然报错。
因为这种外部定义的宏,跟真实写在代码里的宏,还不一样。
实际在代码里定义一个 COMMIT_HASH
对比一下效果(以下报错都来自 VS Code):
#define COMMIT_HASH 1234
,引用处报错no suitable constructor exists to convert from "int" to "QString" : C/C++(415)
;#define COMMIT_HASH 123aef
(没有0x
前缀的 16进制数,模拟真实的 commit hash 值),报错user-defined literal operator not found : C/C++(2486)
;#define COMMIT_HASH "123aef"
,可以正常编译。
有这些差别,是因为写在代码里的宏定义,就在编译器和工具链 眼皮底下 ,完全可以先展开,再做静态分析。
可对于外部定义的宏,这些分析就没法做。工具链无法确定这是什么东西,也就无法确定究竟是以上哪种情况。
套娃的 # 展开
我们需要给工具链一个保证:在不知道宏的值的情况下,拿到的仍然是一个字符串。
对 C++ 来说,我们需要预处理运算符(stringizing operator):#
,它会把参数对应的字面文本转换为字符串。
是的,#
不仅是所有预处理指令(preprocessing directive)的开头,单独的 #
也是一个预处理运算符(preprocessing operator)。
为此我找了两个参考文档,分别来自
- GNU : https://gcc.gnu.org/onlinedocs/cpp/Stringizing.html
- 微软 : https://docs.microsoft.com/en-us/cpp/preprocessor/stringizing-operator-hash
两边结合着理解。
第一层:直接用
上来直接把代码改为 version = #COMMIT_HASH;
,得到的错误是 '#' not expected here : C/C++(10)
。
第二层:函数宏
结合例子理解一下,哦,貌似 #
只能在宏里面生效。好,改成这样
|
|
再试一下。这回可以正常编译。可是运行之后发现,得到的还是一个 "COMMIT_HASH"
。
还是着急了,只看了文档开头就动手。还是要把文档看完。
咦,不对啊,为什么 GNU 的例子展开了。你看
|
|
等价于
|
|
这 EXP
不是展开成 x == 0
了吗?
真的吗?
这里暂停让读者自己理一下。
1
2
3
4
5
哎呀,把自己绕进去了。让我们一一对应一下:
EXP
对应S
;- 展开之后
x == 0
对应COMMIT_HASH
; - 字符串里,
x
没有进一步展开,COMMIT_HASH
也没有。
好吧,确实没错。
EXP
的参数会 原封不动 替换到 if 语句中,并且被字符串化成为fprintf
的参数。如果x
是一个宏,它会在 if 语句中继续展开,但在字符串中不会。(The argument for EXP is substituted once, as-is, into the if statement, and once, stringized, into the argument to fprintf. If x were a macro, it would be expanded in the if statement, but not in the string.)
看到这里我懂了,#
的字符串化 先起作用。然后 双引号阻止了里面的宏进一步展开。
第三层:套娃
那我就是需要继续展开怎么办?
其实两边文档后面都给出了例子。还是以 GNU 的来参考(毕竟我们用的 g++):
|
|
str (foo)
对应的展开是 "foo"
, xstr (foo)
则经历了两次展开:
- 第一步先展开成
str(4)
, - 第二步再展开成
"4"
。
其实就是多展开一次,让 #
不那么快生效,好让传进去的宏有机会展开。
果然,我的代码改为
|
|
就达到了目的。到这里,运行之后,version
输出的就是具体的 git commit hash 了。
一点扩展
这里有了一个问题,如果传进去的宏,里面还有一层宏呢?
以 GNU 的例子为例,如果变成这样
|
|
会得到什么?
是 "bar"
还是 "4"
?
函数宏的层数需要总是比参数宏多一层吗?
需要再定义一层 #define ystr(s) xstr(s)
吗?
还说说两层的函数宏就能应对所有情况?
这个问题就留给读者自己尝试吧。
欢迎在留言说说你的看法。
后记
我有十年不怎么写过 C++ 了。
C++ 性能好,可以访问底层接口,可以精确控制代码行为和内存分配,支持多范式,开发自由度大,优点很多。所以在很多领域是首选。
但真的太复杂了,新手容易玩脱,老手把代码写对也不算轻松。心智负担大,历史包袱重。
要获得它那些优点,我甚至宁可写 C。其它场景,也优先 选择 Java,或者 Go。反正我是不可能选 C++ 作为新项目的开发语言了。何况现在还多了 Rust 这个选项。
偏偏碰上这么一个遗留项目。
只好把 C++ 临时捡起来,一顿重构。
原作者大概水平不高,或者写得不用心,又或者两者都有。代码质量很差,大量面条代码,动辄几百行的函数,宁可拷贝也不肯抽取函数,前后打脸的变量命名和代码逻辑,想当然的并发代码 …… 反正就一个编程规范的反面教材,吐槽不完。他大概也发现自己维护不下去,在我接手之前就撂挑子了。
重构过程按下不表,反正是一边猜测代码的意图,一边看运行效果去验证,等理解了,再去修正逻辑错误。
慢慢算是改出一点效果。但是还是有很多地方不太确定意图,为了保持跟服务端的兼容性,需要进行灰度更新。
出问题时,我需要能区分具体版本。
于是就有了上面的一出。
补充
关于 hash 部分,之所以说预测 hash 值只是 几乎 不可能,是因为还有一种办法:随机碰撞。
随机构造一个合法的 hash,尝试写入之后求真正的 hash,不断重试,直到构造的 hash 刚好和生成的 hash 一致为止。
理论上这是可以做到的。但这种做法会消耗大量的算力和时间。比特币挖矿的 POW 跟这个接近,但只是限定得到的 hash 的开头的零的数量,就已经耗费如此巨大的算力。如果要求算出的 hash 跟 构造的值 完全一致,需要的碰撞次数可能是天文数字。我不是这方面的专业人员,估算得可能不准确。但要说对普通开发电脑来说不可能,应该还是中肯的。(Google 破解 SHA-1 时,动用了大量的计算机集群,才构造出一个 PDF,获得预先构造的 hash)
而这,只是一次提交。难道每次提交都要动用超算,还要跑半天?
本文为本人原创,采用知识共享 “署名-非商业性使用-相同方式共享” 4.0 (CC BY-NC-SA 4.0)”许可协议进行许可。
本作品可自由复制、传播及基于本作品进行演绎创作。如有以上需要,请留言告知,在文章开头明显位置加上署名(Jayce Chant)、原链接及许可协议信息,并明确指出修改(如有),不得用于商业用途。谢谢合作。
请点击查看协议的中文摘要。