跳转到主内容发布 CLI、crate 和版本管理 | 物联网民工

发布 CLI、crate 和版本管理

C 项目交付给别人用时,问题通常不在“源码能不能编译”,而在这些细节:

工具怎么安装到 PATH
库文件和头文件放在哪里
用户该链接哪个版本
升级会不会破坏已有代码
公开 API 改了算不算兼容
二进制 ABI 能不能长期稳定

Rust 也要处理这些问题。只是发布对象分成了两类:

CLI       -> 给人直接运行
crate     -> 给别的 Rust 项目依赖
C ABI 库  -> 给 C 或其他语言链接

这三类不要混在一个发布模型里看。CLI 关注安装和运行体验;crate 关注源码级 API 兼容;C ABI 库才关注动态链接、符号和 ABI。

CLI 可以用 cargo install 发布

一个 Rust 命令行工具通常是 binary crate:

[package]
name = "device-tool"
version = "0.1.0"
edition = "2021"

[[bin]]
name = "device-tool"
path = "src/main.rs"

本地安装:

cargo install --path .

如果发布到了 crates.io,用户可以安装:

cargo install device-tool

它更接近 C 世界里的:

make
make install

区别是 Cargo 会从源码构建并安装可执行文件,默认放到 Cargo 的 bin 目录。这个目录需要在 PATH 里。

CLI 发布时要关心:

  • 命令名是否稳定
  • 参数是否向后兼容
  • stdout 是否适合脚本解析
  • stderr 是否只放诊断信息
  • 退出码是否稳定
  • 配置文件和环境变量是否有清楚边界

对 CLI 来说,命令行参数、输出格式和退出码就是 API。

crate 发布的是源码级库 API

Rust crate 发布到 crates.io 后,别的项目这样依赖:

[dependencies]
device-core = "0.3"

这不是发布一个 .a 或 .so 给别人链接,而是发布源码包。下游项目会用自己的编译目标、feature、profile 和依赖图重新编译它。

所以 Rust crate 的兼容性主要是源码级 API 兼容:

pub struct DeviceId(String);

pub fn parse_device_id(input: &str) -> Result<DeviceId, ParseError> {
    // ...
}

这些东西一旦 pub 出去,就进入兼容性范围:

  • public struct、enum、trait、fn
  • public module 路径
  • 函数参数和返回类型
  • trait bound
  • enum variant
  • feature 名称
  • 文档里承诺的行为

C 里头文件暴露什么,用户就会依赖什么。Rust 里 pub 暴露什么,用户也会依赖什么。

Cargo.toml 是发布清单

发布 crate 前,Cargo.toml 需要像一个可读的发布清单:

[package]
name = "device-core"
version = "0.3.0"
edition = "2021"
description = "Device protocol parsing and validation"
license = "MIT OR Apache-2.0"
repository = "https://github.com/example/device-core"
readme = "README.md"
keywords = ["device", "protocol"]
categories = ["command-line-utilities"]
exclude = [
    "tests/fixtures/private-*",
]

常见发布检查:

cargo package
cargo publish --dry-run

cargo package 会生成实际要上传的包。不要只看工作目录,要看包里是否包含了该包含的文件,也排除了不该发布的文件。

可以查看打包结果:

cargo package --list

这一步类似 C 项目发布 tarball 前检查:

头文件是否齐全
示例是否可编译
LICENSE 是否存在
私有测试数据是否被排除
生成文件是否不该混进去

版本号表达兼容承诺

Rust crate 通常遵循语义化版本:

MAJOR.MINOR.PATCH

大致含义:

PATCH -> 修 bug,不破坏兼容
MINOR -> 增加兼容能力
MAJOR -> 允许破坏兼容

例如:

0.3.1 -> 0.3.2  通常是补丁
0.3.1 -> 0.4.0  可能增加能力,也可能在 0.x 阶段破坏兼容
1.2.3 -> 1.3.0  增加兼容 API
1.2.3 -> 2.0.0  允许破坏 API

Rust 生态里还要注意 0.x:在 1.0.0 之前,兼容承诺通常更弱。库还没稳定时可以停留在 0.x,但一旦有人依赖,就要认真对待升级影响。

对 C 开发者来说,可以把版本号理解成公开头文件的承诺。改内部实现不需要大版本;删函数、改类型、改语义,就要考虑破坏兼容。

Cargo 依赖版本不是精确锁死

下游写:

device-core = "1.2.3"

通常表示允许兼容版本更新,不是只允许 1.2.3。Cargo 会按兼容规则选择版本,应用项目还会用 Cargo.lock 固定最终解析结果。

库 crate 一般提交 Cargo.toml,但不依赖自己的 Cargo.lock 影响下游。应用和 CLI 则应该保留 Cargo.lock,让交付构建可复现。

一个实用判断:

library crate -> 关注依赖范围是否过窄或过宽
CLI/app       -> 关注 Cargo.lock 是否固定最终版本
workspace     -> 关注多个 crate 是否共享同一套依赖约束

这和 C 项目也类似:库的 pkg-config 约束不能乱写,应用交付时则要知道最终链接了哪些版本。

feature 也是公开接口

feature 名称发布后,也会被用户写进依赖配置:

[dependencies]
device-core = { version = "1", features = ["serde"] }

所以 feature 不能随意改名或删除。它们和函数名一样,会变成外部依赖。

好的 feature 通常表达可选能力:

[features]
default = []
serde = ["dep:serde"]
std = []

差的 feature 容易变成全局宏开关:

[features]
fast = []
new-mode = []
use-v2 = []

如果一个 feature 改变了核心语义,下游很难判断自己到底依赖了哪个库。feature 更适合表达“加不加某个能力”,不适合表达“同一个 API 换一种含义”。

C ABI 库不要承诺 Rust ABI

如果发布对象是给 C 调用的库,版本问题又回到 ABI:

#[repr(C)]
pub struct DeviceInfo {
    version: u32,
    flags: u32,
}

#[no_mangle]
pub extern "C" fn device_open(path: *const c_char) -> *mut DeviceHandle {
    // ...
}

这里要稳定的是:

  • 导出的符号名
  • extern "C" 函数签名
  • #[repr(C)] 类型布局
  • 结构体大小和字段顺序
  • 内存归属规则
  • 错误码和错误消息获取方式

不要把 Rust crate 的源码级 API 兼容,误认为 C ABI 兼容。Rust 普通函数、trait、泛型和 enum 都不是稳定 C ABI。

更稳妥的做法是分层:

device-core -> Rust 内部库,按 Rust API 演进
device-ffi  -> C ABI 包装层,少暴露类型,稳定符号
device-cli  -> 命令行工具,稳定参数和输出

这样发布边界清楚,版本策略也清楚。

发布前检查清单

CLI 发布前检查:

cargo build --release
cargo install --path .
device-tool --help
device-tool --version

crate 发布前检查:

cargo test
cargo package
cargo publish --dry-run

库 API 检查:

pub 暴露是否过多
错误类型是否适合下游处理
feature 是否稳定
README 示例是否能编译
版本号是否匹配兼容性变化

C ABI 库检查:

头文件是否和导出符号一致
结构体是否 repr(C)
内存释放函数是否成对提供
错误码是否稳定
动态库 soname 或包版本是否符合发布策略

Rust 降低了构建和发布的摩擦,但没有替你决定兼容承诺。发布出去的东西,一旦被脚本、Cargo.toml、C 头文件或动态链接器依赖,就已经是 API。