Rust 错误处理实战:anyhow + thiserror 的黄金搭档

适合人群:已经写过几百行 Rust 但看到 Box<dyn Error> 就头皮发麻的同学。读完本文,你将掌握一套工程级错误处理范式,彻底告别"我也不知道这里会报什么错"的状态。


为什么你需要了解它?(Why)

刚学 Rust 时,很多人的第一反应是:错误处理好麻烦。

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 实现快把人搞崩
  • 所有人:出了问题,错误信息得有上下文,不然排查问题像大海捞针

这正是 thiserroranyhow 诞生的原因,它们来自同一位作者(David Tolnay),天生一对,分工明确:

版本 定位
thiserror 2.0.18 为**库(library)**定义精确的错误类型,减少样板代码
anyhow 1.0.102 为**应用(application)**方便地传播和追踪错误链

它到底是什么?(What)

thiserror:错误类型的自动打工仔

手动实现一个错误类型,你需要写 std::fmt::Displaystd::error::Error,有时还要写一堆 From<SomeOtherError>thiserror 用派生宏帮你全干了。

rust 复制代码
use thiserror::Error;

#[derive(Debug, Error)]
pub enum MyError {
    #[error("文件读取失败: {0}")]
    Io(#[from] std::io::Error),
    
    #[error("配置字段缺失: {field}")]
    MissingField { field: String },
}

三行属性宏,DisplayErrorFrom<io::Error> 全部到位。调用方可以精准地 match MyError::MissingField { .. } 处理不同情况,这是库设计的基本礼貌。

anyhow:错误传播的瑞士军刀

anyhow 提供一个 anyhow::Error 类型,它能包裹任何实现了 std::error::Error 的错误,还能附加上下文信息。

rust 复制代码
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)
}

出错时,你会看到完整的错误链:

lua 复制代码
Error: 无法读取配置文件: config.toml

Caused by:
    No such file or directory (os error 2)

工作原理图

rust 复制代码
应用层 (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 月最新稳定版):

toml 复制代码
[dependencies]
anyhow = "1.0"
thiserror = "2.0"

场景一:为库定义精确错误类型

假设你在写一个用户认证库:

rust 复制代码
// 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 精确处理每种情况:

rust 复制代码
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 大显身手:

rust 复制代码
// 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 包裹并附加上下文:

rust 复制代码
// === 库侧:精确定义 ===
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(())
}

错误发生时的输出:

ini 复制代码
处理请求时无法找到用户 0

Caused by:
    记录不存在: id=0

场景四:axum Web 服务的错误处理

axum 的 handler 函数要求返回值中的错误类型必须实现 IntoResponse trait,这意味着你不能直接在 handler 里用 anyhow::Result------因为 anyhow::Error 不知道怎么变成一个 HTTP 响应。

标准解法是:用 thiserror 定义业务错误枚举,再为它实现 IntoResponse,让错误自动映射到对应的 HTTP 状态码。

依赖配置:

toml 复制代码
[dependencies]
anyhow = "1.0"
thiserror = "2.0"
axum = "0.8"
tokio = { version = "1", features = ["full"] }
serde_json = "1"
http = "1"

错误类型定义(src/errors.rs):

rust 复制代码
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):

rust 复制代码
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))
}

客户端收到的响应示例:

json 复制代码
// GET /users/999 → 404
{ "error": "用户 999 不存在" }

// GET /users/-1 → 400
{ "error": "user_id 必须为正整数,收到: -1" }

// 数据库宕机 → 500(不暴露内部细节)
{ "error": "内部服务器错误" }

设计要点AppError 是应用层的"错误路由器",决定哪类错误给客户端什么反馈。数据库错误和内部错误只写 tracing 日志,对外只说"内部服务器错误"------这是生产级 API 的标准安全实践。


场景五:sqlx 数据库操作的错误集成

sqlx 的错误类型 sqlx::Error 是一个枚举,包含了连接失败、行不存在、约束冲突等多种情况。在实际项目中,最常见的需求是:RowNotFound 映射为业务层的"未找到"错误,其他错误透传。

依赖配置:

toml 复制代码
[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):

rust 复制代码
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):

rust 复制代码
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 聚合错误并添加业务上下文:

rust 复制代码
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
}

整体数据流示意:

rust 复制代码
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,不要让它变成 500sqlx::Error::RowNotFound 是正常的业务情况,不是系统故障。用 map_err 显式转换。

  • 不要在库的公共 API 里用 anyhow::Error :调用方无法 match,等于告诉别人"出了错你自己看着办",是库设计的大忌。

  • 不要滥用 unwrap()expect() :生产代码中,expect("这里绝对不会出错") 是程序在挑衅墨菲定律。

  • 不要在热路径上用 context() 拼接昂贵字符串 :改用 with_context(|| ...) 让字符串构造只在出错时发生。


常见误区与避坑指南

误区 正确理解 解决方案
"我的 crate 既是库也是二进制,用哪个?" 分层处理:lib.rsthiserror 定义类型,main.rsanyhow 收集 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 }

进阶资源


小结

一句话总结这套搭档的哲学:给别人用的代码,精确定义错误;给自己用的代码,方便传播即可。

  • 写库/Repository 层 → thiserror:让调用者能精准匹配,让错误信息足够描述问题
  • 写 Service/应用层 → anyhow:用 ? + .context() 打通全部错误通道,出问题有完整链路
  • 写 axum handler → AppError + IntoResponse:错误自动映射 HTTP 状态码,业务与协议解耦
  • 处理 sqlx → 显式匹配 RowNotFound,用辅助函数将数据库错误转为语义化业务错误

Rust 的错误处理确实比其他语言啰嗦,但这些"啰嗦"迫使你在写代码时想清楚:这里会出什么错?调用方应该如何处理?这恰好是很多 bug 在编译期就被消灭的原因。用好 anyhow + thiserror,你会发现 Rust 的错误处理不是负担,而是护城河。

相关推荐
Zarek枫煜3 小时前
C3 编程语言 - 现代 C 的进化之选
c语言·开发语言·青少年编程·rust·游戏引擎
咚为9 小时前
Rust 经典面试题255道
开发语言·面试·rust
@atweiwei10 小时前
用 Rust 构建 LLM 应用的高性能框架
开发语言·后端·ai·rust·langchain·llm
chrislearn13 小时前
Salvo 为什么不采用宏式路由
rust
Amos_Web1 天前
Solana开发(1)- 核心概念扫盲篇&&扫雷篇
前端·rust·区块链
golang学习记2 天前
VS Code官宣:全面支持Rust!
开发语言·vscode·后端·rust
叹一曲当时只道是寻常2 天前
Tauri v2 + Rust 实现 MCP Inspector 桌面应用:进程管理、Token 捕获与跨平台踩坑全记录
开发语言·后端·rust
怪我冷i2 天前
Rust错误处理之unwrap
rust·cloudflare·unwrap
楚国的小隐士3 天前
为什么说Rust是对自闭症谱系人士友好的编程语言?
java·rust·编程·对比·自闭症·自闭症谱系障碍·神经多样性