Cargo build script 实战编译前自动生成 protobuf 和版本号一、那些你总想偷懒但又必须做的编译前工作项目做久了总有一些编译前的准备工作让人头疼。比如每次改完.proto文件要手动跑protoc重新生成 Rust 代码每次发版本要记得更新代码里的版本号字符串编译时需要把 Git commit hash 嵌入到二进制里方便排查问题最开始这些东西都是手动搞的。但手动就意味着可能忘记——有次发了一个版本排查线上问题时发现版本号还是旧的根本不知道用户跑的是哪次提交的代码。从那以后我就下定决心所有编译前的生成工作全部交给 Cargo 的 build script 来做。这篇文章就来分享几个我在实际项目中用得比较多的 build script 场景。二、build.rs 的执行时机在进入具体代码之前先理清 build.rs 在整个编译流程中的位置flowchart TD A[Cargo 开始编译] -- B[解析 Cargo.toml] B -- C{存在 build.rs?} C --|是| D[编译并运行 build.rs] C --|否| E[直接编译项目代码] D -- F[build.rs 生成代码或配置] F -- G[将生成物放在 OUT_DIR] G -- E E -- H[链接和输出]关键点有两个build.rs 在项目代码编译之前执行生成的文件会放在OUT_DIR环境变量指向的目录Cargo 会根据 build.rs 的rerun-if-changed指令判断是否需要重新执行三、三个实用场景的代码实现场景一自动编译 protobuf假设项目结构是这样的my_project/ ├── build.rs ├── Cargo.toml ├── proto/ │ ├── user.proto │ └── order.proto └── src/ └── main.rsCargo.toml中需要添加 build-dependencies[package] name my_project version 0.1.0 edition 2021 # build.rs 执行时需要这些 crate [build-dependencies] prost-build 0.12 tonic-build 0.10 # 项目代码需要这些 crate 来使用生成的代码 [dependencies] prost 0.12 tonic 0.10 tokio { version 1, features [full] }然后是build.rsuse std::io::Result; fn main() - Result() { // // 第一部分编译 protobuf 文件 // // 定义 proto 文件所在的目录 let proto_dir proto; // 编译所有 .proto 文件生成 Rust 代码 tonic_build::configure() // 指定输出目录Cargo 会自动设置 OUT_DIR .out_dir(src/pb) // 生成到 src/pb/ 下方便直接 include! // 为消息类型添加 serde 的 Serialize/Deserialize .type_attribute(., #[derive(serde::Serialize, serde::Deserialize)]) // 编译指定目录下的所有 .proto 文件 .compile( [ proto/user.proto, proto/order.proto, ], [proto_dir], // include 路径 )?; // 告诉 Cargo如果 proto 目录下的文件发生变化重新运行 build.rs println!(cargo:rerun-if-changedproto/); Ok(()) }项目代码中这样引入生成的代码// src/main.rs // include! 在编译时将指定文件的内容嵌入到当前位置 pub mod pb { tonic::include_proto!(my_project); // package 名对应 proto 文件中的 package } fn main() { println!(protobuf 模块已就绪); }场景二嵌入 Git commit hash 作为版本号这个场景在实际排查问题时特别有用——当用户反馈 bug 时你能从日志里看到精确的 commit hash// build.rs use std::process::Command; fn main() { // // 获取 Git 信息 // // 获取当前 commit 的短 hash前 8 位 let git_hash Command::new(git) .args([rev-parse, --short8, HEAD]) .output() .ok() .and_then(|output| { if output.status.success() { String::from_utf8(output.stdout).ok() } else { None } }) .map(|s| s.trim().to_string()) .unwrap_or_else(|| unknown.to_string()); // 获取当前分支名 let git_branch Command::new(git) .args([rev-parse, --abbrev-ref, HEAD]) .output() .ok() .and_then(|output| { if output.status.success() { String::from_utf8(output.stdout).ok() } else { None } }) .map(|s| s.trim().to_string()) .unwrap_or_else(|| unknown.to_string()); // 获取最后一次提交的时间 let git_timestamp Command::new(git) .args([log, -1, --format%cd, --dateshort]) .output() .ok() .and_then(|output| { if output.status.success() { String::from_utf8(output.stdout).ok() } else { None } }) .map(|s| s.trim().to_string()) .unwrap_or_else(|| unknown.to_string()); // // 生成版本信息文件 // // 获取 Cargo.toml 中定义的版本号 let cargo_version env!(CARGO_PKG_VERSION); // OUT_DIR 由 Cargo 自动设置指向 build 输出目录 let out_dir std::env::var(OUT_DIR).unwrap(); let version_content format!( r#// 此文件由 build.rs 自动生成请勿手动修改 /// 完整的版本信息字符串 pub const VERSION_INFO: str {} ({} {}); /// 从 Cargo.toml 读取的版本号 pub const CARGO_VERSION: str {}; /// Git commit 短 hash pub const GIT_HASH: str {}; /// Git 分支名 pub const GIT_BRANCH: str {}; /// 最后一次提交日期 pub const GIT_TIMESTAMP: str {}; #, cargo_version, git_hash, git_branch, cargo_version, git_hash, git_branch, git_timestamp, ); let version_file_path std::path::Path::new(out_dir).join(version.rs); std::fs::write(version_file_path, version_content).unwrap(); // 告诉 Cargo如果 HEAD 变化就重新运行 println!(cargo:rerun-if-changed.git/HEAD); println!(cargo:rerun-if-changed.git/refs/); }项目代码中使用// src/main.rs // 引入 build.rs 生成的版本信息 include!(concat!(env!(OUT_DIR), /version.rs)); fn main() { println!(应用版本: {}, VERSION_INFO); println!(Git Hash: {}, GIT_HASH); println!(分支: {}, GIT_BRANCH); println!(提交时间: {}, GIT_TIMESTAMP); }场景三合并使用 —— 一个完整的 build.rs// build.rs —— 同时处理 protobuf 编译和版本号生成 use std::io::Result; use std::process::Command; fn main() - Result() { // 编译 protobuf tonic_build::configure() .out_dir(src/pb) .compile([proto/user.proto], [proto])?; println!(cargo:rerun-if-changedproto/); // 生成版本信息 let git_hash get_git_info(rev-parse --short8 HEAD); let git_branch get_git_info(rev-parse --abbrev-ref HEAD); let out_dir std::env::var(OUT_DIR).unwrap(); let content format!( r#pub const VERSION: str {} ({});#, env!(CARGO_PKG_VERSION), git_hash, ); std::fs::write( std::path::Path::new(out_dir).join(version.rs), content, )?; println!(cargo:rerun-if-changed.git/HEAD); Ok(()) } // 提取出来的辅助函数 fn get_git_info(args: str) - String { let parts: Vecstr args.split_whitespace().collect(); Command::new(git) .args(parts) .output() .ok() .and_then(|o| { if o.status.success() { String::from_utf8(o.stdout).ok() } else { None } }) .map(|s| s.trim().to_string()) .unwrap_or_else(|| unknown.into()) }四、边界与注意事项build script 的缓存行为。Cargo 会根据rerun-if-changed指令判断 build.rs 是否需要重新运行。如果你没有正确设置这些指令Cargo 可能会在不需要的时候重新运行或者在需要的时候跳过。首次开发时建议先用cargo clean cargo build来验证。跨平台问题。上面的 Git 命令在 macOS/Linux 上工作正常但在 Windows 上需要 Git Bash 或者调整命令格式。如果项目需要在 Windows 上编译记得做平台判断。build script 的执行时间。build.rs 每次运行都会增加编译时间。如果 build.rs 里的操作很重比如下载文件建议加上缓存逻辑。对于 protobuf 这种只在.proto文件变化时才重新执行的操作Cargo 已经通过rerun-if-changed做了很好的缓存。OUT_DIR与src/的选择。上面例子中我把 protobuf 生成到了src/pb/。OUT_DIR是更标准的做法但生成在src/下的好处是 IDE 可以直接索引跳转。没有绝对的对错看个人偏好。还有一个冷启动陷阱首次cargo build时build.rs 的运行时间会计入总编译时间。很多人看到第一次编译特别慢以为是依赖下载的问题其实是 build.rs 在生成大量代码。可以在 build.rs 中加入耗时打印让用户知道时间花在哪一步。透明度比优化更重要。build.rs 的另一个隐患是它本身也是一个 cargo package有自己的依赖。如果 build.rs 依赖了 serde而项目本身也用 serde版本冲突会导致编译变慢甚至编译失败。建议 build.rs 的依赖尽量精简能用标准库就不用第三方。另外如果 build.rs 的输出在 CI 不同 job 之间需要复用记得把OUT_DIR下的产物加入 cache——这些中间文件在一次 compile 之后就不会再变了。五、总结build.rs 是 Cargo 生态里一个容易被忽视但极其实用的功能。它让你把编译前的生成工作自动化避免手动操作带来的遗漏和错误。三个场景里嵌入 Git commit hash 是我认为性价比最高的——代码量不多但排查线上问题时能省很多时间。protobuf 编译则是刚需只要是 gRPC 项目基本都会用到。如果你的项目里还有类似每次编译前需要执行某个命令的需求与其写在 Makefile 里让人手动跑不如直接搬到 build.rs 中。一次配置长期受益。