Cargo feature 管理:AI 工具不要默认打开所有能力
刚开始学 Rust 的时候,我对 Cargo.toml 里的[features]段几乎没什么概念。需要什么依赖就往[dependencies]里加,能用就行。等我的 AI CLI 工具长了几个月,依赖表拉到四十多行——wasmtime、tokio、reqwest、tracing、clap、各种编解码库——每次编译都够我喝完一整杯咖啡。
更糟糕的是,有些用户只想要一个简单的命令行工具,根本不需要 WASM 插件系统。但他们也不得不把整个 wasmtime 编译一遍,下载几百兆的依赖,等好几分钟。那一刻我才理解 Cargo feature 的意义:不是让你创建一百种编译组合,而是让不同用户拿到刚好够用的二进制。
在自学的过程中,我以前的思路是"一个二进制解决所有问题"。但 feature 系统让我学会了克制——默认只打开最需要的能力,其他功能让用户按需开启。
一、先把能力拆开,画出边界
在动手写 feature 之前,我习惯先把项目的组件能力画成一张图:
flowchart TD A[核心 CLI Core CLI] --> B{Feature 开关 Features} B -->|provider-http| C[远程 HTTP 调用 Remote Provider] B -->|provider-local| D[本地模型推理 Local Inference] B -->|wasm-plugin| E[WASM 插件系统 Plugin System] B -->|tracing| F[日志追踪 Tracing Log] B -->|rich-cli| G[彩色输出与进度条 Rich Output] C -->|依赖 reqwest| C1[reqwest / hyper] E -->|依赖 wasmtime| E1[wasmtime] F -->|依赖 tracing-subscriber| F1[tracing-subscriber] G -->|依赖 indicatif/owo-colors| G1[indicatif] style A fill:#bbf,stroke:#333,stroke-width:2px style C1 fill:#ddd,stroke:#999 style E1 fill:#ddd,stroke:#999 style F1 fill:#ddd,stroke:#999 style G1 fill:#ddd,stroke:#999核心 CLI 本身应该轻到几乎只有参数解析、配置加载和任务编排。所有"重量级能力"都通过 feature 开关控制。这样普通用户编译时只拿到一个轻量的二进制,高级用户打开对应 feature 就有了完整能力。
二、Cargo.toml 的 feature 配置
下面是一个实际的 feature 配置示例。关键原则是:默认功能要克制,Optional dependency 要跟 feature 绑定,互斥能力要在编译期给清楚错误:
[package] name = "ai-cli" version = "0.5.0" edition = "2021" [features] # 默认只打开 HTTP provider,这是最常用的能力 default = ["provider-http"] # 各个 feature 的定义和关联依赖 provider-http = ["dep:reqwest", "dep:serde_json"] provider-local = ["dep:candle-core", "dep:tokenizers"] wasm-plugin = ["dep:wasmtime"] tracing = ["dep:tracing", "dep:tracing-subscriber"] rich-cli = ["dep:indicatif", "dep:owo-colors"] [dependencies] # 核心依赖(总是编译) clap = { version = "4", features = ["derive"] } serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["full"] } # 可选依赖(由 feature 控制是否参与编译) reqwest = { version = "0.12", features = ["json"], optional = true } serde_json = { version = "1", optional = true } candle-core = { version = "0.6", optional = true } tokenizers = { version = "0.19", optional = true } wasmtime = { version = "20", optional = true } tracing = { version = "0.1", optional = true } tracing-subscriber = { version = "0.3", optional = true } indicatif = { version = "0.17", optional = true } owo-colors = { version = "4", optional = true }需要注意两个细节:一是用dep:xxx语法来在 feature 中引用 optional dependency(这是 Rust 1.60 以后的标准写法);二是不要把 feature 名字起得像"技术债"——provider-http比use-reqwest-backend更好,因为未来你可能换掉 reqwest 但 feature 名不用变。
三、代码里用 cfg 条件编译控制模块
有了 feature 定义,代码里就可以用#[cfg(feature = "...")]来控制哪些代码参与编译:
// 将插件系统整个模块挂到 feature 开关下 #[cfg(feature = "wasm-plugin")] pub mod plugin; // 初始化日志系统(仅在 tracing feature 开启时可用) #[cfg(feature = "tracing")] pub fn init_tracing() { tracing_subscriber::fmt() .with_env_filter("ai_cli=debug") .init(); } // 无 tracing feature 时提供一个空实现,避免编译错误 #[cfg(not(feature = "tracing"))] pub fn init_tracing() { // 不做任何事 } /// 根据编译时 feature 选择 Provider 实现 pub fn create_default_provider(config: &Config) -> Box<dyn AiClient> { // 注意:这两个 feature 是互斥的,Cargo 会保证只有一个开启 #[cfg(feature = "provider-http")] { Box::new(HttpAiClient::new(config)) } #[cfg(feature = "provider-local")] { Box::new(LocalAiClient::new(config)) } }如果两个 feature 互斥(比如不能同时开启 HTTP 和 Local provider),可以在代码里加编译期检查:
#[cfg(all(feature = "provider-http", feature = "provider-local"))] compile_error!("provider-http 和 provider-local 不能同时开启,请只选择其中一个");这样用户在编译时就会看到清晰的错误信息,而不是一百行不知所云的类型推导失败。
四、CI 要测关键 feature 组合
feature 一多,很容易出现"某个冷门组合编译不过"的情况。我给自己定的 CI 最低检查清单是:
# 无默认功能 — 验证核心 CLI 的纯粹性 cargo check --no-default-features # 默认组合 — 大多数用户的使用方式 cargo check # 全部功能(如果有互斥则选最大可用集) cargo check --features "provider-http,wasm-plugin,tracing,rich-cli" # 本地模型用户组合 cargo check --no-default-features --features "provider-local,rich-cli" # 测试 cargo test --no-default-features cargo test安装文档里也要写清楚不同用户应该用哪个命令:
# 普通用户:默认安装,只需 HTTP provider cargo install ai-cli # 插件用户:额外开启 WASM 支持 cargo install ai-cli --features wasm-plugin # 极简用户:只要核心功能,连 HTTP 都不用 cargo install ai-cli --no-default-features让用户在安装时就选择能力,比给他们一个 200MB 的二进制然后说"很多功能你不用可以忽略"要好得多。
给个真实数字:我的 CLI 工具默认不开 wasm-plugin 时编译时间大约 15 秒,开启后要 90 秒,二进制体积也从 4MB 涨到 28MB。对只想问个问题的用户来说,多耗的这 75 秒和 24MB 就是不必要的代价。Feature 开关帮我们省下的,不是代码行数,是用户的时间。
还有一点容易被忽略:feature 之间如果有间接依赖,--all-features可能会引入你根本没想要的 crate。比如只开了provider-http,但wasm-plugin的一个可选依赖悄悄拉进了wasmtime,编译时间翻倍。建议定期跑cargo tree --edges features审计,看看哪些 feature 在污染依赖树。
五、总结
Cargo feature 管理的核心不是"把功能拆得越细越好",而是默认能力要克制,做到"用户需要时才开启"。核心 CLI 保持极简,重量级功能做成 optional feature,互斥能力在编译期报错,CI 覆盖关键组合。
作为自学者,我以前喜欢"一个二进制打天下"的感觉。但 feature 让我学会了另一种思路:做减法也是一种能力。少一点默认依赖,编译就快一点,二进制就小一点,供应链风险也少一点。给用户刚好够用的工具,比塞给用户一个万能瑞士军刀更负责任。