工程能力 CMake · 依赖管理 · CI Web 工程化迁移视角

现代 CMake 工程体系:从 npm/webpack 思维到 target-based 构建、依赖管理与持续集成

2026-09-13 · 每日技术导师 · 接续 08-22《编译、链接与 ABI》,从「原理」走到「工程落地」

01为什么今天必须啃构建系统

在前端世界,构建这套东西是送你的:npm install 装依赖,vite/webpack 打包,npm run dev 起服务,路径解析、模块图、增量缓存、Tree-shaking 全是默认行为。你几乎不需要「设计」构建系统。而在 C++ 里,这件事重新变成了团队的第一号工程问题:新同学 clone 下来能不能 15 分钟内跑起来?CI 一次全量构建是 3 分钟还是 40 分钟?多平台(Windows/macOS/Linux)是不是各写一套项目文件?第三方库怎么进项目、怎么锁定版本、怎么保证每个人的构建可复现?

更关键的是,构建文件是架构的显式化:模块边界、依赖方向、公开接口与实现细节的分界,全部写在 CMake 里。一个 Team Leader 看 CMakeLists.txt 的水平,基本等于看他能不能守住架构。这就是今天这篇要解决的问题——把 CMake 从「抄来的几十行魔法」变成你手里可控的工程系统。

02核心知识:CMake 到底在解决什么

2.1 三个阶段的本质:Configure → Generate → Build

CMake 不是编译器也不是构建程序的替代品,它是一台构建系统生成器(build system generator)。跑 cmake -S . -B build 时它做两件事:configure(读所有 CMakeLists.txt,探测编译器、平台、依赖,执行脚本逻辑,生成内部的目标图)与 generate(把这个目标图翻译成某个具体构建工具的输入文件:Ninja 的 build.ninja、Make 的 Makefile、MSVC 的 .sln/.vcxproj、Xcode 的 .xcodeproj)。随后 cmake --build build 才真正调用 Ninja/MSBuild 做增量编译。

理解这三阶段的价值在于:configure 很慢、build 要快。凡是能在 configure 阶段确定的信息(平台、库路径、特性开关)都不该塞进编译期宏或运行时判断;而频繁改动的源码必须让增量构建(Ninja + ccache)来承担。前端类比:configure 类似 Vite 的「解析配置 + 建立模块依赖图」,build 类似实际 bundle,只不过 C++ 的依赖图是「翻译单元级」的,粒度粗得多。

2.2 现代 CMake 的原子是 target,不是变量

老式 CMake(2.x 风格)靠全局变量:include_directories()、link_libraries()、CMAKE_CXX_FLAGS。它们的致命问题是作用域是目录及其子目录,会污染一切——A 模块需要的一个头文件路径,会被无差别地传给同目录下的 B、C、D 模块,久而久之「谁依赖谁」变成谁也说不清的一团。

现代 CMake(3.x 起的 target-based 风格)只认一个原子:target(由 add_library / add_executable 产生),所有配置都以 target_* 命令挂在它身上:target_sources、target_include_directories、target_compile_definitions、target_compile_options、target_compile_features、target_link_libraries。

而真正的精髓在三个关键字的传递性(usage requirements,CMake 文档称之为「使用需求」):

add_library(hashlib STATIC src/sha256.cpp)
target_include_directories(hashlib
    PUBLIC  include          # 我自己编译要用,链接我的人也要用
    PRIVATE src/internal     # 只有我自己编译要用,不对外暴露
    INTERFACE generated)     # 我自己不用,但用我的人要用

target_link_libraries(hashlib
    PUBLIC  fmt::fmt        # 我的公开头文件里出现了 fmt 的类型
    PRIVATE zlib::zlib)     # 只在我的 .cpp 里用了 zlib

读法很简单:PUBLIC = PRIVATE + INTERFACE。PRIVATE 表示「实现细节」,INTERFACE 表示「给下游的契约」,PUBLIC 是两者皆有。这一套机制让「依赖」随 target 图自动、精确地向下游传播,而不再靠人肉维护全局变量——这是 CMake 从「脚本」进化成「依赖图 DSL」的分水岭。你只需要描述局部事实(我这个 target 需要什么),全局一致性由 CMake 推导。

2.3 依赖从哪来:find_package、FetchContent 与包管理器

C++ 没有 npm registry 那样的「一个标准答案」,于是依赖获取有多条路径,按优先级如下:

一句话原则:能用预编译的 Config 包就别编源码,能锁版本的清单模式就别裸的 find_package。 依赖解析稳定性和构建时间,比「我本地正好装了」重要得多。

2.4 Qt6 的 CMake 集成:官方推荐路径

Qt6 已全面拥抱 CMake(qmake 只保留兼容),标准骨架是:find_package(Qt6 REQUIRED COMPONENTS Core Widgets) → qt_standard_project_setup()(一次设好 AUTOMOC/AUTORCC/AUTOUIC 与 C++17 标准)→ qt_add_executable(app ...) / qt_add_qml_module(app URI ...) → target_link_libraries(app PRIVATE Qt6::Widgets)。要点:链接 Qt6::Widgets 这个 target 就已经隐含了头文件路径、编译定义(如 -DQT_WIDGETS_LIB)、必要的库文件和平台依赖——你不需要再手写 include_directories(${Qt6Widgets_INCLUDE_DIRS})(那是 Qt5 时代的写法)。资源用 qt_add_resources、QML 模块用 qt_add_qml_module,它们都会生成对应的 target 供你链接或部署。跨平台分发再由 qt_generate_deploy_app_script() 这类 API 接管。

2.5 工程化设施:Presets、工具链、CTest 与可复现构建

03实际案例:成熟产品为什么这么设计

Qt 自身与 KDE 生态:Qt6 的整个代码库用 CMake 构建,并在内部维护一套 qt_internal_* 辅助 API(统一警告级别、特性开关、模块化 test 目标),KDE 框架(KConfig、KCoreAddons 等)也已全部从 autotools 迁到 CMake。原因很实际:C++ 项目要跨 Windows/macOS/Linux/Android/iOS 五个平台,只有 CMake 能做到「一份描述、多套生成器」。它们把平台差异压缩进少量 toolchain 与条件分支,把模块边界表达成 target 图——而不是维护五套 Visual Studio / Xcode 工程。

Chrome 与 Chromium 为什么不用 CMake:Chromium 的代码量在千万行级、目标数万个,它对构建图有极端需求(跨平台、增量精确性、分布式编译)。社区评估后自研了 GN + Ninja 组合:GN 专注「生成构建图的 DSL 与依赖分析」,Ninja 专注「极快的执行」。这说明一个道理——构建系统的选择是规模与约束的函数:几万行、几十个 target 的项目用 CMake 完全够;到了「构建时间本身成为核心竞争力」的体量,才会付出自研成本。反过来,CMake 生成 Ninja 文件也正是 Chromium 团队把 Ninja 做出来的原因之一。

CLion / Visual Studio / VS Code 的 C++ 生态:JetBrains 的 CLion 直接把 CMake(及 Presets)作为唯一项目模型——没有 CMake 就没有代码理解(索引、跳转、重构都来自 CMake 描述的编译数据库 compile_commands.json)。VS 则在 2017 后加入「Open Folder」模式来支持 CMake 项目,因为新项目普遍不再手工维护 .vcxproj。VS Code 的 C++ 插件同样靠 compile_commands.json 获得 IntelliSense。这三件事串起一个结论:CMake 工程不仅是给编译器看的,也是给 IDE、静态分析器、clang-tidy、覆盖率工具看的。你把构建描述做对,整条工具链自动受益;做成「魔法脚本」,IDE 索引就全是红的。

微信 / QQ 这类超大型客户端:它们内部多为自研或深度定制的构建体系(并大规模使用预编译库、模块化动态库、按需加载),但对外部依赖一律采用「预编译 + 版本锁定 + 内部镜像仓库」的策略——因为 C++ 从源码编所有第三方库在团队规模下是不可控的(编译时间、编译选项冲突、安全补丁追溯)。这提示我们:依赖策略的选择本质是「控制权 vs 便利性」的权衡,团队越大越要向「锁定 + 预编译」倾斜。

04常见错误:五个真实会踩的坑

05最佳实践:五条能落到团队规范里的规则

06与 Web 技术的联系:把工程化经验平移过来

Web 生态C++/Qt 生态关键差异
package.jsonvcpkg.json / conanfile.py声明依赖,但 C++ 还要声明编译选项与工具链
package-lock.jsonbuiltin-baseline / conan.lock锁的是 commit/哈希,粒度更粗但更接近「可复现」
node_modulesbuild/_deps / vcpkg_installedC++ 常按「构建类型」分裂出多份产物
webpack / vite(模块图 + bundle)CMake(target 图)+ 链接器前端源码级打包,C++ 是编译单元 + 二进制链接
npm run dev / buildcmake --preset dev / --build --preset relCMake 把「配置」与「构建」显式拆成两步
vitest / jestCTest + GoogleTest/QTest测试本身也是 target,参与依赖图与增量
tsconfig(编译选项)target_compile_features / optionsC++ 选项可沿 target 图条件传播
ESLintclang-tidy + 严格警告依赖 compile_commands.json 才能工作
CI 的 node_modules 缓存ccache / sccache缓存的是「编译结果」,命中率决定 CI 速度

这张表里最值得你停下来想的是「为什么 C++ 需要 usage requirements,而 npm 不需要」。因为 JavaScript 是源码级分发:你 import 一个包,得到的是文本,TypeScript 类型在编译期被抹掉,运行时的模块解析由宿主(浏览器/Node)负责。而 C++ 是二进制契约分发:目标文件里嵌着布局、名字修饰、调用约定、内联展开、模板实例化结果。链接两个库时,编译器必须知道「A 的头文件在被包含时,需要哪些额外的宏与路径才能和 A 的 .o 匹配」——这正是 PUBLIC/INTERFACE 存在的根本原因:它们描述的是「要用我,你必须额外带上什么」,是 ABI 契约的一部分。

由此也能解释几个你迟早会遇到的现象:为什么 C++ 世界流行 header-only 库(不需要链接、天然规避 ABI 问题,代价是编译时间)、为什么没有出现过 npm 那样统一的 registry(无法保证 ABI 与编译选项一致,只能靠源码或「同配置预编译」)、为什么「升级一个依赖版本」在 C++ 里是比前端风险高一个数量级的动作(可能引入 CRT/ABI/警告等级变更)。把前端那套「依赖随便升、CI 一定绿」的习惯带进 C++ 项目,就是事故的开始。

07管理者视角:Team Leader 该怎么抓这摊事

构建系统是团队生产力的基座:它坏一天,全组停滞一天;它慢 30%,每个工程师的编辑-编译循环都慢 30%。所以我把它当基础设施而不是「某个人顺手写的东西」,具体抓三件事。

一是度量。固定跟踪两个数:新人从 clone 到跑起来的时间(目标 < 15 分钟)、CI 从 push 到出结果的时间(超预算就专门排期优化)。这两个数字能提前暴露 90% 的工程债。

二是 Code Review 关注点。看依赖方向是否倒置(底层库是否反向依赖上层)、有没有全局命令混进来、公开头文件是否泄漏了实现依赖(看到 #include <zlib.h> 出现在 include/ 下就要追问)、有没有引入未锁版本的新依赖(这是供应链风险而非风格问题)、新 target 的命名与目录结构是否符合既有约定。

三是降低门槛。把「正确的做法」做成最省力的做法:维护一个脚手架模板仓库(含 presets、CI 模板、静态检查、测试骨架),新项目直接 fork;把构建约定写进 CONTRIBUTING.md 而不是口头传;让 CI 成为唯一权威——本地能过、CI 过不了的事,一次都不要容忍。

08延伸阅读

09今日思考题

1. 你的库里有一个 Widget 类,其公开头文件为了可以持有 QNetworkAccessManager 成员而包含了 Qt Network 头。请给出两种不改公开 API 语义、但能切断这条依赖传播的方案,并说明各自的代价。
2. 团队里有人主张「全部依赖都用 FetchContent,反正零安装最省事」。请从构建时间、离线可复现、供应链安全、调试收益四个维度写一份反驳或支持意见。
3. 为什么 -Werror 更该出现在 CI 而不是本地?如果你要升级 CI 的编译器大版本(如 GCC 13→14),构建流程上应该怎么安排,才能让这次升级「可控而不阻塞发布」?
4. 前端项目里「升级依赖版本」通常无痛,而 C++ 里往往伴随 CRT/ABI/编译选项风险。请解释这一差异的根因,并推导出:内部私有库对外分发时,最低限度必须公开哪些信息才让别人能用得安全?
5. 假设你的 Qt 客户端要同时支持 Windows(MSVC)、macOS(Clang)与 Linux(GCC),并共用一套 CI。请设计一份 Presets 与 CI 矩阵的大致结构,指出哪些配置必须按平台分支、哪些必须统一。

10今日实践任务(30–60 分钟)

目标:搭出一个「最小但符合现代规范」的 C++20 工程,亲手验证 usage requirements 的传播行为。

目录结构:include/logkit/log.hpp、src/log.cpp、app/main.cpp、tests/CMakeLists.txt、顶层 CMakeLists.txt、CMakePresets.json。

cmake_minimum_required(VERSION 3.24)
project(logkit LANGUAGES CXX)

set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)   # 给 clangd / IDE 用

add_library(logkit STATIC src/log.cpp)
target_include_directories(logkit
    PUBLIC  include                      # 契约:下游也要看到 log.hpp
    PRIVATE src)                         # 实现细节:不外泄
target_compile_features(logkit PUBLIC cxx_std_20)
target_compile_options(logkit
    PRIVATE $<$<CXX_COMPILER_ID:GNU,Clang>:-Wall -Wextra>)

add_executable(demo app/main.cpp)
target_link_libraries(demo PRIVATE logkit)

enable_testing()
add_subdirectory(tests)
{
  "version": 3,
  "configurePresets": [
    { "name": "dev", "generator": "Ninja",
      "binaryDir": "${sourceDir}/build/dev",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "Debug" } },
    { "name": "rel", "inherits": "dev",
      "binaryDir": "${sourceDir}/build/rel",
      "cacheVariables": { "CMAKE_BUILD_TYPE": "Release",
                          "CMAKE_COMPILE_WARNING_AS_ERROR": "ON" } }
  ],
  "buildPresets": [ { "name": "dev", "configurePreset": "dev" },
                    { "name": "rel", "configurePreset": "rel" } ]
}

验证步骤(重点在观察,而不是跑通):

交付标准:你能口头解释「为什么 ② 里那个 -I 参数是有害的」,并说出你项目里当前有几处类似的泄漏。

11一句话总结

构建系统是 C++ 团队的架构显式化与生产力基座——把 CMake 从「抄来的魔法脚本」升级为以 target 为单位、以 PUBLIC/PRIVATE/INTERFACE 描述依赖方向、以 Presets 固化配置、以包管理器锁定依赖的工程体系,你才能像管理前端工程化那样管理桌面客户端:可复现、可协作、可增量、可审计。