为什么要设计和开发 kmpkg
为什么要设计和开发 kmpkg
1. 问题:C++ 软件包管理在生产中崩溃
C++ 软件包管理问题的*主要原因不是功能缺失。 它们来自生产现实:
- 编译必须在不同机器和不同时间内可重现
- 二进制文件必须重复使用而不是重建
- 网络可能受到限制或完全离线
- 部署环境不同于开发环境
- 构建系统和工具链是异构的、长期存在的
大多数现有的 C/C++ 软件包管理器在开发环境下运行良好,但在长期、生产规模的使用环境下就会开始失效。
kmpkg 就是专为解决这一问题而设计的。
2. 从现有工具中汲取的经验教训
在使用 kmpkg 之前,我们在实际项目中评估并使用了多个软件包管理器:
cget:简单,但对于复杂的依赖关系图来说过于有限 Conan:功能强大,但配置繁重,难以进行大规模推理 Conda:对以 Python 为中心的生态系统有效,但与 C++ 构建现实不匹配 vcpkg:基础牢固,但与集中式假设和源代码优先的工作流程紧密耦合
上述每种工具都能解决部分问题,但当二进制文件、镜像和部署限制成为头等大事时,它们都无法形成一个**清晰、确定性强且便于生产的系统。
3. 二进制优先的思维来自实际成本
在大型 C++ 系统中
- 重建成本高昂
- CI 时间主导迭代速度
- 重复重建相同的依赖关系是一种浪费
- “纯源代码 ”工作流无法在运行中扩展
在使用 kmpkg 之前,我们开发了一款名为 carbin 的内部工具,用于试验以二进制为中心的依赖关系工作流。这项实验清楚地表明了一点: > 二进制重用并不是一种好方法:
二进制重用不是一种优化,而是一种需求。
kmpkg 直接继承了这一经验。
4. 镜像必须是一等公民的,而不是事后想到的
大多数软件包管理器将镜像视为
- 可有可无
- 次要的
- 或只是缓存层
在生产环境中,镜像不是这些东西。
镜像对于以下方面至关重要
- 网络隔离
- 合规性和审计
- 部署确定性
- 灾难恢复
- 多区域一致性
kmpkg 将镜像和分发控制提升为顶级概念。 镜像是明确的、可配置的,是系统运行的核心。
这种设计使 kmpkg 自然适用于
- 私人基础设施
- 受限网络或隔绝空气的网络
- 企业生产部署
5. 灵感而非模仿
kmpkg 的灵感来源于
vcpkg:清晰的软件包模型和 CMake 集成 Go 模块:最小化配置和确定性解决 Rust Cargo:统一的工作流程和严格的版本规范
然而,kmpkg 并不***试图复制这些系统。
C++ 有其独特的限制:
- 多个构建系统
- ABI 和工具链碎片化
- 长期依赖
- 编译成本高
kmpkg 是围绕这些现实而设计的,而不是将其抽象化。
kmpkg 是工具链的一部分,而非独立工具
单独的软件包管理器无法解决 C++ 的生产力问题。
kmpkg 的设计目的是与周围的工具自然集成:
kcmmake 用于项目和构建生成 kmdo 用于克隆、合并、发布镜像和软件包等操作工作流
这些工具共同构成了一个连贯的系统,其中包括
- 依赖性管理
- 构建配置
- 二进制分发
- 发布和部署
被视为同一工作流程的组成部分,而不是互不关联的步骤。
7. 设计目标
kmpkg的核心设计目标是:
- 决定论:相同的输入产生相同的输出
- 可操作性:易于推理、调试、操作
- 二进制重用:避免不必要的重建
- 镜像优先:分发控制是明确且集中的
- 工具链集成:自然地融入真实的 C++ 工作流程
kmpkg 故意避免以牺牲这些原则为代价来追求功能完整性。
8. kmpkg 并不想成为什么
kmpkg 不试图:
- 替换所有现有的 C++ 工具
- 抽象掉 CMake、编译器或链接器
- 通过不透明的魔法隐藏复杂性
- 针对长期运行的快速演示进行优化
它专为关心系统能经受多年生产使用的工程师而设计。
需求驱动、实践驱动的设计
kmpkg 并不是通过列出功能来设计的。 它是由实际生产故障、运营限制和长期维护成本决定的。
在系统设计层面,kmpkg 遵循一条严格的规则:
用户是唯一的一等公民。 系统是一个工具,而不是政策。
这个原则是不容谈判的。
大多数包管理器逐渐朝着保护其内部一致性的方向发展: 全局注册、集中假设、隐藏默认值以及悄然成为强制性的“推荐”工作流程。随着时间的推移,系统开始针对自身进行优化,而不是由操作它的工程师进行优化。
kmpkg 明确拒绝这个方向。
kmpkg 中的系统设计是需求驱动的: 每个抽象的存在只是因为真正的用户需要它来解决生产中的实际问题。如果一个功能不能被实际使用证明是合理的,那么它就不属于系统。
系统永远不会对用户环境承担权限。
- 它不假设网络可访问。
- 它不假设统一的工具链。
- 它不假设二进制兼容性。
- 它不假设单一的“正确”构建或部署方式。
相反,kmpkg 公开机制并将政策保持在外部。用户决定工件来自哪里、它们如何构建、哪些二进制文件是可接受的以及何时重用是安全的。
这种设计理念直接影响每个主要系统决策,包括二进制重用。
具体示例:RocksDB
考虑 RocksDB。
在实际生产中,RocksDB 几乎从不作为单一通用二进制文件使用。仅压缩支持就已经造成空间碎片:lz4、zstd、snappy 或 none。添加 RTTI、异常处理、特定于平台的工具链,“标准二进制”的想法立即崩溃。
此时,纯二进制分发不再是解决方案。
二进制存储库呈指数增长,但仍然无法提供正确的工件。客户端无法可靠地选择正确的二进制文件,因为兼容性取决于在包边界不可见的构建时决策。
结果是可以预见的:
- 工程师使用略有不同的标志反复重建相同的依赖项
- 项目在不同机器上的表现不同
- 配置随着时间的推移悄然发生变化
- 可重复性退化为部落知识
这不是工具错误。 这是一种系统设计失败——将包管理器的抽象优先于用户的现实。
kmpkg 采取相反的立场。
它接受像 RocksDB 这样的库不能被规范化。构建可变性是明确的。分配控制权归用户所有。仅在操作安全的情况下才允许二进制重用——默认情况下从不假设。
系统适应用户,而不是相反。
系统作为基础设施,而不是权威
kmpkg 并不试图“标准化”C++ 开发。 C++不需要另外的权威。
相反,kmpkg 的行为就像基础设施:
- 可预测的
- 可检查
- 可更换
- 不需要时保持沉默
当系统强迫用户改变工作方式以满足其内部模型时,它就已经达到了目的。
kmpkg 的存在是为了减少摩擦,而不是为了定义正确性。
比较包管理器的概念
不同的 C/C++ 包管理器通常看似解决相同的问题,但它们是建立在根本不同的假设之上的。这些假设决定了系统是否能够在真实的生产环境中生存或在操作压力下崩溃。
本节在概念层面比较主要的包管理器,而不是功能列表。
核心设计范式
对于生产 C++ 系统,以下轴主导所有现实世界的结果:
- 谁拥有构建决策
- 如何处理二进制文件
- 控制边界所在
- 系统是否为自身优化或为用户优化
获取
概念模型: 最小的基于源的依赖项获取器。
关键假设:
- 构建发生在本地
- 用户完全控制编译
- 无全局依赖图
- 无二进制重用模型
优点:
- 简单
- 透明
- 低抽象成本
结构限制:
- 无系统级协调
- 不保证跨机器的再现性
- 无生产规模分销模式
cget 作为一个机械助手运行良好,但并不试图成为一个生态系统。它的扩展能力很差,超出了单个项目的范围。
conan
概念模型: 具有配置驱动的二进制文件的集中式包管理器。
关键假设:
- 可以通过设置/选项对二进制文件进行充分参数化
- 中央登记处是可以接受的
- 依赖图可以全局解析
优点:
- 丰富的元数据模型
- 显式配置空间
- 高度关注二进制重用
结构限制:
- 现实世界库中的配置爆炸
- 二进制兼容性变为组合兼容性
- 中央权力逐渐决定工作流程
- 操作复杂性泄露给用户
在实践中,柯南经常将复杂性从构建转移到元数据和基础设施中。随着可变性的增加,系统变得更加复杂。
conda
概念模型: 二进制优先、环境隔离的分发系统。
关键假设:
- 预构建的二进制文件是主要工件
- 环境是隔离且一次性的
- 工具链集中控制
优点:
- 极强的重现性
- 适用于同类堆栈(Python、数据科学)
结构限制(对于 C++):
- 二元刚性
- 不适合异构工具链
- 对自定义构建标志的适应性有限
- 环境和本机系统之间的硬边界
Conda 的成功在于限制可变性,这与大多数大规模 C++ 生产环境直接冲突。
vcpkg
概念模型: 基于源的端口,具有可选的二进制缓存。
关键假设:
- 源代码构建是最安全的基线
- 可以接受共享端口注册表
- 二进制复用是性能优化,而不是要求
优点:
- 强大的 CMake 集成
- 可预测的源代码构建
- 相对简单的心智模型
结构限制:
- 以注册表为中心的世界观
- 镜像被视为次要的
- 运营控制仍然隐含
- 分配政策的有限表达
vcpkg 适用于许多开发场景,但其架构假定良性、全局连接的环境。
kmpkg
概念模型: 用户控制的分发和构建基础设施。
关键假设:
- 用户在不同的约束下操作
- 镜像和分布都是一等公民的
- 二进制重用必须是明确的和有条件的
- 系统绝不能凌驾于用户意图之上
主要区别:
- 镜像是顶层概念,不是实现细节
- 不假设全球注册表
- 构建可变性是预期的,而不是标准化的
- 机制优先于政策
kmpkg 并非旨在定义构建 C++ 的“正确方法”。 它旨在适应长期生产使用,其中需求不断变化,限制不断累积。
概念总结
| 尺寸 | 获取 | conan | conda | vcpkg | kmpkg |
|---|---|---|---|---|---|
| 二进制优先 | 没有 | 是的 | 是的 | 可选 | 有条件 |
| 来源优先 | 是的 | 部分 | 没有 | 是的 | 是的 |
| 中央登记处 | 没有 | 是的 | 是的 | 是的 | 没有 |
| 镜如一等公民 | 没有 | 没有 | 部分 | 没有 | 是的 |
| 建立可变性容忍度 | 高 | 中等 | 低 | 中等 | 高 |
| 用户作为超级公民 | 部分 | 没有 | 没有 | 部分 | 是的 |
为什么这很重要
C++ 包管理中的大多数失败都不是工具错误。 它们是不匹配的假设的必然结果。
kmpkg 是围绕一个现实而设计的:
生产系统的变化比包管理器更快。
任何将内部一致性置于用户控制之上的系统最终都会在实际操作压力下失败。
一个真实的生产失败案例
vcpkg
当向外部团队推荐kmpkg时,常见的故障模式再次出现。
该团队在生产中使用 vcpkg。为了保持构建稳定,他们别无选择,只能固定 vcpkg 存储库版本并严格遵循官方注册表。
这很有效——直到没有效果。
当上游 vcpkg 注册表演变时,他们的项目停止编译。失败不是由自己系统中的代码更改引起的,而是由上游依赖项构建逻辑和端口定义的更改引起的。
那时,恢复变得手动且昂贵:
- 他们必须在 vcpkg 存储库中搜索 Git 历史记录
- 识别之前使用过的提交
- 手动查看历史状态
- 希望它仍然匹配他们的工具链和环境
这个过程是:
- 慢
- 容易出错
- 不可能可靠地实现自动化
- 与项目自己的版本控制完全脱节
最重要的是,依赖系统(而不是用户)控制升级路径。
结构性问题
这不是 vcpkg 错误。 这是设计结果。
当包管理器时:
- 以官方注册机构为权威
- 将镜像视为次要的
- 夫妻紧密地构建逻辑以适应上游进化
那么用户就失去了操作控制权。
版本固定成为一种防御性攻击,而不是一等公民的概念。 回滚成为考古学。
kmpkg 的设计响应
kmpkg 是专门为避免这种故障模式而设计的。
- 镜像是一等公民且用户拥有
- 分布状态是显式的,而不是隐式的
- 上游进化是选择加入的,而不是强迫的
- 历史状态作为工作流程的一部分保留,而不是 Git 意外
在 kmpkg 中,用户决定生态系统何时以及如何移动。 该系统从不假设上游比生产现实更了解。
原理
在生产中,稳定并不是通过冻结世界来实现的。 这是通过拥有你所依赖的世界来实现的。
kmpkg 的存在是因为太多的 C/C++ 团队通过惨痛的教训吸取了这一教训。
为什么以注册表为中心的设计大规模失败
传统的 C/C++ 包管理器严重以注册表为中心。他们假设上游知道“正确”的配置、二进制布局和依赖关系图。这个假设适用于简单的项目,但在规模上会被打破。
示例:SIMD 相关库。 许多高性能库(如压缩引擎、线性代数或数据库)提供多种构建变体:
- SIMD指令集:AVX2、SSE4、NEON
- 可选功能:LZ4、ZSTD、Snappy 支持
- RTTI、例外或 ABI 差异
假设构建选项是通用的,以注册表为中心的方法通常会提供每个版本一个规范的二进制文件。现实中:
- 客户端可能运行在具有不同CPU能力的不同机器上。
- 根据子系统的不同,一个库可能需要不同的压缩或 SIMD 标志。
- RTTI 或 ABI 不匹配可能导致二进制存储库爆炸,因为每个小的组合都会使所需的二进制文件数量成倍增加。
在实践中,这会导致:
- 注册表爆炸:中央存储库与每个可选功能组合增长。
- 二进制不兼容:客户端无法为其具体环境选择正确的预构建二进制文件。
- 操作摩擦:团队被迫在本地重建二进制文件或修补注册表,从而违反了可重复性和自动化。
更糟糕的是,用户失去了控制:
- 用户无法声明性地选择每台机器或每个子系统所需的确切变体。
- 如果不分叉注册表或手动重建数十种组合,可能无法回滚到以前的状态。
- 生产稳定性受制于上游决策。
kmpkg 的回应
kmpkg 翻转了这个模型:
- 以用户为中心:用户选择镜像、二进制变体和部署策略。
- 二进制复用:系统明确支持多种变体,不会导致注册表爆炸。
- 可重复的工作流程:每个项目都可以完全控制使用哪些二进制文件,即使在异构机器上也是如此。
- 功能灵活性:可选功能(如 SIMD 或压缩后端)是一等公民的,无需中央协调即可在本地组合。
原理: 在真实的生产环境中,注册表是一个工具,而不是老大。 控制权必须属于用户,而不是包管理器。
当然。这是 kmpkg 核心设计 要点的统一英文版本,重点是简洁、实践驱动:
kmpkg 核心设计
-
清除依赖配置 所有项目依赖项都经过显式声明和版本控制,使每个项目所依赖的内容一目了然。
-
功能感知依赖性 依赖项支持功能标志和传递要求,允许对构建中包含的功能进行细粒度控制。
-
镜像优先架构 镜像被视为一等公民实体,可在专用网络、气隙环境或受限基础设施中实现可靠运行。
-
灵活的二进制变体 二进制工件可能会根据编译器、平台或功能集的不同而有所不同。这可以防止二进制爆炸,同时支持跨异构环境的可重复构建。
-
用户作为超级公民 用户控制工作流程;该系统纯粹作为一个工具。这确保了运营决策始终有利于开发人员或项目团队的需求。
-
操作简单 旨在最大限度地减少配置开销,减少日常操作中的摩擦,并确保可重复性,而无需复杂的手动干预。
-
与工具链集成 与现有 C++ 生态系统工具(例如 CMake、MSBuild)以及内部工具(例如 kmcmake 和 kmdo)无缝集成。项目生成、依赖管理、构建配置和发布形成统一的工作流程,同时保持与标准 C++ 工程实践的兼容性。
及简工作流
kmpkg的典型工作流程
- 获取原始的中央仓库 kmpkg或者是vcpkg
- git初始化个人或者团队git repo 作为mirror
- 实用kmdo工具按需从中央仓库导入需要的ports
- 在自己mirror里进行操作,添加自己的库
- 根据需要,使用kmdo工具随时从中央仓库,或者其他仓库导入需要的ports
以上是kmpkg的工作流程,如果仓库历史陈旧了,提交记录太多,可以以分支管理或者丢弃仓库,使用kmdo工具clone到新的仓库,git从头开始。
具体案例
infrastructure team: 提供基础架构镜像,我们叫 inf-mirror 镜像包括rpc,store,fs等模块
ad team 广告团队提供若干算法库,镜像为 ad-mirror 提供 ctr,fm等基础算法库
bg team,业务团队的mirror为bg-mirror, 业务团队先从 info的mirror clone rpc和store库
kmdo kmpkg clone --input inf-mirror --output bg-mirror\
--port rpc --port store
第二部从 ad 团队clone ctr推理库
kmdo kmpkg merge --input ad-mirror --output bg-mirror\
--port ctr
到此为止,前期的导入工作就结束了。已经生成了bg的mirror。 mirror之间是隔离的,因此,无论哪个团队去升级自己的mirror,都不会影响到其他对团队,每个团队都以自己的基线为基准迭代开发,在快速迭代的业务场景这是非常关键的,中央仓库的好处是有总控制,带来的弊端是中央仓库在推送 后必须要跑ci保证基线的正确性。这相当于是一个单线程的锁,锁住了并发。
在很多场景下,中央仓库的基础库是不能完全满足所有团队的需求。比如rpc框架为例子。cdn,存储团队的业务模型相对简单,考量的重点是IOPS和磁盘的调度能力,支持的协议以常规的http和二进制协议为主。但是业务团队就完全不一样,是另一个极端,他们要负责线上业务,线上业务要解决长尾,要解决开发迭代的速度问题,更多会选择更容易上手的协程版本,尽量少的回调函数,尽量支持dsl协议,如pb,thrift等。那就可能带来的结果是如果是同一套rpc框架, 他们有不同编译宏定义,也可能是不同的库,这种需求,在现实环境中普遍存在。这时候要考量的就是工具和平台的设计,到底作为什么角色,协调者还是控制者,还是一个工具。vcpkg在这种情况有overlay和registry可去做分管,但是本质上,他的分管替换体系操作台复杂。影响开发效率,并且overlay的port,并不好管理。kmpkg的方案是,完全独立mirror,这是两者设计理念上的最大差异。