背景

对大型项目来说,必然会有很多的依赖项。特别是现代化的组件都会尝试去复用社区资源。而对于C/C++而言,依赖管理一直是一个比较头大的问题。 很多老式的系统和工具都会尝试去走相对标准化的安装过程,比如说用 pkg-config 或者用系统自带的包管理工具装在系统默认路径里。 当然这样很不方便,也不容易定制组件。我使用 cmake 比较多,所以一直以来在我的 atframework 项目集中有一个 utility 项目 atframe_utils,里面包含一些常用的构建脚本。 并且在 atsf4g-co 中实现了一些简单的包管理和构建流程。

但是随着依赖日益复杂,在增加依赖和更新依赖的时候,测试多个包之间的兼容性也变得更加频繁,有时候也需要自己打patch。有些组件可能仅仅需要某几个依赖包,这时候也要导入 atframe_utils 的话就有点不合适了。 所以产生了把构建系统配置和包管理抽离出来单独维护的想法。

其实现在已经有挺多C/C++的包管理系统了。比较主流的有 bazel, vcpkg 等。还有一些不太主流的比如 conan, build2, cget, spack 等等。我没有去研究这里面每一种的细节和差异。 但是即便是比较主流的 bazelvcpkg,也无法满足我们的需求。

Bazel的问题

bazel 号称是原生支持分布式编译的构建系统。它的工程管理主要分两个阶段,首先是 WORKSPACE 声明阶段,大多数开源项目在这个阶段管理了依赖包的声明和配置;接下来是 BUILD 阶段,这就是实际编译执行的行为。

但是 bazel 有一些问题。首先,他需要所有的依赖包都提供 bazel 构建系统支持。现有支持 bazel 的包并不是特别多,而且即便支持,也并不是都支持得很好(有些环境编译还是有问题的)。有一点 bazel 还比较好的是,它的依赖包是靠包名来的索引的。所以当存在依赖包之间互相有依赖的时候,父级节点声明的包名标准化统一,那么也可以控制子依赖的版本。因为C/C++多个包之间的调用是直接使用符号的,所以并不能实现某些语言的同包名的多版本并存。上游系统有能力选择合适的依赖组合就非常重要。

其次 C/C++ 很多包和库都有功能开关,会根据系统环境和选项的不同来选择不同的功能开关组合。 而 bazel 构建的包,大多情况下由那个包本身去提供一些config,来实现不同的功能组,而编译的时候需要用户去设置使用哪些功能组。但是 C/C++ 的但大多数包和库都是通过检测环境和功能的方式多每个细节做切换的,每个功能之间的组合配置显然不实际,所以一般 bazel 构建的包都只会提供几个典型的选项,这对我们希望精确控制功能细节非常不友好。相比起来,我觉得 Rust 的 Cargo 和模仿 Cargo 的 build2 在这点上就做得非常好。

也可能是我对 bazel 的理解有限,我没有找到使用 bazel 做功能检测进行功能开关或者依赖包切换的方法。举个例子,我前段时间给 opentelemetry-cpp 提交了一个PR,就碰到了一些问题。opentelemetry-cpp 官方对编译器的支持是 GCC 4.8-最新,MSVC 2019+,Clang忘记版本最低要求了。其依赖的包里有gRPC和protobuf,其中gRPC又依赖abseil-cpp 。在使用GCC的时候:

  • gRPC 要 1.33 版本开始升级 了protobuf 到 1.34,这是第一个支持 bazel 4 的版本
  • 原先依赖的 gRPC 1.28 仅支持 bazel 3。
  • 但是 gRPC 要 1.34 版本开始 abseil-cpp 到 20200923.X ,这个版本开始不再支持 gcc 4.8 。

所以最佳的方法是高版本的gcc使用最新的gRPC,但是 gcc 4.8 仅使用 gRPC 1.33 。

  1. bazel select 和宏仅能在 BUILD 阶段使用,WORKSPACE 声明阶段无法使用。但是包的声明在 WORKSPACE 阶段。
  2. alias功能似乎也是 BUILD 阶段的功能,无法影响其他依赖库?
  3. 我也尝试过用 --override_repository 来覆盖包的信息,但是这个选项似乎仅仅能替换成本地路径。

总而言之,我没有找到合适的方法完成这个功能。希望有熟悉 bazel 的小伙伴能提供解决方案。

上面提到的我尝试的使用 --override_repository 方式类似这样:

maybe(
    http_archive,
    name = "com_github_grpc_grpc_legacy",
    strip_prefix = "grpc-1.33.2",
    urls = [
        "https://github.com/grpc/grpc/archive/v1.33.2.tar.gz",
    ],
)

maybe(
    http_archive,
    name = "com_github_grpc_grpc",
    sha256 = "abd9e52c69000f2c051761cfa1f12d52d8b7647b6c66828a91d462e796f2aede",
    strip_prefix = "grpc-1.38.0",
    urls = [
        "https://github.com/grpc/grpc/archive/v1.38.0.tar.gz",
    ],
)

然后命令行里执行 bazel build --override_repository=com_github_grpc_grpc=@com_github_grpc_grpc_legacy //...

vcpkg/conan 它不香吗?

那使用 vcpkg 或者 conan 之类的怎么样呢?首先我们可以在 vcpkg 的页面里找到其和 conan 的主要区别(https://github.com/microsoft/vcpkg/blob/master/docs/about/faq.md#why-not-conan)。简单地翻译一下区别如下:

Vcpkg VS Conan:

  1. Conan仅提供工具,不保证包的质量和互相兼容性。编译环境支持较vcpkg更多。
  2. Vcpkg统一了包管理副本,而Conan需要用户自己负责包之间的兼容性和搭配。也可能多个包的依赖同一个包的不同版本,这在C/C++里十分危险。
  3. Vcpck基于CMake,Conan基于Python,但是包构建过程大多数又依赖cmake。

显然易用性和安全性 vcpkg 好很多,而且基于 gitcmakevcpkg 也可以实现非常灵活的功能,但是 vcpkg 也有一些缺陷。

首先是和 bazel 类似的,很难对依赖包做一些定制。导入一个包的时候,要么不要,要么全要。比如使用 libwebsockets 的时候,本来是可以选择使用openssl,mbedtls或者其他的库作为SSL库的,或者说有些功能不需要可以连依赖库带功能都不开。但是使用 vcpkg 安装 libwebsockets 的话,就没得选了。不过现在好像设计了一个有点类似 build2 的方案(Selecting library featuresvcpkg_check_features),一定层度上解决了这个问题。

第二个问题就是 vcpkg 官方支持的编译环境比较新。Windows下要求 VS 2015 Update 3以上,Linux 下要求 GCC 6以上, macOS也要求 Homebrew 且安装gcc 6以上。其他的环境有些也能支持但是是不受官方支持的。

相对来说Conan的环境支持就比较好: https://docs.conan.io/en/latest/reference/config_files/settings.yml.html#settings-yml 。但是每个包的版本和对应工具链的兼容性得自己管理,还是十分不便。

第三个问题比较难解决。大多数 vcpkg 里的包都是配置了从github下载的,也有些只从一些其他的URL下载。虽然说可以配置github的地址,但是 vcpkg 的从github下载包版本的代码里写的是使用的github的开放平台接口。新版本好像是加了个 https://github.com/microsoft/vcpkg/blob/master/docs/users/registries.md 可以解决这个问题,不过操作方式还比较麻烦,相当于对依赖的包要自己重写 ports 了,对使用者的要求还是有点高。

还有一些周边的问题,有一个也是这几天搞 opentelemetry-cpp 的时候发现的。截至我写这篇文章的时候,vcpkg 的最后的Release版本是 2021.05.12 ,里面的protobuf版本是 3.15.8 。前几天MSVC更新了 1929版本(VS 16.10) ,然后这个版本的 protobuf 刚好不支持,这就很尴尬了。

所以综合来说,大部分情况下 vcpkg 还是挺香的。但是某些场景,比如自定义内部源、组件版本好控制和低版本编译器支持它也不是很香。

cmake-toolset

我原先项目管理使用的也是 cmake ,所以现在也是使用的 cmakegit。另外学了一手 opentelemetry-cpp 的CI检测,要保证发布版本在各种环境下都能正常构建使用。

这套工具主要的功能之一是实现原先 atframe_utils 里的一些对编译器功能的检测,比如是否开启了异常,是否支持RTTI,是否支持C++20 Coroutine等等。 我们项目都是开了比较严格的编译告警选项的(GCC和Clang下 -Wall -Wextra -Werror, MSVC下 /W4 /WX),所以要提供工具让某些功能使用这些选项。 另外还要提供工具让子模块继承部分父级项目的选项,比如如果外层使用 clang+libc++,那么依赖库和子仓库也要用 clang+libc++

我个人觉得 vcpkg 的发展前景还比较好,很多问题慢慢地都能够妥善解决,所以对于比较新的编译器环境和首支持的平台还是更推崇直接用 vcpkg。在 cmake-toolset 里我也添加了对 vcpkg 的适配支持。可以直接导入 vcpkg 的toolchain文件使用,大多数导入的依赖库都支持直接从 vcpkg 中查找 。

另外就是在不使用 vcpkg 或者 vcpkg 内未安装某个依赖的时候,我会走自己内部的统一编译安装流程,并且预留了可以由上层应用来控制下载的源和版本号,甚至是一些编译参数。这样在上层需要定制化的时候就比较容易了。

稍微列举一下整理迁移过程中的新问题吧:

NOCONFIG

有些环境会生成 NOCONFIG 的cmake config模块。然后如果父级项目指定了 CMAKE_BUILD_TYPE 的话会找不到匹配的链接目标。所以我写了工具自动导出某些特定 CMAKE_BUILD_TYPE fallback到未指定的配置。 这样可以适配一些依赖包的查找过程。

交叉编译的二进制

交叉编译的时候,有时候需要编译出host版本的二进制使用。比如我们如果使用了protobuf,那么链接库是要使用目标平台的库的,但是如果要使用 protoc 生成代码,就需要用host平台的版本了。 所以针对这类库,目前的做法是走了特殊的编译流程,同时编译出两个平台的可执行程序。

然后我先尝试的做法是目标平台不编译二进制,仅编译库。host平台仅编译可执行程序。但是发现这会导致 find_package() 查找cmake config模块时缺失部分目标。所以最后我采取了目标平台全编译,然后编完host平台的可执行程序以后,patch掉可执行程序的 IMPORTED_LOCATION 的方法。

另外就是对于 iOS ,tvOS 和 watchOS需要给可执行程序设置BUNDLE,我这里仅仅是编译时工具链,并不需要安转运行。所以我就直接把 CMAKE_MACOSX_BUNDLE 设置 OFF 了。

CMake的REGISTRY机制

CMake内置了一个包仓库机制,有些依赖包会在安装的时候注册进去。这会影响到 find_package() 的结果,所以我给继承的变量增加了 CMAKE_EXPORT_PACKAGE_REGISTRY , CMAKE_EXPORT_NO_PACKAGE_REGISTRY, CMAKE_FIND_USE_PACKAGE_REGISTRY , CMAKE_FIND_PACKAGE_NO_PACKAGE_REGISTRY , CMAKE_FIND_USE_PACKAGE_REGISTRY, CMAKE_FIND_PACKAGE_NO_SYSTEM_PACKAGE_REGISTRY 。并且继承了 CMP0090 这个policy。

  • Windows下这个REGISTRY的数据会写在注册表 HKEY_CURRENT_USER\Software\Kitware\CMake\Packages\<PackageName>HKEY_LOCAL_MACHINE\Software\Kitware\CMake\Packages\<PackageName> 里。
  • Unix类环境这个REGISTRY的数据会写在 ~/.cmake/packages/<PackageName> 里。

如果 find_package() 找打了非预期的奇怪的路径,可以去这里面找找删掉就行了。

Windows 长路径问题(260路径长度限制)

还有个问题是使用 cmake-toolset 的时候,默认是的依赖编译目录是 BUILD目录/_deps/平台及工具链名/包名 。容易名字很长,Windows下就碰到了碰到了编译时路径过长的问题。

解决方法之一是直接改注册表, 可以用 powershell 脚本 New-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\FileSystem" -Name "LongPathsEnabled" -Value 1 -PropertyType DWORD -Force 或者注册表文件

Windows Registry Editor Version 5.00

[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Control\FileSystem]
"LongPathsEnabled"=dword:00000001

但是似乎并不是都有的工具对这个都有良好支持,并且这需要管理员权限。所以最终我再Windwows下构建的时候会把依赖编译目录改成 用户目录/cmake-toolset-<hash> 中,用来减少一定的长度。

Windows SDK版本

新版本的MSVC支持C11了,但是需要使用新版本的Windows SDK,这可以通过 CMAKE_SYSTEM_VERSION 来指定。特别是某些C的依赖包,使用了C11支持以后不需要再做额外的patch就能编译了(比如lua,libwebsockets等)。 具体查找并使用最新版本Windows SDK的做法可以参考CI脚本 https://github.com/atframework/cmake-toolset/blob/main/ci/do_ci.ps1

CI的内存不足

因为使用的github的免费Action,有些依赖包开多进程编译还是会OOM。然后cmake对docker环境检测CPU数量和控制并发数也不准确,所以我加入了低内存模式。人工降低并发度来让github action不OOM。

最后

我的大部分工具已经迁移到新的 cmake-toolset 了,后续的也会慢慢迁移。以后再碰到什么问题再来写吧。

最终重构抽离出来的构建工具集 (cmake-toolset) 位于 https://github.com/atframework/cmake-toolset 。主版本号保证API兼容,二级版本号指示是否有新功能,三级版本号是实现优化和修订。

以下是支持的环境(配置在CI测试中):

  • 默认行为:
    • 启用cmake能够识别的编译器所支持的最新标准(当前最新会开启 C++20C11)
    • 对支持的编译环境启用 C++20 协程支持
    • 对支持的编译环境启用 C++20 Module支持
    • MSVC:
      • 默认使用 UTF-8 编码
      • 对高版本编译器设置 __cplusplus == _MSVC_LANG
        • 即设置 /Zc:__cplusplus
        • 即和C++标准保持一致
        • 可通过 -DCOMPILER_OPTION_MSVC_ZC_CPP=OFF 来关闭
      • (非 vcpkg 模式)默认设置 CMAKE_MSVC_RUNTIME_LIBRARYMultiThreaded$<$<CONFIG:Debug>:Debug>$<$<NOT:$<STREQUAL:${VCPKG_CRT_LINKAGE},static>>:DLL> (影响 /MDd, /MD/MTd, /MT)
    • Clang/AppleClang: 尝试优先使用 libc++ 作为STL库。
      • 可通过 -DCOMPILER_OPTION_CLANG_ENABLE_LIBCXX=OFF 来关闭
    • Windows:
      • option(COMPILER_OPTION_WINDOWS_ENABLE_NOMINMAX "Add #define NOMINMAX." ON)
      • option(COMPILER_OPTION_WINDOWS_ENABLE_WIN32_LEAN_AND_MEAN "Add #define WIN32_LEAN_AND_MEAN." OFF)
    • 额外的编译告警
      • 提供 COMPILER_STRICT_CFLAGS 来开启严格的编译警告,并且关闭一些常用的设计模式导致的告警。(-Wall -Werror, /W4 /WX)
      • 提供 COMPILER_STRICT_EXTRA_CFLAGS 来开启更严格的编译警告,并且关闭一些常用的设计模式导致的告警。(-Wextra)
  • 支持平台:
    • Linux
    • Windows
    • MinGW
    • macOS
    • Android
    • iOS
    • iPhone.Simulator
  • 编译器:
    • GCC 4.8-最新(当前GCC 11)
    • Clang 5.0-最新(当前GCC 12)
    • Visual Studio 2017-最新(当前Visual Studio 2019, 16.10, MSVC 1929)
  • 特殊的包:
    • SSL替代: openssl,libressl,mbedtls(boringssl依赖golang,会在未来添加)

      未来还会添加: libsodium

欢迎有兴趣的小伙伴互相交流。