现代CMake项目中的依赖管理

2020-6-30

在 C/C++ 项目中使用第三方库有两种方式:

  1. 第三方库在项目外部单独构建:从库的官网或是系统包管理程序上下载预编译好的包,或者事先在项目外部的其他路径下使用库的源码进行编译
  2. 第三方库的构建集成到项目的构建过程里,从源码开始编译

第一种方式对外部环境编译的要求是不确定的,很可能会打击构建项目的积极性,毕竟并不是所有的平台/发行版/系统版本都能轻松完成各种库的编译和安装。但这种方式很适合编译时间久或者工具链复杂的第三方库,比如说 Qt、V8、OSPRay 等。

第二种方式对开发者比较友好,简单粗暴的实现方式是使用 Git Submodule 拉取依赖源码,或者编写一些脚本管理第三方库。 但如果是使用 CMake 作为构建系统的项目,我们可以利用 CMake 的 FetchContent 模块来管理依赖。 FetchCotent 是 CMake 3.11 版本开始引入的依赖管理模块,和其他方式相比主要有以下几个优点:

  1. 支持 Git Clone、下载源码压缩包等多种方式获取代码
  2. 可以处理依赖树中存在的重复依赖
  3. 在 CMake Configure 阶段拉取代码,build 阶段编译代码,符合 CMake 原有机制,减少了执行多个命令的麻烦
  4. 用 CMake 一套工具控制一切编译、安装任务

上面提到了两种使用第三方库的方式,在 CMake 项目中还可以分出两种子情况,即第三方库是否也使用 CMake 作为构建系统,下面就介绍如何处理这四种情况。

1、第三方库使用 CMake, 并集成到项目的构建过程里

这种情况可以使用FetchContent模块获取第三方库的源码,核心函数只有两个:FetchContent_DeclareFetchContent_Populate,前者用于声明信息,后者用于下载代码。

下面的例子声明了两个依赖,AAA 和 bbb:

include(FetchContent) # 引入该CMake模块
FetchContent_Declare( # 声明依赖的相关信息
  AAA
  GIT_REPOSITORY https://github.com/AAA/AAA.git
  GIT_TAG        v1.0.0
  GIT_SHALLOW    TRUE # 不拉取完整历史,相当于`git clone --depth=1`
)
FetchContent_Declare(
  bbb
  URL  https://bbb.com/v2.0.0/bbb.tar.gz
  HASH qwerty # 可选,确保文件的正确性
)

但仅声明不会有代码被下载,还需要执行FetchContent_Populate才能使代码能在 Configure 阶段被下载,下载前也可以设置一些变量对子 CMake 项目进行控制:

set(AAA_BUILD_TESTS OFF) # 设置好变量用于关掉AAA项目的测试

FetchContent_GetProperties(AAA)
if(NOT AAA_POPULATED) # 确保只拉取一次
  FetchContent_Populate(AAA) # 此函数执行后将设置AAA_POPULATED变量
  # 通过AAA_SOURCE_DIR和AAA_BINARY_DIR就可以拿到源码所在目录的路径以及编译产物的目标路径
  # 此外还有其他变量可以用,见CMake FetchContent文档
  add_subdirectory(${AAA_SOURCE_DIR} ${AAA_BINARY_DIR})
endif ()

FetchContent_GetProperties(bbb)
if(NOT bbb_POPULATED)
  FetchContent_Populate(bbb)
  add_subdirectory(${bbb_SOURCE_DIR} ${bbb_BINARY_DIR})
endif ()

add_subdirectory后,AAA 项目的 target 都会进入到当前项目的作用域里,使用target_link_libraries即可完成关联。 (如果不了解 target 的概念,可以看我的另一篇文章:现代 CMake 的设计理念和使用。)

AAA_POPULATED这个变量会被FetchContent_Populate设置,可以用于确保同名依赖只被拉取一次。 因此当依赖树中存在同名的重复依赖时,最先被拉取的将会覆盖其他的版本。 假设上面的 AAA 和 bbb 两个依赖,同时使用FetchContent_Declare声明依赖了不同版本的 Ccc。如果 AAA 项目先执行FetchContent_Populate,则最终 Ccc 项目会使用 AAA 项目中定义的版本。 除此之外,我们还可以在声明 AAA 和 bbb 两个依赖前,提前 populate 特定版本的 Ccc,就可以实现版本的覆盖。

顺带一提,所有使用 FetchContent 模块下载的源码相关目录都在 build 目录下的_deps文件夹里。

2、第三方库未使用 CMake,将其集成到项目的构建过程里

使用的第三方库不一定使用了 CMake,或者使用不是现代 CMake。这些情况下利用FetchContent_GetProperties可以拿到依赖库的各种目录,结合 CMake 的其他命令完成各种操作。

比如Eigen这个 header-only 库,虽然使用了 CMake,但项目中测试相关的 target 过多,并且难以方便的禁用,我们可以在拿到源代码路径后自己创建一个简单的 target

include(FetchContent)
FetchContent_Declare(
    eigen3
    URL https://gitlab.com/libeigen/eigen/-/archive/3.3.7/eigen-3.3.7.tar.bz2
    URL_MD5 b9e98a200d2455f06db9c661c5610496
)
FetchContent_GetProperties(eigen3)
if (NOT eigen3_POPULATED)
  FetchContent_Populate(eigen3)
endif ()

add_library(eigen INTERFACE)
target_include_directories(eigen INTERFACE ${eigen3_SOURCE_DIR})

还有很多使用 make 作为编译工具的项目,我们可以通过拿到源码目录后,使用add_custom_commandadd_custom_target原地编译,并创建一个简单的 imported target。 这里以uWebSockets为例,这个库本身是 header-only 的,但使用 Git Submodules 依赖了一个使用 make 的子项目 uSockets:

# 常规操作,declare后polulate
include(FetchContent)
FetchContent_Declare(
    uWebSockets-git
    GIT_REPOSITORY https://github.com/uNetworking/uWebSockets.git
    GIT_TAG v18
)

FetchContent_GetProperties(uWebSockets-git)
if (NOT uWebSockets-git_POPULATED)
  FetchContent_Populate(uWebSockets-git)
endif ()

# 创建一个命令用于编译出uSockets的静态库,并且创建好头文件目录
add_custom_command(
    OUTPUT ${uWebSockets-git_SOURCE_DIR}/uSockets/uSockets.a
    COMMAND cp -r src uWebSockets && make
    WORKING_DIRECTORY ${uWebSockets-git_SOURCE_DIR}
    COMMENT "build uSockets"
    VERBATIM
)
# 创建一个自定义target,依赖上面自定义命令的OUTPUT,但这样CMake还不会编译这个target,还需要一个真正的target依赖此target
add_custom_target(uSockets DEPENDS ${uWebSockets-git_SOURCE_DIR}/uSockets/uSockets.a)

# 创建一个imported target,依赖上面的自定义target,从而确保在使用这个imported target时,上面的编译命令能被执行
add_library(uWebSockets STATIC IMPORTED)
set_property(TARGET uWebSockets PROPERTY IMPORTED_LOCATION ${uWebSockets-git_SOURCE_DIR}/uSockets/uSockets.a)
set_target_properties(
    uWebSockets PROPERTIES
    INTERFACE_INCLUDE_DIRECTORIES "${uWebSockets-git_SOURCE_DIR};${uWebSockets-git_SOURCE_DIR}/uSockets/src"
)
add_dependencies(uWebSockets uSockets) # 见上面add_custom_target的说明

总之当拿到源码目录后,可以结合 CMake 的其他命令完成各种操作,毕竟我们需要的可以只有头文件和链接库文件。

3、第三方库使用 CMake,在项目外部构建

一个靠谱的 CMake Library 项目应该在 install package 时提供xxx-config.cmake或者XXXConfig.cmake文件,其中包含项目相关的 imported target, 或者设置链接库路径等 CMake 变量。 (具体怎么做可以参考 CMake 官方的这个教程: Adding Export Configuration (Step 11),或者这篇更详细的指导:Tutorial: Easily supporting CMake install and find_package())

这种情况下可以使用find_package命令来寻找依赖。假设库的名称为Aaa,调用find_package(Aaa 1.0.0)时,CMake 会尝试在/usr/lib/cmake默认路径下寻找Aaa-config.cmake或者AaaConfig.cmake,这个文件可以放在以Aaa*为前缀的文件夹下来支持多版本并存。(Linux 用户可以执行ls /usr/lib/cmake看看)

当然,这个第三方库不一定就安装在默认路径,那么用户可以设置Aaa_DIR这个变量,用于提示 CMake 应该去哪里寻找 config 文件。 在找到该文件后,Aaa_FOUND变量会被设置,同时 config 文件中包含的 target 以及 CMake 变量都会存在于fink_packge之后的的作用域里,可以按需使用。

4、第三方库未使用 CMake,在项目外部构建

现实并不总是那么美好,第三方库安装时可能没有提供 config 文件,比如使用make作为构建工具的项目。

我们可以直接使用find_path, find_library两个命令来寻找头文件以及链接库所在的路径,CMake 会尝试到默认路径下寻找, 但同样的,库不一定被安装在默认路径下,于是我们可以允许使用一个变量来提示位置:

# 可以设置POCO_INCLUDE_DIR这个变量进行路径的提示
find_path(
  POCO_INCLUDE_PATH
  NAMES Poco.h Poco/Poco.h
  HINTS ${POCO_INCLUDE_DIR} "${CMAKE_PREFIX_PATH}/include"
)
# 可以设置POCO_LIB_DIR这个变量进行路径的提示
find_library(
  POCO_FOUNDATION_LIB
  NAMES PocoFoundation
  HINTS ${POCO_LIB_DIR} "${CMAKE_PREFIX_PATH}/lib"
)

在找到头文件以及链接库后,我们可以直接用,或者创建个 imported target 使用。

add_library(Poco)STATIC IMPORTED)
set_property(TARGET Poco PROPERTY IMPORTED_LOCATION ${POCO_FOUNDATION_LIB})
set_target_properties(
    Poco PROPERTIES
    INTERFACE_INCLUDE_DIRECTORIES ${POCO_INCLUDE_DIR}
)

更优雅的方式是向 CMake 提供一个FindXxx.cmake脚本,其中可以使用各种方法(比如find_pathfind_library)找到库,并并导出库的信息。

上一节提到find_package(Aaa 1.0.0)会去寻找 config 文件,这个描述实际上并不完整。 find_packageMODULECONFIG两种模式,MODULE模式寻找FindXxx.cmake文件,CONFIG模式寻找 config 文件。 如果像本文里没有指定模式,CMake 优先按MODULE模式寻找库,没找到的话 fallback 到CONFIG模式。(详见Basic Signature and Module Mode)。两者一个重要的区别在于,config 脚本由库的开发者提供,find 脚本由使用者提供。

很多基于 make 构建工具的第三方库都可以在网上可以找到 find 脚本,同时 CMake 官方也为我们写好了很多常用库的Find 脚本,比如 OpenGL, JPEG, ZLIB,对于这些库无需编写 find 脚本直接使用find_package就可以了。

寻找 find 脚本时,CMake 会优先到CMAKE_MODULE_PATH变量对应的路径找,随后是 CMake 自带的 find 脚本目录。 如果我们准备好了某个库的 find 脚本,可以把其所在的目录加到CMAKE_MODULE_PATH里,这样find_package就能找到他。

list(APPEND CMAKE_MODULE_PATH "./cmake/")
find_package(MyLib)
if (MyLib_FOUND)
  # ...

5、创建对下游友好的 CMake 项目

目前想到了下面这几点,对于最佳实践的追求总是没有尽头的,但希望大家可以一起建设更友好的 C/C++ 开发生态:

  1. 使用基于 target 的现代 CMake
  2. 作为库的开发者,在预编译的 package 里提供 config 脚本
  3. 代码仓库里不要放太多代码无关的大文件,避免下载时间过长
  4. 打好版本 tag,方便控制版本