Rust 项目的工具链与 MSRV、Cargo.lock、rustfmt 与 Clippy、unsafe、错误处理、异步(Tokio)、tracing 日志、测试工具、cargo-deny、数据库访问与迁移工具、FFI 和 HTTP 框架怎么选、怎么写。
RS-001硬约束检查:自动Rust对外提供服务的后端命令行工具 应用、服务和命令行工具在仓库根目录提交 rust-toolchain.toml,把工具链固定到具体补丁版,并列出需要的组件和 target
怎么检查:CI 检查文件存在,channel 是完整的补丁版本号而不是 stable;构建日志打印 rustc -V 与之对照
为什么、来源
为什么:只写 stable 时,不同机器、不同日期会用不同编译器构建,Clippy 新增的告警也会让 CI 无故变红。
- 组件至少写
rustfmt、clippy;交叉编译用到的 target 也写进 targets。
- 升级工具链单独一个 PR,同时处理新出现的 Clippy 告警。
- 断网环境把对应版本的工具链和 target 预置在构建镜像或内部镜像里。
RS-002硬约束检查:自动Rust供别人引用的库或 SDK 供别人引用的库在 Cargo.toml 写 rust-version 声明 MSRV,CI 用这个版本实际构建并测试
怎么检查:CI 增加一个用 MSRV 工具链的任务(或 cargo hack check --rust-version),失败即阻断
为什么、来源
为什么:不在 MSRV 上实测,声明的最低版本很快就会失真;用户升级依赖后才发现编不过。
- 库不要用 rust-toolchain.toml 把下游开发者绑死在某个补丁版;仓库里放它只为本仓库的开发和 CI,不能代替
rust-version。
- 2024 edition 默认的依赖解析器(resolver 3)会参考
rust-version 选依赖版本,声明准确才有用。
RS-003软指导检查:半自动Rust供别人引用的库或 SDK 抬高库的 MSRV 要写进变更日志并说明原因,不随每次 stable 发布自动抬高
怎么检查:CI 发现 rust-version 变化时,要求 PR 描述和变更日志里有对应说明
为什么、来源
为什么:抬高 MSRV 会让还在用旧编译器的下游无法升级;是否算破坏性变化由项目的版本政策决定,但必须明说。
MSRV 政策(例如「最近 N 个 stable」)写进 README;N 由项目自定,没有官方统一要求。
新项目用当前最新的 edition;迁移 edition 单独一个 PR,用 cargo fix --edition 完成
怎么检查:CI 检查各 crate 的 edition 字段;迁移 PR 里只含 edition 相关改动
为什么、来源
为什么:新 edition 收紧了一批容易出错的写法(如 unsafe 相关规则),和功能改动混在一起评审不清。
当前最新为 2024 edition,需要 Rust 1.85 及以后。Agent 和贡献者不要顺手改 edition、rust-version 或 target。
RS-005硬约束检查:自动Rust对外提供服务的后端命令行工具 应用、服务和命令行工具必须提交 Cargo.lock,CI 和发布构建一律加 --locked
怎么检查:CI 检查 Cargo.lock 已入库;所有 cargo build、cargo test、cargo clippy 命令带 --locked,锁文件需要变化时失败
为什么、来源
为什么:没有锁文件,每次解析都可能拿到不同的补丁版,构建结果不可复现,也无法用 git bisect 定位问题。
cargo install 默认忽略锁文件;发布可安装的命令行工具时,文档里写 cargo install --locked。
RS-006软指导检查:自动Rust供别人引用的库或 SDK 库也默认提交 Cargo.lock;另在 CI 里跑一次用最新兼容依赖的构建和测试
怎么检查:定时任务或 PR 任务先 cargo update 再构建测试,失败时提醒
为什么、来源
为什么:官方已改为 cargo new 默认跟踪锁文件,但库的锁文件不影响下游用户,只锁住自己的 CI 会掩盖下游会遇到的问题。
依赖版本不写 *;git 依赖必须固定 rev;依赖只能来自批准的 registry 或镜像
怎么检查:cargo-deny 的 [sources] 只允许批准的 registry 和 git 地址,[bans] 设 wildcards = "deny"
为什么、来源
为什么:* 和分支 HEAD 会在不知情时引入任意新代码,是供应链攻击的入口;crates.io 本身也拒绝发布通配版本依赖。
新增依赖在 PR 里写明用途;只开需要的 feature,关掉不用的默认 feature
怎么检查:评审时看 Cargo.toml 和 Cargo.lock 的变化;用 cargo tree -e features 查看实际启用的 feature,用 cargo-machete 等工具找未使用的依赖
为什么、来源
为什么:全量 feature(如 tokio 的 full)会拖慢编译、扩大攻击面和许可证范围。
代码必须通过 rustfmt;CI 跑 cargo fmt --all -- --check
怎么检查:CI 运行该命令,非零退出即失败;如有 rustfmt.toml 则入库
为什么、来源
为什么:格式交给工具,评审只看逻辑。
CI 用固定的工具链跑 cargo clippy --all-targets --locked -- -D warnings,不得有告警
怎么检查:CI 运行该命令;feature 互斥时按 feature 组合分别跑,不硬加 --all-features
为什么、来源
为什么:Clippy 默认启用的正确性、可疑写法检查能直接拦下真实缺陷;工具链不固定时新告警会无故打断 CI。
不得在 crate 根写 #![allow(warnings)] 或整组关闭 clippy::correctness。
lint 级别统一写在 Cargo.toml 的 [lints] 表;多 crate 工作区写在 [workspace.lints],各成员用 lints.workspace = true 继承
怎么检查:CI 检查各成员 crate 都继承了工作区 lint 配置;评审时看是否有散落在 crate 根的 #![deny(...)]
为什么、来源
为什么:lint 配置集中一处才看得清、改得动;散在各文件里容易漏改、互相矛盾。
示例:
[workspace.lints.rust]
unsafe_code = "forbid" # 需要 unsafe 的 crate 单独放宽
[workspace.lints.clippy]
undocumented_unsafe_blocks = "deny"
unwrap_used = "warn"
expect_used = "warn"
dbg_macro = "warn"
配合 CI 的 -D warnings,warn 实际也会阻断;希望只提示不阻断的 lint 不要放进来。
pedantic、nursery 等可选 lint 组逐条评估后启用,不整组设为 deny
怎么检查:评审时看 lint 配置里是否有整组 pedantic = "deny" 之类的写法
为什么、来源
为什么:这些组里有风格争议和误报较多的 lint,整组开启会引来大量 allow,反而淹没真问题。
局部关闭 lint 时写明理由;优先用 #[expect(lint, reason = "...")],不用不带理由的 #[allow]
怎么检查:启用 Clippy 的 allow_attributes_without_reason(和 allow_attributes)检查
为什么、来源
为什么:expect 在告警消失后会反过来提醒删除,理由写在代码里,评审和日后清理都有依据。
不需要 unsafe 的 crate 设 unsafe_code = "forbid";需要的 unsafe 集中在少数模块,并对外提供安全的接口
怎么检查:[lints] 表里配置 unsafe_code;CI 用 grep 或 cargo-geiger 统计 unsafe 出现的位置
为什么、来源
为什么:内存安全只在 unsafe 之外由编译器保证;unsafe 越分散,越难审查。
每个 unsafe 块前写 // SAFETY: 注释,说明满足了哪些前提;每个 unsafe fn 写 # Safety 文档说明调用方要满足的前提
怎么检查:Clippy 的 undocumented_unsafe_blocks 和 missing_safety_doc 设为 deny;unsafe_op_in_unsafe_fn 不得放宽
为什么、来源
为什么:unsafe 代码的正确性靠人推理,前提不写下来就无法评审,也无法在改动后重新检查。
2024 edition 起 unsafe_op_in_unsafe_fn 默认告警:unsafe fn 体内的不安全操作也要写在显式的 unsafe {} 块里。
新增或修改 unsafe 代码必须由指定的评审人批准,作者不能自己批准
怎么检查:CI 检测改动里出现 unsafe 时打标签并要求指定评审人;集中放 unsafe 的模块写进 CODEOWNERS
为什么、来源
为什么:编译器不再替 unsafe 代码把关,独立的人工审查是唯一的防线。
含 unsafe 或原始指针操作的代码,用 Miri 跑相关单元测试
怎么检查:CI 用 nightly 工具链跑 cargo miri test(可只跑相关模块)
为什么、来源
为什么:Miri 能在测试执行时发现越界、悬垂指针、数据竞争等未定义行为,普通测试发现不了。
Miri 不支持 FFI 调用和部分系统调用,这类测试要跳过;跳过的部分改用 sanitizer。
RS-018软指导检查:半自动Rust供别人引用的库或 SDK 库对外暴露具体的错误类型(如用 thiserror 定义的枚举),不在公开接口里返回 anyhow::Error 或 Box
怎么检查:评审公开接口;用 cargo-semver-checks 发现错误类型的破坏性变化
为什么、来源
为什么:调用方要按错误种类分支处理,模糊的错误类型只能当字符串看;错误类型也是公开接口的一部分。
- 错误枚举实现
std::error::Error,用 #[source] 保留底层原因;对外可能新增变体的枚举加 #[non_exhaustive]。
- 应用入口、命令行 main、任务装配层用 anyhow 加
.context(...) 补上下文。
- 同一个模块不要混用 thiserror 和 snafu 两套写法。
- 错误怎么映射成接口响应见 16;对外不暴露 SQL、路径、栈等内部信息。
生产代码里可预期的失败(输入、网络、文件、数据库)不得用 unwrap、expect、panic 处理
怎么检查:Clippy 开启 unwrap_used、expect_used、panic;clippy.toml 设 allow-unwrap-in-tests = true、allow-expect-in-tests = true 豁免测试;剩下的用 #[expect(..., reason = "...")] 逐处说明
为什么、来源
为什么:可预期的失败变成 panic,轻则一个请求失败、重则整个进程退出,且没有可用的错误信息。
真正不可能失败的地方(如编译期常量的正则)可以 expect,在消息里写清为什么不会失败。
RS-020软指导检查:自动Rust供别人引用的库或 SDK 公开函数会 panic 或返回错误时,在文档里写 # Panics 和 # Errors 小节
怎么检查:Clippy 开启 missing_panics_doc、missing_errors_doc
为什么、来源
为什么:调用方要知道什么情况下会失败、会不会崩溃,才能正确处理。
async 代码里不直接做阻塞调用或长时间 CPU 计算;阻塞操作用 spawn_blocking,CPU 密集任务交给 Rayon 或专用线程池
怎么检查:评审时检查 async 函数里的 std::fs、std::thread::sleep、同步数据库驱动、重计算循环;压测时用 tokio-console 或 tracing 的耗时数据找长时间不让出的任务
为什么、来源
为什么:Tokio 的工作线程数量有限,一个阻塞任务会拖住同一线程上的所有请求,表现为尾延迟飙升。
不在 .await 期间持有 std 或 parking_lot 的同步锁
怎么检查:Clippy 的 await_holding_lock、await_holding_refcell_ref 设为 deny
为什么、来源
为什么:持锁让出后,同线程的其他任务再取同一把锁会死锁;锁也会被持有到不可预期的时长。
先缩小临界区、在 await 前释放;确实要跨 await 持有时用 tokio::sync::Mutex。
任务之间的 channel 默认用有界的;用无界 channel 要写明为什么不会无限增长
怎么检查:用 grep 找 unbounded_channel,评审时确认理由
为什么、来源
为什么:无界 channel 没有背压,生产者快于消费者时内存会一直涨到进程被杀。
spawn 出的任务要有归属:用 JoinSet 或 TaskTracker 管理,停机时发出取消并限时等待结束;需要保证完成的工作不能只靠 tokio::spawn
怎么检查:评审时检查每个 tokio::spawn 的 JoinHandle 是否被持有或交给任务组;停机路径有取消信号(如 CancellationToken)
为什么、来源
为什么:没人管的任务出错无人知道,停机时被直接丢掉;进程一退出,内存里的任务就没了。
需要可靠执行、可重试的工作放进持久化的任务队列或 outbox(见 17、18)。在 tokio::select! 里使用的 future 要确认是否取消安全。
每个外部调用都设超时;HTTP 客户端在进程内复用同一个 Client,并配置连接和整体超时
怎么检查:评审时检查 reqwest::Client::builder() 是否设置了 timeout、connect_timeout;其他调用用 tokio::time::timeout 包裹;故障注入测试验证
为什么、来源
为什么:reqwest 默认没有整体超时,对端不响应时请求会一直挂着,占住连接和任务;每次新建 Client 会丢掉连接池。
非幂等的写请求不做自动重试,除非接口支持幂等键;重试和幂等语义按 16 执行
怎么检查:评审重试中间件(如 reqwest-retry、backon)的作用范围,确认只覆盖幂等方法或带幂等键的请求
为什么、来源
为什么:超时不等于失败,盲目重试写请求会造成重复扣款、重复下单。
同一个幂等键对应不同请求内容时,服务端返回 422(见 16)。
日志和追踪用 tracing;库只发事件和 span,不初始化 subscriber,只有应用入口初始化一次
怎么检查:CI 用 grep 检查库 crate 里没有 tracing_subscriber 的初始化调用、没有 println! / eprintln! 打日志(Clippy 的 print_stdout、print_stderr)
为什么、来源
为什么:库自己初始化会和应用的配置冲突;打印到标准输出的内容没有级别和结构,收集不了。
- 服务用
tracing-subscriber 输出 JSON;需要跨服务追踪时接 OpenTelemetry(见 26)。
- 日志字段、级别和「同一错误只在边界记一次」的约定见 02。
- 用
#[instrument(skip(...))] 时跳过大参数和敏感参数。
密钥和敏感字段不进日志、span 和 Debug 输出;持有密钥的类型用 secrecy 之类的包装或手写脱敏的 Debug
怎么检查:评审时检查含密钥字段的结构体是否 #[derive(Debug)];日志测试断言输出里没有测试密钥;密钥扫描见 25
为什么、来源
为什么:#[derive(Debug)] 会把所有字段原样打印,一次 ? 格式化或 instrument 就能把密钥写进日志。
用 cargo-nextest 跑测试时,必须另外跑 cargo test --doc
怎么检查:CI 同时有 cargo nextest run --locked 和 cargo test --doc --locked 两步
为什么、来源
为什么:nextest 不执行文档测试,只跑它会让文档里的示例悄悄失效。
解析器、编解码器和处理外部输入的函数,用 proptest 做属性测试;面向不可信输入的,再用 cargo-fuzz 做模糊测试
怎么检查:评审时看这些模块是否有属性测试;模糊测试放定时任务跑,发现的崩溃样本加入回归测试
为什么、来源
为什么:内存安全不等于不会 panic、不会死循环、不会耗尽内存;随机输入最容易找出这些问题。
自己写的并发原语(原子操作、无锁结构、自定义同步)用 loom 测试
怎么检查:评审时检查使用 std::sync::atomic 实现同步逻辑的模块是否有 loom 测试
为什么、来源
为什么:并发缺陷依赖线程交错顺序,普通测试几乎跑不出来;loom 会穷举交错。
insta 快照的差异由人审查后接受;CI 里不自动写入或接受快照
怎么检查:CI 设 INSTA_UPDATE=no(或使用 cargo insta test --check),快照不一致即失败;快照文件改动在 PR 里单独评审
为什么、来源
为什么:自动接受快照等于把回归直接写成新的预期。
CI 运行 cargo-deny 检查漏洞、许可证、重复与禁用的 crate、依赖来源;deny.toml 入库
怎么检查:CI 运行 cargo deny check,非零退出即失败;忽略某条公告时在 deny.toml 写明理由和复查日期
为什么、来源
为什么:一次检查同时覆盖 RustSec 漏洞公告、许可证白名单、重复依赖和来源,比分别配多个工具省事。
- 漏洞数据来自 RustSec 公告库;断网环境用内部镜像,并在结果里记录公告库的更新时间——离线扫描只对那份快照有效。
- 已在用 cargo-audit 的可以保留;两者用同一份公告库,不必叠加。
- 许可证白名单的口径见 27,SCA 与 SBOM 的总体要求见 25。
断网或私有化交付的构建,用 cargo vendor 或内部 registry 镜像准备依赖,并用 cargo build --release --locked --offline 验证
怎么检查:CI 里有一个禁网的构建任务执行该命令
为什么、来源
为什么:能联网编译不代表能离线编译;vendor 只包含 crate 源码,不含工具链、原生库、protoc 等,必须在真正断网的环境里验证。
能离线构建不等于能离线运行,运行层的验收见 26。
SQL 一律用参数绑定(query! 宏或 .bind()),不用 format! 拼接用户输入
怎么检查:用 grep 或 Semgrep 找传给 sqlx::query / query_as 的 format! 结果;动态拼接的标识符只能来自白名单
为什么、来源
为什么:拼接 SQL 是注入漏洞的直接来源。
用 SQLx 编译期检查宏且 CI 不连数据库时,提交 .sqlx 目录,CI 跑 cargo sqlx prepare --check
怎么检查:CI 运行 cargo sqlx prepare --check;构建时不设 DATABASE_URL,确认走的是离线缓存
为什么、来源
为什么:查询或表结构改了而缓存没更新,离线构建会按旧结构校验,问题拖到运行时才暴露。
设了 DATABASE_URL 时宏仍会尝试连接数据库,CI 的离线构建步骤要确保它没被设置。
迁移文件入库,用所选库配套的迁移工具(SQLx 用 sqlx migrate)执行;迁移原则按 17
怎么检查:评审迁移文件;确认生产环境由发布流程单独执行迁移,而不是每个实例启动时各跑一遍
为什么、来源
为什么:一个项目只用一套迁移工具,迁移记录才可查;多副本启动时同时跑迁移会互相冲突。
FFI 边界上不让 panic 跨越 extern "C";跨边界的类型用 #[repr(C)],不直接传 String、Vec 或普通 Rust 结构体
怎么检查:improper_ctypes、improper_ctypes_definitions 不得放宽;评审时检查每个导出函数是否用 catch_unwind 兜住 panic 或明确使用 extern "C-unwind"
为什么、来源
为什么:Rust 的内存布局不保证稳定;panic 跨 C 边界会直接中止进程或造成未定义行为。
- 谁分配谁释放:Rust 分配的内存提供配套的释放函数,不让对方用 free 释放。
- 写清字符串编码、整数宽度、空指针约定和 ABI 版本。
- 2024 edition 起
extern 块要写成 unsafe extern,no_mangle 等属性要写成 #[unsafe(no_mangle)]。
- 跨语言测试覆盖每个导出函数;条件允许时 CI 用 AddressSanitizer 跑。
给 Python、Node、C++ 写绑定用成熟的绑定库(PyO3、napi-rs、cxx),不手写一层 C ABI
怎么检查:评审新增的跨语言模块用的绑定方式
为什么、来源
为什么:手写 C ABI 要自己处理所有权、异常转换、GIL 和事件循环,绑定库已经处理好了这些边界。
升级绑定库时,按目标语言版本和平台矩阵重新测试,不能只看 Rust 侧能否编译。
RS-040软指导检查:自动Rust供别人引用的库或 SDK 发布库之前用 cargo-semver-checks 检查公开接口的破坏性变化,版本号按 SemVer 递增
怎么检查:发布流程里运行 cargo semver-checks,发现破坏性变化而版本号未相应递增时失败
为什么、来源
为什么:公开接口的破坏性变化人眼容易漏,下游会在 cargo update 后突然编不过。
没有符合条件的规则。
工具链管理
rustup + 仓库根目录的 rust-toolchain.toml
备选:Nix 或 mise(一台机器上要统一管理多种语言和系统依赖)
不推荐:系统包管理器自带的 rustc
lint 配置
Cargo.toml 的 [lints] / [workspace.lints] 表 + clippy.toml(只放 lint 的参数)
不推荐:在每个 crate 根写一串 #![deny(...)]
异步运行时
Tokio · Tokio 为 MIT
备选:smol(想要更小的运行时,且用到的库不依赖 Tokio)
不推荐:async-std
HTTP 服务框架
Axum + tower / tower-http 中间件 · Axum 为 MIT
备选:Actix Web(团队已熟悉,或已有 Actix 代码);tonic(提供 gRPC 接口)
不推荐:普通业务服务直接基于 hyper 自己搭
HTTP 客户端
reqwest(进程内复用一个 Client,设好超时)
备选:hyper 客户端(需要极细的连接控制,或写底层库)
不推荐:每个请求新建 Client
数据库访问
SQLx(以 SQL 为准,编译期检查查询) · SQLx 为 MIT OR Apache-2.0
备选:SeaORM(增删改查多、需要动态组装查询);Diesel(想要强类型的查询构建器,接受同步接口或配合 diesel-async);tokio-postgres + deadpool-postgres(只用 PostgreSQL、需要驱动层的细粒度控制)
不推荐:同一项目混用多套数据库访问库
数据库迁移工具
sqlx-cli(sqlx migrate),迁移文件放 migrations/ 入库
备选:sea-orm-migration(使用 SeaORM);diesel_migrations(使用 Diesel)
不推荐:在应用启动代码里手写 DDL
错误处理
库用 thiserror 定义错误枚举;应用入口用 anyhow
备选:snafu(需要大量带结构化上下文的错误;同一模块不和 thiserror 混用)
不推荐:到处 unwrap,或把所有错误转成 String
日志与追踪
tracing + tracing-subscriber(服务输出 JSON)
备选:log + env_logger(很小的命令行工具,不需要 span);tracing-opentelemetry(需要跨服务追踪(见 26))
不推荐:println! / eprintln! 当日志
测试执行
cargo-nextest + cargo test --doc
备选:只用 cargo test(测试很少、不在乎速度和隔离)
进阶测试
proptest(属性测试)、cargo-fuzz(模糊测试)、loom(并发交错)、Miri(未定义行为)、insta(快照)
依赖审计
cargo-deny · cargo-deny 为 MIT OR Apache-2.0;作为 CI 里独立运行的工具使用
备选:cargo-audit(只需要漏洞检查,或要用它的 cargo audit fix 等功能)
序列化
serde + serde_json
不推荐:serde_yaml
命令行参数
clap(derive 写法)
备选:argh、lexopt(很在意二进制体积和编译时间)
跨语言绑定
C/C++ 用 cxx 或最薄的 extern "C";Python 用 PyO3 + maturin;Node 用 napi-rs;浏览器用 wasm-bindgen
不推荐:把 Rust 结构体的内存布局直接当 C ABI
交叉编译
优先用目标架构的原生构建机;不行再用 rustup target + cross
备选:cargo-zigbuild(需要指定 glibc 版本,或不想依赖容器)
不推荐:以为加 --target 就能解决原生依赖
✕ 错误做法:rust-toolchain.toml 里只写 channel = "stable"
✓ 应该:固定到补丁版,升级单独一个 PR
✕ 错误做法:依赖写 git = "...", branch = "main"
✓ 应该:发布到 registry 的版本,或固定 rev,并在 cargo-deny 里登记来源
✕ 错误做法:遇到借用检查报错就加 'static、Box::leak 或到处 Arc<Mutex<T>>
✓ 应该:重新划分所有权;共享不可变状态用 Arc<T>,可变状态优先用消息传递
✕ 错误做法:在 Tokio handler 里直接读大文件、调同步驱动或做重计算
✓ 应该:spawn_blocking、Rayon,或拆成独立服务
✕ 错误做法:用 tokio::spawn 发邮件、写审计当作可靠的后台任务
✓ 应该:写进持久化任务队列或 outbox(见 17、18)
✕ 错误做法:只跑 cargo nextest run,文档示例早就编不过了
✓ 应该:另跑 cargo test --doc
✕ 错误做法:cargo insta accept 一键接受所有快照差异
✓ 应该:逐个查看差异,在 PR 里说明为什么预期变了
✕ 错误做法:为了让 CI 变绿在 crate 根写 #![allow(warnings)]
✓ 应该:逐处修复,确需例外时写 #[expect(..., reason = "...")]
✕ 错误做法:新项目照抄旧教程引入 async-std 或 serde_yaml
✓ 应该:用 Tokio;YAML 选仍在维护的库
✕ 错误做法:因为「Rust 快」就把所有新服务都用 Rust 写
✓ 应该:先确认瓶颈在计算而不是数据库或网络,再拿基准数据和团队能力做决定