你写了一个命令行工具,本地一直跑得好好的。某天同事拉下代码,编译结果却和你不一样;你加了一个看似无害的依赖,编译时间突然翻倍;你明明关掉了某个库的默认特性,最终产物里却还是出现了它。更让人头大的是,错误常常不在你刚写的那几十行 Rust 里,而是在一张几百个节点的依赖图深处。
这些问题表面上各不相同,背后都绕不开 Cargo。它不只是“帮你运行 rustc 的命令”,还要回答一连串很现实的问题:项目里究竟有几个可编译目标、每个目标需要哪些依赖、允许选择哪些版本、这次构建实际锁定了什么版本、哪些特性被谁打开、工作空间里哪些包一起参与解析,以及发布时到底会上传哪些文件。
你可以把 Cargo 想成一个认真到有点较真的项目管家。Cargo.toml 是你交给它的需求清单,Cargo.lock 是它算完之后留下的成交明细,target/ 是它的工作间,crates.io 则是默认采购依赖和发布 crate 的公共仓库。只要把这几样东西的分工弄清楚,许多“玄学构建问题”就会变成可以逐项排查的普通问题。
这一章不会把 Cargo 写成万能按钮。Cargo 能帮你统一构建流程,却不能替你判断一个依赖是否值得引入;features 能裁剪能力,却也会制造组合爆炸;工作空间能统一配置,也可能让一次全量检查变得更慢;锁文件能复现版本,但不会自动保证这些版本没有漏洞。我们要学的是怎么读懂它的决定,并在关键位置保留控制权。
刚接触 Cargo 时,最容易把 package 和 crate 当成同一个词。小项目里它们看起来确实差不多,但项目一复杂,这种混用会直接影响你理解命令输出。
package 是 Cargo 管理和发布的单位。 一个 package 由一份 Cargo.toml 描述,里面有包名、版本、Edition、依赖、特性和发布元数据。你在 crates.io 上看到的一个版本,也对应某个 package 的一次发布。
crate 是 Rust 编译器一次处理的编译单元。 一个库会编译成 library crate,一个可执行程序会编译成 binary crate。crate 有自己的根模块,src/lib.rs、src/main.rs 或某个显式指定的文件都可能是 crate root。代码里的 crate:: 指向当前这个编译单元,不是指向整个工作空间,也不一定等于整个 package。
target 是 Cargo 从哪些源文件构建什么产物的描述。 library、binary、example、integration test 和 benchmark 都是 target。每个 target 编译时都会形成一个 crate,因此一个 package 完全可以包含多个 crate:一个库 target,加上几个命令行 binary target,再加若干 examples 和集成测试。
把它们放到一个具体场景里就清楚了。假设 image-lab 这个 package 既提供图片处理库,也提供 img-resize 和 img-info 两个命令:
image-lab/ # 一个 package
├── Cargo.toml
└── src/
├── lib.rs # 一个 library target,编译成 library crate
└── bin/
├── img-resize.rs # 一个 binary target,编译成 binary crate
└── img-info.rs # 另一个 binary target,编译成 binary crate
这个 package 只能有一个 library target,却可以有多个 binary target。二进制 target 可以直接使用同一 package 的公开库 API。这样组织通常比把所有逻辑塞进 main.rs 更舒服:命令行入口只处理参数和输出,真正可测试、可复用的逻辑放进 lib.rs。
包名和 crate 名还会遇到一个小差异。package 名可以带连字符,例如 image-lab;在 Rust 源码里引用对应库 crate 时,连字符会变成下划线,也就是 image_lab。如果你看到“Cargo.toml 里明明叫这个名字,use 时为什么换了一个名字”,通常不是 Cargo 搞错了,而是标识符规则不允许连字符。
还有一个细节会影响你读错误信息:依赖解析围绕 package 进行,Rust 源码导入围绕 library crate 名进行,命令选择则经常围绕 target 名进行。三者默认值接近,却都可以被配置改变。例如依赖项可以用 package 字段重命名,library target 也能通过 [lib] 里的 name 改 crate 名,binary target 又可以单独命名。项目没有特殊需求时,最好保留默认映射;名字越多,报错里越容易出现“这几个词明明指向同一份代码”的认知负担。
[dependencies]
old_json = { package = "serde_json", version = "1" }这时 manifest 解析的 package 仍叫 serde_json,源码里却要写 old_json::Value。重命名可以让两个版本或两个来源在源码里共存,也可以避开名字冲突,但不该只为了个人偏好使用。尤其当可选依赖被重命名时,自动生成的 feature 名跟依赖键走,而不是跟原 package 名走,排查 feature 时要看 manifest 左边的键。
Cargo 在日志中说“Compiling 某 package”,不代表最后只生成一个文件。它可能先为 build script 编译一个 crate,再为 library target 编译一个 crate,随后为测试 target 再编译测试入口。相同源码在不同 target、profile 或 feature 集下也可能分别生成产物。看到日志里同一个名字出现多次,先检查构建单元是否不同,不要立刻判断缓存坏了。
如果这三个概念仍有些缠在一起,可以在下面的交互解剖中切换文件与构建目标。先观察一个 package 怎样展开成多个 target,再看每个 target 对应哪个 crate root。
判断一个 Cargo 概念时,可以先问它属于哪一层:package 解决元数据与发布,target 解决“要构建什么”,crate 解决 Rust 编译边界。工作空间则位于更外层,用来一起管理多个 package。
Cargo 很喜欢约定。文件放在约定位置,它就能自动发现 target;只有目录不适合表达需求时,你才需要在 Cargo.toml 里显式配置。
一个同时包含库、默认命令、额外命令、示例、集成测试和基准目标的 package,可以长这样:
telemetry-kit/
├── Cargo.toml
├── Cargo.lock
├── build.rs
├── src/
│ ├── lib.rs
│ ├── main.rs
│ └── bin/
│ ├── collector.rs
│ └── exporter/
│ └── main.rs
├── examples/
│ ├── quick_start.rs
│ └── custom_sink/
│ └── main.rs
├── tests/
│ ├── cli.rs
│ └── common/
│ └── mod.rs
└── benches/
└── encode.rs
src/lib.rs 是默认 library target。一个 package 最多自动或显式定义一个库 target。src/main.rs 是默认 binary target,名字默认取 package 名。src/bin/collector.rs 和 src/bin/exporter/main.rs 会被发现为额外的 binary target。运行时如果 package 里不止一个可执行目标,你应明确告诉 Cargo 要运行哪一个:
cargo run --bin collector
cargo run --bin exporter -- --format json第二条命令中,-- 左边是 Cargo 自己的参数,右边才会原样交给你的程序。忘记这个分界时,你的程序参数可能被 Cargo 当成自己的选项解析。
examples/ 里的每个入口也是独立 target。它们适合展示公开 API 的完整用法,而不是存放只有维护者才能理解的调试脚本。示例可以使用 package 的普通依赖和开发依赖,运行方式是:
cargo run --example quick_starttests/ 放集成测试。这里每个顶层 .rs 文件都是单独的 crate,只能像外部用户一样使用 library crate 的公开 API。测试之间共享辅助代码时,常见做法是放进 tests/common/mod.rs,再由测试文件写 mod common;。如果把辅助文件直接写成 tests/common.rs,Cargo 会把它也当成一个集成测试 target,虽然未必出错,却会多产生一个没什么内容的测试程序。
benches/ 是 benchmark target 的约定位置。要注意,目录被 Cargo 识别不等于稳定 Rust 自动提供了完整的性能测试方案。实际项目常用稳定版基准库,并为 benchmark 设置自定义 harness。性能测试还会受到 CPU 调频、后台任务、样本量和链接配置影响,跑出一个数字不代表结论就可靠。
build.rs 是 package 的构建脚本入口。它在主 crate 编译前运行,常用于探测本机库、生成代码或编译少量原生代码。构建脚本会让构建更依赖环境,也可能破坏缓存,所以不要把“普通 Rust 代码也能做的事”顺手塞进去。需要它时,把输入、输出和重新运行条件写清楚,避免每次构建都无谓执行。
当约定目录不够用,可以显式配置 target:
[lib]
path = "src/core.rs"
[[bin]]
name = "telemetry-agent"
path = "cmd/agent.rs"
required-features = ["agent"]
[[example]]
name = "remote-export"
path = "demos/remote.rs"
required-features = ["network"]required-features 的意思不是自动开启这些特性,而是只有所需特性已经启用时,这个 target 才参与构建。想运行上面的示例,需要显式写:
cargo run --example remote-export --features network显式 target 很有用,但不值得为了“看起来可控”把所有默认路径重写一遍。约定目录更容易被新人和工具识别,配置也更少。只有入口路径、target 名称、crate 类型或构建条件确实不同,才需要增加表项。
Cargo.toml 叫 manifest,也就是 package 的清单。它告诉 Cargo 你的意图,但通常不会写死整个依赖图。例如 serde = "1" 表达的是“接受 1.x 兼容版本”,不是“永远使用某个确切版本”。
一份适合当前新项目的基础 manifest 可以这样写:
[package]
name = "log-lens"
version = "0.1.0"
edition = "2024"
rust-version = "1.85"
description = "读取并汇总本地服务日志"
license = "MIT OR Apache-2.0"
readme = "README.md"
[dependencies]
serde = { version = "1", features = ["derive"] }
[dev-dependencies]
tempfile = "3"edition 控制当前 package 使用哪一版 Rust Edition 的语言与工具行为。它不是编译器版本,也不会自动限制用户至少安装哪个 Rust。当前新项目可以使用 edition = "2024"。如果你承诺最低支持某个工具链,再用 rust-version 明确最低支持版本。两者要分别维护:Edition 说的是语言版本边界,rust-version 说的是工具链兼容承诺。
Cargo.lock 则记录 Cargo 实际解析出的 package 版本、来源和校验信息。它由 Cargo 维护,不要手改。第一次构建、检查或生成锁文件时,Cargo 会创建它;之后只要 manifest 的约束仍允许原有选择,Cargo 会优先沿用锁文件里的版本,而不是每次都追逐仓库中的最新发布。

这正是“我的 Cargo.toml 没变,为什么删掉锁文件后突然坏了”的原因。manifest 描述一个可接受范围,锁文件描述这个范围内的一次具体解。删掉锁文件等于让 Cargo 重新做整张依赖图的选择,新的传递依赖版本可能暴露之前没遇到的问题。
关于要不要提交 Cargo.lock,最稳妥的默认答案是:提交。应用程序、服务和命令行工具尤其需要它,因为团队、CI 和发布构建通常希望落在同一组依赖版本上。库项目也可以提交锁文件,让维护者自己的测试与开发环境更可复现;但库的下游用户不会因为你仓库里的锁文件就被锁死,他们会在自己的依赖图里重新解析符合你 manifest 的版本。
库项目还有一项额外责任:不能只验证锁住的老依赖。你可以在常规 CI 使用锁文件保证稳定,再安排一条任务运行 cargo update 后测试,尽早发现兼容范围内的新版本是否真的兼容。锁文件解决“这次用了什么”,持续更新测试解决“声明支持的范围还能不能用”,两者不是同一道题。
审查 Cargo.lock 时,不需要逐行理解校验和,但要看变化是否符合动作范围。如果你只升级一个直接依赖,锁文件可能连带更新它的传递依赖;这很正常。若同时出现一批毫无关联的 package 变化,通常意味着执行了全量更新,或者某个版本约束变化让解析器重新收拢了依赖图。此时最好拆分提交,避免业务代码回滚时顺手把安全修复也回滚。
锁文件中出现同一 package 的多个版本,也不是“去重失败”。只有版本要求存在兼容交集时,Cargo 才有机会统一;0.3 与 0.4、1 与 2 一般被视为不同兼容线,可以共存。真正需要判断的是:它们是否增加了不可接受的构建成本,是否把原生库链接两次,以及这些版本的类型是否会在公开接口中相遇。
应用交付还应区分“源码可复现”和“产物可复现”。Cargo.lock 固定 Rust package 的解析结果,却不会固定系统链接器、C 编译器、操作系统库、环境变量或构建脚本访问到的外部文件。涉及原生依赖时,还需要固定构建镜像、工具链和系统包。锁文件是复现链条的重要一环,不是整个链条。
不要通过删除 Cargo.lock 来完成日常升级,也不要把“锁住版本”误当成安全审计。前者会让更新范围失控,后者只保证版本一致。更稳妥的做法是定向更新、检查差异、运行测试,再提交新的锁文件。
Cargo 把依赖按使用阶段分开。分类做对了,读 manifest 的人能立刻看出某个库为什么存在,Cargo 也能避免在不需要的目标中引入它。
[dependencies]
anyhow = "1"
[dev-dependencies]
assert_cmd = "2"
tempfile = "3"
[build-dependencies]
cc = "1"
[target.'cfg(unix)'.dependencies]
nix = { version = "0.30", features = ["signal"] }[dependencies] 是普通依赖,library 和 binary 等正常 target 会使用。[dev-dependencies] 服务于测试、示例和基准等开发目标,不会因为用户正常构建你的库就被链接进产物。[build-dependencies] 只提供给 build.rs;构建脚本和主 crate 是分开的编译单元,双方不会自动看到彼此的依赖。
平台依赖可以放在 [target.'cfg(...)'.dependencies] 下。这样写表达的是“为哪个目标平台构建”,不是“Cargo 当前运行在哪个平台”。交叉编译时,这个区别很重要:你可能在 macOS 主机上为 Linux target 构建,依赖选择应跟目标平台走。
不要在 target 表里写 cfg(feature = "...") 来按 feature 增删依赖。Cargo 解析依赖图时,feature 条件不在这里生效。需要按能力选择依赖时,用 optional = true 和 [features];需要按系统选择依赖时,才用 target 条件。能力和平台是两条不同维度。
添加依赖时,优先让 Cargo 修改 manifest:
cargo add serde --features derive
cargo add tempfile --dev
cargo add cc --build
cargo add image --optional --no-default-featurescargo add 不只是少打几行 TOML。它会根据 package 的 Rust 版本承诺和仓库信息尽力选择合适的版本要求,并把启用、禁用的 features 展示出来。命令执行后仍要看一眼 diff,因为“能添加”不代表“应该添加”,默认 features 也未必符合你的场景。
依赖越多,风险面和编译成本通常越大,但也别走到另一个极端:为了少一个成熟依赖,自己写一份边界条件更多、缺少维护的实现。可以从四个问题判断:它解决的代码量有多少;传递依赖有多重;维护与发布是否持续;它是否进入你的公开 API。最后一项尤其容易被忽视。公开函数一旦直接返回某依赖的类型,升级这个依赖就可能变成你自己的兼容性问题。
Cargo 默认使用 caret 风格的版本要求,只是通常省略 ^。因此:
[dependencies]
alpha = "1.2.3" # >= 1.2.3, < 2.0.0
beta = "0.2.3" # >= 0.2.3, < 0.3.0
gamma = "0.0.3" # >= 0.0.3, < 0.0.4
delta = "0.2" # >= 0.2.0, < 0.3.0
epsilon = "0" # >= 0.0.0, < 1.0.0
版本字符串很短,真正允许的集合却未必符合第一眼直觉。你可以在下面的交互实验中调整主版本、次版本和补丁版本,立即比较默认要求、波浪号要求与精确要求的边界。
直觉规则是:从左往右找到第一个非零数字,兼容更新不能改变它左侧和它自身代表的兼容边界。对 1.x 来说,主版本不变即可;对 0.2.3 来说,0.2 被视为兼容线;对 0.0.3 来说,连补丁位都成了边界。这就是 0.x 依赖经常让人觉得“只是小版本怎么就冲突”的原因。
Cargo 的 0.x 兼容规则比“所有 1.0 前版本互不兼容”更实用,但它依然只是约定。维护者是否真的遵守兼容承诺,要靠项目质量。版本解析器能判断数字范围,不能读懂 API 是否偷偷改变语义。
除默认要求外,你还会看到这些写法:
[dependencies]
patch_only = "~1.4.2" # >= 1.4.2, < 1.5.0
series = "1.4.*" # >= 1.4.0, < 1.5.0
pinned = "=1.4.2" # 只接受 1.4.2
bounded = ">=1.4, <2"精确锁定 =1.4.2 看起来最安全,放在库里却常常让依赖解析更难。如果另一个库需要同一 package 的 1.4.3,本来兼容的两个要求会被你的人为钉死变成冲突。应用要固定实际构建版本,通常交给 Cargo.lock;库的 manifest 保留合理兼容范围,让下游有统一版本的空间。只有预发布、强耦合代码生成等确有理由的场景,才考虑精确要求。
同样,不要为了“谨慎”随便加一个过窄上界,例如只允许 >=1.4, <1.6。这会把本来兼容的 1.x 新版本挡在门外,让依赖图更容易无解。默认 caret 要求通常就是最合适的起点。
来看一个很常见的“failed to select a version”现场。你的应用依赖 reporter,它要求 protocol = "^0.4.2";另一个库 collector 要求 protocol = "=0.4.1"。前者允许 0.4.2 到 0.5.0 之前的版本,后者只接受 0.4.1,两个集合没有交集。Cargo 不能凭感觉认定它们“反正都叫 0.4”,只能宣布无解。
面对这种冲突,先用依赖树确认两条要求来自哪里,再看 collector 是否有新版本放宽了约束。如果能升级 collector,让两边落到同一个 protocol,通常最好。若不能升级,而两个 protocol 版本允许并存,Cargo 有时会分别解析;但如果上游通过 links 声明同一原生库,或依赖必须在公共类型上交互,多版本共存可能受限或根本没有意义。
不要直接编辑 Cargo.lock 把 0.4.1 改成 0.4.2。锁文件不是绕过 manifest 约束的后门,下一次 Cargo 解析时仍会失败,校验信息也对不上。也不要随意用 [patch] 强行把 API 不兼容版本塞进去。patch 改的是来源,不会自动证明新源码满足旧版本调用方的假设。
当错误提示里出现“previously selected package”时,编译器像是在复述自己的解题过程:它先根据一条路径选了版本,继续走到另一条路径时发现要求无法同时满足。顺着这两条路径查约束,比在 crates.io 上随机试版本有效得多。
预发布版本不会被普通稳定版要求自动选中。你明确写了预发布要求,Cargo 才会进入对应的预发布序列。预发布阶段本就允许快速调整,库项目如果自己不是预发布状态,最好慎重把预发布依赖放进公开发布。
升级也要分清两个动作:修改 Cargo.toml 是改变“允许范围”,运行 cargo update 是在现有范围内重算 Cargo.lock。例如:
# 更新锁文件中的所有可更新依赖
cargo update
# 只尝试更新指定 package,尽量不动无关项
cargo update serde
# 把指定 package 调整到要求范围内的确切版本
cargo update serde --precise 1.0.219
# 先预览会发生什么,不写锁文件
cargo update --dry-run实际团队里,全量 cargo update 最好单独提交。这样测试失败时,问题范围不会和业务改动混在一起。遇到上游问题需要临时回退,也应写明原因和退出条件,等修复发布后解除固定,而不是让临时钉死变成没人敢动的永久配置。
默认情况下,只写名字和版本就是从 crates.io 获取:
[dependencies]
tracing = "0.1"如果团队使用私有 registry,需要先在 Cargo 配置中定义 registry,再在依赖上写明来源。registry 名称是本机或 CI 配置的一部分,不应该把访问令牌写进项目 manifest。
[dependencies]
company-protocol = { version = "2", registry = "company" }path 依赖适合同一仓库内协作或本地联调。路径相对于写下该依赖的 Cargo.toml,并且必须直接指向包含目标 package manifest 的目录;Cargo 不会像搜索 git 仓库那样替你递归猜位置。
[dependencies]
protocol = { path = "../protocol" }这种依赖修改后立刻参与本地构建,反馈很快,但它依赖你的目录结构。准备发布到 crates.io 时,普通依赖不能只剩一个本地 path。常用写法是同时给出可发布版本:
[dependencies]
protocol = { path = "../protocol", version = "0.6" }本地构建使用 path,并校验该 package 自己声明的版本是否满足 0.6;打包发布后,下游可以从 registry 获取相应版本。发布顺序也要安排好:先发布被依赖的底层 package,等待 registry 可解析,再发布上层 package。
git 依赖适合试用尚未发布的修复或团队内部代码:
[dependencies]
parser = { git = "ssh://git@example.invalid/team/parser.git", rev = "8d31f6a" }只写 git URL 会跟随默认分支最新提交,第一次看很方便,长期复现却很脆弱。Cargo 会把当时选中的提交写进 Cargo.lock,之后通常要到 cargo update 才推进,但新环境重新解析或锁文件变化时仍可能得到不同提交。正式协作至少固定 rev,并把“何时换回 registry 版本”写进任务记录。
branch 适合跟踪开发线,tag 适合对齐仓库标记,rev 最接近不可移动的具体提交。分支和 tag 在服务器上理论上都可能被移动,提交哈希更明确。不过 git 提交仍可能被删除或仓库失联,所以长期可分发依赖最好进入受支持的 registry。
crates.io 发布包不能把普通构建建立在外部 git 或纯 path 依赖上。开发依赖在发布校验中的处理较宽松,但不要把这当成绕过发布可复现性的手段。真正需要发布的依赖,要先发布到 crates.io,或者用带 version 的多位置配置提供 registry 回退。
需要临时测试依赖修复时,还可以在工作空间根 manifest 使用 [patch],让整个依赖图把某 registry package 替换为本地路径或 git 版本。它像是在依赖源上铺一层临时覆盖,不用逐个修改每个成员:
[patch.crates-io]
parser = { path = "../parser" }补丁完成使命后要及时移除,并确认锁文件回到了计划发布的来源。[patch] 只在工作空间根生效;把它写进成员 package,Cargo 不会按你期待的方式处理。
features 最常见的用途,是让用户选择某部分 API 或依赖是否参与编译。先看一个日志解析库:
[dependencies]
serde = { version = "1", features = ["derive"], optional = true }
serde_json = { version = "1", optional = true }
rayon = { version = "1", optional = true }
[features]
default = ["json"]
json = ["dep:serde", "dep:serde_json"]
parallel = ["dep:rayon"]
full = ["json", "parallel"]optional = true 表示这个依赖默认不必参与构建。dep:serde 把可选依赖接到一个由你命名的 feature 上,同时避免把内部依赖名自动暴露成同名公共 feature。用户看到的是 json 这项能力,而不是你的内部实现清单。
代码中再通过 cfg 决定哪些项目存在:
#[cfg(feature = "json")]
mod json;
#[cfg(feature = "parallel")]
pub fn parse_many(inputs: &[String]) {
// 并行实现
}
pub fn parser_mode() -> &'static str {
if cfg!(feature = "parallel") {
"parallel"
} else
#[cfg(...)] 会决定被标记的项目是否进入编译;cfg!(...) 则始终生成一个布尔值,两个分支仍需要通过类型检查。某段代码只有特性开启时才合法,就用属性控制,而不是指望 if cfg!(...) 把非法分支彻底藏起来。
cfg 还可以表达目标平台和编译环境。例如 Unix 专用模块可以写 #[cfg(unix)],测试辅助代码可以写 #[cfg(test)]。这些条件与 Cargo feature 可以组合,但组合越复杂,越需要让模块边界清楚:
#[cfg(all(feature = "network", target_os = "linux"))]
mod linux_network;
#[cfg(not(any(feature = "json", feature = "yaml")))]
compile_error!("至少启用 json 或 yaml 中的一项");第二段适合表达确实不能缺少的组合约束。不过互斥 feature 要更谨慎。你当然能为“两项同时开启”写 compile_error!,但由于依赖图会合并 features,下游可能在完全不知情的情况下触发它。能用运行时策略、不同类型或拆分 package 表达的选择,通常比互斥 feature 更能适应生态依赖图。
还要分清 cfg(test) 与测试时启用某个 feature。cfg(test) 只在当前 crate 以测试模式编译时设置;作为依赖被其他 crate 测试时,它的普通构建并不会自动得到 cfg(test)。如果公共 API 的测试需要某项能力,应通过 dev-dependency 或明确的 feature 组合安排,不能依赖传递的 cfg(test)。
命令行可以选择 features:
cargo check
cargo check --no-default-features
cargo check --features parallel
cargo check --no-default-features --features json,parallel
cargo check --all-features真正容易坑人的是:features 会合并。依赖图中只要有一条路径为同一个 package 开启 json,另一条路径开启 parallel,Cargo 通常会以两者并集构建这个 package。feature 应被设计为“增加能力”,不要让两个 feature 分别表达互斥模式,也不要让启用某 feature 关闭已有 API。加法模型下,fast 和 safe 如果不能同时开启,就迟早会在某个依赖图里撞车。

只看一份 manifest,很难察觉另一条依赖路径带来了什么。下面的交互实验允许你逐条打开依赖边,观察 feature 怎样汇入同一个 package 的最终集合。
default-features = false 也没有想象中那么绝对:
[dependencies]
flate2 = { version = "1", default-features = false, features = ["zlib-rs"] }它只表示这条依赖边不主动开启默认 feature。如果依赖图中的另一条边仍以默认配置引用同一个 package,默认 features 仍会进入最终并集。于是你明明写了 false,结果还是编进来了。Cargo 不是没听你的,它同时也听了别人。
排查“谁打开了 feature”,不要盯着自己的 Cargo.toml 猜:
# 展示 feature 在依赖图中的传播
cargo tree -e features
# 反向查看是谁让 serde 的某些 feature 生效
cargo tree -e features -i serde
# 紧凑显示每个 package 最终启用的 feature
cargo tree -f "{p} {f}"默认 features 是给常见用户提供顺手体验,不是把所有能力都装进去的篮子。默认集合一旦公开,移除其中的 feature 可能让依赖默认行为改变,属于需要认真评估的兼容性变化。库作者应让默认集合小而稳定,昂贵后端、原生系统依赖和小众格式更适合显式开启。
features 还会制造测试组合。只测默认配置,无法证明 --no-default-features 能编译;只测 --all-features,也可能掩盖某个 feature 忘记声明依赖的问题。最少应覆盖默认、无默认、全量和用户最常见的关键组合。组合数量多到失控时,往往说明 feature 设计需要拆包或重新划边界,而不是继续堆 CI 矩阵。
feature 不应承载秘密、授权或运行时安全边界。它只决定编译配置,最终使用者可以重新构建并开启任何公开 feature。需要权限控制的能力必须在运行时验证。
项目拆成多个 package 后,如果每个目录各跑各的 Cargo,很快会遇到重复构建目录、依赖版本散落和命令难统一的问题。workspace 把这些 package 纳入同一个管理范围:共享根目录的 Cargo.lock、共享 target/,并允许命令一次选择多个成员。
一个虚拟工作空间根 manifest 可以这样写:
[workspace]
members = ["crates/*", "apps/cli", "apps/server"]
exclude = ["crates/legacy-demo"]
default-members = ["apps/cli"]
resolver = "3"
[workspace.package]
edition = "2024"
rust-version = "1.85"
license = "MIT OR Apache-2.0"
[workspace.dependencies]
serde = { version = "1", features = ["derive"] }
它只有 [workspace],没有 [package],因此叫虚拟 manifest,本身不会生成 crate。虚拟工作空间无法从根 package 的 Edition 推断 resolver,应该显式写 resolver = "3"。如果根 manifest 同时有 [package] 和 [workspace],根 package 自己也是 workspace 成员。
成员 package 可以继承公共字段:
[package]
name = "log-lens-cli"
version = "0.1.0"
edition.workspace = true
rust-version.workspace = true
license.workspace = true
[dependencies]
serde.workspace = true
tracing = { workspace = true, features = ["log"] }继承能减少版本漂移,却不会让所有 package 自动拥有这些依赖。成员仍要在自己的依赖表中写 workspace = true,明确说明它确实使用。成员额外指定的 features 会和工作空间依赖上声明的 features 相加。[workspace.dependencies] 本身不能把依赖标成 optional;是否可选是成员 package 自己的决定。

工作空间的共享关系一多,单凭目录名不容易判断一次命令会触达哪些成员。下面的交互地图可以切换 package 与构建目标,帮助你把成员选择、依赖边和共享构建结果对到一起。
工作空间命令要留意选择范围:
# 检查全部成员
cargo check --workspace
# 只检查一个 package
cargo check -p log-lens-cli
# 排除某个成员
cargo test --workspace --exclude slow-fixtures在虚拟工作空间根目录运行而又不指定 package 时,通常会选中所有成员;default-members 可以改变根目录下的默认选择。这个字段适合把常用应用设成默认,但 CI 最好仍显式写 --workspace,免得新成员加入后从未被检查。
package 选择和 target 选择是两步。-p log-lens-cli 先选 package,--bin log-lens、--lib、--tests 再在其中选 target。工作空间报“无法确定运行哪个 binary”时,问题通常不是成员选择,而是选中的 package 里有多个 binary target。可以在命令里写 --bin,也可以在 package manifest 设置 default-run,但 CI 脚本显式写目标更容易审查。
工作空间共享 target/ 能复用编译结果,也意味着不同成员的构建会争用同一缓存和磁盘空间。成员很多时,cargo clean 会清掉整个工作空间的构建产物,代价可能非常大。遇到单个 package 的奇怪增量问题,先确认是否真需要全量清理;很多情况下重新构建目标 package、切换单独的 target 目录做验证,或者删除明确的小范围产物更合适。
成员之间的 path 依赖仍然要显式声明。把两个 package 都列进 members,不会自动让它们互相可见。这个设计很重要:workspace 是管理关系,不是隐式依赖注入。依赖边仍写在各自 manifest 中,因此 package 单独发布后也能说明自己需要什么。
工作空间也不允许普通依赖环。若 core 依赖 adapter,adapter 又依赖 core,Cargo 无法建立编译顺序。常见修复不是寻找“允许循环”的开关,而是把双方共享的类型和 trait 下沉到第三个更基础的 package,或把具体实现通过泛型、trait object、回调放到上层组装。
profile、[patch] 等配置以工作空间根为准。你在成员 manifest 里悄悄写一份 [profile.release],不会得到“这个成员专属的发布优化”。这是有意设计:同一次工作空间构建需要一套一致的 profile 规则。真的要为某些 package 调整优化级别,可以使用根 profile 的 package override,但不要假设每个成员可以各自决定 LTO 或 panic 策略。
resolver 是依赖解析规则的版本,不等于 Rust Edition,但 Edition 会为普通根 package 推断合适的默认值。2021 Edition 默认 resolver 2,2024 Edition 默认 resolver 3。resolver 3 在 resolver 2 的 feature 行为基础上,默认更关注依赖的 rust-version,当最新版不兼容当前工具链承诺时,会倾向选择兼容版本作为回退。
这项改进不是魔法。前提是依赖维护者正确声明 rust-version,你自己的版本要求范围里也得确实存在兼容版本。工作空间成员的最低 Rust 版本不同,版本统一仍可能让解析结果偏向某个较低版本,或者出现某个成员能用、另一个成员不能用的拉扯。遇到这种情况,要先看每个 package 的 rust-version 是否真实,再判断它们是否适合共享同一个依赖版本策略。
resolver 是整个工作空间的全局设置。依赖 package 自己写什么 resolver,不会推翻顶层工作空间的选择。虚拟工作空间尤其要显式声明,否则没有根 package 的 Edition 可供推断。
resolver 2 以后减少了一些容易意外合并的 feature 场景,例如特定目标平台的依赖、构建依赖与普通依赖之间的无关 feature 不再一概混在一起。但同一次构建里对同一普通依赖的 features 仍以并集为核心。不要把升级 resolver 当成“不再需要理解 feature 合并”。
如果一个工作空间同时构建多个成员,它们对共同依赖启用的 features 也可能合并。某些极端场景确实希望成员 A 和成员 B 分别用同一依赖的不同 feature 集,这时最直接的办法是分成两次 Cargo 调用。共享 workspace 不要求每次都把所有成员塞进同一构建命令。
依赖图出现两个不同版本也不一定是错误。Cargo 可以同时编译 semver 不兼容的多个版本,但代价是编译时间和产物体积增加;如果这些版本的类型跨 API 边界流动,还可能出现“名字一样却不是同一个类型”的困惑。先用命令找出重复版本:
cargo tree -d
cargo tree -i some-package然后查看是谁要求旧版本。能升级上游依赖就升级;不能时,评估重复成本是否真的值得立即处理。不要为了让树看起来整齐,强行 patch 到一个并不兼容的版本。依赖解析成功只说明版本条件有解,不说明替换后的代码语义一定正确。
很多人每改几行就 cargo run,项目变大后便觉得 Rust 编译“慢得没法用”。其实你常常只是想知道类型和借用是否正确,不需要完成链接,更不需要启动程序。
cargo checkcargo check 会完成解析、类型检查等主要编译步骤,但不生成最终可执行产物,通常比完整 cargo build 快。日常编辑时把它当第一道反馈;需要真正运行、验证链接或测量产物时,再进入 build 和 run。
一个实用的本地反馈阶梯是:
先运行 cargo check,尽快处理语法、类型、借用和 feature 条件下的编译问题。工作空间很大时,先用 -p 缩小到正在修改的 package。
再运行相关测试,例如 cargo test -p package_name test_filter。先验证受影响范围,避免每次都等待全仓库测试。
提交前运行格式化、静态检查和更完整的 cargo test --workspace。如果项目支持多种 features,再执行约定的 feature 组合。
测试命令也不只是“把所有测试跑一遍”:
# 当前选择范围内的全部测试
cargo test
# 只运行名称包含 parse_header 的测试
cargo test parse_header
# 只测试指定集成测试 target
cargo test --test cli
# 运行文档中的 Rust 代码示例测试
cargo test --doc
# 把 --nocapture 交给测试程序,显示标准输出
cargo test -- --nocapturecargo test 会编译测试相关 target,因此 dev-dependencies 和测试路径下的代码会加入依赖图。你发现“普通 build 很快,test 却突然多编译一大堆”,先看开发依赖和 feature 合并,不要直接归咎于编译器。
文档是库 API 的一部分。生成并打开本地文档可以用:
cargo doc --open
cargo doc --no-deps --open--no-deps 适合只检查自己的文档,速度更快。代码块中的 Rust 示例默认可能参与 doctest,这很好,因为过期示例会直接失败;但示例如果依赖网络、环境变量或外部服务,就需要重构成可控接口,或明确设置合适的文档测试属性,不能假装它在任何环境都能稳定运行。
CI 中可以增加 --locked:
cargo test --workspace --locked如果锁文件缺失或 Cargo 需要改写它,命令会失败。这样能抓住“开发者改了 manifest 却忘记提交锁文件”的情况。完全离线构建还涉及缓存和依赖是否已下载,--locked 本身不等于离线。
Rust 项目一慢,最常见的反应是立刻打开 LTO、改 codegen-units,结果日常构建反而更慢。profile 不是“优化越多越高级”,它是在编译时间、调试体验、运行速度和产物体积之间做选择。
默认开发 profile 偏向快速编译和调试,优化级别低,保留调试信息并开启增量编译。默认 release profile 使用较高优化、关闭增量编译,但默认并不启用跨 crate 的完整 LTO,panic 策略也仍是 unwind。旧教程经常把自己项目的激进配置写成 Cargo 默认值,这会误导你对构建耗时的预期。
可以从一份克制的配置开始:
[profile.dev]
opt-level = 0
[profile.dev.package."*"]
opt-level = 1
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 8
strip = "debuginfo"
panic = "abort"
[profile.dev.package."*"] 给依赖适度优化,有时能改善开发态运行速度,又不把你正在频繁修改的本地 crate 全部重优化。不过过程宏、构建脚本和大型泛型依赖可能仍很耗时,是否划算要测。
LTO 让链接阶段看到更大范围的代码,可能改善运行速度或体积,代价是链接更久。"thin" 常是实际项目里的折中;true 或 "fat" 更激进,只有基准证明值得时再用。codegen-units 越多,编译器越容易并行,跨单元优化空间通常越少;设为 1 可能得到更充分优化,也会显著拖慢构建。上例里的 8 不是通用答案,只是提醒你它应该通过测量决定。
panic = "abort" 会在 panic 时直接终止进程,通常能减少展开相关开销和体积,却失去栈展开及 catch_unwind 场景。对独立命令行程序或某些服务可能合适,对需要跨边界清理资源、捕获 panic 或以特定 ABI 交互的库则要谨慎。库作者也不能假设最终应用一定沿用自己的 panic 偏好。
strip = "debuginfo" 可以减小分发产物,但线上崩溃排查可能需要符号信息。更成熟的发布流程会把可执行文件和调试符号分开保存,而不是为了小几兆把诊断能力永久丢掉。
排查构建慢时,顺序比参数更重要:先看是不是每次都在全 workspace 构建;再用 cargo tree -d 查重复版本;查看 feature 是否意外带入重依赖;确认 build script 是否反复运行;最后才针对 release 产物调整 profile。开发构建慢和发布构建慢是两类问题,不应该用同一把旋钮处理。
过程宏是另一个常见成本来源。带 derive 的便利 API 可能引入解析与代码生成依赖,而且过程宏要为构建主机编译和执行。它们能显著减少样板代码,完全不用并不现实;但如果一个只需要简单数据结构的叶子 package 因为默认 feature 拉入整套宏工具链,就值得检查能否关闭默认 feature,或把派生需求留在上层。
build script 反复运行时,重点看它是否正确声明输入变化。脚本应通过 Cargo 支持的输出协议告诉构建系统哪些文件或环境变量变化后才重跑。若它每次扫描大目录、访问网络或生成带时间戳的文件,增量缓存很容易失效。构建必须依赖网络才能成功也很脆弱:离线 CI、上游服务抖动和供应链变化都会让相同提交得到不同结果。能提前生成并纳入 package 的稳定内容,通常不要在每次 build 时远程获取。
链接慢则和编译慢不同。cargo check 很快、cargo build 最后长时间停在链接阶段,说明你应该关注链接器、LTO、调试信息和产物规模,而不是继续减少类型检查。先准确定位等待发生在哪个阶段,才能选到有效的旋钮。
profile 改动必须带着测量结果提交。至少记录干净构建、增量构建、最终体积和关键基准中的一项,否则几行看似专业的配置很可能只是把成本从运行时搬到了每位开发者的等待时间里。
当 Cargo 选择了你没预期的版本或 feature,最浪费时间的做法是从顶层 manifest 一行行猜。依赖行为来自整张图,应该直接看图。
# 查看当前 package 的依赖树
cargo tree
# 只看出现多个版本的 package
cargo tree -d
# 查看谁依赖了某个 package
cargo tree -i openssl-sys
# 查看 feature 从哪条边传入
cargo tree -e features -i tokio
# 工作空间范围内反向追踪
cargo tree --workspace -i tracing假设你关闭了某 HTTP 客户端的默认 TLS feature,构建时却仍出现原生 TLS 库。cargo tree -e features -i 目标包 往往能看到另一条依赖路径开启了默认 feature。修复点可能在你自己的另一个成员,也可能要升级某个中间依赖。只改当前这一行的 default-features = false,通常不够。
另一个常见场景是同一库编译两个主版本。cargo tree -d 会把重复版本列出来,再用 cargo tree -i package@version 分别反向追踪。若旧版本来自长期未更新的上游 crate,你可以升级那个 crate;如果它们确实不兼容,也可以接受共存。要警惕的是公共类型跨版本边界:例如两个依赖分别返回同名但不同版本的类型,Rust 会正确地把它们视为不同类型,错误信息看起来却像“期望 X,得到 X”。
锁文件冲突也不要手工拼 TOML。合并分支后,让 Cargo 在完整 manifest 上重新解析并运行测试,检查 Cargo.lock diff 中出现和消失的 package。锁文件是机器生成结果,人工解决文本冲突只能说明文件语法勉强合上,不能证明依赖图符合两边意图。
如果依赖版本突然上升,先判断是谁改变了允许范围:是直接依赖被 cargo add 改写,还是一次全量 cargo update 只推进了锁文件。前者属于 API 兼容范围变化,后者属于具体解析结果变化。把这两种改动放在一个巨大提交里,回滚和定位都会更困难。
发布 crate 最危险的错觉是:仓库里能看到的文件,就是上传内容;本地能编译,就是用户拿到包也能编译。实际上 Cargo 会先根据 manifest、版本控制忽略规则以及 include 或 exclude 组装 .crate 包,再在临时目录解包验证。你应该检查这个包,而不是只看工作树。
准备发布前,manifest 至少要有清楚的包名、版本、描述和许可证信息。README、repository、documentation、keywords 与 categories 便于用户判断用途。rust-version 则是兼容承诺,不要为了显得“支持广泛”填一个从未在 CI 验证过的旧版本。
先看文件列表:
cargo package --list这一条很便宜,却能抓出不少事故:测试夹具大到超出包大小限制、生成代码没有被包含、私有配置被误打包、README 路径写错、许可证文件缺失。需要控制内容时,可以在 [package] 使用 exclude 排除确定不需要的文件,或者使用 include 建立允许列表。两者不要同时依赖;设置 include 后,它会成为主要规则。
[package]
include = [
"src/**",
"Cargo.toml",
"README.md",
"LICENSE-*",
]允许列表更严格,但也更容易在新增必要文件后忘记更新。如果构建脚本读取 schema、模板或原生源码,而 include 没把它们带上,本地仓库构建会成功,打包验证却失败。别急着用跳过验证的选项,失败正是在帮你提前模拟用户环境。
接着生成并验证包:
cargo package
cargo publish --dry-run生成的 .crate 文件位于 target/package/。有疑问时可以查看其中内容,并确认它在没有仓库其他文件帮助的情况下能通过验证。cargo publish --dry-run 不上传,适合发布流水线的预检。日常流程不要把 --allow-dirty 和 --no-verify 当省事开关:工作树不干净会让发布内容和提交记录对不上,跳过验证会把本来能本地发现的问题送到所有用户面前。
打包还会规范化一部分 manifest 内容,所以审核发布包时不要只比较源码文件。workspace 继承字段、本地多位置依赖和锁文件都会按发布规则处理。一个成员在工作空间里能编译,不代表脱离根 manifest 后仍拥有完整元数据。cargo package 的临时解包验证就是在替你做这次“离家测试”。
exclude 与 .gitignore 也不是同一个概念。版本控制忽略规则经常会影响默认打包选择,但一旦你使用显式 include,就应把它当作独立允许列表维护。反过来,文件没有提交到 Git 也不代表它绝对不会进入本地打包结果。发布前以 cargo package --list 的实际输出为准,安全检查不要建立在记忆中的规则上。
如果 package 带有 examples、tests 或 benches,要确认发布包中的这些 target 是否还能解析其文件与依赖。测试数据特别容易被 exclude 清掉,而 target 文件仍保留,导致用户下载源码后运行全套测试失败。你可以选择不发布某些内部测试资源,但应让被发布的 target 自洽。
真正发布前还要完成语义检查:版本号是否符合本次 API 变化;CHANGELOG 是否描述用户能感知的变化;提交是否已经通过测试;tag 是否准备指向同一个提交;工作空间底层依赖是否已按顺序发布。对于多个相互依赖的 package,registry 索引可见性可能有短暂等待,上层包过早发布会解析不到刚发布的版本。
最后才执行:
cargo publish已发布的版本不能覆盖,也不能真正删除。发现版本有严重问题时,可以 yank:
cargo yank --version 1.4.2
cargo yank --version 1.4.2 --undoyank 的含义是阻止新的依赖解析选择该版本,已有 Cargo.lock 仍可继续使用它,源代码也仍保留。它适合处理缺文件、无法编译或严重回归,不适合“修正文案”式随意撤回。修复方式通常是 yank 问题版本,再发布一个新版本。
发布到 crates.io 需要身份令牌。令牌可以由 Cargo 登录流程保存在本机凭据配置中,也可以由 CI 通过受保护环境变量提供。无论哪种方式,都不要把 token 放进 Cargo.toml、源码、示例命令、构建日志或提交记录。
如果怀疑 token 泄露,第一动作是撤销并轮换令牌,不是先删 Git 历史,也不是先 yank crate。yank 不会删除已经上传的源文件,拿到旧包的人仍能读取其中内容。数据库密码、云密钥等其他 secret 也是同理:只要进入提交或发布包,就应视为已经泄露,立即在对应服务端失效。
更稳妥的 CI 发布流程应使用权限尽量小、有效期合理的令牌,并把发布限制在受保护分支、tag 或人工批准环境。普通 pull request 不应拿到发布 secret,来自 fork 的代码更不能在带有高权限令牌的上下文中任意执行构建脚本。
令牌最好按用途拆分。个人电脑上的发布 token、自动化流水线 token 和临时维护 token 不要共用,这样某一处泄露时可以单独撤销,也更容易从审计记录判断操作来源。人员离开团队、机器报废或流水线停用时,对应 token 应随之撤销,不能指望“没人知道它在哪”充当保护。
日志脱敏也不能只匹配变量名。某些命令在详细模式下会打印请求配置,脚本的 set -x 可能展开环境变量,失败处理又可能把整个环境 dump 出来。发布任务应默认少打印敏感上下文,并在 secret 进入命令前确认日志系统会遮蔽。最稳妥的 secret 是从未传给不需要它的步骤。
build.rs、过程宏和依赖本身都可能在构建期间执行代码。发布机器既有网络权限又有 registry token 时,依赖供应链风险会被放大。可以把“构建与测试”和“最终上传”拆开,上传阶段使用更小的执行面,并确保实际上传的 .crate 正是之前审核、验证过的产物。
发布前可以按这个顺序走:
确认版本、变更记录、许可证和最低 Rust 版本承诺,并在干净工作树上运行完整测试。
用 cargo package --list 审核文件清单,特别搜索 .env、私钥、凭据、内部地址、大型夹具和未声明的生成输入。
运行 cargo package 或 cargo publish --dry-run,不要跳过解包后的构建验证。工作空间按依赖顺序逐个 package 检查。
由受保护环境注入短权限令牌,执行 ,确认 registry 已出现正确版本后再创建或核对发布 tag。
Cargo 命令很多,但日常工作不需要靠背菜单。按问题类型组织,会比按字母顺序记得牢。
开始一个新 package:
cargo new log-lens
cargo new protocol --lib添加或调整依赖后,先看 manifest 与锁文件 diff,再快速检查:
cargo add tracing
cargo check
cargo tree -dfeature 行为不符合预期时,验证不同组合并追踪来源:
cargo check --no-default-features
cargo check --all-features
cargo tree -e features -i tracing修改完成后,按范围逐步扩大验证:
cargo test -p log-lens-core
cargo test --workspace
cargo doc --workspace --no-deps
cargo build --release -p log-lens-cli依赖升级时,先预览、再定向更新,确认锁文件差异:
cargo update --dry-run
cargo update tracing
cargo test --workspace --locked发布时,先看包内容,再 dry run,最后才上传:
cargo package --list
cargo publish --dry-run
cargo publish这套流程的重点不在命令数量,而在每一步都能回答一个问题:manifest 改了什么、解析结果改了什么、谁开启了 feature、哪些 target 通过了测试、release 配置是否真实可用、最终包里有什么。你能回答这些问题,Cargo 就不再是一个吐出几百行日志的黑箱。
脚本和编辑器如果需要读取 workspace 结构,不要解析 cargo tree 面向人的缩进文本,也不要自己遍历目录猜 package。Cargo 提供机器可读的元数据输出:
cargo metadata --format-version 1
cargo metadata --format-version 1 --no-deps完整输出包括 package、target、workspace 成员和解析图等信息;--no-deps 只保留工作空间 package,适合快速发现成员和 target。它输出 JSON,字段含义比终端展示更适合工具消费。脚本仍应声明自己支持的格式版本,并避免依赖未承诺稳定的字段排列。
跨目录执行命令时,可以用 --manifest-path path/to/Cargo.toml 明确入口。这样 CI 不必靠一连串 cd 猜当前目录,日志也更容易复现。进入 workspace 后仍要配合 -p、--workspace 或 target 选择参数说明作用范围。自动化里越少依赖“当前恰好站在哪个目录”,后续重组仓库时越不容易误构建或漏测试。
最后给命令失败保留原始输出。Cargo 的错误通常已经列出 package、版本要求和依赖路径,包装脚本如果只打印一句“构建失败”,反而抹掉了最有价值的线索。自动重试也应克制:网络偶发错误可以重试,版本冲突、manifest 错误和测试失败不会因为再跑三遍就变正确。
假设你维护一个 workspace,里面有 core、cli 和 server。某次为 server 增加监控依赖后,CI 构建时间翻倍,cli 的二进制也莫名变大。先别急着调 profile,按依赖图排查。
第一步,只构建 cli 与全工作空间分别对比。如果 cargo check -p cli 正常,而 cargo check --workspace 明显变慢,说明成本可能来自同时选择多个成员,不一定污染了 cli 自己。
第二步,运行 cargo tree -d,看新依赖是否带来了某些库的第二个不兼容版本。若存在,再反向追踪旧版本由谁引入。上游能升级就升级,不能升级则记录这份重复成本,不要盲目 patch。
第三步,对可疑库执行 cargo tree -e features -i package-name。你可能发现 server 的监控栈启用了一个共同依赖的重型 feature,而全 workspace 同次构建时 feature 被合并。分别执行 package 构建可以避免这次合并;如果 cli 单独 release 仍带着它,才继续查 cli 自身依赖路径。
第四步,检查 Cargo.lock diff。新增的传递依赖来自 manifest 新边,还是刚好有人同时做了全量更新?如果两件事混在一起,把升级拆开重新验证。这样才能知道构建变慢究竟由哪个变化造成。
最后才测 release profile。比较默认 release、thin LTO 和不同 codegen units 的构建时长、二进制体积与关键请求性能。如果 LTO 多花三分钟只省下几百 KB,而你的分发场景并不在意体积,那就没有必要为了配置看起来“很优化”而保留它。
Cargo 最有价值的地方,不是替你消灭所有依赖问题,而是把问题留下足够清楚的痕迹:manifest 写着允许什么,lockfile 写着选中了什么,tree 告诉你为什么会选中,package 预检告诉你将发布什么。你真正要养成的习惯,是沿着这些痕迹把判断做实。下一次再看到依赖冲突或 feature 意外开启,编译器和 Cargo 依然会很严格,但它们不再像在故意刁难你,更像两个把账本摊开、等你一起对账的同事。
只有涉及性能、链接、原生库或发布产物时,才补做 cargo build --release 和真实运行验证。release 构建通过不代表性能一定更好,仍需要基准或场景数据。
cargo publish如果发布错误,先判断是否需要 yank;如果包含任何 secret,立即在 secret 的发行方撤销和轮换,因为 yank 不会抹掉内容。