考虑到一个较大公司或者团队开发的服务端程序可能会用到一个或者几个库,本文主要讨论如何组织这些不同团队开发的库与应用程序。
在谈具体的C++库文件的组织之前,先了解一下更基本的话题:依赖管理。
假设一个服务程序Server,经过充分测试后,Server 1.0上线运行,一切顺利。Server 1.0用到的网络库(net 1.0)和消息中间件的库(hub 1.0),并且hub 1.0本身也用到了net 1.0,依赖关系如下图 所示
![35ee8087352f6df2dbcfb78cfcde9ba6.png](https://i-blog.csdnimg.cn/blog_migrate/c3d0d309cd8be8ee2f6052f7c0268146.jpeg)
尽管在发布之前QA人员sign-off的是server 1.0,但是我们应该认为他们sign-off的是server 1.0和它依赖的所有库构成的bundle。因为server 1.0和它用到的库联系非常密切。如果改变任何一个库,server 1.0都有可能发生变化(尽管该程序的源码和之前可能一模一样),也就可能跟当时充分测试通过的“server 1.0”的行为不一致。
这个问题对于C++之外的语言也同样存在问题,凡是可以再编译之后替换库的语言都需要考虑类似的问题。对于脚本语言来说,除了库之外,解释器的版本(Python 2.5/2.6/2.7 3.0+就更不用说了 )也会影响程序的行为,因此有Python virtualenv 和Ruby rbenv这样的工具,允许一台机器同事安装多个解释器版本
除了库和运行环境,还有一种依赖是对外边进程的依赖,例如server程序依赖某些数据源(运行在别的机器上的进程),会再运行的时候通过某种网络协议从这些数据源定期或者不定期读取数据。数据源可能会升级,其行为也可能会发生变化。
其实在实际的工程中,一旦选定了生产环境的操作系统的版本,Linux操作系统的版本、libstdc++版本、glibc版本是统一的,而且C++应用程序和库的代码都是操作系统原生的g++编译的,这样一来我们讨论应用程序和库的组织。
Linux的共享库(shared library)比Windows的动态链接库在C++编程方面要好用的多,对应用程序来说基本可以算是透明的,跟使用静态库无区别。主要体现在:
- 一致的内存管理。Linux动态库与应用程序共享一个heap,因此动态库分配的内存可以交给应用程序去释放,反之亦可。
- 一致的初始化。动态库里的静态对象(全局对象、namespace级的到对象等等)的初始化和程序其他地方的静态对象一样,不用特别的区分对象的位置。
- 在动态库的接口中可以放心地使用class、STL、boost(如果版本相同)
- 没有dllimport/dllexport的累赘。直接include头文件就能使用。
- DLL Hell的问题也小很多,因为Linux允许多个版本的动态库并存,而且每个符号可以有多个版本。
DLL hell指的是安装新的软件的时候更新某个公用的DLL,破坏了其他已有的软件功能。例如安装some 1.0会把net库升级为1.1版,覆盖了原来server 1.0和hub 1.0依赖的net 1.0,这就有潜在的风险
![56c3052288884de77147b3d5cd4febcf.png](https://i-blog.csdnimg.cn/blog_migrate/91a8f2f8db3c738e1a70f0d115c44d9f.png)
现在Window 7里面有side-by-side assembly,基本上解决DLL hell问题,代价是系统里有一个巨大的且不断增长的WinSxS目录。
一个C++库的发布方式有三种:动态库(.so)、静态库(.a)、源码库(.cc),下表总结了一些基本特征
![caddeaf45eb5698e7277239f352d778e.png](https://i-blog.csdnimg.cn/blog_migrate/90d9e9128e5050272da3a56f3f8e3b57.png)
作为应用程序的作者,如果在多台Linux机器上运行这个程序,要先把执行程序部署到那些机器上。如果程序只依赖操作系统本身提供的库,那么只要把可执行文件拷到目标机器上就能运行。这是静态库和源码库在分布式环境下的突出优点之一了。
相反,如果依赖公司内部实现的动态库,这些库必须事先(或者同时)部署到这些机器上,应用程序才能正常运行。这立刻面临运维方面的挑战;部署动态库的工作是由库的作者还是应用程序的作者来做呢,如果动态库的作者修正了bug,他可以自主更新所有机器上的库吗?
我们暂且认为库的作者可以独立地部署并更新动态库,并影响到使用这个库的应用程序。否则的话,如果每个程序都把自己用到的动态库和应用程序一起打包发布,库的作者不负责库的更新,那么和使用静态库就没什么区别了。
无论哪种方式,我们都必学保证应用程序之间的独立性,也就是让动态库的多个版本之间能够共存。例如部署sever 1.0和some 1.0之后的依赖关系就如下图了。
![85a0b52f40c2702ebf35614c8eec931e.png](https://i-blog.csdnimg.cn/blog_migrate/2a420779f33ec465572ec7b43b081f99.png)
按照传统的观点,动态库比静态库节省磁盘空间和内存空间,并且具备动态更新的能力,似乎动态库是首选,但是动态更新的能力让动态库有不可避免的问题。
动态库是有问题的
Jeffrey Richter对动态库的本质问题有精辟的论述:
一旦替换了某个应用程序用到的动态库,先前运行正常的这个程序使用的将不再是当地build和测试使用的代码。结果使程序变得不可预期。
怎样在fix bug和增加feature的同时,还能保证不会损坏现有的应用程序?得出的结论是不可能的。
作为库的作者,肯定不希望更新部署一个看似有益无害的bug fix之后,结果运维告诉你程序不能启动了(新的库破坏了二进制兼容性)或者表现了不符合预期的行为。
静态库也是半斤八两
静态库相比动态库有几点好处
- 依赖管理在编译器决定,不用担心日后它用的库会变。同理,调试core dump不会遇到库更新导致的debug符号消失的情况
- 运行速度可能更快,因为没有PLT(过程查找表),函数调用的开销更小
- 发布方便,只要把单个可执行程序拷到模板机器上就行。
静态库有个小缺点就是链接比动态库慢。
静态库把库之间的版本依赖完全放到了编译器,这比动态库要省心得多,但仍然不是一件容易的事,比如:
- 迫使升级高级版本。假设一开始应用程序 server 1.0依赖 net 1.0 和 hub 1.0,一切正常,如图所示。在开发server 1.1的时候,我们要用到net 1.1的功能。但是 hub 1.0 依然依赖 net 1.0,hub作者暂时没有升级到net1.1的打算,如果不小心的话,就会造成 hub1.0链接到 net 1.1,如图所示,这就跟编译hub1.0的环境完全不同了。
![6cecc927753355f1bf953dcd7ef16cc0.png](https://i-blog.csdnimg.cn/blog_migrate/8fb2bee00e51ea6d3fabb618259ac351.png)
- 重复链接。如果Makefile编写不当,有可能出现 hub1.0 继续链接到 net1.0 ,而应用程序连接到 net1.1 的情况,比如net库里有internal linkage的静态变量,可能造成奇怪的行为,因为同一个变量现在有两个实体。
- 版本冲突。比如说server 升级到了1.2版,想加入一个库cab 1.0,但是cab 1.0依赖net 1.2。这时,如果用net 1.1,则不满足cab 1.0的需求,如果用net 1.2,则不满足hub 1.1的需求怎么办?
![66378d52bc47ca069bb4bfcf0a410603.png](https://i-blog.csdnimg.cn/blog_migrate/04345e4a63d36f35f1c108e641f7dbe8.png)
可见静态库的版本管理并非想象中的那么简单,如果一个程序用到三四个公司内部的静态库,那么协调库之间的版本要花费一番力气了,单独升级任何一个库都可能破坏它原来的依赖。
源码编译是正解
每个应用程序自己选择用到的库,并自行编译为单个可执行文件,彻底避免头文件与库文件之间的时间差,确保整个项目的源文件采用相同的编译选项,也不用为库的版本搭配操心。这么做的缺点就是编译时间长,以为把各个库的编译任务从库文件的作者转嫁到了每个应用程序的作者。
另外,最好能和源码版本工具结合,让应用程序只需制定到哪个库,build工具能自动帮我们check out库的源码。
在目前看到的开源build工具里,最接近的是Chromium的gyp和腾讯的typhoon-blade,其他如SCons、CMake、Premake等工具依然是以库的思路来搭建项目。
总结
由于C++的头文件与源文件分离,并且目标文件里没有足够的元数据编译器使用,因此必须同时提供库文件和头文件。也就是说要想使用一个已经编译好的C/C++库(无论是静态库还是动态库),需要两样东西,一个是头文件,一个是库文件,这就存在两样东西不匹配的可能。这就造就C++简陋脆弱的模块机制的根本原因。C++库之间的依赖管理远比其他现代语言复杂,在编写程序库和应用程序时,要熟悉各种机制的优缺点,采用开发及维护成本较低的方式来组织和发布库。