
Rust semver技巧:无需下游同步升级即可发布破坏性变更
来源标题:Semver Trick
Rust生态存在一种semver技巧,让库能在不要求下游依赖图同步升级的情况下发布破坏性变更。做法是让旧版本库声明依赖同一库的新版本。历史上libc 0.1到0.2升级曾被称为libcpocalypse,pre-1.0的Serde多次升级也需全生态配合,原因是大量crate在公开API中使用了这些库的类型。
以下正文由 AI 翻译自 Hacker News 最新(原文版权归原站所有),可能存在偏差,重要内容请以原文为准。
semver trick 指的是对 Rust 库发布破坏性变更,而不需要下游依赖图进行协同升级。这个技巧的核心在于让库的某一个版本声明依赖于同一个库的更新版本。
Rust 库生态系统有着一部痛苦的库升级史。2015 年 libc 从 0.1 升级到 0.2,被称为“libcpocalypse”。另一个常见的罪魁祸首是 1.0 之前的 Serde,从 0.7 到 0.8、再到 0.9、再到 1.0 的升级需要整个生态系统的共同努力。
困难的根源在于大量 crate 在这些库的公开 API 中使用了它们的类型。
举例来说,考虑一个简化版的 libc crate,它只暴露两样东西:c_void 类型和来自 NetBSD 的 EVFILT_AIO 常量。
// libc 0.2.0 pub type c_void = /* it's complicated */; pub const EVFILT_AIO: i32 = 2;
c_void 类型被广泛使用,因为数百个库想要暴露与 C 的 void * 类型 ABI 兼容的函数。而 EVFILT_AIO 常量则不那么常用,并且从不出现在下游 crate 的公开 API 中。
extern "C" {
// Usable from C as:
//
// void qsort(
// void *base,
// size_t nitems,
// size_t size,
// int (*compar)(const void , const void));
//
// The c_void type is now part of the public API of this crate.
pub fn qsort(
base: *mut c_void,
nitems: usize,
size: usize,
compar: Option<unsafe extern fn(*const c_void, *const c_void) -> c_int>,
);
}
一段时间之后,人们发现 EVFILT_AIO 应该定义为 uint32_t 而不是 int32_t,以匹配它在 NetBSD 头文件其他地方的用法(rust-lang/libc#506)。
这个修复对 libc crate 来说是一个破坏性变更。现有代码如果把 libc::EVFILT_AIO 传给一个接受 int32_t 类型参数的函数,就会出错,而这需要反映在 libc crate 的 semver 版本号上。
假设我们做了这个修复,并把它作为破坏性变更发布。
// libc 0.3.0 pub type c_void = /* it's complicated */; pub const EVFILT_AIO: u32 = 2;
尽管 c_void 的定义没有变化,但从技术上讲,libc 0.2 中的 c_void 和 libc 0.3 中的 c_void 是不同的类型。在 Rust 中(就这一点而言,在 C 中也一样),两个 struct 并不会仅仅因为字段相同就可以互换;把一个传给声明接受另一个的函数,是编译错误。
这意味着,如果 crate A 依赖于 crate B,而 B 又依赖于 libc,且 B 在 A 所调用的某个函数的公开 API 中使用了 c_void,那么 A 就无法升级到 libc 0.3,直到 B 升级到 libc 0.3 为止。如果 A 先于 B 升级,那么 A 会试图把 libc 0.3 的 c_void 传给 B 中仍然期望 libc 0.2 的 c_void 的函数,从而无法编译。
需要发生的是:首先 B 升级到 libc 0.3,并作为 B 的主版本号提升来发布(因为它的公开 API 以破坏性方式发生了变化),然后 A 才可以升级到新版 B。
对于更长的依赖链来说,这是一场巨大的磨难,需要几十位开发者协同努力。在最近一次 libcpocalypse 中,Servo 发现他们要在三个月的时间里协调升级 52 个库(servo/servo#8608)。
问题的核心在于,一个被广泛使用的 API 被卷入了一个远不那么广泛使用的 API 的破坏之中。Rust 和 Cargo 有能力以更好的方式处理这种困境。
我们只需要对上面 c_void / EVFILT_AIO 的例子做一处修改。
在做出破坏性变更并作为 libc 0.3.0 发布之后,我们再发布 0.2 系列的最后一个次版本,并从 0.3 中重新导出未变更的 API。
[package] name = "libc" version = "0.2.1"
[dependencies] libc = "0.3" # future version of itself
而在 lib.rs 中:
// libc 0.2.1 pub use libc::c_void; // reexport from libc 0.3, as per Cargo.toml pub const EVFILT_AIO: i32 = 2;
这样一来,我们就避免了存在两个看起来相同但不可互换的 c_void 类型的问题。在这里,libc 0.2.1 中的 c_void 和 libc 0.3.0 中的 c_void 正是同一个类型。
libcpocalypse 场景得以避免,因为 libc 的用户可以按自己的节奏、以任意顺序从 0.2 升级到 0.3,而不需要提升自己的 semver 主版本号。
稍加小心和发挥创意,上述技巧可以推广到许多不同的破坏性变更情形。本仓库中包含的 semver-trick 示例 crate 演示了可以应对的几种变更类型。
这并不是解决所有依赖地狱问题的银弹。从根本上说,当一个 crate 需要破坏一个很少使用的 API 而保持广泛使用的 API 不变时,或者当一个 crate 想要在其模块层级中重新安排类型的位置时,semver trick 是有益的。
大多数其他类型的破坏并不能从这个技巧中获益,包括以下具体例子:
正文由 探How 从来源站点 RSS 同步,内容未经改写;遇到排版缺失或需要图片、视频时请以原文为准。