一个 C++ 项目引入 fmt、spdlog、protobuf 之后,构建文档通常会变成一份手工步骤清单:先装系统依赖、再 clone 某个 tag、再 cmake 编译安装到 /usr/local。这套流程在开发者本机能跑通,但在 CI 上、在新同事的机器上、在三年后的容器镜像里几乎必然失败。根本原因是 C++ 缺少统一的 ABI 与构建产物标准,库的「包」形态从未被标准化。vcpkg 与 Conan 是当前最主流的两条路线,本文从机制到落地逐一拆解。
一、C++ 依赖管理的困境
1.1 为什么 C++ 没有 npm
对比其他语言可以看清问题所在:
| 语言 | 包格式 | 分发内容 | ABI 保证 |
|---|---|---|---|
| JavaScript | npm 包 | 源码 | 无(解释执行) |
| Rust | crate | 源码 | 无(每项目全量编译) |
| Go | module | 源码 | 无(静态链接) |
| Python | wheel | 预编译二进制 | 有限(限版本) |
| C++ | 无统一标准 | 源码 + 二进制混合 | 无 |
C++ 的困难来自三个层面:ABI 不统一(同一份代码用 GCC 11 与 GCC 13 编译出的库二进制不兼容)、构建配置爆炸(Debug/Release、静态/动态、异常/RTTI 开关的笛卡尔积)、依赖传播复杂(头文件路径、链接库顺序、编译定义都要向下传递)。任何包管理器都必须先解决这三件事。
1.2 依赖管理的四个层次
一个成熟的依赖管理方案需要同时提供:
- 获取:从注册表/仓库下载源码或二进制
- 构建:按当前工具链与配置编译出可用产物
- 解析:处理传递依赖与版本冲突
- 缓存:避免重复编译,加速 CI
vcpkg 与 Conan 在这四个层次上的设计哲学截然不同,这也是选型的核心分歧点。
二、vcpkg 机制与实战
2.1 manifest 模式
vcpkg 由微软主导,采用「port 文件描述如何构建」的模型,每个库是一个 portfile.cmake。现代用法是 manifest 模式,依赖声明在项目根目录的 vcpkg.json:
{
"name": "myapp",
"version": "1.0.0",
"dependencies": [
"fmt",
"spdlog",
{ "name": "protobuf", "default-features": false, "features": ["lite"] }
],
"builtin-baseline": "a42af01b72c28a8e1d7b48107b33e4f286a55ef6",
"overrides": [
{ "name": "fmt", "version": "10.2.1" }
]
}
builtin-baseline 指向 vcpkg 仓库的一个 commit,是版本锁定的关键:它决定了所有未显式指定版本的依赖解析到哪个时间点。overrides 则用于强制某个依赖的精确版本。
# 使用 manifest 模式安装依赖
vcpkg install --triplet x64-linux
# 在 CMake 配置时自动安装(推荐)
cmake -B build -DCMAKE_TOOLCHAIN_FILE=$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake
vcpkg 的 triplet 是「目标平台 + 链接方式 + 运行时」的组合,如 x64-linux、x64-windows-static、arm64-osx,它直接对应到那一堆构建配置组合。
2.2 版本控制与注册表
vcpkg 支持自定义注册表,可以搭建私有 port 集合:
{
"registries": [
{
"kind": "git",
"repository": "https://git.example.com/team/vcpkg-registry",
"baseline": "5c2b1f0d3a8e4f6b9c0d1e2f3a4b5c6d7e8f9a0b",
"packages": ["internal-crypto", "company-sdk"]
}
]
}
# 生成版本数据库(注册表维护者操作)
vcpkg x-add-version internal-crypto --overwrite-version
私有注册表让内部库也能像开源库一样被声明式引用,这是 vcpkg 相对 Conan 更「开箱即用」的一点。
2.3 二进制缓存
vcpkg 每次构建产物会按「源码哈希 + 编译选项哈希」计算出一个 ABI 标识,缓存可避免 CI 反复编译同一份依赖:
# 本地文件系统缓存
vcpkg install --binarysource=files,/mnt/cache,readwrite
# 对象存储(如 Azure Blob、S3 兼容)
export VCPKG_BINARY_SOURCES="clear;x-azblob,https://acct.blob.core.windows.net/cache,sas-token,readwrite"
在 GitHub Actions 上配置缓存后,原本需要 20 分钟的依赖编译通常能压缩到 1 分钟以内——这是 vcpkg 落地 CI 收益最大的一环。
三、Conan 机制与实战
3.1 conanfile 与 profile
Conan 是社区驱动的去中心化方案,把「构建配方」与「二进制包」彻底分离。conanfile.py 描述如何构建与打包:
from conan import ConanFile
from conan.tools.cmake import CMake, cmake_layout
class MyAppConan(ConanFile):
name = "myapp"
version = "1.0.0"
settings = "os", "compiler", "build_type", "arch"
generators = "CMakeDeps", "CMakeToolchain"
requires = "fmt/10.2.1", "spdlog/1.14.1", "protobuf/3.21.12"
def layout(self):
cmake_layout(self)
def build(self):
cmake = CMake(self)
cmake.configure()
cmake.build()
profile 描述构建环境(编译器、架构、构建类型),依赖解析结果会写入 conan.lock 锁文件,保证 CI 与开发机解析出完全一致的依赖图:
# profiles/linux-release
[settings]
os=Linux
arch=x86_64
compiler=gcc
compiler.version=13
compiler.libcxx=libstdc++11
build_type=Release
[conf]
tools.build:jobs=8
conan install . --profile=linux-release --build=missing
conan lock create . --profile=linux-release # 生成 conan.lock
conan install . --lockfile=conan.lock # CI 中复现
3.2 包 ID 与二进制兼容
Conan 最核心的设计是 package_id:它由 settings、options、依赖的 package_id 共同哈希而成,标识一个二进制包是否能在当前环境下复用。
def package_id(self):
# 默认:编译器版本参与哈希,GCC 12 与 13 的产物不共享
# 优化:头文件库与编译器版本无关,可放宽
self.info.settings.compiler.version = "any"
# 优化:Debug/Release 对纯头文件库无影响
self.info.settings.build_type = "any"
理解 package_id 是排查「为什么 CI 又从头编译了一遍」的关键:任何参与哈希的设置变化(哪怕只是 compiler.version 从 13.1 变成 13.2)都会导致缓存失效。
3.3 私有仓库
Conan 2 支持多种远程类型,私有包与公共包可以共存:
# 添加官方中心与私有仓库
conan remote add conancenter https://center.conan.io
conan remote add company https://conan.example.com/artifactory/api/conan/company
# 上传私有包
conan upload internal-crypto/1.2.0 -r company --confirm
# 按模式批量上传
conan upload "company-*" -r company --confirm
Artifactory、Nexus 等制品仓库都提供 Conan 仓库支持,权限模型与 Maven/npm 仓库统一,便于企业级治理。
四、两者对比
4.1 架构差异
| 维度 | vcpkg | Conan |
|---|---|---|
| 主导方 | 微软 | 社区(JFrog 支持) |
| 包描述 | portfile.cmake + vcpkg.json | conanfile.py / .txt |
| 版本锁定 | builtin-baseline + overrides | conan.lock 锁文件 |
| 二进制缓存 | ABI 哈希 + 文件/云缓存 | package_id + 远程仓库 |
| 私有库 | 自定义注册表(git) | 私有 remote(Artifactory) |
| 学习曲线 | 低 | 中高 |
| 生态规模 | 2000+ 端口 | 1800+ 中心包 + 企业私库 |
| 与 CMake 集成 | toolchain 文件(最简) | CMakeDeps 生成器 |
4.2 选型建议
- 中小项目、快速起步:选 vcpkg。toolchain 一行搞定,manifest 声明式清晰,CI 集成成本最低
- 大型企业、多语言制品统一治理:选 Conan。锁文件、私有仓库、package_id 精细控制更适合规模化
- 需要精细控制构建选项与 ABI:选 Conan。
options与package_id的表达能力更强 - 已有 Artifactory/Nexus 体系:Conan 与制品仓库的集成更自然
- Windows 生态、Visual Studio 用户:vcpkg 与 MSVC 的集成度最高
两者并非互斥:部分团队用 vcpkg 管理第三方开源库,用 Conan 管理内部私有库,但混用会显著增加心智负担,除非有明确收益,否则不建议。
五、与 CMake 集成
5.1 三种集成方式
无论用哪个包管理器,最终都要落到 CMake。核心是用 find_package 的 CONFIG 模式消费依赖提供的 xxxConfig.cmake:
cmake_minimum_required(VERSION 3.25)
project(myapp LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(fmt CONFIG REQUIRED)
find_package(spdlog CONFIG REQUIRED)
find_package(Protobuf CONFIG REQUIRED)
add_executable(myapp src/main.cpp)
target_link_libraries(myapp PRIVATE
fmt::fmt
spdlog::spdlog
protobuf::libprotobuf)
三种集成方式的差异在于 xxxConfig.cmake 从哪来:
# 1. vcpkg toolchain:配置阶段自动注入前缀路径
cmake -B build -DCMAKE_TOOLCHAIN_FILE=$VCPKG_ROOT/scripts/buildsystems/vcpkg.cmake
# 2. Conan:先生成再配置
conan install . --output-folder=build --build=missing
cmake -B build -DCMAKE_TOOLCHAIN_FILE=build/conan_toolchain.cmake
# 3. CMake Presets:把上述命令固化进 CMakePresets.json(推荐)
cmake --preset conan-release
第三种方式把工具链路径、缓存变量、环境变量全部固化到 CMakePresets.json,让「clone 下来就能构建」成为现实:
{
"version": 6,
"configurePresets": [
{
"name": "conan-release",
"generator": "Ninja",
"binaryDir": "${sourceDir}/build/Release",
"cacheVariables": {
"CMAKE_BUILD_TYPE": "Release",
"CMAKE_TOOLCHAIN_FILE": "${sourceDir}/build/Release/generators/conan_toolchain.cmake"
}
}
]
}
5.2 常见坑
- 工具链文件冲突:Conan 与 vcpkg 的 toolchain 不能同时使用,必须二选一
- 静态库链接顺序:C++ 静态库有依赖顺序要求,用
target_link_libraries传递依赖而非手工排列 - Debug/Release 混用:依赖的构建类型必须与主项目一致,否则在 MSVC 上会因
_ITERATOR_DEBUG_LEVEL不匹配而链接失败 - 动态库运行时路径:Windows 上 DLL 需拷贝到可执行文件目录,Linux 上需设置
RPATH - protoc 版本不一致:生成的
.pb.cc与运行时库版本必须匹配,需把protoc版本也纳入依赖管理
关于 ABI 兼容与符号可见性的更多细节,参见 https://plumephp.com/cpp-cross-platform-build-matrix/;而 target_link_libraries 的传播语义(PUBLIC/PRIVATE/INTERFACE)则是 https://plumephp.com/cpp-cmake-project/ 的核心内容。
六、工程实践清单
把依赖管理落地为团队规范,建议固化以下条目:
- 依赖清单入库:
vcpkg.json/conanfile.py与锁文件必须提交到 Git,禁止隐式依赖系统库 - 锁定版本:vcpkg 用
builtin-baseline+overrides,Conan 用conan.lock,CI 中强制校验 - 缓存 CI:把二进制缓存挂到持久化存储,可把构建时间降低一个数量级
- 统一工具链:用 CMake Presets 固化编译器、生成器与工具链文件,杜绝「我这儿能跑」
- 定期升级依赖:季度级批量升级 + 完整回归,避免版本落后到无法升级
- 私有库也要有 CI:内部库同样要走构建、测试、上传的流水线,否则会成为新的「手工步骤」
- 记录构建环境:在 CI 中打印编译器版本、包管理器版本、依赖解析结果,便于事后追溯
相关阅读
- https://plumephp.com/cpp-cmake-project/ — target-based CMake 与现代工程化实践
- https://plumephp.com/cpp-cross-platform-build-matrix/ — CMake Presets 与 CI 构建矩阵、ABI 兼容
- https://plumephp.com/cpp-engineering-practices/ — 代码规范、静态分析与 CI/CD 整体工程实践
延伸阅读
- https://plumephp.com/posts/devops/ — CI/CD 流水线设计与制品仓库治理
- https://plumephp.com/posts/docker/ — 用容器固化 C++ 构建环境与依赖缓存
文末完整示例
# 完整可运行示例:CMakeLists.txt + vcpkg.json + CMakePresets.json 三件套
# 目录结构:
# myapp/
# CMakeLists.txt
# CMakePresets.json
# vcpkg.json
# src/main.cpp
# 构建:cmake --preset default && cmake --build build
# ===================== vcpkg.json =====================
# {
# "name": "myapp",
# "version": "1.0.0",
# "dependencies": ["fmt", "spdlog"],
# "builtin-baseline": "a42af01b72c28a8e1d7b48107b33e4f286a55ef6"
# }
# ===================== CMakePresets.json =====================
# {
# "version": 6,
# "configurePresets": [
# {
# "name": "default",
# "generator": "Ninja",
# "binaryDir": "${sourceDir}/build",
# "cacheVariables": {
# "CMAKE_BUILD_TYPE": "Release",
# "CMAKE_TOOLCHAIN_FILE": "$env{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake"
# }
# }
# ]
# }
# ===================== CMakeLists.txt =====================
cmake_minimum_required(VERSION 3.25)
project(myapp LANGUAGES CXX)
set(CMAKE_CXX_STANDARD 20)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
set(CMAKE_EXPORT_COMPILE_COMMANDS ON)
# 默认只允许 CONFIG 模式,避免误匹配系统库
set(CMAKE_FIND_PACKAGE_PREFER_CONFIG ON)
find_package(fmt CONFIG REQUIRED)
find_package(spdlog CONFIG REQUIRED)
add_executable(myapp src/main.cpp)
# 用现代 target 用法:依赖以 INTERFACE 形式传播
target_link_libraries(myapp PRIVATE
fmt::fmt
spdlog::spdlog)
target_compile_options(myapp PRIVATE
$<$<CXX_COMPILER_ID:GNU,Clang>:-Wall -Wextra>)
# 打印依赖解析结果,便于排查版本问题
message(STATUS "fmt : ${fmt_VERSION}")
message(STATUS "spdlog : ${spdlog_VERSION}")
// src/main.cpp —— 消费被管理的依赖
#include <fmt/core.h>
#include <spdlog/spdlog.h>
int main() {
spdlog::info("依赖管理演示启动");
fmt::print("fmt 格式化:{}\n", fmt::format("pi ≈ {:.3f}", 3.14159));
spdlog::warn("这是一条来自 spdlog 的告警");
return 0;
}
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。