如何拆分 crate?

解读

在国内 Rust 岗位面试中,"拆分 crate" 不是单纯问"把代码分到多个文件",而是考察候选人能否在编译期就隔离稳定性、可见性与构建耗时,并兼顾团队协作与发布节奏。面试官希望听到:

  1. 何时必须拆(循环依赖、二进制体积、发布频率差异);
  2. 用什么机制拆(workspace、feature、visibility、semver);
  3. 拆完后如何保障接口兼容性CI 构建效率
    回答若只提"mod 子模块"会被认为理解过浅;必须落到 Cargo.workspacesemver 约束 才算及格。

知识点

  • Cargo workspace:根目录 Cargo.toml 里声明 [workspace],成员各自拥有 Cargo.toml,共用顶层 target 目录,避免重复编译依赖
  • crate 类型差异:lib crate 提供接口,bin crate 只生成可执行文件;proc-macro crate 必须单独成包且无法导出普通 Rust 项。
  • 可见性层级:pub(in crate::xxx)、pub(crate)、pub(self) 可在 crate 内部再划边界,减少误用。
  • feature 门控:用 [features] 与 optional = true 把大块功能拆成可选依赖,降低编译时与运行时开销。
  • semver 与发布线:workspace 成员各自版本号独立,但对外发布时需保证公共 trait 与类型做向后兼容(additive only)。
  • 循环依赖破解:把公共 trait 下沉到 "-core" 或 "-sys" 最小 crate,上层通过 trait 解耦,反向依赖消失
  • CI 分层缓存:workspace 下利用 Swatinem/rust-cache 按成员哈希 Cargo.lock,增量编译时间可降 60% 以上

答案

  1. 先画依赖图:把稳定且被多处引用的基础类型(错误码、协议定义)抽到 common-core crate,保证其几乎不随业务迭代而改版本
  2. 在根目录新建 Cargo.toml,声明
    [workspace]
    members = ["crates/*"]
    这样所有子 crate 共用一份 Cargo.lock 与 target,避免重复编 openssl/sys。
  3. 按"单向依赖"原则垂直拆分:
    • network-core:仅放 trait 与数据结构,无 IO 实现;
    • network-tokio:依赖 network-core 做 tokio 实现;
    • cli:只依赖 network-tokio,不直接依赖 network-core,确保未来可换实现(如 async-std)而 cli 无感。
  4. 若某功能编译耗时大(如 prost 生成码、bindgen),将其设为 optional feature,默认关闭;CI 在 pr 阶段只编译 default features,合并到 main 后再跑 full features,平均节省 30% 时间。
  5. 发布前跑 cargo public-api, diff 上一版本;若发现删除了 pub 项,立即打 major 版本号,避免下游团队编译通过却运行期链接失败
  6. 最后用 cargo tree -e features -f "{p} {f}" 检查 feature 联合启用是否带来重复依赖,把冲突特征统一到 workspace.dep 里统一版本,实现"零重复编译"。

拓展思考

  • monorepo vs multirepo:国内大厂往往用 monorepo 做 workspace,配合 bazel 或 cargo-nextest 的远程缓存,可把全链路测试压到 5 分钟内;若拆成 multirepo,需自建 cargo registry 镜像(如 gitlab cargo registry),维护成本指数级上升
  • ABI 稳定:如果计划把核心 crate 编译成 cdylib 给 C++/Go 调用,需显式打开 "-C prefer-dynamic" 并用 #[repr(C)] 导出,此时拆 crate 必须保留 "-sys" 层做裸导出,否则一改 Rust 类型就破坏 ABI。
  • 异步生态陷阱:tokio 与 async-std 的 rt-multi-thread feature 互斥,拆 crate 时若两者都被间接依赖,需在 workspace 顶层统一 feature 决议,否则会出现"编译通过,运行 panic"的诡异 bug。