面向 C++ 的现代 CMake 教程(三)(1)https://developer.aliyun.com/article/1525576
进一步阅读
关于本章涵盖的主题,你可以参考以下内容:
- ELF 文件的结构:
- 关于add_library()的 CMake 手册:
- 依赖地狱:
- 模块与共享库的区别:
第七章:使用 CMake 管理依赖
你的解决方案是大型还是小型,并不重要;随着它的成熟,你最终会决定引入外部依赖。避免根据普遍的商业逻辑创建和维护代码的成本是很重要的。这样,你就可以将时间投入到对你和你的客户有意义的事情上。
外部依赖不仅用于提供框架和功能以及解决古怪的问题。它们在构建和控制代码质量的过程中也起着重要的作用——无论是特殊编译器如Protobuf,还是测试框架如GTest。
无论你是在处理开源项目,还是在使用你公司其他开发者编写的项目,你仍然需要一个良好、干净的流程来管理外部依赖。自己解决这个问题将花费无数的设置时间和大量的额外支持工作。幸运的是,CMake 在适应不同风格和依赖管理的历史方法的同时,还能跟上行业批准标准的不断演变。
为了提供一个外部依赖,我们首先应该检查宿主系统是否已经有了这个依赖,因为最好避免不必要的下载和漫长的编译。我们将探讨如何找到并把这样的依赖转换成 CMake 目标,在我们的项目中使用。这可以通过很多方式完成,特别是当包支持 CMake 开箱即用,或者至少提供给一个稍微老一点的 PkgConfig 工具的文件时。如果情况不是这样,我们仍然可以编写自己的文件来检测并包含这样的依赖。
我们将讨论当一个依赖在系统上不存在时应该做什么。正如你可以想象,我们可以采取替代步骤来自动提供必要的文件。我们将考虑使用不同的 Git 方法来解决这个问题,并将整个 CMake 项目作为我们构建的一部分引入。
在本章中,我们将涵盖以下主要内容:
- 如何找到已安装的包
- 使用FindPkgConfig0发现遗留包
- 编写自己的 find-modules
- 与 Git 仓库协作
- 使用ExternalProject和FetchContent模块
技术要求
你可以在这个章节中找到的代码文件在 GitHub 上,地址为github.com/PacktPublishing/Modern-CMake-for-Cpp/tree/main/examples/chapter07。
为了构建本书中提供的示例,总是使用推荐的命令:
cmake -B <build tree> -S <source tree> cmake --build <build tree>
请确保将占位符 tree>和 tree>替换为适当的路径。作为提醒:build tree 是目标/输出目录的路径,source tree 是源代码所在的位置的路径。
如何找到已安装的包
好的,假设你已经决定通过网络通信或静态存储数据来提高你的技能。纯文本文件、JSON,甚至是老旧的 XML 都不行。你希望将你的数据直接序列化为二进制格式,最好使用业界知名的库——比如谷歌的 protocol buffers(Protobuf)。你找到了文档,在系统中安装了依赖项,现在怎么办?我们实际上如何告诉 CMake 找到并使用你引入的这项外部依赖?幸运的是,有一个find_package()命令。在大多数情况下,它都像魔法一样起作用。
让我们倒带并从头开始设置场景——我们必须安装我们想要使用的依赖项,因为find_package(),正如其名,只是关于在系统中发现包。我们假设依赖项已经安装,或者我们解决方案的用户知道如何在提示时安装特定的、必要的依赖项。为了覆盖其他场景,你需要提供一个备份计划(关于这方面的更多信息可以在与 Git 仓库一起工作部分中找到)。
在 Protobuf 的情况下,情况相当直接:你可以从官方存储库(github.com/protocolbuffers/protobuf)下载、编译并自行安装库,也可以使用你操作系统的包管理器。如果你正在使用第章 1《CMake 初步》中提到的 Docker 镜像,你将使用 Debian Linux。安装 Protobuf 库和编译器的命令如下:
$ apt update $ apt install protobuf-compiler libprotobuf-dev
每个系统都有它自己的安装和管理包的方式。找到一个包所在的路径可能会很棘手且耗时,特别是当你想要支持今天大多数操作系统时。幸运的是,如果涉及的包提供了一个合适的配置文件,允许 CMake 确定支持该包所需的变量,find_package()通常可以为你完成这个任务。
如今,许多项目都符合这一要求,在安装过程中提供了这个文件给 CMake。如果你计划使用某个流行的库而它没有提供此文件,暂时不必担心。很可能 CMake 的作者已经将文件与 CMake 本身捆绑在一起(这些被称为find-modules,以便与配置文件区分开来)。如果情况不是这样,我们仍然还有一些选择:
- 为特定包提供我们自己的 find-modules,并将其与我们的项目捆绑在一起。
- 编写一个配置文件,并请包维护者将该包与文件一起分发。
你可能会说你还没有完全准备好自己创建这样的合并请求,这没关系,因为很可能你不需要这么做。CMake 附带了超过 150 个查找模块,可以找到如 Boost、bzip2、curl、curses、GIF、GTK、iconv、ImageMagick、JPEG、Lua、OpenGL、OpenSSL、PNG、PostgreSQL、Qt、SDL、Threads、XML-RPC、X11 和 zlib 等库,幸运的是,还包括我们在这个例子中将要使用的 Protobuf 文件。完整的列表在 CMake 文档中可以找到:cmake.org/cmake/help/latest/manual/cmake-modules.7.html#find modules。
查找模块和配置文件都可以在 CMake 项目中用一个find_package()命令。CMake 寻找匹配的查找模块,如果找不到任何模块,它会转向配置文件。搜索将从存储在CMAKE_MODULE_PATH变量中的路径开始(默认情况下这个变量是空的)。当项目想要添加和使用外部查找模块时,这个变量可以被项目配置。接下来,CMake 将扫描安装的 CMake 版本的内置查找模块列表。
如果没有找到适用的模块,该寻找相应的包配置文件了。CMake 有一长串适合宿主操作系统的路径,可以扫描与以下模式匹配的文件名:
- Config.cmake
- -config.cmake
让我们稍微谈谈项目文件;在这个例子中,我其实并不打算设计一个带有远程过程调用和所有附件的网络解决方案。相反,我只是想证明我能构建并运行一个依赖于 Protobuf 的项目。为了实现这一点,我将创建一个尽可能小的合同的.proto文件。如果你对 Protobuf 不是特别熟悉,只需知道这个库提供了一种机制,可以将结构化数据序列化为二进制形式。为此,我们需要提供一个此类结构的模式,它将用于将二进制形式写入和读取 C++对象。
我想出的是这样的:
chapter07/01-find-package-variables/message.proto
syntax = "proto3"; message Message { int32 id = 1; }
如果你不熟悉 Protobuf 语法(这其实不是这个例子真正关注的),不必担心。这是一个只包含一个 32 位整数的简单message。Protobuf 有一个特殊的编译器,它会读取这些文件,并生成可以被我们的应用程序使用的 C++源文件和头文件。这意味着我们需要将这个编译步骤以某种方式添加到我们的过程中。我们稍后再回到这个问题。现在,让我们看看我们的main.cpp文件长什么样:
chapter07/01-find-package-variables/main.cpp
#include "message.pb.h" #include <fstream> using namespace std; int main() { Message m; m.set_id(123); m.PrintDebugString(); fstream fo("./hello.data", ios::binary | ios::out); m.SerializeToOstream(&fo); fo.close(); return 0; }
如我所说,Message包含一个唯一的id字段。在main.cpp文件中,我创建了一个代表这个消息的对象,将字段设置为123,并将其调试信息打印到标准输出。接下来,我创建了一个文件流,将这个对象的二进制版本写入其中,并关闭流——这是序列化库最简单的可能用途。
请注意,我已经包含了一个message.pb.h头文件。这个文件还不存在;它需要在message.proto编译期间由 Protobuf 编译器protoc创建。这种情况听起来相当复杂,暗示这样一个项目的列表文件必须非常长。根本不是!这就是 CMake 魔法发生的地方:
chapter07/01-find-package-variables/CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0) project(FindPackageProtobufVariables CXX) find_package(Protobuf REQUIRED) protobuf_generate_cpp(GENERATED_SRC GENERATED_HEADER message.proto) add_executable(main main.cpp ${GENERATED_SRC} ${GENERATED_HEADER}) target_link_libraries(main PRIVATE ${Protobuf_LIBRARIES}) target_include_directories(main PRIVATE ${Protobuf_INCLUDE_DIRS} ${CMAKE_CURRENT_BINARY_DIR})
让我们来分解一下:
- 前两行我们已经知道了;它们创建了一个项目和声明了它的语言。
- find_package(Protobuf REQUIRED) 要求 CMake 运行捆绑的FindProtobuf.cmake查找模块,并为我们设置 Protobuf 库。那个查找模块将扫描常用路径(因为我们提供了REQUIRED关键字)并在找不到库时终止。它还将指定有用的变量和函数(如下面的行所示)。
- protobuf_generate_cpp 是 Protobuf 查找模块中定义的自定义函数。在其内部,它调用add_custom_command(),该命令使用适当的参数调用protoc编译器。我们通过提供两个变量来使用这个函数,这些变量将被填充生成的源文件(GENERATED_SRC)和头文件(GENERATED_HEADER)的路径,以及要编译的文件列表(message.proto)。
- 如我们所知,add_executable 将使用main.cpp和前面命令中配置的 Protobuf 文件创建我们的可执行文件。
- target_link_libraries 将由find_package()找到的(静态或共享)库添加到我们的main目标链接命令中。
- target_include_directories() 将必要的INCLUDE_DIRS(由包提供)添加到包含路径中,以及CMAKE_CURRENT_BINARY_DIR。后者是必需的,以便编译器可以找到生成的message.pb.h头文件。
换句话说,它实现了以下功能:
- 查找库和编译器的所在位置
- 提供辅助函数,教会 CMake 如何调用.proto文件的定制编译器
- 添加包含包含和链接所需路径的变量
在大多数情况下,当你调用find_package()时,你可以期待一些变量会被设置,不管你是使用内置的查找模块还是随包附带的配置文件(假设已经找到了包):
- _FOUND
- _INCLUDE_DIRS或_INCLUDES
- _LIBRARIES或_LIBRARIES或_LIBS
- _DEFINITIONS
- 由查找模块或配置文件指定的IMPORTED目标
最后一个观点非常有趣——如果一个包支持所谓的“现代 CMake”(以目标为中心),它将提供这些IMPORTED目标而不是这些变量,这使得代码更简洁、更简单。建议优先使用目标而不是变量。
Protobuf 是一个很好的例子,因为它提供了变量和IMPORTED目标(自从 CMake 3.10 以来):protobuf::libprotobuf,protobuf::libprotobuf-lite,protobuf::libprotoc和protobuf::protoc。这允许我们编写更加简洁的代码:
chapter07/02-find-package-targets/CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0) project(FindPackageProtobufTargets CXX) find_package(Protobuf REQUIRED) protobuf_generate_cpp(GENERATED_SRC GENERATED_HEADER message.proto) add_executable(main main.cpp ${GENERATED_SRC} ${GENERATED_HEADER}) target_link_libraries(main PRIVATE protobuf::libprotobuf) target_include_directories(main PRIVATE ${CMAKE_CURRENT_BINARY_DIR})
protobuf::libprotobuf导入的目标隐式地指定了包含目录,并且多亏了传递依赖(或者我叫它们传播属性),它们与我们的main目标共享。链接器和编译器标志也是同样的过程。
如果你需要确切知道特定 find-module 提供了什么,最好是访问其在线文档。Protobuf 的一个可以在以下位置找到:cmake.org/cmake/help/latest/module/FindProtobuf.html。
重要提示
为了保持简单,本节中的示例如果用户系统中没有找到 protobuf 库(或其编译器)将简单地失败。但一个真正健壮的解决方案应该通过检查Protobuf_FOUND变量并相应地行事,要么打印给用户的清晰诊断消息(这样他们可以安装它)要么自动执行安装。
关于find_package()命令的最后一点是它的选项。完整的列表有点长,所以我们只关注基本的签名。它看起来像这样:
find_package(<Name> [version] [EXACT] [QUIET] [REQUIRED])
最重要的选项如下:
- [version],它允许我们选择性地请求一个特定的版本。使用major.minor.patch.tweak格式(如1.22)或提供一个范围——1.22...1.40.1(使用三个点作为分隔符)。
- EXACT关键字意味着我们想要一个确切的版本(这里不支持版本范围)。
- QUIET关键字可以静默所有关于找到/未找到包的消息。
- REQUIRED关键字如果找不到包将停止执行,并打印一个诊断消息(即使启用了QUIET也是如此)。
有关命令的更多信息可以在文档页面找到:cmake.org/cmake/help/latest/command/find_package.html。
为包提供配置文件的概念并不新鲜。而且它肯定不是 CMake 发明的。还有其他工具和格式为此目的而设计。PkgConfig 就是其中之一。CMake 还提供了一个有用的包装模块来支持它。
使用 FindPkgConfig 发现遗留的包
管理依赖项和发现它们所需的所有编译标志的问题与 C++库本身一样古老。有许多工具可以处理这个问题,从非常小和简单的机制到作为构建系统和 IDE 的一部分提供的非常灵活的解决方案。其中一个(曾经非常流行)的工具被称为 PkgConfig(freedesktop.org/wiki/Software/pkg-config/)。它通常在类 Unix 系统中可用(尽管它也适用于 macOS 和 Windows)。
pkg-config正逐渐被其他更现代的解决方案所取代。这里出现了一个问题——你应该投入时间支持它吗?答案一如既往——视情况而定:
- 如果一个库真的很受欢迎,它可能已经有了自己的 CMake find-module;在这种情况下,你可能不需要它。
- 如果没有 find-module(或者它不适用于您的库)并且库只提供 PkgConfig .pc文件,只需使用现成的即可。
许多(如果不是大多数)库已经采用了 CMake,并在当前版本中提供了包配置文件。如果您不发布您的解决方案并且您控制环境,请使用find_package(),不要担心遗留版本。
遗憾的是,并非所有环境都可以快速更新到库的最新版本。许多公司仍在使用生产中的遗留系统,这些系统不再获得最新包。在这种情况下,用户可能只能使用较旧的(但希望兼容)版本。而且经常情况下,它会提供一个.pc文件。
此外,如果这意味着您的项目可以为大多数用户无障碍地工作,那么支持旧的 PkgConfig 格式的努力可能是值得的。
在任何情况下,首先使用find_package(),如前一部分所述,如果_FOUND为假,则退回到 PkgConfig。这样,我们覆盖了一种场景,即环境升级后我们只需使用主方法而无需更改代码。
这个助手工具的概念相当简单——库的作者提供一个小型的.pc文件,其中包含编译和链接所需的信息,例如这个:
prefix=/usr/local exec_prefix=${prefix} includedir=${prefix}/include libdir=${exec_prefix}/lib Name: foobar Description: A foobar library Version: 1.0.0 Cflags: -I${includedir}/foobar Libs: -L${libdir} -lfoobar
这个格式相当直接,轻量级,甚至支持基本变量扩展。这就是为什么许多开发者更喜欢它而不是像 CMake 这样的复杂、健壮的解决方案。尽管 PkgConfig 极其易于使用,但其功能却相当有限:
- 检查系统中是否存在库,并且是否提供了与之一起的.pc文件
- 检查是否有一个库的足够新版本可用
- 通过运行pkg-config --libs libfoo获取库的链接器标志
- 获取库的包含目录(此字段技术上可以包含其他编译器标志)——pkg-config --cflags libfoo
为了在构建场景中正确使用 PkgConfig,您的构建系统需要在操作系统中找到pkg-config可执行文件,运行它几次,并提供适当的参数,然后将响应存储在变量中,以便稍后传递给编译器。在 CMake 中我们已经知道如何做到这一点——扫描已知存储辅助工具的路径以检查是否安装了 PkgConfig,然后使用几个exec_program()命令来发现如何链接依赖项。尽管步骤有限,但似乎每次使用 PkgConfig 时都这样做是过于繁琐的。
幸运的是,CMake 提供了一个方便的内置查找模块,正是为了这个目的——FindPkgConfig。它遵循大多数常规查找模块的规则,但不是提供PKG_CONFIG_INCLUDE_DIRS或PKG_CONFIG_LIBS变量,而是设置一个变量,直接指向二进制文件的路径——PKG_CONFIG_EXECUTABLE。不出所料,PKG_CONFIG_FOUND变量也被设置了——我们将使用它来确认系统中是否有这个工具,然后使用模块中定义的pkg_check_modules()帮助命令扫描一个pkg_check_modules()包。
我们来实际看看这个过程。一个提供.pc文件的相对受欢迎的库的一个例子是一个 PostgreSQL 数据库的客户端——libpqxx。
为了在 Debian 上安装它,您可以使用libpqxx-dev包(您的操作系统可能需要不同的包):
apt-get install libpqxx-dev
我们将创建一个尽可能短的main.cpp文件,其中包含一个虚拟连接类:
chapter07/02-find-pkg-config/main.cpp
#include <pqxx/pqxx> int main() { // We're not actually connecting, but // just proving that pqxx is available. pqxx::nullconnection connection; }
现在我们可以通过使用 PkgConfig 查找模块为之前的代码提供必要的依赖项:
chapter07/03-find-pkg-config/CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0) project(FindPkgConfig CXX) find_package(PkgConfig REQUIRED) pkg_check_modules(PQXX REQUIRED IMPORTED_TARGET libpqxx) message("PQXX_FOUND: ${PQXX_FOUND}") add_executable(main main.cpp) target_link_libraries(main PRIVATE PkgConfig::PQXX)
让我们分解一下发生了什么:
- 我们要求 CMake 使用find_package()命令查找 PkgConfig 可执行文件。如果因为REQUIRED关键字而没有pkg-config,它将会失败。
- 在FindPkgConfig查找模块中定义的pkg_check_modules()自定义宏被调用,以创建一个名为PQXX的新IMPORTED目标。查找模块将搜索一个名为libpxx的依赖项,同样,因为REQUIRED关键字,如果库不可用,它将会失败。注意IMPORTED_TARGET关键字——没有它,就不会自动创建目标,我们必须手动定义由宏创建的变量。
- 我们通过打印PQXX_FOUND来确认一切是否正确,并显示诊断信息。如果我们之前没有指定REQUIRED,我们在这里可以检查这个变量是否被设置(也许是为了允许其他备选机制介入)。
- 我们创建了main可执行文件。
- 我们链接了由pkg_check_modules()创建的PkgConfig::PQXX IMPORTED目标。注意PkgConfig::是一个常量前缀,PQXX来自传递给该命令的第一个参数。
这是一种相当方便的方法,可以引入尚不支持 CMake 的依赖项。这个查找模块还有其他一些方法和选项;如果你对了解更多感兴趣,我建议你参考官方文档:cmake.org/cmake/help/latest/module/FindPkgConfig.html。
查找模块旨在为 CMake 提供一个非常方便的方式来提供有关已安装依赖项的信息。大多数流行的库在所有主要平台上都广泛支持 CMake。那么,当我们想要使用一个还没有专用的查找模块的第三方库时,我们能做些什么呢?
编写你自己的查找模块
在少数情况下,你真正想在项目中使用的库没有提供配置文件或 PkgConfig 文件,而且 CMake 中没有现成的查找模块可供使用。在这种情况下,你可以为该库编写一个自定义的查找模块,并将其与你的项目一起分发。这种情况并不理想,但为了照顾到你的项目的用户,这是必须的。
既然我们已经在上一节中熟悉了libpqxx,那么现在就让我们为它编写一个好的查找模块吧。我们首先在项目中源代码树的cmake/module目录下创建一个新文件FindPQXX.cmake,并开始编写。我们需要确保当调用find_package()时,CMake 能够发现这个查找模块,因此我们将这个路径添加到CMakeLists.txt中的CMAKE_MODULE_PATH变量里,用list(APPEND)。整个列表文件应该看起来像这样:
chapter07/04-find-package-custom/CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0) project(FindPackageCustom CXX) list(APPEND CMAKE_MODULE_PATH "${CMAKE_SOURCE_DIR}/cmake/module/") find_package(PQXX REQUIRED) add_executable(main main.cpp) target_link_libraries(main PRIVATE PQXX::PQXX)
现在我们已经完成了这个步骤,接下来我们需要编写实际的查找模块。从技术上讲,如果FindPQXX.cmake文件为空,将不会有任何事情发生:即使用户调用find_package()时使用了REQUIRED,CMake 也不会抱怨一些特定的变量没有被设置(包括PQXX_FOUND),这是查找模块的作者需要尊重 CMake 文档中概述的约定:
- 当调用find_package( REQUIRED)时,CMake 将提供一个_FIND_REQUIRED变量,设置为1。如果找不到库,查找模块应该调用message(FATAL_ERROR)。
- 当调用find_package( QUIET)时,CMake 将提供一个_FIND_QUIETLY变量,设置为1。查找模块应避免打印诊断信息(除了前面提到的一次)。
- 当调用列表文件时,CMake 将提供一个_FIND_VERSION变量,设置为所需版本。查找模块应该找到适当的版本,或者发出FATAL_ERROR。
当然,为了与其他查找模块保持一致性,最好遵循前面的规则。让我们讨论创建一个优雅的PQXX查找模块所需的步骤:
- 如果已知库和头文件的路径(要么由用户提供,要么来自之前运行的缓存),使用这些路径并创建一个IMPORTED目标。在此结束。
- 否则,请找到嵌套依赖项——PostgreSQL 的库和头文件。
- 在已知的路径中搜索 PostgreSQL 客户端库的二进制版本。
- 在已知的路径中搜索 PostgreSQL 客户端包含头文件。
- 检查是否找到了库和包含头文件;如果是,创建一个IMPORTED目标。
创建IMPORTED目标发生了两次——如果用户从命令行提供了库的路径,或者如果它们是自动找到的。我们将从编写一个函数来处理我们搜索过程的结果开始,并保持我们的代码 DRY。
要创建一个IMPORTED目标,我们只需要一个带有IMPORTED关键字的库(以便在CMakeLists.txt中的target_link_libraries()命令中使用它)。该库必须提供一个类型——我们将其标记为UNKNOWN,以表示我们不希望检测找到的库是静态的还是动态的;我们只想为链接器提供一个参数。
接下来,我们将IMPORTED_LOCATION和INTERFACE_INCLUDE_DIRECTORIES``IMPORTED目标的必需属性设置为函数被调用时传递的参数。我们还可以指定其他属性(如COMPILE_DEFINITIONS);它们对于PQXX来说只是不必要的。
在那之后,我们将路径存储在缓存变量中,这样我们就无需再次执行搜索。值得一提的是,PQXX_FOUND在缓存中被显式设置,因此它在全局变量作用域中可见(所以它可以被用户的CMakeLists.txt访问)。
最后,我们将缓存变量标记为高级,这意味着除非启用“高级”选项,否则它们不会在 CMake GUI 中显示。对于这些变量,这是一种常见的做法,我们也应该遵循约定:
chapter07/04-find-package-custom/cmake/module/FindPQXX.cmake
function(add_imported_library library headers) add_library(PQXX::PQXX UNKNOWN IMPORTED) set_target_properties(PQXX::PQXX PROPERTIES IMPORTED_LOCATION ${library} INTERFACE_INCLUDE_DIRECTORIES ${headers} ) set(PQXX_FOUND 1 CACHE INTERNAL "PQXX found" FORCE) set(PQXX_LIBRARIES ${library} CACHE STRING "Path to pqxx library" FORCE) set(PQXX_INCLUDES ${headers} CACHE STRING "Path to pqxx headers" FORCE) mark_as_advanced(FORCE PQXX_LIBRARIES) mark_as_advanced(FORCE PQXX_INCLUDES) endfunction()
接下来,我们覆盖第一种情况——一个用户如果将他们的PQXX安装在非标准位置,可以通过命令行(使用-D参数)提供必要的路径。如果是这种情况,我们只需调用我们刚刚定义的函数并使用return()放弃搜索。我们相信用户最清楚,能提供库及其依赖项(PostgreSQL)的正确路径给我们。
如果配置阶段在过去已经执行过,这个条件也将为真,因为PQXX_LIBRARIES和PQXX_INCLUDES变量是被缓存的。
if (PQXX_LIBRARIES AND PQXX_INCLUDES) add_imported_library(${PQXX_LIBRARIES} ${PQXX_INCLUDES}) return() endif()
是时候找到一些嵌套依赖项了。为了使用PQXX,宿主机器还需要 PostgreSQL。在我们的查找模块中使用另一个查找模块是完全合法的,但我们应该将REQUIRED和QUIET标志传递给它(以便嵌套搜索与外层搜索行为一致)。这不是复杂的逻辑,但我们应该尽量避免不必要的代码。
CMake 有一个内置的帮助宏,正是为此而设计——find_dependency()。有趣的是,文档中指出它不适合用于 find-modules,因为它如果在找不到依赖项时调用return()命令。因为这是一个宏(而不是一个函数),return()将退出调用者的作用域,即FindPQXX.cmake文件,停止外层 find-module 的执行。可能有些情况下这是不希望的,但在这个情况下,这正是我们想要做的——阻止 CMake 深入寻找PQXX的组件,因为我们已经知道 PostgreSQL 不可用:
# deliberately used in mind-module against the documentation include(CMakeFindDependencyMacro) find_dependency(PostgreSQL)
为了找到PQXX库,我们将设置一个_PQXX_DIR帮助变量(转换为 CMake 风格的路径)并使用find_library()命令扫描我们在PATHS关键字之后提供的路径列表。该命令将检查是否有与NAMES关键字之后提供的名称匹配的库二进制文件。如果找到了匹配的文件,其路径将被存储在PQXX_LIBRARY_PATH变量中。否则,该变量将被设置为-NOTFOUND,在这种情况下是PQXX_HEADER_PATH-NOTFOUND。
NO_DEFAULT_PATH关键字禁用了默认行为,这将扫描 CMake 为该主机环境提供的默认路径列表:
file(TO_CMAKE_PATH "$ENV{PQXX_DIR}" _PQXX_DIR) find_library(PQXX_LIBRARY_PATH NAMES libpqxx pqxx PATHS ${_PQXX_DIR}/lib/${CMAKE_LIBRARY_ARCHITECTURE} # (...) many other paths - removed for brevity /usr/lib NO_DEFAULT_PATH )
接下来,我们将使用find_path()命令搜索所有已知的头文件,这个命令的工作方式与find_library()非常相似。主要区别在于find_library()知道库的系统特定扩展,并将这些扩展作为需要自动添加,而对于find_path(),我们需要提供确切的名称。
在这里也不要混淆pqxx/pqxx。这是一个实际的头文件,但库作者故意省略了扩展名,以符合 C++风格#include指令(而不是遵循 C 风格.h扩展名):#include :
find_path(PQXX_HEADER_PATH NAMES pqxx/pqxx PATHS ${_PQXX_DIR}/include # (...) many other paths - removed for brevity /usr/include NO_DEFAULT_PATH )
现在是检查PQXX_LIBRARY_PATH和PQXX_HEADER_PATH变量是否包含任何-NOTFOUND值的时候。同样,我们可以手动进行这项工作,然后根据约定打印诊断信息或终止构建执行,或者我们可以使用 CMake 提供的FindPackageHandleStandardArgs列表文件中的find_package_handle_standard_args()帮助函数。这是一个帮助命令,如果路径变量被填充,则将_FOUND变量设置为1,并提供关于成功和失败的正确诊断信息(它将尊重QUIET关键字)。如果传递了REQUIRED关键字给 find-module,而其中一个提供的路径变量为空,它还将以FATAL_ERROR终止执行。
如果找到了库,我们将调用函数定义IMPORTED目标并将路径存储在缓存中:
include(FindPackageHandleStandardArgs) find_package_handle_standard_args( PQXX DEFAULT_MSG PQXX_LIBRARY_PATH PQXX_HEADER_PATH ) if (PQXX_FOUND) add_imported_library( "${PQXX_LIBRARY_PATH};${POSTGRES_LIBRARIES}" "${PQXX_HEADER_PATH};${POSTGRES_INCLUDE_DIRECTORIES}" ) endif()
就这些。这个 find-module 将找到PQXX并创建相应的PQXX::PQXX目标。你可以在整个文件中找到这个模块,文件位于书籍示例仓库中:chapter07/04-find-package-custom/cmake/module/FindPQXX.cmake。
如果一个库很受欢迎,并且很可能会在系统中已经安装,这种方法非常有效。然而,并非所有的库随时都能获得。我们能否让这个步骤变得简单,让我们的用户使用 CMake 获取和构建这些依赖项?
使用 Git 仓库工作
许多项目依赖于 Git 作为版本控制系统。假设我们的项目和外部库都在使用它,有没有某种 Git 魔法能让我们把这些仓库链接在一起?我们能否构建库的特定(或最新)版本,作为构建我们项目的一个步骤?如果是,怎么做?
通过 Git 子模块提供外部库
一个可能的解决方案是使用 Git 内置的机制,称为Git 子模块。子模块允许项目仓库使用其他 Git 仓库,而实际上不将引用的文件添加到项目仓库中。它们的工作方式与软链接类似——它们指向外部仓库中的特定分支或提交(但你需要显式地更新它们)。要向你的仓库中添加一个子模块(并克隆其仓库),执行以下命令:
git submodule add <repository-url>
如果你拉取了一个已经包含子模块的仓库,你需要初始化它们:
git submodule update --init -- <local-path-to-submodule>
正如你所看到的,这是一个多功能的机制,可以利用第三方代码在我们的解决方案中。一个小缺点是,当用户克隆带有根项目的仓库时,子模块不会自动拉取。需要一个显式的init/pull命令。暂时保留这个想法——我们也会用 CMake 解决它。首先,让我们看看我们如何在代码中使用一个新创建的子模块。
为了这个例子,我决定写一个小程序,从 YAML 文件中读取一个名字,并在欢迎消息中打印出来。YAML 是一种很好的简单格式,用于存储可读的配置,但机器解析起来相当复杂。我找到了一个由 Jesse Beder(及当时 92 名其他贡献者)解决这个问题的整洁小型项目,称为 yaml-cpp(github.com/jbeder/yaml-cpp)。
这个例子相当直接。它是一个问候程序,打印出欢迎<名字>的消息。name的默认值将是Guest,但我们可以在 YAML 配置文件中指定一个不同的名字。以下是代码:
第七章/05-git-submodule-manual/main.cpp
#include <string> #include <iostream> #include "yaml-cpp/yaml.h" using namespace std; int main() { string name = "Guest"; YAML::Node config = YAML::LoadFile("config.yaml"); if (config["name"]) name = config["name"].as<string>(); cout << "Welcome " << name << endl; return 0; }
这个示例的配置文件只有一行:
第七章/05-git-submodule-manual/config.yaml
name: Rafal
让我们回到main.cpp一会儿——它包含了"yaml-cpp/yaml.h"头文件。为了使其可用,我们需要克隆yaml-cpp项目并构建它。让我们创建一个extern目录来存储所有第三方依赖项(如第三章、设置你的第一个 CMake 项目部分中所述)并添加一个 Git 子模块,引用库的仓库:
$ mkdir extern $ cd extern $ git submodule add https://github.com/jbeder/yaml-cpp.git Cloning into 'chapter07/01-git-submodule-manual/extern/yaml-cpp'... remote: Enumerating objects: 8134, done. remote: Total 8134 (delta 0), reused 0 (delta 0), pack-reused 8134 Receiving objects: 100% (8134/8134), 3.86 MiB | 3.24 MiB/s, done. Resolving deltas: 100% (5307/5307), done.
Git 已经克隆了仓库;现在我们可以将其作为项目的依赖项,并让 CMake 负责构建:
chapter07/05-git-submodule-manual/CMakeLists.txt
cmake_minimum_required(VERSION 3.20.0) project(GitSubmoduleManual CXX) add_executable(welcome main.cpp) configure_file(config.yaml config.yaml COPYONLY) add_subdirectory(extern/yaml-cpp) target_link_libraries(welcome PRIVATE yaml-cpp)
让我们分解一下我们在这里给予 CMake 的指令:
- 设置项目并添加我们的welcome可执行文件。
- 接下来,调用configure_file,但实际上不配置任何内容。通过提供COPYONLY关键字,我们只是将我们的config.yaml复制到构建树中,这样可执行文件在运行时能够找到它。
- 添加 yaml-cpp 仓库的子目录。CMake 会将其视为项目的一部分,并递归执行任何嵌套的CMakeLists.txt文件。
- 将库提供的yaml-cpp目标与welcome目标链接。
yaml-cpp 的作者遵循在第三章《设置你的第一个 CMake 项目》中概述的实践,并将公共头文件存储在单独的目录中——<项目名称>/include/<项目名称>。这允许库的客户(如main.cpp)通过包含"yaml-cpp/yaml.h"库名称的路径来访问这些文件。这种命名实践非常适合发现——我们立即知道是哪个库提供了这个头文件。
正如你所看到的,这并不是一个非常复杂的过程,但它并不理想——用户在克隆仓库后必须手动初始化我们添加的子模块。更糟糕的是,它没有考虑到用户可能已经在他们的系统上安装了这个库。这意味着浪费了下载并构建这个依赖项的过程。一定有更好的方法。
面向 C++ 的现代 CMake 教程(三)(3)https://developer.aliyun.com/article/1525578