说真的,我一开始看到"caveman"这个词,第一反应是格斗游戏里那个拿着大棒、只会跳劈的原始人角色。直到有一天,我为了给一个小工具项目配上C++依赖,先被网上那些动辄几百兆、配置起来像解谜游戏的包管理器折磨了一下午,随手搜到caveman这个单文件C/C++依赖管理器,才明白这个"穴居人"绝对不是什么原始人,而是一种"我偏不用复杂的构建系统,就要简单粗暴地把头文件拉下来用"的极简主义。
这篇文章想聊的就是这个caveman,一个真正的C语言和C++语言生态里的"返祖"工具。它能做什么?它能在你不想装vcpkg、不想配Conan、不想折腾CMake FetchContent的时候,只靠一个脚本、一个依赖清单,从GitHub上把C/C++库直接拉到本地,让你安安静静写代码。它适合谁?适合写小型工具、做快速原型、维护那些"只需要几个header-only库"的项目的朋友,尤其是手头网络环境能直连GitHub、又希望省掉一大圈构建系统心智负担的人。当然,它也有一堆"穴居人"特有的毛病,我在实际使用里踩了不少,正好一并写出来。
下面我按自己的实操顺序,从工作原理到配置细节,再到排坑记录,尽量一次讲透。
1. 被依赖管理折磨到想骂人时,我遇到了"穴居人"
先交代一下我用caveman之前的真实处境。手上有一个内部用的命令行小工具,主要功能是解析配置文件、格式化输出、处理一些参数。技术栈选了C++,因为说到底要处理的数据量不小,用C++顺手。依赖其实没几个:一个做命令行参数解析的CLI11,一个做JSON解析的nlohmann/json,再加上一个fmt做格式化输出。
就这三个库,放在其他语言里就是一条pip install或者go get的事,但在C++生态里,折腾空间可以非常大。
最开始我图省事,直接跟同事说"用vcpkg吧",结果vcpkg clone下来就花了几分钟,然后编译这些依赖又花了一堆时间。尤其是那个JSON库,明明就是个纯头文件库,vcpkg也会给你生成一整套CMake config,把三四个附加依赖全带上,最后我的CMakeLists.txt里多了十几行find_package和target_link_libraries。那一刻我真的开始怀疑人生:我只是想调用一个json::parse而已。
后来我试过Conan,配置文件写起来更讲究,得建profile、写conanfile.txt,还要搞清楚remote仓库里有哪些version。对于一个只想"尽快把三个库拿来用"的小项目来说,这些相当于杀鸡用牛刀。
我也试过CMake自带的FetchContent,这个算轻量了,但它有一个让人头皮发麻的问题:它会在构建阶段强制联网、下载、然后参与你的构建图,稍微处理不当,构建服务器一断网就全盘崩溃,而且清理起来特别难搞。
就在这时候,我在查找"单头文件库怎么管理"的时候翻到了caveman。它口气很大,叫"一个C/C++包管理器",但它的实现只有一个脚本,依赖描述就几行文本,没有任何CMake集成,没有任何profile,没有任何daemon,下载完之后就把头文件往指定目录一丢,完事了。
这一下子就戳中我了。用大白话说,vcpkg和Conan是"给你一个完整的厨房,菜谱、灶台、锅碗瓢盆全都配齐",而caveman就是"给你一根削尖的木棍,让你自己想办法把肉弄熟"。但有时候,你要的真的只是一根削尖的木棍。
我实际用了几天之后,最大的感受是:它把"依赖管理"这件事的复杂度,从"管理依赖"降级成了"管理下载"。而对我这种小工具的维护者来说,这恰好是合适的抽象层次。接下来我就从它的工作机制讲起,看看这根"木棍"到底是怎么做到这一点的。
2. 它凭什么零配置:caveman的依赖解析机制拆解
caveman之所以敢叫"穴居人",核心就在于它的机制足够原始。不搞本地缓存数据库,不搞二进制ABI管理,不搞编译期集成,它的整个工作路径可以概括成三步:读清单、查GitHub、下载解压。
2.1 依赖清单:一份极其朴素的文本配置
caveman的依赖清单比很多同类工具的配置都要简单。我用的是类似这样一个文本文件,每一行描述一个依赖:
fmt fmtlib/fmt 9.1.0 CLI11 CLIUtils/CLI11 v2.3.2 json nlohmann/json v3.11.2每一行的三列分别是:本地库名、GitHub仓库路径(owner/repo)、你要的tag或者release版本号。就这么简单。
有的版本还支持在第三列后面追加一些选项,比如指定子目录、指定只拷贝哪些路径,不同分支语法略有差异,但核心思路一致:让用户告诉它"从哪里拉"和"拉什么版本",仅此而已。
我第一次看到这个配置的时候,第一反应是"这也能叫包管理器?"。但转念一想,这正是它聪明的地方。对于header-only库来说,所谓"安装",本质上就是"把头文件下载到某个目录"。你根本不需要什么pkg-config、不需要CMake config文件、不需要动态库符号导出,你要的就是一个include/目录,里面恰好有fmt/、nlohmann/、CLI/这些文件夹。caveman把这个"恰好"帮你实现了。
2.2 它下载的到底是什么:GitHub tag的"考古式"拉取
caveman执行install的时候,做的事情可以概括为三步:
- 解析依赖清单,拿到仓库名和版本号
- 向GitHub仓库的API或release页面发起请求,定位到对应的tag
- 下载该tag对应的压缩包,解压后按照规则把头文件(或源码)拷贝到目标include目录
这个机制和vcpkg那种从源码编译、由portfile控制整个流程的机制有本质区别。vcpkg会把每个库当做一个"包"来构建,编译出二进制、生成CMake config、安装到统一的vcpkg根目录。而caveman不管这些,它直接把库的源码摊在你面前,跟"考古"一样,扒开tag对应的目录结构,找到include子目录,复制完就走人。
为什么这样设计是可行的?因为现代C++库中,header-only已经成了默认偏好。fmt早期是需要编译的,但现在可以塞进-DFMT_HEADER_ONLY;nlohmann/json从头到尾就是纯头文件;CLI11也纯头文件。这些库的共同点是:源码发布了,头文件就在仓库里,不需要任何编译步骤,拿到就能用。
那么caveman真正要解决的,就只剩下一件事:怎么把GitHub上某个commit或tag的内容"抠"出来,放到你本地正确的位置。它没有去发明什么新格式或者新协议,而是直接复用了GitHub的archive能力,这就让它的实现可以压缩到单文件脚本的级别。
2.3 为什么它不需要构建系统集成
市面上大多数包管理器都要跟构建系统深度绑定,因为编译、链接、ABI、版本冲突这些"脏活"需要它们来管。C++的包管理器尤其如此,vcpkg有toolchain文件,Conan有generator,它们必须在自己掌控的一切环境变量和目标系统里工作。
caveman的思路是直接绕开这些脏活。它交付给你的不是"可供CMake find_package的包",而是"一组原始头文件目录"。你用不用CMake、用不用Makefile、用不用裸g++,它完全不关心。只要你在编译命令里写上-I <目录>,让编译器能找到头文件,你的依赖就算装好了。
这种设计的代价是——它没有一个"依赖解析器"帮你算传递依赖。比如说你装了spdlog,而spdlog内部需要fmt,caveman不会自动帮你拉fmt。你必须自己在清单里把fmt写上。我第一次没注意到这点,编译的时候报了一堆"找不到fmt/core.h",还以为是caveman坏了,后来查了一下源码模版才知道,它压根没有传递依赖的概念。
这也决定了它的边界:适合依赖树简单、以header-only库为主的小项目。如果你的项目有几十个库,而且库之间还有复杂的版本约束,用caveman就是给自己挖坑。这一点我后面会单独展开讲。
3. 跑通一个真实项目:依赖清单、执行过程与编译验证
光讲原理还是有点虚,我直接用一个真实的小项目带大家走完整条路。这个项目就是我前面提到的那个配置解析工具,它用到CLI11、nlohmann/json和fmt三个库。下面每一步都是实测过的。
3.1 准备依赖清单并安装
我的项目目录结构大致如下:
my-tool/ caveman.txt main.cpp CMakeLists.txt vendor/先在当前目录创建一个caveman.txt,内容就三行:
fmt fmtlib/fmt 9.1.0 CLI11 CLIUtils/CLI11 v2.3.2 json nlohmann/json v3.11.2然后我运行:
python3 caveman.py install这个过程会生成一个vendor/目录,里面主要是vendor/include/。安装完成后,我习惯性地看一眼生成结果:
find vendor/include -maxdepth 2 -type d输出大概长这样:
vendor/include vendor/include/CLI vendor/include/fmt vendor/include/nlohmann三个库的头文件目录都在了,干净利落。接下来就好办了。
3.2 CMakeLists怎么配合:最省心的写法
我不太想让CMake和caveman产生什么强绑定,所以我的CMakeLists.txt里只做了一个include目录的声明:
cmake_minimum_required(VERSION 3.16) project(my_tool CXX) add_executable(my_tool main.cpp) target_include_directories(my_tool PRIVATE ${CMAKE_SOURCE_DIR}/vendor/include ) target_compile_features(my_tool PRIVATE cxx_std_17)就这么简单。不需要点find_package,不需要target_link_libraries去连接一堆库名字。变通方式也有:如果你不想让构建依赖于本地vendor目录存在,你可以在CMake里加一个自定义target,先执行python3 caveman.py install再开始编译:
add_custom_target(deps COMMAND python3 ${CMAKE_SOURCE_DIR}/caveman.py install COMMENT "Fetching headers via caveman" ) add_dependencies(my_tool deps)但说实话我不推荐在正式构建里强依赖这个行为,因为caveman本身不是为持续集成设计的,每次构建都去访问GitHub,一旦网络受限整个构建就挂了。我更推荐的做法是:开发机上把依赖装好,vendor目录跟着项目走或者单独打包,构建机直接用现成的。
3.3 main.cpp里实际调用这三个库
到这一步才算真正"出头了"。我的main.cpp大概这样:
#include <CLI/CLI.hpp> #include <fmt/format.h> #include <nlohmann/json.hpp> #include <fstream> #include <iostream> int main(int argc, char** argv) { CLI::App app{"config parser demo"}; std::string path; app.add_option("-c,--config", path, "config file path")->required(); CLI11_PARSE(app, argc, argv); std::ifstream fin(path); nlohmann::json cfg = nlohmann::json::parse(fin); for (auto& [key, val] : cfg.items()) { std::cout << fmt::format("{} = {}\n", key, val.dump()); } return 0; }编译命令更是直接:
g++ -std=c++17 -I vendor/include main.cpp -o my_tool一次通过。这种"少就是多"的体验,真的让我有种回到远古时代用简陋工具解决大问题的快感。虽然它没有任何花哨的输出,连"安装成功"都是静默的,但当你发现-I vendor/include就能搞定一切的时候,你就理解了什么叫"穴居人式快乐"。
3.4 版本切换和卸载:一个脚本都替你干了
caveman最简单的点,其实还在于升级和卸载。升级就是把清单里的版本号改一下,重新执行install;卸载更是原始到极点——直接删掉vendor/目录。对于小项目来说,这种"物理"级别的卸载反而最让人安心,因为它把"清理残留"这种问题从源头消灭了。
我在实际切换版本时遇到过一次有趣的情况:fmt从9.1.0切到10.0.0之后,头文件目录结构有个小变化,导致我代码里一个#include路径要改。caveman不会帮你感知这些API变化,它只会傻乎乎地把新版本的头文件放到位。这当然没法跟Conan那种带版本约束和兼容性检测的体系比,但换个角度想,这种"不替你做决定"的透明性,也让问题变得更容易排查。
4. 实测中的坑:从GitHub拉库不是想象中那么顺利
如果你以为caveman就是"写三行配置,跑一下,完事",那你就跟我第一次用的时候一样天真了。把世界的复杂性剥掉一层,总会在另一个地方冒出来。我实际使用的第一个星期,就把下面这几个坑踩了个遍。
4.1 坑一:tag命名的不规范,版本号怎么都对不上
依赖清单写法很简单,给了你一个错觉:版本号随便写。但caveman不会做模糊匹配,它拉取GitHub tag的时候是精确查找的。现实世界里的仓库tag命名非常随意,有的带前导v,有的不带,有的直接是个日期,有的甚至没有tag只有release。
比如我在配置一个库的时候,写7.4.0,死活下载失败。我用git ls-remote --tags看了一下才发现,原来它只有v7.4.0这种tag。把清单改成v7.4.0就通了。
遇到这种情况,最有效的排查方法是先拿到真实的tag列表。如果你不想装git,也可以直接跑:
python3 caveman.py list <owner/repo>有些版本支持类似这样的命令来列出一个仓库的可用tag,或者你直接去GitHub网页上看releases页面也行。
我的经验是:写依赖清单之前,先确认你要的版本号跟仓库实际tag完全一致,包括大小写和前缀,一个字符都不能差。这属于caveman比较"原始"的一面——它把精确性完全交给了用户。
4.2 坑二:头文件目录结构不一致,include路径对不上
这是最让我恼火的一个问题。不同仓库打包出来的目录结构千差万别。有些仓库的根目录就是include的根,比如nlohmann/json,它的头文件直接躺在include/nlohmann/下面。但有些仓库的根目录会多包一层,比如你在tag上看到的是fmt-9.1.0/include/fmt/——仓库解压之后顶层目录还带个版本号。
第一次遇到时,我的vendor/include下面空空如也,头文件全跑到了vendor/include/fmt-9.1.0/这种乱七八糟的位置。后来我研究了一下caveman的规则,发现它通常会根据仓库的tag名生成一个临时解压目录,再把其中指定目录的内容拷贝到目标目录。
遇到这种问题,解决办法有两种:
- 改依赖清单,给caveman指定子目录映射的规则参数(不同版本语法不一样,一般是第三列之后加一个相对路径)
- 或者干脆手动进解压临时目录,看看真实结构长什么样,然后调整编译的include路径
我个人更偏向在清单里解决,因为这样至少保证"重新安装"之后结果是一致的。这里也给大家留个建议:跑完install之后,不要急着编译,先find vendor/include -maxdepth 2看一眼目录结构,确认头文件位置符合预期。这一步能帮你省下后面排查诡异compile error的半小时。
4.3 坑三:GitHub API限流和网络波动
caveman的下载压缩包行为依赖GitHub的接口。在没有配置访问令牌(token)的情况下,GitHub API对匿名请求有速率限制,我记得大约是一小时60次。如果你同时维护好几个项目,每个项目都跑一次install,很容易就触发限流,然后莫名其妙地下载失败。
我的应对方案很土但有效:在环境变量里配置一个GitHub Personal Access Token,让caveman的请求带上身份认证。具体地,我是在执行install前,设置好GITHUB_TOKEN环境变量,这样匿名限流就大幅放宽了。
另外,网络波动会造成下载中断。caveman对失败的提示有时候不是很友好,会直接抛一个异常退出,也不告诉你"下载到一半失败了"还是"找不到这个tag"。我的做法是:在CI或者脚本里,对install强制加重试逻辑,比如用shell的循环包裹它,失败就隔几秒重试,最多三次。虽然原始,但实测对网络抖动有效。
4.4 坑四:没有校验和,你得信任拉下来的内容
用过vcpkg或Conan的朋友知道,正规包管理器都会校验下载文件的hash,确保它没有被篡改或者损坏。caveman默认没有给每个依赖做强校验,它拉下来的是GitHub官方archive,大部分时候是可信的,但一旦遇到下载截断、代理缓存污染之类的情况,你得到的就是一个坏掉的目录,而且编译期不会立刻暴露,直到你用了某个源头文件才会爆雷。
所以我的习惯是,在正式项目里会偶尔检查一下vendor目录的总文件数和关键头文件大小,或者隔一段时间清空重新拉一遍,确保本地文件没有静默损坏。这个操作成本很低,但能避开很多诡异的编译问题。
4.5 踩坑之后,我的排查链路是什么
如果你也遇到caveman下载失败,我建议按照下面这个顺序排查:
- 确认清单里仓库路径和版本号与GitHub完全一致,用
git ls-remote --tags核对 - 手动访问一次该仓库的release页面,确认这个版本归档确实存在
- 检查网络代理设置,GitHub偶尔会连接超时,重试基本上能解决
- 配置
GITHUB_TOKEN,排除匿名限流 - 跑install之后,立刻检查
vendor/include目录结构,别急着编译
这套链路我后来几乎成了肌肉记忆,每次出问题,按顺序跑一遍,最多十分钟定位完毕。
5. 认清边界:caveman和vcpkg/conan各自该什么时候上
任何一个工具的存在都是有边界的,caveman尤其如此。我可以很坦白地说,它适合我的小工具项目,但绝不适合所有C++项目。说清楚它的适用边界,能帮你避免在不合适的场景里硬上然后骂它难用。
5.1 三种工具的分工
我先用一张表把caveman、vcpkg、Conan的核心差异拉出来:
| 对比维度 | caveman | vcpkg | Conan |
|---|---|---|---|
| 安装产物 | 头文件/源码目录 | 编译后的二进制+CMake config | 编译后的二进制+多种generator |
| 配置复杂度 | 极低,一个文本文件 | 中等,需要了解triplet和toolchain | 较高,需要profile和remote管理 |
| 传递依赖解析 | 不支持 | 支持 | 支持 |
| 与CMake集成 | 无,你要自己加include路径 | 深度集成,toolchain文件 | 通过generator集成 |
| 离线可用性 | 依赖GitHub,无法完全离线 | 有缓存机制,但首次仍需联网 | 可配置私有remote,离线友好 |
| 适合场景 | header-only小项目、原型、本机工具 | 需要编译二进制库、平台适配广泛的桌面项目 | 大型项目、多平台发布、复杂依赖链 |
从这张表里能明显看到,caveman的舒适区非常窄,但在这个窄区里,它极其顺手。
5.2 什么时候打死也别用caveman
我总结了几个坚决不推荐用caveman的场景:
- 依赖涉及需要编译的C/C++库:比如OpenSSL、libcurl、zlib这种,它们不是header-only,caveman拉下来你还是要自己编译、处理ABI、处理动态库路径。真到了这一步,vcpkg或Conan的价值就体现出来了。
- 需要交叉编译:vcpkg和Conan对目标平台的三元组(triplet)支持非常成熟,caveman完全没有这个概念。
- 项目要长期维护、多人协作:caveman的清单太简陋,传递依赖的缺失会让每个新接手的人都踩一遍"库怎么少了"的坑。多人协作场景下,一个能锁定完整依赖图的工具远比"极简"重要。
- 发布体量较大的商业项目:这类项目通常需要严格的版本审计、hash校验、可复现构建,这些都不是caveman能提供的。
5.3 我个人现在的选型标准
我自己的经验是,先问自己三个问题,再决定用哪个工具:
- 这个项目是不是以header-only库为主?如果是,caveman直接进候选。
- 要不要编译第三方库?如果有,直接放弃caveman。
- 项目的生命周期是几周还是几年?临时工具、原型项目,caveman很合适;要持续演进的核心服务,我建议老老实实上vcpkg或Conan。
说实话,我现在手上有两三个小工具项目,依赖清单加起来不超过五个库,caveman仍然是第一选择。但一旦项目开始涉及"需要在用户机器上编译动态库"这类需求,我会毫不犹豫换Conan。
结尾:一点真实体会
最后说点用过一段时间之后的个人感受。caveman这个工具最打动我的,不是它的功能多么强大,而是它提醒了我一件事:C++生态里不是所有问题都需要一套庞大的工程化体系去解决。有时候,你只需要一个能帮你把远程头文件放到本地include目录的"穴居人",然后你就可以回到写代码本身了。
这个小工具当然有很多毛病,它不解决ABI、不解决传递依赖、不提供缓存仓库、不校验哈希,但你换个角度想,正因为它把这些责任都丢还给了用户,它的行为才变得如此可预测。我到现在还保持着用它拉完依赖后习惯性看一眼vendor/include目录结构的毛病,就因为这个动作救过我好几次。
如果你也想试试,建议从一个小而干净的项目开始,把CLI11或者fmt这种纯头文件库装进去跑通一次,体会一下那种"零构建系统负担"的感觉。等你的依赖复杂到它真的撑不住了,再去用那些大家伙,你会更理解每个工具存在的意义。
另外,caveman这个名字起得真好。穴居人的工具不多,每一件都直接、结实、没有冗余。依赖管理这件事,在合适的场景下,也许就该这么简单。