C/C++符号隐藏与依赖管理(四):依赖管理

一个项目中,除了非常底层的软件模块外,大多数模块都需要其它的模块的协助才能完成功能,这需要借助模块之间的依赖管理能力。

依赖管理包含如何控制模块间的最小化依赖,如何发布自己的API,如何获取别人的API,以及如何对依赖关系进行追溯和控制,包括解决冲突。

依赖管理不仅决定了模块间的协作方式,还决定了单一模块能否高效的独立开发、构建和测试,以及能否独立的进行发布。

前文我们总结了每个模块如何做好自己的符号隐藏与头文件设计,那么模块之间的依赖又要如何管理和维护呢?

为了回答这个问题,我们先来审视下不同开发阶段对于所依赖的其它模块到底需要哪些东西。

在写代码或者阅读代码的时候,我们需要看到当前模块所依赖的其它模块的外部头文件。只有这样代码才能不缺失符号声明,IDE才能正常解析、跳转和提醒,我们才能正确调用所依赖的接口完成自己的代码开发。

所以在模块的独立开发过程中,能看到所依赖模块的公开头文件是至关重要的,而对其它模块的内部细节(内部头文件、实现文件、构建脚本等)都是不需要的。

当然开发过程中还要能对所开发的模块执行独立的构建,以便能快速验证当前的代码能否被正确编译和链接。这时有可能需要依赖的其它模块的二进制,这取决于当前模块是要构建成静态库、动态库还是可执行程序。

如果当前模块是构建成静态库,那么它的构建活动主要是编译和打包,所以从严格意义上说是不需要依赖方的二进制的。

如果当前开发的模块是要构建成一个动态库或者可执行程序,那么如我们前文所述它必须要能完整链接,这时就必须能获得它所依赖的其它模块的二进制。

所以从独立构建的角度来说,我们最多还需要所依赖的其它模块的二进制。

但是如果正在开发的模块有基于代码的测试工程,无论是单元测试工程还是针对整个模块的功能测试工程,就可以通过运行测试工程的构建来触发模块源码的构建。这时缺失的外部符号可以用桩来填补,因此可以降低对外部的二进制依赖。

为了提高测试工程对构建的验证有效性,我们需要遵循一些原则:1)测试的构建环境和生产构建环境尽可能保持一致;2)测试工程尽量复用被测模块的生产环境构建脚本;3)测试构建产生的模块二进制库最好和生产环境保持一致。

在满足上述条件后,我们可以在开发阶段大胆的使用测试构建代替真实构建,以降低我们对其它模块的二进制依赖,提高我们的开发效率。

不过对于动态库和可执行程序,不要忘了在最终发布的时候,仍然是需要和真正的依赖方的二进制进行链接的。所以从完整意义上来说,对于动态库和可执行程序,在构建阶段仍是需要能够获取到依赖方的二进制的。

通过上面的分析我们看到,想要独立开发、构建和测试,最重要的是能够获取依赖方的公开头文件,而在一些场合下还需要依赖方的二进制。有上述这些就够了。

那么回到依赖管理上,好的依赖管理技术就是要保证我们在不同阶段所依赖的东西可以低成本的精准获得,同时又不会过度获得。

我们来看看当前C/C++常见的依赖管理手段。

首先是基于源码的依赖管理。常见的做法是将所有代码都在一个代码仓中,模块之间通过目录进行隔离。这种情况下,我们只要clone下代码,就可以看到其它所有模块的代码,无论是头文件还是实现代码。

这种依赖管理方式简单、低成本,但却不是“精准”的。模块之间太容易“过度”看到对方的细节,因此容易导致从源码到构建上不必要的耦合。

这种方式下经常遇到的第一个问题就是模块间的头文件耦合。由于源码都在一起,所以很容易要求所有模块的公开头文件全部集中放置在一个目录下,每个模块都可以include这个目录。

这种方式下每个模块依赖其它模块的公开头文件成本很低,但也正是因为成本低所以很容易随意包含。最终导致大家都互相交织在了一起而难以独立发布。

上述集中式头文件管理存在两个常见的变种:

  • 一种做法是在构建开始阶段先把所有模块的公共头文件拷贝到一起然后执行构建;

  • 另一种做法是在构建准备阶段用一个构建变量将所有模块的公开头文件路径串在一起,然后逐一传递给每个待构建模块的构建脚本。

上述两种做法和前面头文件集中管理的问题是一样的,每个模块仍旧可以看到并随意包含其它所有模块的头文件。

更糟的是,这两个变种做法还进一步带来了构建上的依赖:每个模块的构建都必须先从根构建开始执行(因为根构建可以跨模块完成构建前的文件拷贝或者构建变量拼接行为),这会导致内部所有模块丧失独立构建的能力。

这就是我们容易在基于源码的依赖管理方式下遇到的第二个问题:构建的耦合。

在基于全量源码可见的情况下,构建往往喜欢采用自顶向下设计:即整个项目需要从根构建开始执行,先准备环境,初始化各种构建变量,然后按照依赖顺序逐一执行每个模块的构建,最后再链接和打包。

这种构建设计方式虽然整体看起来简单高效,但是却造成了了每个内部模块之间以及与全局之间的构建耦合。每个内部模块的构建都必须从根构建开始执行,不仅构建速度慢,而且丧失了模块的独立构建的能力。

说了这么多,那么在基于全量源码的依赖管理方式下,是否就不能做到内部模块的独立的开发、构建、测试与发布呢?答案是可以做到。

看看以下做法:

  • 每个模块在自己的目录下自行维护自己的公开头文件(还记得前文中推荐过的模块目录布局吗);

  • 每个模块有自己内置的独立构建脚本和启动入口,并且有一致的模块级构建和模块级测试的触发命令;

  • 每个模块可以通过构建入口参数或者环境变量获得PROJECT_ROOT,作为整个项目源码的根目录;

  • 所有模块基于PROJECT_ROOT的相对路径遵循一套统一的约定,包括二进制的发布路径(可以是在每个模块内部的临时目录,也可以是在PROJECT_ROOT下的某个集中目录);

  • 每个模块根据自己的对外依赖,在自己的构建脚本里面描述所依赖的其它模块的头文件路径(可以按照约定用PROJECT_ROOT和模块名计算出来)。如果构建需要其它模块的二进制,就在约定的二进制目录下获得,如果找不到就调用统一的模块构建命令触发依赖模块进行构建;

  • 构建成功后,将生成的二进制发布到约定二进制目录中;

  • 如果模块要独立发布给第三方,需要模块里有内置的打包脚本(可以写到构建脚本里面),在构建成功后将自己的头文件和二进制(如果是动态库或者可执行程序,则还要包含所依赖的二进制)按照打包格式进行打包,并发布到对应的仓库中;

上面的这套做法,有点类似早期golang语言的依赖管理方式。没错,golang在没有引入module机制之前采用的就是基于GOPATH的单一代码库管理方式,它是Google的单一代码库实践在golang语言中的应用。

Google在单一代码库中能做到内部软件模块的独立开发、构建、测试与发布,是由良好的设计规划能力,工程工具能力,以及以团队自治为基础但又不缺乏整体协作纪律的组织方式和文化做基础的。

上述这套做法,解决了前面说的在全量源码管理方式下的模块与全量头文件耦合以及模块与外部构建之间的耦合问题,最终让每个模块可以做到独立开发、构建、测试和发布。

这里的核心是每个模块相当于是一个闭包,它自行管理自己的头文件和完整的构建以及命令入口。这套做法遵循约定优于配置的原则,制定了一套需要共同严格遵守的约定,每个模块的独立构建和发布过程都基于这套约定之上。

采用上述这种做法,对构建工具和构建设计会有一些更高的要求。

首先,构建设计需要解决每个模块的构建代码中共享的构建配置、构建参数和工具脚本的重复问题。

由于现在每个模块是一个自治的构建单元,拥有自己独立的构建脚本和内置的构建启动入口。这样所有模块的构建都需要干更多重复的事情,比如配置一样的构建环境、选择相同的体系架构和编译链接参数等等。

可以采取的解决方案是这些公共活动和代码通过设计进行提炼,然后将其作为共享的构建库,让每个模块在构建时自行依赖和调用(而非像之前只能由触发根构建开始统一为所有模块准备好)。最后如果模块采用源码发包的话,这些共享的构建代码库还需要作为包的构建时依赖一起发布,以便客户在构建时也能获得。

而对构建工具更高的要求,主要是需要构建工具能按照构建目标控制构建变量的作用域和传递性。

软件模块在构建的时候可能创建了某些构建变量,用于保存编译参数、预编译宏或者所有依赖的头文件路径等,这些构建变量我们希望它们的作用域和传递性是可以控制的。

比如我们不希望在构建执行过程中触发了依赖模块的构建后,当前模块的这些构建变量被默认继承了过去,也不希望依赖模块构建结束后修改了当前模块的构建变量的值。因此我们希望构建工具能支持更好的模块化构建,即按照不同的构建目标控制构建变量的作用域和传递性。

幸运的是CMake从3.0版本开始支持模块化构建,它引入了target的概念,以及基于target建立起了构建上下文的可见性和传播控制机制,可以满足我们的上述需求。

关于CMake的这些用法和实践方式,建议看看我的朋友尉刚强的这篇文章:《Modern CMake最佳实践》。强烈建议那些在用CMake,但仍旧以老的directory为中心的方式在用的项目,能够切换成以target为中心的使用方式,不要浪费了Modern Cmake的这个核心特性的价值。

还剩下一个问题是:采用上面这种方式,整个项目完整的构建和发布怎么做呢?

可以把项目的完整构建和发布也当做一个内部模块,它可以没有任何业务代码(或者只有main函数的实现),但是拥有自己独立的构建脚本。和其它普通的模块一样,它通过自己的构建脚本描述自己的依赖。先在约定的二进制目录中寻找它所依赖的模块的二进制,如果找不到就触发对应模块的构建和发布,最后再完成整体的打包和发布。

可见这种方式下,我们根本不需要之前的自顶向下的构建过程,每个模块的构建都是平等且独立的。另外,由于构建的闭包化,还更加容易的进行并行构建。

如果是一个集中式的项目,上述方式就已经能够满足依赖管理的需要了。上述方式可以帮每个模块“轻易而精准”的获取依赖,虽然仍旧有些“过度”(毕竟还是能够看到别人的源码),但是通过工具以及纪律约束,也可以保证不会有副作用。

然而上述方式对于社区化开发是完全不够的。

社区化开发很难将所有依赖的源码都放在一起,也很难控制其它依赖的变更时机及其兼容性,这时就需要更强大的依赖管理手段了。这个手段就是我们都知道的“包管理”。

包管理最大的价值在于制定了一套管理软件包的统一标准,其中包含了包的版本标准、打包发布标准、全链条的依赖追踪与冲突解决标准,以及基于这套标准之上的工具链。

包管理可以满足我们对依赖管理的完整定义:即可以保证我们在不同阶段的依赖都能够低成本的精准获得,同时又不会过度获得。因此,大多数编程语言都把包管理作为语言的工程核心对待。遗憾的是由于C/C++包管理的不成熟,所以对包管理的使用并不如其它语言那么普遍。

关于C/C++包管理的最新进展以及使用建议,可以看看我的这篇文章C/C++代码复用与包管理,这里就不再赘述了。

最后总结一下关于依赖管理的话题:

1) 依赖管理技术要保证在不同阶段所依赖的东西可以低成本的精准获得,同时又不会过度获得;
2) 依赖管理关乎软件模块能否独立的开发、构建、测试与发布。做好依赖管理需要好的设计规划能力,工程工具能力以及纪律约束;
3)对C/C++来说,构建工具和构建设计是依赖管理中非常重要的一环;
4)根据自己的项目特点,选择在合适的时机使用包管理器,对依赖进行更好的管理;

C/C++符号隐藏与依赖管理(五):代码库推荐

推荐阅读更多精彩内容