目录
- 错误处理哲学
- thiserror vs anyhow 选型
2.5. 何时使用 panic 而非 Result - Result 组合子
- Panic 边界与自定义 Hook
- 测试金字塔
- Mockall 模拟外部依赖
- Property-based Testing
- 代码覆盖率
- 错误处理设计模式
1. 错误处理哲学
Rust 的错误处理哲学:可恢复错误用 Result,不可恢复错误用 panic。没有异常机制,所有错误路径必须显式处理。
// 可恢复错误:文件可能不存在
fn read_config(path: &str) -> Result<String, io::Error> {
fs::read_to_string(path)
}
// 不可恢复错误:内部逻辑矛盾,应立刻终止
fn divide(a: f64, b: f64) -> f64 {
if b == 0.0 {
panic!("division by zero"); // 程序员的 bug
}
a / b
}
2. thiserror vs anyhow 选型
| crate | 用途 | 适用场景 |
|---|---|---|
| thiserror | 定义结构化错误类型 | 库开发(API 暴露错误) |
| anyhow | 简化错误传播 | 应用开发(快速处理错误) |
2.1 thiserror:库级错误定义
use thiserror::Error;
#[derive(Error, Debug)]
pub enum DataError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("Parse error at line {line}: {source}")]
Parse {
line: usize,
#[source]
source: serde_json::Error,
},
#[error("Validation failed: {0}")]
Validation(String),
#[error("Not found: {resource}")]
NotFound { resource: String },
}
// 使用
fn load_data(path: &str) -> Result<Data, DataError> {
let content = std::fs::read_to_string(path)?; // 自动 from io::Error
let data: Data = serde_json::from_str(&content)
.map_err(|e| DataError::Parse { line: 42, source: e })?;
Ok(data)
}
2.2 anyhow:应用级快速错误
use anyhow::{Result, Context};
fn main() -> Result<()> {
let config = std::fs::read_to_string("config.json")
.with_context(|| "Failed to read config file")?;
let data: Config = serde_json::from_str(&config)
.context("Failed to parse config")?;
process(&data)?;
Ok(())
}
2.3 混合使用
// 库代码使用 thiserror
pub fn library_api() -> Result<Value, LibraryError> { ... }
// 应用代码使用 anyhow
fn main() -> anyhow::Result<()> {
let v = my_crate::library_api().context("library call failed")?;
Ok(())
}
2.5 何时使用 panic 而非 Result
Rust 社区有一个经典原则:panic 用于不可能发生的 bug,Result 用于可能发生的错误。但在实际编码中,这条界线有时模糊不清。
应当 panic 的场景:
| 场景 | 说明 | 示例 |
|---|---|---|
| 内部状态不一致 | 程序员的逻辑错误,正常流程不应触发 | 数组索引越界(若已做前置检查) |
| 前置条件违反 | API 调用方未按契约使用 | PoisonError(锁中毒表示内部 bug) |
| 类型系统无法表达的不变量 | 编译器无法静态保证 | 模式匹配遗漏(unreachable!()) |
应当返回 Result 的场景:
| 场景 | 说明 | 示例 |
|---|---|---|
| 外部系统故障 | 网络、磁盘、数据库都可能失败 | 文件读写、HTTP 请求 |
| 输入数据无效 | 用户输入、API 参数 | JSON 解析、表单验证 |
| 资源不足 | 内存、句柄可能耗尽 | 线程创建、连接池获取 |
// ✅ 外部错误用 Result
pub fn read_file(path: &Path) -> Result<String, io::Error> {
fs::read_to_string(path)
}
// ✅ 内部不一致用 panic(已做前置校验不应触发)
pub fn get_first_element(arr: &[i32]) -> i32 {
// 调用方已检查非空的前提下
*arr.first().expect("BUG: empty array passed after length check")
}
// ❌ 滥用 panic
pub fn parse_port(s: &str) -> u16 {
s.parse().expect("Invalid port") // 用户输入不应 panic!
}
// ✅ 正确做法
pub fn parse_port(s: &str) -> Result<u16, ParseError> {
s.parse().map_err(|_| ParseError::InvalidPort(s.to_string()))
}
最佳实践:库代码绝不 panic(让用户决定),仅在
debug_assert!中使用 panic;应用代码在启动阶段可以 panic(配置缺失直接退出),运行时一律用 Result。使用unwrap()前问自己:“这个值为 Err 或 None 是否意味着程序有 bug?”
3. Result 组合子
3.1 链路式处理
let user = fetch_user(id)
.map_err(|e| Error::Database(e))?
.ok_or(Error::UserNotFound)?
.validate()?
.into_dto();
3.2 map / and_then / or_else
let parsed: Result<i32, _> = "42".parse();
let doubled = parsed.map(|n| n * 2); // Ok(84)
let or_zero = parsed.unwrap_or(0); // 42
// 链式转换
let result = config
.get("timeout")
.and_then(|s| s.parse::<u64>().ok())
.unwrap_or(30);
3.3 收集多个错误
// 使用 itertools 收集所有验证错误
let errors: Vec<_> = inputs.iter()
.filter_map(|input| validate(input).err())
.collect();
if !errors.is_empty() {
return Err(Error::BatchValidation(errors));
}
4. Panic 边界与自定义 Hook
4.1 catch_unwind
use std::panic;
let result = panic::catch_unwind(|| {
may_panic()
});
match result {
Ok(val) => println!("Success: {}", val),
Err(_) => println!("Task panicked"),
}
⚠️
catch_unwind不捕获所有 panic(如abort模式),且不应作为常规错误处理。
4.2 自定义 Panic Hook
std::panic::set_hook(Box::new(|info| {
log::error!("Panic occurred: {}", info);
// 发送到 Sentry/Error Tracking
sentry::capture_event(sentry::protocol::Event {
message: Some(format!("{}", info)),
level: sentry::Level::Fatal,
..Default::default()
});
}));
4.3 不 panic 的 API 设计
// ❌ 容易 panic
pub fn get_unchecked(&self, index: usize) -> &T { &self.items[index] }
// ✅ 返回 Result
pub fn get(&self, index: usize) -> Result<&T, BoundsError> {
self.items.get(index).ok_or(BoundsError { index })
}
// ✅ 或返回 Option
pub fn try_get(&self, index: usize) -> Option<&T> {
self.items.get(index)
}
5. 测试金字塔
Rust 内置三种测试:
| 类型 | 位置 | 用途 |
|---|---|---|
| 单元测试 | 模块内 #[cfg(test)] | 测试单个函数/模块 |
| 集成测试 | tests/ 目录 | 测试公共 API 组合 |
| 文档测试 | /// 中的代码块 | 确保文档示例可运行 |
5.1 单元测试
// src/calculator.rs
pub fn add(a: i32, b: i32) -> i32 { a + b }
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_add() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn test_add_negative() {
assert_eq!(add(-2, -3), -5);
}
#[test]
#[should_panic(expected = "overflow")]
fn test_add_overflow() {
let _ = i32::MAX + 1; // debug 模式会 panic
}
}
5.2 集成测试
// tests/api_test.rs
use my_crate::Client;
#[tokio::test]
async fn test_create_user() {
let client = Client::new("http://localhost:3000");
let user = client.create_user("Alice").await.unwrap();
assert_eq!(user.name, "Alice");
}
5.3 文档测试
/// 计算两个数的最大公约数
/// ```
/// use my_crate::gcd;
/// assert_eq!(gcd(12, 8), 4);
/// ```
pub fn gcd(a: u64, b: u64) -> u64 {
if b == 0 { a } else { gcd(b, a % b) }
}
5.4 异步测试
#[tokio::test]
async fn test_async_fetch() {
let result = fetch_data().await.unwrap();
assert!(!result.is_empty());
}
#[tokio::test]
async fn test_timeout() {
let result = tokio::time::timeout(
Duration::from_secs(1),
slow_operation(),
).await;
assert!(result.is_err());
}
6. Mockall 模拟外部依赖
6.1 生成 Mock
use mockall::automock;
#[automock]
pub trait Database {
async fn get_user(&self, id: u64) -> Result<User, DbError>;
async fn save_user(&self, user: &User) -> Result<(), DbError>;
}
6.2 使用 Mock
#[tokio::test]
async fn test_user_service() {
let mut mock_db = MockDatabase::new();
mock_db
.expect_get_user()
.with(eq(42))
.times(1)
.returning(|_| Ok(User { id: 42, name: "Alice".to_string() }));
let service = UserService::new(mock_db);
let user = service.find_user(42).await.unwrap();
assert_eq!(user.name, "Alice");
}
6.3 序列与状态
mock_db
.expect_save_user()
.times(2)
.returning(|_| Ok(()));
// 验证调用顺序
let mut seq = Sequence::new();
mock_db.expect_get_user()
.times(1)
.in_sequence(&mut seq)
.returning(|_| Ok(User::default()));
mock_db.expect_save_user()
.times(1)
.in_sequence(&mut seq)
.returning(|_| Ok(()));
7. Property-based Testing
使用 proptest 自动生成随机输入,发现边界情况。
use proptest::prelude::*;
proptest! {
#[test]
fn test_reverse_reverse(xs: Vec<i32>) {
let mut rev = xs.clone();
rev.reverse();
rev.reverse();
prop_assert_eq!(xs, rev);
}
#[test]
fn test_add_commutative(a: i32, b: i32) {
prop_assert_eq!(a + b, b + a);
}
#[test]
fn test_sort_idempotent(mut xs: Vec<i32>) {
xs.sort();
let sorted_once = xs.clone();
xs.sort();
prop_assert_eq!(sorted_once, xs);
}
}
失败时自动最小化反例:
测试失败,最小化后的反例:xs = [3, 1, 2]
8. 代码覆盖率
8.1 tarpaulin
cargo install cargo-tarpaulin
# HTML 报告
cargo tarpaulin --out Html
# 忽略测试代码
# 在 Cargo.toml 中添加
# [package.metadata.tarpaulin]
# exclude-files = ["tests/*"]
8.2 llvm-cov
cargo install cargo-llvm-cov
# 生成并查看覆盖率
cargo llvm-cov --html
open target/llvm-cov/html/index.html
# CI 集成:lcov 格式
cargo llvm-cov --lcov --output-path lcov.info
8.3 CI 集成示例
# .github/workflows/test.yml
- name: Run tests with coverage
run: cargo llvm-cov --all-features --workspace --lcov --output-path lcov.info
- name: Upload coverage
uses: codecov/codecov-action@v3
with:
files: lcov.info
| 工具 | 用途 |
|---|---|
| thiserror | 结构化错误类型定义 |
| anyhow | 应用级错误快速传播 |
| mockall | 模拟 trait/外部依赖 |
| proptest | 属性/随机测试 |
| cargo-tarpaulin | 代码覆盖率分析 |
| cargo-llvm-cov | 基于 LLVM 的覆盖率 |
9. 错误处理设计模式
生产级 Rust 应用不仅需要正确的错误类型,还需要合理的错误处理策略,如重试、退避和断路器。
9.1 指数退避重试
use tokio::time::{sleep, Duration};
async fn retry_with_backoff<F, Fut, T, E>(
mut f: F,
max_retries: u32,
) -> Result<T, E>
where
F: FnMut() -> Fut,
Fut: std::future::Future<Output = Result<T, E>>,
E: std::fmt::Debug,
{
let mut attempts = 0;
loop {
match f().await {
Ok(val) => return Ok(val),
Err(e) if attempts < max_retries => {
attempts += 1;
let delay = Duration::from_millis(100 * 2_u64.pow(attempts));
eprintln!("Attempt {} failed: {:?}, retrying in {:?}", attempts, e, delay);
sleep(delay).await;
}
Err(e) => return Err(e),
}
}
}
// 使用
let result = retry_with_backoff(
|| fetch_data_from_api(),
5,
).await?;
9.2 断路器模式(Circuit Breaker)
use std::sync::atomic::{AtomicU32, Ordering};
use std::sync::Arc;
#[derive(Clone)]
pub struct CircuitBreaker {
failure_threshold: u32,
success_threshold: u32,
timeout: Duration,
state: Arc<AtomicU32>, // 0=Closed, 1=Open, 2=HalfOpen
failures: Arc<AtomicU32>,
}
impl CircuitBreaker {
pub async fn call<F, Fut, T>(&self, f: F) -> Result<T, CircuitBreakerError>
where
F: FnOnce() -> Fut,
Fut: std::future::Future<Output = Result<T, Box<dyn std::error::Error>>>,
{
if self.state.load(Ordering::SeqCst) == 1 {
return Err(CircuitBreakerError::Open);
}
match f().await {
Ok(val) => {
self.failures.store(0, Ordering::SeqCst);
if self.state.load(Ordering::SeqCst) == 2 {
self.state.store(0, Ordering::SeqCst); // HalfOpen → Closed
}
Ok(val)
}
Err(_) => {
let fails = self.failures.fetch_add(1, Ordering::SeqCst) + 1;
if fails >= self.failure_threshold {
self.state.store(1, Ordering::SeqCst); // Open
}
Err(CircuitBreakerError::Failure)
}
}
}
}
设计原则:库代码返回具体错误(thiserror),应用代码决定错误处理策略(anyhow + 重试/断路器)。永远不要在库中悄悄吞掉错误,也不要让应用层被迫处理每个底层错误细节。
延伸阅读
- https://plumephp.com/rust-desktop-wasm/ — Rust WebAssembly 与桌面开发
- https://plumephp.com/rust-systems-programming/ — Rust 系统级编程
- https://plumephp.com/python-typing-pydantic/ — 对比 Pydantic 与 Rust 类型系统
- https://plumephp.com/event-driven-architecture-domain-events/ — 异步事件处理中的错误传播
Rust 的测试生态提供了从单元测试到覆盖率的全链路支持,配合编译期类型检查,可以在部署前捕获绝大多数 bug。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。