Rust 错误处理实战:anyhow + thiserror 的黄金搭档
适合人群:已经写过几百行 Rust 但看到
Box<dyn Error>就头皮发麻的同学。读完本文,你将掌握一套工程级错误处理范式,彻底告别"我也不知道这里会报什么错"的状态。
为什么你需要了解它?(Why)
刚学 Rust 时,很多人的第一反应是:错误处理好麻烦。
// 新手三件套:能用就行,别来烦我
fn read_config() -> Result<Config, Box<dyn std::error::Error>> {
let content = std::fs::read_to_string("config.toml")?;
let config: Config = toml::from_str(&content)?;
Ok(config)
}
Box<dyn Error> 就像一个万能收纳袋——什么错误都能塞进去,但等你要取出来细看的时候,里面一团乱麻,调用方根本不知道该如何处理。
更大的痛点出现在项目规模增长后:
-
库的调用者:你的错误类型应该精确,让我能
match不同情况 -
应用层开发者:我要整合来自文件、网络、数据库的各路错误,手动写
From实现快把人搞崩 - 所有人:出了问题,错误信息得有上下文,不然排查问题像大海捞针
这正是 thiserror 和 anyhow 诞生的原因,它们来自同一位作者(David Tolnay),天生一对,分工明确:
| 库 | 版本 | 定位 |
|---|---|---|
thiserror |
2.0.18 | 为**库(library)**定义精确的错误类型,减少样板代码 |
anyhow |
1.0.102 | 为**应用(application)**方便地传播和追踪错误链 |
它到底是什么?(What)
thiserror:错误类型的自动打工仔
手动实现一个错误类型,你需要写 std::fmt::Display、std::error::Error,有时还要写一堆 From<SomeOtherError>。thiserror 用派生宏帮你全干了。
use thiserror::Error;
#[derive(Debug, Error)]
pub enum MyError {
#[error("文件读取失败: {0}")]
Io(#[from] std::io::Error),
#[error("配置字段缺失: {field}")]
MissingField { field: String },
}
三行属性宏,Display、Error、From<io::Error> 全部到位。调用方可以精准地 match MyError::MissingField { .. } 处理不同情况,这是库设计的基本礼貌。
anyhow:错误传播的瑞士军刀
anyhow 提供一个 anyhow::Error 类型,它能包裹任何实现了 std::error::Error 的错误,还能附加上下文信息。
use anyhow::{Context, Result};
fn load_config(path: &str) -> Result<Config> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("无法读取配置文件: {path}"))?;
let config: Config = toml::from_str(&content)
.context("配置文件格式错误")?;
Ok(config)
}
出错时,你会看到完整的错误链:
Error: 无法读取配置文件: config.toml
Caused by:
No such file or directory (os error 2)
工作原理图
应用层 (main.rs / bin/) 库层 (lib.rs)
┌─────────────────────────┐ ┌─────────────────────────┐
│ │ │ #[derive(Error)] │
│ anyhow::Result<T> │◄────┤ pub enum DbError { .. } │
│ .context("做某事时") │ │ pub enum ParseError {..}│
│ ? 自动转换 │ │ │
│ │ │ 精确、可匹配、有文档 │
└─────────────────────────┘ └─────────────────────────┘
↓ 错误链自动串联
Error: 保存用户时出错
Caused by: 数据库写入失败
Caused by: connection refused
怎么用?(How)
快速上手:环境准备
在 Cargo.toml 中添加依赖(截至 2026 年 4 月最新稳定版):
[dependencies]
anyhow = "1.0"
thiserror = "2.0"
场景一:为库定义精确错误类型
假设你在写一个用户认证库:
// src/lib.rs
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AuthError {
/// 数据库操作失败,透传底层原因
#[error("数据库错误")]
Database(#[from] sqlx::Error),
/// 密码不匹配,不暴露更多细节(安全考虑)
#[error("用户名或密码错误")]
InvalidCredentials,
/// Token 过期,附带过期时间信息
#[error("Token 已过期,过期时间: {expired_at}")]
TokenExpired { expired_at: String },
/// 包裹其他任意错误(透明转发,直接展示内部错误的 Display)
#[error(transparent)]
Other(#[from] anyhow::Error),
}
pub fn login(username: &str, password: &str) -> Result<String, AuthError> {
if password.is_empty() {
return Err(AuthError::InvalidCredentials);
}
Ok(format!("token_for_{username}"))
}
调用方可以 match 精确处理每种情况:
match login("alice", "") {
Ok(token) => println!("登录成功: {token}"),
Err(AuthError::InvalidCredentials) => println!("账号或密码有误,请重试"),
Err(AuthError::TokenExpired { expired_at }) => {
println!("Token 于 {expired_at} 过期,请重新登录")
}
Err(e) => println!("系统错误: {e}"),
}
场景二:应用层用 anyhow 串联多源错误
应用层常常需要整合来自不同库的错误,这时 anyhow 大显身手:
// src/main.rs
use anyhow::{Context, Result, bail, ensure};
fn setup_app() -> Result<()> {
// context: 静态字符串,直接传入,成本极低
let config = std::fs::read_to_string("config.toml")
.context("读取配置文件失败")?;
// with_context: 闭包,只在出错时执行,适合有格式化开销的消息
let port: u16 = config.trim().parse()
.with_context(|| format!("配置内容 '{config}' 无法解析为端口号"))?;
// ensure!: 断言式错误,条件为 false 时直接返回 Err
ensure!(port > 1024, "端口号 {port} 需大于 1024,当前不支持特权端口");
// bail!: 无条件构造并返回一个 anyhow::Error
if port == 8080 {
bail!("端口 8080 已被其他服务占用,请换一个");
}
println!("服务启动在端口 {port}");
Ok(())
}
fn main() {
if let Err(e) = setup_app() {
eprintln!("启动失败: {e:?}"); // {:?} 会打印完整错误链
std::process::exit(1);
}
}
场景三:库与应用的完整协作示例
这是最接近真实项目的组合拳——库用 thiserror 定义错误,应用用 anyhow 包裹并附加上下文:
// === 库侧:精确定义 ===
use thiserror::Error;
#[derive(Debug, Error)]
pub enum DbError {
#[error("连接失败: {addr}")]
ConnectionFailed { addr: String },
#[error("记录不存在: id={id}")]
NotFound { id: u64 },
#[error("IO 错误")]
Io(#[from] std::io::Error),
}
pub fn find_user(id: u64) -> Result<String, DbError> {
if id == 0 {
return Err(DbError::NotFound { id });
}
Ok(format!("user_{id}"))
}
// === 应用侧:灵活传播 ===
use anyhow::{Context, Result};
fn handle_request(user_id: u64) -> Result<()> {
let user = find_user(user_id)
.with_context(|| format!("处理请求时无法找到用户 {user_id}"))?;
println!("处理用户: {user}");
Ok(())
}
错误发生时的输出:
处理请求时无法找到用户 0
Caused by:
记录不存在: id=0
场景四:axum Web 服务的错误处理
axum 的 handler 函数要求返回值中的错误类型必须实现 IntoResponse trait,这意味着你不能直接在 handler 里用 anyhow::Result——因为 anyhow::Error 不知道怎么变成一个 HTTP 响应。
标准解法是:用 thiserror 定义业务错误枚举,再为它实现 IntoResponse,让错误自动映射到对应的 HTTP 状态码。
依赖配置:
[dependencies]
anyhow = "1.0"
thiserror = "2.0"
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde_json = "1"
http = "1"
错误类型定义(src/errors.rs):
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
use serde_json::json;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum AppError {
/// 资源未找到(→ 404)
#[error("资源未找到: {0}")]
NotFound(String),
/// 请求参数非法(→ 400)
#[error("请求参数错误: {0}")]
BadRequest(String),
/// 未授权(→ 401)
#[error("未授权,请先登录")]
Unauthorized,
/// 数据库错误(→ 500,不向客户端暴露细节)
#[error("数据库内部错误")]
Database(#[from] sqlx::Error),
/// 兜底:其他所有错误(→ 500)
#[error("内部服务器错误")]
Internal(#[from] anyhow::Error),
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
let (status, message) = match &self {
AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()),
AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()),
AppError::Unauthorized => (StatusCode::UNAUTHORIZED, self.to_string()),
// 数据库错误和内部错误:记录日志,但不向外暴露实现细节
AppError::Database(e) => {
tracing::error!("数据库错误: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, "内部服务器错误".to_string())
}
AppError::Internal(e) => {
tracing::error!("内部错误: {e:?}");
(StatusCode::INTERNAL_SERVER_ERROR, "内部服务器错误".to_string())
}
};
// 统一返回 JSON 格式的错误响应
(status, Json(json!({ "error": message }))).into_response()
}
}
// 为 anyhow::Error 提供便捷的 From 转换,让 ? 在 handler 里可以直接用
impl From<anyhow::Error> for AppError {
fn from(e: anyhow::Error) -> Self {
AppError::Internal(e)
}
}
在 handler 中使用(src/handlers.rs):
use axum::{extract::{Path, State}, Json};
use sqlx::PgPool;
use crate::errors::AppError;
// handler 返回 Result<Json<T>, AppError>,出错时 AppError 自动转成 HTTP 响应
pub async fn get_user(
State(pool): State<PgPool>,
Path(user_id): Path<i64>,
) -> Result<Json<User>, AppError> {
if user_id <= 0 {
return Err(AppError::BadRequest(format!("user_id 必须为正整数,收到: {user_id}")));
}
let user = sqlx::query_as::<_, User>("SELECT * FROM users WHERE id = $1")
.bind(user_id)
.fetch_one(&pool)
.await
// sqlx::Error::RowNotFound → 手动转换为 404,其他 sqlx 错误 → 500
.map_err(|e| match e {
sqlx::Error::RowNotFound => AppError::NotFound(format!("用户 {user_id} 不存在")),
other => AppError::Database(other),
})?;
Ok(Json(user))
}
pub async fn create_user(
State(pool): State<PgPool>,
Json(payload): Json<CreateUserRequest>,
) -> Result<Json<User>, AppError> {
// 业务逻辑中可以用 anyhow::Context 添加追踪信息,最终被 Internal 变体包裹
let user = do_create_user(&pool, payload)
.await
.map_err(AppError::Internal)?;
Ok(Json(user))
}
客户端收到的响应示例:
// GET /users/999 → 404
{ "error": "用户 999 不存在" }
// GET /users/-1 → 400
{ "error": "user_id 必须为正整数,收到: -1" }
// 数据库宕机 → 500(不暴露内部细节)
{ "error": "内部服务器错误" }
设计要点:
AppError是应用层的"错误路由器",决定哪类错误给客户端什么反馈。数据库错误和内部错误只写 tracing 日志,对外只说"内部服务器错误"——这是生产级 API 的标准安全实践。
场景五:sqlx 数据库操作的错误集成
sqlx 的错误类型 sqlx::Error 是一个枚举,包含了连接失败、行不存在、约束冲突等多种情况。在实际项目中,最常见的需求是:把 RowNotFound 映射为业务层的"未找到"错误,其他错误透传。
依赖配置:
[dependencies]
anyhow = "1.0"
thiserror = "2.0"
sqlx = { version = "0.8", features = ["postgres", "runtime-tokio", "macros"] }
tokio = { version = "1", features = ["full"] }
定义数据访问层的错误类型(src/db/error.rs):
use thiserror::Error;
#[derive(Debug, Error)]
pub enum RepoError {
/// 记录不存在(从 RowNotFound 显式转换而来)
#[error("{entity} 未找到 (id={id})")]
NotFound { entity: &'static str, id: i64 },
/// 唯一约束冲突(如重复邮箱)
#[error("{field} 已被占用: {value}")]
UniqueViolation { field: String, value: String },
/// 其他所有数据库错误,透传原始信息
#[error("数据库错误: {0}")]
Sqlx(#[from] sqlx::Error),
}
/// 将 sqlx::Error 转换为更具语义的 RepoError
/// 使用辅助函数而非 From,因为需要额外的上下文参数
pub fn map_sqlx_err(e: sqlx::Error, entity: &'static str, id: i64) -> RepoError {
match e {
sqlx::Error::RowNotFound => RepoError::NotFound { entity, id },
sqlx::Error::Database(db_err) => {
// PostgreSQL 唯一约束违反错误码为 "23505"
if db_err.code().as_deref() == Some("23505") {
let constraint = db_err.constraint().unwrap_or("unknown");
return RepoError::UniqueViolation {
field: constraint.to_string(),
value: String::new(), // 实际项目中可从 payload 中提取
};
}
RepoError::Sqlx(sqlx::Error::Database(db_err))
}
other => RepoError::Sqlx(other),
}
}
数据访问层实现(src/db/user_repo.rs):
use sqlx::PgPool;
use anyhow::Context;
use crate::db::error::{RepoError, map_sqlx_err};
pub struct User {
pub id: i64,
pub name: String,
pub email: String,
}
pub async fn find_by_id(pool: &PgPool, id: i64) -> Result<User, RepoError> {
sqlx::query_as!(User, "SELECT id, name, email FROM users WHERE id = $1", id)
.fetch_one(pool)
.await
// 用辅助函数做精确的错误映射
.map_err(|e| map_sqlx_err(e, "User", id))
}
pub async fn find_by_email(pool: &PgPool, email: &str) -> Result<Option<User>, RepoError> {
// fetch_optional 不会产生 RowNotFound,直接用 #[from] 转换即可
let user = sqlx::query_as!(
User,
"SELECT id, name, email FROM users WHERE email = $1",
email
)
.fetch_optional(pool)
.await?; // sqlx::Error 经 #[from] 自动转为 RepoError::Sqlx
Ok(user)
}
pub async fn create(pool: &PgPool, name: &str, email: &str) -> Result<User, RepoError> {
sqlx::query_as!(
User,
"INSERT INTO users (name, email) VALUES ($1, $2) RETURNING id, name, email",
name,
email
)
.fetch_one(pool)
.await
.map_err(|e| map_sqlx_err(e, "User", 0))
}
在 Service 层用 anyhow 聚合错误并添加业务上下文:
use anyhow::{Context, Result};
use crate::db::{user_repo, error::RepoError};
pub async fn register_user(pool: &PgPool, name: &str, email: &str) -> Result<User> {
// RepoError 实现了 std::error::Error,可自动转换为 anyhow::Error
let existing = user_repo::find_by_email(pool, email)
.await
.with_context(|| format!("注册时检查邮箱 {email} 是否已存在失败"))?;
if existing.is_some() {
// 业务层直接用 anyhow::bail! 抛出业务错误
anyhow::bail!("邮箱 {email} 已被注册");
}
let user = user_repo::create(pool, name, email)
.await
.with_context(|| format!("创建用户 {name} 失败"))?;
Ok(user)
}
pub async fn get_user_profile(pool: &PgPool, user_id: i64) -> Result<User, RepoError> {
// Service 层也可以直接返回 RepoError,让上层(如 axum handler)做 HTTP 映射
user_repo::find_by_id(pool, user_id).await
}
整体数据流示意:
HTTP 请求
│
▼
axum Handler
│ 返回 Result<Json<T>, AppError>
│
▼
Service 层
│ 返回 anyhow::Result<T> 或 Result<T, RepoError>
│ 用 .with_context() 附加业务上下文
│
▼
Repository 层 (sqlx)
│ 返回 Result<T, RepoError>
│ RowNotFound → RepoError::NotFound
│ 23505 → RepoError::UniqueViolation
│ 其他 → RepoError::Sqlx
│
▼
PostgreSQL
小技巧:
sqlx::query_as!宏在编译期会检查 SQL 语句和返回类型是否匹配(需要设置DATABASE_URL环境变量),相当于把一部分运行时错误提前到了编译期消灭——和 Rust 的错误处理哲学一脉相承。
最佳实践
-
✅ 库用
thiserror,应用用anyhow:这是社区最广泛认可的分界线。库的调用者需要精确匹配错误,应用层只需要传播并展示。 -
✅ 用
with_context代替context处理含格式化的消息:context("msg")无论是否出错都会立即执行参数表达式;with_context(|| ...)是懒求值,只在真正出错时才执行闭包,性能更好。 -
✅ axum handler 统一返回
Result<T, AppError>,由AppError决定 HTTP 状态码:业务逻辑与 HTTP 协议解耦,handler 代码干净清晰。 -
✅ 数据库错误不要直接透传给 HTTP 客户端:
sqlx::Error可能包含表结构、SQL 语句等敏感信息,在IntoResponse实现里要过滤,只记日志不对外暴露。 -
✅
RowNotFound要主动映射为 404,不要让它变成 500:sqlx::Error::RowNotFound是正常的业务情况,不是系统故障。用map_err显式转换。 -
❌ 不要在库的公共 API 里用
anyhow::Error:调用方无法match,等于告诉别人"出了错你自己看着办",是库设计的大忌。 -
❌ 不要滥用
unwrap()和expect():生产代码中,expect("这里绝对不会出错")是程序在挑衅墨菲定律。 -
❌ 不要在热路径上用
context()拼接昂贵字符串:改用with_context(|| ...)让字符串构造只在出错时发生。
常见误区与避坑指南
| 误区 | 正确理解 | 解决方案 |
|---|---|---|
| "我的 crate 既是库也是二进制,用哪个?" | 分层处理:lib.rs 用 thiserror 定义类型,main.rs 用 anyhow 收集 |
在 Cargo.toml 里同时声明两者,按层使用 |
"升级到 thiserror 2.0 后编译报错" |
2.0 是重大版本,有 breaking change:{r#type} 格式需改为 {type},且使用方必须直接依赖 thiserror
|
检查格式字符串中的原始标识符,确保 Cargo.toml 里有直接依赖 |
"axum handler 里能直接用 anyhow::Result 吗?" |
不能。anyhow::Error 没有实现 IntoResponse,axum 不知道怎么把它变成 HTTP 响应 |
定义 AppError 包裹 anyhow::Error 并实现 IntoResponse
|
"sqlx::Error::RowNotFound 导致用户收到 500" |
RowNotFound 是业务错误,不应产生 500 |
在 map_err 里手动匹配 RowNotFound,转为 AppError::NotFound
|
"anyhow::Error 能不能反向提取原始错误?" |
可以,用 err.downcast_ref::<MyError>()
|
anyhow 内部保留了类型信息,可以 downcast 回具体类型 |
| "错误信息怎么显示完整调用链?" |
println!("{:?}", err) 会打印 Debug 含调用链;println!("{}", err) 只打印顶层 |
调试时用 {:#?} 或 {:?},用户展示时用 {}
|
| "no_std 环境能用吗?" |
thiserror 2.0 支持 no_std
|
thiserror = { version = "2", default-features = false } |
进阶资源
- anyhow 官方文档
- thiserror 官方文档
- thiserror 2.0.0 Release Notes — 升级前必读
- realworld-axum-sqlx — sqlx 官方出品的真实项目,错误处理范式值得参考
- Rust Book: Error Handling — 官方基础
- Error Handling in Rust(Jon Gjengset) — 深入视频讲解
- 书籍推荐:《Zero To Production In Rust》— 有大量 axum + sqlx 真实项目中的错误处理实践
小结
一句话总结这套搭档的哲学:给别人用的代码,精确定义错误;给自己用的代码,方便传播即可。
- 写库/Repository 层 →
thiserror:让调用者能精准匹配,让错误信息足够描述问题 - 写 Service/应用层 →
anyhow:用?+.context()打通全部错误通道,出问题有完整链路 - 写 axum handler →
AppError+IntoResponse:错误自动映射 HTTP 状态码,业务与协议解耦 - 处理 sqlx → 显式匹配
RowNotFound,用辅助函数将数据库错误转为语义化业务错误
Rust 的错误处理确实比其他语言啰嗦,但这些"啰嗦"迫使你在写代码时想清楚:这里会出什么错?调用方应该如何处理?这恰好是很多 bug 在编译期就被消灭的原因。用好 anyhow + thiserror,你会发现 Rust 的错误处理不是负担,而是护城河。