该文章来自于阿里巴巴技术协会(ATA)精选文章。
1.编译的严谨性
(1) 头文件的正确性
C++ 采用"separate compilation"(分离式编译)意思就是说在编译一个 foo.cpp时,唯一的对其他依赖代码的要求就只是看到它们的头文件
(header files),所以,只要每次编译时可以确保 foo.cpp和它 include的所有header
files都是一致的就可以了。但是,我们目前并没有做到这一点,因为,
- 一个员工不同时候的编译
- 不同员工的编译
- 不同机器上的编译
在以上的各种情况下,这些 header文件有可能不同或被其他人更改而无法察觉:
- linux headers
- glibc headers
- gcc headers
- 三方库 headers
alicpp 意在解决这个问题,因为在 alicpp环境下编译时,所有以上文件,甚至包括编译器本身,都是
alicpp gitrepo里的文件,并且这些文件是只读的(永远不会更改内容)。
(2) 三方库的菱形依赖问题
假如MyClass.cpp依赖了两个三方库 a和
b,它们又都同时依赖第三个三方库 c,此时我们必须保证 a和
b依赖的是同一个版本的 c,而不能是稍有差异的不同的 c,否则会出现难以查询的
buildproblem,编译会通过,但是生成的执行文件是有问题的。
alicpp 清楚记录每一套三方库的依赖关系,精确到版本号,以确保完全避免菱形依赖关系可能带来的隐患 。
2.链接的严谨性
(1) 动态库的正确性
我们有没有问过自己一个问题,那就是线上运行的时候找到的 .so是否是我们研发或测试时使用的同一个
.so文件?这些 .so包括:
- linux 和 glibc 的,比如 libpthread.so, librt.so
- C++ 的,比如 libstdc++.so, libboost_xxx.so
- 三方库的,比如 openssl 的 libcrypto.so
- 二方库的
对任何这些机载的 .so文件的依赖都会造成程序运行的不确定性,因为任何其他部门的人或任何人的错误操作都有可能对这些文件作变动。
前面提到的三方库菱形依赖问题在链接时依然存在,当两个三方库想要链接不同版本的 .so时,它们在执行时是冲突和危险的。
(2) 全静态链接
假如我们用静态链接的方式链接所有依赖的库,突然世界变的非常的美好,
- 我们发布的时候不再需要准备什么 package,因为只有一个执行文件需要 push 到目标机器
- 在目标机器上也不需要什么 ldconfig 或 LD_LIBRARY_PATH,不会有 .so 查询不到的问题
- 因此也就不会链接到错误的 .so
- 静态链接的程序因为没有 GOT (Global Offset Table) 的间接符号查询,所以执行速度会快
我们对目标机器的依赖性因此降至最低,只要是正确的 Linux大版本,就不会出任何问题,所有其他团队可以升级更换机器上的其他软件而不受其影响。
当然,全静态链接也有一些问题,
- 链接速度慢:下面会提到解决方案
- 文件可能很大:其实今天来讲 1GB 之内都是没有问题的
- 执行时不能多个执行文件共享一个 copy 的 .so:但假如整个机器给了一个应用,那 .so 和 .a是使用相同内存量的
- 在一些特殊情况下,不得已必须用 .so,比如动态生成的 .cpp 代码,Oracle 未开源的代码库,等等特殊情况
无论如何,大家应该看到全静态的美,尽可能的用静态方式链接更多的库,alicpp帮助大家准备全静态的链接指令。
(3) 半静态链接
如果我们很在意线上运行时不能链接到错误的 .so,我们可以靠生成特殊的 .so文件名来解决这个问题,比如:
pangu-trunk-3412562.so
<项目>-<repo>-<revision>.so
此时的 .so就成为“只读文件”(意思是名字和内容是一一对应的,没有人可以用同一个名字定义另一个改变了内容的文件),可以确保链接的绝对正确性。
3.编译和链接的速度
有了严谨的编译和链接守则,我们就可以把大家统一到一个编译和链接的标准和流程上来,我们也就可以统一的来解决我们的编译和链接的速度问题。
(1) 编译的速度
alicpp 将致力于建立一套完整和庞大的编译系统来优化我们的日常编译过程 T264,其中会用到,
- 大规模集群上的分布式编译
- pre-compiled headers (PCH):因为我们完整的操控了所有 header files,我们可以提前编译好这些头文件
- cached .o:我们绝大部分编译工作是和同事们编译相同的 .cpp,我们可以把编译结果记录在类似于 ccache 的内存中
- offline compilations: 我们会在凌晨时分提前编译好常用文件
(2) 链接的速度
alicpp 会尝试 Google的 Gold Linker,可以
5到 10倍的提高链接速度 。
4.多模块代码共建
可以说我所看到的我们目前的 C/C++团队和模块之间的协作关系是混乱不堪的,因为我们没有遵守应有的法则。这里详细的记录和解释了每一个步骤和理由:
IMPORTANT: 这里的守则是“充分”和“必要”的,换句话讲,没有一个是不需要的,也没有一个是没有提到的。
(1) 模块的依赖性
我们每一个团队对其他团队的依赖性都可以按照进度要求来分成三种情况,
(a) 弱耦合
我们对对方的进度要求不高,我们需要的功能目前已经提供了,如果将来有新版本的话,升级了当然好,但是不升级也问题不大,即使升级也是低优先级的工作。典型的例子是对大多数三方库的要求。
(b) 强耦合
我们必须尽量跟上对方的进度,不然就造成软件对接的诸多问题或是线上支持的困难,但是我们又担心跟的太紧会看到对方不必要的新代码带来的 bug,此时我们要的是“尽量跟上,但并不要最新版本”。我们很多团队之间的关系就是这样的,比如
ODPS 软件依赖底层的飞天系统,但是 ODPS有自己的稳定性需求,不能对新写的飞天代码跟进太快。
(c) 强强耦合(一体)
我们必须和对方是相同进度,因为我们代码的依赖性太大,同时双方又在不断做调整。我们小团队之内就是这种情况。假如两三个小团队之间也相互依赖的非常紧密,也是处于一体状态中。
(2) 代码库的结构
针对以上三种依赖性,我们就可以直接确立代码库的结构:
IMPORTANT: 明明是强耦合的情况却自己定义成弱耦合是偷懒!明明是强强耦合的情况却自己定义成强耦合是分裂主义!我们要尽可能的把依赖性朝着强的方向确立。
(a) 弱耦合
弱耦合的模块可以在不同的 git repo(svn库)里,比如
alicpp的三方库,甚至于一些二方库,它们可以有自己的 git repo,只要我们有办法找到他们的
include 和 lib就可以和它们对接编译和链接,我们也可以从容的针对它们的不同版本进行引进,非常长期的做版本升级工作。
(b) 强耦合
强耦合的模块必须在同一个 git repo(svn库)里开发,编译速度不是我们分属不同
git repo的借口,我们正在解决这个技术问题。代码权限是人为的分属不同 git repo的障碍,我们正在解决这个行政问题。我们之所以说“必须”在同一个
git repo,是因为下面会介绍到 git/svn的命令在做代码操作时,只有在一个
git repo里才能最容易和自然的实现。
(c) 强强耦合(一体)
强强耦合的模块必须在同一个 git branch(svn
branch)里进行,git branch或
svn branch是我们开发的最小单位,在同一个 branch里研发的人员应该坐在一起,有问题可以马上解决,只有这样才能让相互非常依赖的代码以高速前进。
(3) 代码周期
上图中,"aliyun"(阿里云)是多个强耦合团队的总和项目,是一个
git repo(svn库),"pangu"(盘古)是阿里云的一个负责底层库的团队,是一个
git/svnbranch,这张图里列出了所有维护代码库需要的 git/svn命令,
- git branch: 一次性的,盘古创始人执行这个命令后,从此所有盘古团队的人就都在这个 branch 里写代码
- git merge: 经常性的,盘古团队负责人负责定期的将盘古的代码 merge 进入 master/trunk,也同时把 master/trunk 的代码 merge 进入盘古 branch
- git cherry-pick: 偶尔性的,有的时候另一个团队的 bug fix 或小改动是急需的,就只把那个diff (代码改动)采摘过来
IMPORTANT:
除了这些命令外,不再需要任何其他命令,更加进一步说,其他任何命令都是不允许的。
见上图,多个团队时,每个团队都在做同样的事,他们各自按照自己的进度 git merge和
git cherry-pick。注意,
- 他们从不相互等待,只看自己的代码,稳定了就马上 git merge
- 每个团队永远保持 master/trunk 的正确性和稳定性
那么如何保证 git merge后代码是正确稳定的呢?靠两件事,
- pass 所有自己的 unit tests
- pass 所有上层团队的 unit tests
NOTE:
这就是为什么我们要在一个 git repo里做强耦合的代码开发,因为底层的改动必须自己编译所有上层代码,并负责跑通上层的人写的保护自己的
unit tests。
假如 git merge后master/trunk变的不正确不稳定了呢?那相互伤害的团队必须同时补足各自的
unit test,因为双方都有责任:
- 害人方没有写到一个 unit test 可以察觉错误
- 被害方没有写好一个 unit test 可以防止别人伤害到你
久而久之,日积月累,我们的 unit test
就会变的无比复杂和盘根错节,变的让 bug无以遁形。
IMPORTANT:
代码的稳定性来自于天长日久积累的 unit tests,不是靠战战兢兢的研发,慢慢悠悠的发布,代码不是红酒,不是放在那里就会自己变好的,所以把发布时间拖长是不会让代码更稳定的。
(4) git merge 周期
一般来讲,我们可以每星期或每半个月 git merge一次,让其他所有团队看到自己的代码变化。刚刚开始
git merge时可能 break master(不是简单的 compilation
failure,而是逻辑错误)很多次,那不是因为我们 git merge太频繁了,而是因为我们的
unit test太少了,要在此期间为每一次 break加 unit test,直到稳定为止。
IMPORTANT:
明明已经可以 git merge而不做是偷懒!是耽误其他所有人进度的不负责行为!
(5) master/trunk break
必须在所有团队告诫大家master/trunk break是不可饶恕的错误!
- 最后 git merge 的人必须负责跑通所有已有的 unit tests
- 一旦 break 必须马上 fix
- fix 里必须有新的 unit test 去避免将来的类似错误
(6) Master + Delta
每个团队发布的软件都是 Master +Delta,"Delta"是指自己团队的代码改动。不可以有任何其他的组合(比如
master +pangu branch + fuxi branch),原因很简单,因为每次 git merge时每个团队已经努力确保
master是正确的,而 pangu branch + fuxibranch并不是 fuxi团队背书认可的组合。
5.测试系统
对于 C/C++
这门语言来说,再也没有比 unit test更能让它稳定不出错的了。unit test就像马路上的车一样,而
bug就像想要跑到路对面的小老鼠一样,我们在抱怨我们的软件 bug特别多,很简单,因为我们的马路上就没有什么车在跑,好的
C++项目 unit test繁多,小老鼠根本没有机会可以跑到路的对面而不被撞到。
一个特别错误的认识就是把发布的时间拖长,认为这样软件问题就会减少。好吧,让我们来分析一下,
- 时间拖的再长,其中做的测试有哪些?假如有更多的测试,或许等待是值得的,但是几乎再多的测试一般来讲一天是可以跑完的,那么,假如一个软件是一个月或半年才发布,只有一天是有效的,其余时间都是在无谓的等待。
- 时间拖的再长,其中的问题是不会自己解决的,我们无非是把解决问题的时间压缩到更晚更短的时候而已。如果一个 bug 在我们头脑刚写完代码时是最容易解决的话,为什么要等到一个月后生疏了才去解决呢?
- 缓慢的节奏让我们的程序员们变的技术迟钝,无法在快速迭代中学到专业的 C++ 代码研发
并不是让我们每天都发布,适当的控制风险是必要的,但是可以认为任何超过一个月的发布都是拖沓的和缓慢的,我们控制风险靠的是测试集群的设立,尽可能模拟线上环境的测试,灰度发布,等等手段,其中没有一个是“等待”。
IMPORTANT: “等待”就是浪费生命,浪费公司财产,是
C/C++ 代码研发效率低下的表现,是不知道如何提高系统稳定性的懦弱做法。希望大家真正的提高我们的工作节奏,摒弃等待的消极做法。
IMPORTANT: “迟迟不敢跟进别的团队的代码”是我们长期没有遵守以上共建守则造成的,希望大家达到共识后,敢于
git merge,在 break时耐心增加
unit test来巩固我们的对 bug的防守线,慢慢的我们就会对
master建立足够的稳定度和信任。
alicpp 将着手于建立 continuous buildsystem(连续
build系统)和 continuous testsystem(连续测试系统),真正建立一套完善的
C/C++测试系统。
6.诊断系统
alicpp 会根据实际需要逐步补充各种线上诊断系统,比如,
- core dump 的自动收集和分析系统
- gdb 的自动符号查询
- request capture/replay 系统,可以让我们更容易的恢复现场
- memory leak detector,自动监测内存泄漏