不灭的焱

革命尚未成功,同志仍须努力 下载Java21

作者:AlbertWen  添加时间:2026-07-14 10:39:56  修改时间:2026-07-21 23:38:27  分类:01.Rust编程  编辑

目录

仓库地址:https://crates.io/crates/thiserror

Rust thiserror 详细使用教程

thiserror 是 Rust 生态中用于定义结构化自定义错误类型的常用库。它通过 #[derive(Error)] 自动生成:

  • std::error::Error 实现
  • Display 实现
  • 部分 From<T> 实现
  • 错误来源链 source()

截至 2026 年 7 月,crates.io 上的当前版本是 thiserror 2.0.18。官方提供的派生属性主要包括 #[error]#[from]#[source]#[backtrace]。(crates.io)

一、thiserror 解决什么问题

假设我们不使用 thiserror,要定义一个自定义错误:

use std::fmt;

#[derive(Debug)]
pub enum AppError {
    NotFound(String),
    InvalidInput(String),
}

impl fmt::Display for AppError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            AppError::NotFound(name) => {
                write!(f, "资源不存在:{name}")
            }
            AppError::InvalidInput(message) => {
                write!(f, "输入参数错误:{message}")
            }
        }
    }
}

impl std::error::Error for AppError {}

代码能用,但每定义一个错误枚举,都要手动实现:

Display
Error
From
source

使用 thiserror 后可以简化为:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum AppError {
    #[error("资源不存在:{0}")]
    NotFound(String),

    #[error("输入参数错误:{0}")]
    InvalidInput(String),
}

thiserror 本质上是帮你生成标准 Rust 错误 trait 的实现;它不会把特殊的 thiserror 类型暴露在你的公开 API 中。因此,将手写实现改成 thiserror,通常不会改变调用方看到的错误接口。(Docs.rs)

二、安装 thiserror

1. 使用 cargo 命令

cargo add thiserror

会在 Cargo.toml 中增加类似配置:

[dependencies]
thiserror = "2"

也可以指定当前具体版本:

[dependencies]
thiserror = "2.0.18"

通常推荐写主版本:

thiserror = "2"

这样允许 Cargo 自动采用兼容的 2.x 新版本。

2. 最小项目示例

项目结构:

thiserror-demo/
├── Cargo.toml
└── src/
    └── main.rs

Cargo.toml

[package]
name = "thiserror-demo"
version = "0.1.0"
edition = "2024"

[dependencies]
thiserror = "2"

src/main.rs

use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error("用户不存在:{0}")]
    UserNotFound(u64),

    #[error("用户名不能为空")]
    EmptyUsername,
}

fn find_user(user_id: u64) -> Result<String, AppError> {
    if user_id == 1 {
        Ok("Albert".to_string())
    } else {
        Err(AppError::UserNotFound(user_id))
    }
}

fn main() {
    match find_user(100) {
        Ok(username) => {
            println!("找到用户:{username}");
        }
        Err(error) => {
            println!("查询失败:{error}");
            println!("调试信息:{error:?}");
        }
    }
}

运行:

cargo run

输出:

查询失败:用户不存在:100
调试信息:UserNotFound(100)

这里:

println!("{error}");

调用的是自动生成的 Display

而:

println!("{error:?}");

调用的是 Debug

因此,错误类型一般同时派生:

#[derive(Debug, Error)]

三、#[error(...)] 的基本用法

#[error(...)] 用于定义错误显示文本,thiserror 会据此生成 Display 实现。它支持单元变体、元组变体、具名字段变体以及结构体错误类型。(Docs.rs)

1. 不带字段的错误

use thiserror::Error;

#[derive(Debug, Error)]
enum ConfigError {
    #[error("配置文件不存在")]
    FileNotFound,

    #[error("配置内容为空")]
    Empty,
}

使用:

fn load_config() -> Result<(), ConfigError> {
    Err(ConfigError::FileNotFound)
}

fn main() {
    let error = load_config().unwrap_err();
    println!("{error}");
}

输出:

配置文件不存在

这种变体适合不需要附加上下文数据的错误。

2. 元组字段:使用 {0}{1}

use thiserror::Error;

#[derive(Debug, Error)]
enum UserError {
    #[error("用户不存在,用户 ID:{0}")]
    NotFound(u64),

    #[error("字段 `{0}` 的值 `{1}` 不合法")]
    InvalidField(String, String),
}

使用:

fn validate_age(age: i32) -> Result<(), UserError> {
    if age < 0 {
        return Err(UserError::InvalidField(
            "age".to_string(),
            age.to_string(),
        ));
    }

    Ok(())
}

对应关系:

InvalidField(String, String)
             │       │
             │       └── {1}
             └────────── {0}

3. 具名字段

use thiserror::Error;

#[derive(Debug, Error)]
enum ValidationError {
    #[error("字段 `{field}` 的值 `{value}` 不合法:{reason}")]
    InvalidValue {
        field: String,
        value: String,
        reason: String,
    },
}

创建错误:

fn validate_email(email: &str) -> Result<(), ValidationError> {
    if !email.contains('@') {
        return Err(ValidationError::InvalidValue {
            field: "email".to_string(),
            value: email.to_string(),
            reason: "缺少 @ 符号".to_string(),
        });
    }

    Ok(())
}

调用:

fn main() {
    if let Err(error) = validate_email("abc.example.com") {
        println!("{error}");
    }
}

输出:

字段 `email` 的值 `abc.example.com` 不合法:缺少 @ 符号

具名字段通常比多个元组字段可读性更好。

例如:

InvalidValue(String, String, String)

不如:

InvalidValue {
    field: String,
    value: String,
    reason: String,
}

4. 使用格式化参数

#[error(...)] 使用的是 Rust 格式化语法,因此支持:

{}
{:?}
{:#?}
{:x}
{:02}

示例:

use thiserror::Error;

#[derive(Debug, Error)]
enum ProtocolError {
    #[error("收到未知状态码:0x{code:x}")]
    UnknownCode { code: u32 },

    #[error("请求头不合法,期望值:{expected:?},实际值:{actual:?}")]
    InvalidHeader {
        expected: Vec<String>,
        actual: Vec<String>,
    },
}

四、在函数中返回 thiserror 错误

自定义错误类型通常和 Result<T, E> 配合:

use thiserror::Error;

#[derive(Debug, Error)]
enum LoginError {
    #[error("用户名不能为空")]
    EmptyUsername,

    #[error("密码长度不能少于 {minimum} 位,实际为 {actual} 位")]
    PasswordTooShort {
        minimum: usize,
        actual: usize,
    },

    #[error("用户名或密码错误")]
    InvalidCredentials,
}

fn login(username: &str, password: &str) -> Result<(), LoginError> {
    if username.trim().is_empty() {
        return Err(LoginError::EmptyUsername);
    }

    if password.len() < 8 {
        return Err(LoginError::PasswordTooShort {
            minimum: 8,
            actual: password.len(),
        });
    }

    if username != "admin" || password != "12345678" {
        return Err(LoginError::InvalidCredentials);
    }

    Ok(())
}

fn main() {
    match login("admin", "123") {
        Ok(()) => println!("登录成功"),
        Err(error) => println!("登录失败:{error}"),
    }
}

输出:

登录失败:密码长度不能少于 8 位,实际为 3 位

五、#[from]:自动转换底层错误

这是 thiserror 最常用的功能之一。

假设读取文件时,底层返回:

std::io::Error

解析整数时,底层返回:

std::num::ParseIntError

我们希望业务函数统一返回:

ConfigError

可以使用 #[from]

1. 基本示例

use std::fs;
use std::num::ParseIntError;
use thiserror::Error;

#[derive(Debug, Error)]
enum ConfigError {
    #[error("读取配置文件失败:{0}")]
    Io(#[from] std::io::Error),

    #[error("端口格式错误:{0}")]
    InvalidPort(#[from] ParseIntError),
}

fn read_port(path: &str) -> Result<u16, ConfigError> {
    let content = fs::read_to_string(path)?;

    let port = content.trim().parse::<u16>()?;

    Ok(port)
}

这里:

fs::read_to_string(path)?

返回类型是:

Result<String, std::io::Error>

但是当前函数要求:

Result<u16, ConfigError>

由于写了:

Io(#[from] std::io::Error)

thiserror 会生成大致如下实现:

impl From<std::io::Error> for ConfigError {
    fn from(error: std::io::Error) -> Self {
        ConfigError::Io(error)
    }
}

所以 ? 可以自动完成:

std::io::Error
    ↓ From
ConfigError::Io

同理:

content.trim().parse::<u16>()?

会把:

ParseIntError

转换成:

ConfigError::InvalidPort

#[from] 字段还会被视为错误来源,因此通常不需要再同时写 #[source]。(Docs.rs)

2. 等价的手动写法

没有 #[from] 时:

fn read_port(path: &str) -> Result<u16, ConfigError> {
    let content = fs::read_to_string(path)
        .map_err(ConfigError::Io)?;

    let port = content
        .trim()
        .parse::<u16>()
        .map_err(ConfigError::InvalidPort)?;

    Ok(port)
}

使用 #[from] 后:

fn read_port(path: &str) -> Result<u16, ConfigError> {
    let content = fs::read_to_string(path)?;
    let port = content.trim().parse::<u16>()?;
    Ok(port)
}

3. #[from] 的字段限制

通常带 #[from] 的变体只能包含:

  • 一个来源错误字段;
  • 或来源错误字段加一个特殊的 backtrace 字段。

例如下面是标准写法:

#[derive(Debug, thiserror::Error)]
enum AppError {
    #[error("I/O 操作失败")]
    Io(#[from] std::io::Error),
}

但是下面这种写法不适合直接使用 #[from]

#[derive(Debug, thiserror::Error)]
enum AppError {
    #[error("读取文件 `{path}` 失败:{source}")]
    ReadFile {
        path: String,

        #[from]
        source: std::io::Error,
    },
}

因为从一个单独的 std::io::Error 无法自动得到 path

这时应使用 #[source],并手动构造错误:

use std::path::PathBuf;
use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error("读取文件 `{path}` 失败:{source}")]
    ReadFile {
        path: PathBuf,

        #[source]
        source: std::io::Error,
    },
}

函数中:

use std::fs;
use std::path::Path;

fn read_file(path: &Path) -> Result<String, AppError> {
    fs::read_to_string(path).map_err(|source| AppError::ReadFile {
        path: path.to_path_buf(),
        source,
    })
}

这比只返回:

No such file or directory

更有业务上下文:

读取文件 `/data/config.toml` 失败:No such file or directory

六、#[source]:建立错误来源链

#[source] 表示某个字段是当前错误的底层原因。

例如:

use std::io;
use thiserror::Error;

#[derive(Debug, Error)]
enum ServiceError {
    #[error("加载用户配置失败:{source}")]
    LoadConfig {
        #[source]
        source: io::Error,
    },
}

thiserror 会生成类似:

impl std::error::Error for ServiceError {
    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
        match self {
            ServiceError::LoadConfig { source } => Some(source),
        }
    }
}

1. 查看错误链

use std::error::Error;
use std::fs;
use std::path::PathBuf;
use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error("读取配置文件 `{path}` 失败")]
    ReadConfig {
        path: PathBuf,

        #[source]
        source: std::io::Error,
    },
}

fn read_config(path: &str) -> Result<String, AppError> {
    fs::read_to_string(path).map_err(|source| AppError::ReadConfig {
        path: PathBuf::from(path),
        source,
    })
}

fn print_error_chain(error: &(dyn Error + 'static)) {
    eprintln!("错误:{error}");

    let mut current = error.source();

    while let Some(source) = current {
        eprintln!("原因:{source}");
        current = source.source();
    }
}

fn main() {
    if let Err(error) = read_config("/data/not-exist.toml") {
        print_error_chain(&error);
    }
}

输出类似:

错误:读取配置文件 `/data/not-exist.toml` 失败
原因:No such file or directory (os error 2)

2. 名为 source 的字段可以自动识别

官方文档说明,字段名直接叫作 source 时,可以作为错误来源被识别。显式写 #[source] 通常更清楚。(Docs.rs)

例如:

#[derive(Debug, thiserror::Error)]
enum AppError {
    #[error("数据库连接失败")]
    Database {
        source: std::io::Error,
    },
}

建议写成:

#[derive(Debug, thiserror::Error)]
enum AppError {
    #[error("数据库连接失败")]
    Database {
        #[source]
        source: std::io::Error,
    },
}

可读性更强。

七、#[error(transparent)]:透明包装错误

透明错误表示:

当前错误只是包装底层错误,不额外改变其显示内容和错误来源行为。

示例:

use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error(transparent)]
    Io(#[from] std::io::Error),
}

假设底层错误是:

No such file or directory (os error 2)

打印 AppError 时仍然显示:

No such file or directory (os error 2)

而不是:

I/O 错误:No such file or directory

什么时候使用 transparent

适合以下场景:

  1. 只是统一错误返回类型;
  2. 不希望改变底层错误文本;
  3. 新类型包装模式;
  4. 需要隐藏内部错误实现。

例如:

use thiserror::Error;

#[derive(Debug, Error)]
#[error(transparent)]
pub struct PublicError(#[from] ErrorRepr);

#[derive(Debug, Error)]
enum ErrorRepr {
    #[error("配置文件不存在")]
    ConfigNotFound,

    #[error("数据库连接失败:{0}")]
    Database(String),
}

调用方只能看到:

PublicError

内部可以自由调整:

ErrorRepr

这种公共错误包装模式也出现在 thiserror 官方文档中。(Docs.rs)

八、结构体错误类型

错误不一定必须定义成枚举,也可以是结构体。

1. 普通结构体错误

use thiserror::Error;

#[derive(Debug, Error)]
#[error("参数 `{parameter}` 不合法:{reason}")]
struct InvalidParameterError {
    parameter: String,
    reason: String,
}

使用:

fn set_port(port: u16) -> Result<(), InvalidParameterError> {
    if port == 0 {
        return Err(InvalidParameterError {
            parameter: "port".to_string(),
            reason: "端口不能为 0".to_string(),
        });
    }

    Ok(())
}

2. 元组结构体错误

use thiserror::Error;

#[derive(Debug, Error)]
#[error("用户不存在:{0}")]
struct UserNotFoundError(u64);

3. 单元结构体错误

use thiserror::Error;

#[derive(Debug, Error)]
#[error("权限不足")]
struct PermissionDeniedError;

枚举还是结构体

只有一种错误时,可以使用结构体:

struct UserNotFoundError;

一个模块存在多种错误时,通常使用枚举:

enum UserServiceError {
    NotFound,
    InvalidEmail,
    Database,
}

九、完整案例:读取应用配置

下面把几个核心功能组合起来。

1. 项目配置

Cargo.toml

[package]
name = "config-demo"
version = "0.1.0"
edition = "2024"

[dependencies]
thiserror = "2"
serde = { version = "1", features = ["derive"] }
toml = "0.8"

2. 配置文件

config.toml

host = "127.0.0.1"
port = 8080
workers = 4

3. Rust 代码

use serde::Deserialize;
use std::fs;
use std::path::{Path, PathBuf};
use thiserror::Error;

#[derive(Debug, Deserialize)]
struct AppConfig {
    host: String,
    port: u16,
    workers: usize,
}

#[derive(Debug, Error)]
enum ConfigError {
    #[error("读取配置文件 `{path}` 失败")]
    ReadFile {
        path: PathBuf,

        #[source]
        source: std::io::Error,
    },

    #[error("解析配置文件 `{path}` 失败")]
    ParseFile {
        path: PathBuf,

        #[source]
        source: toml::de::Error,
    },

    #[error("配置项 workers 必须大于 0")]
    InvalidWorkers,

    #[error("端口不能为 0")]
    InvalidPort,
}

fn load_config(path: impl AsRef<Path>) -> Result<AppConfig, ConfigError> {
    let path = path.as_ref();

    let content = fs::read_to_string(path).map_err(|source| {
        ConfigError::ReadFile {
            path: path.to_path_buf(),
            source,
        }
    })?;

    let config: AppConfig = toml::from_str(&content).map_err(|source| {
        ConfigError::ParseFile {
            path: path.to_path_buf(),
            source,
        }
    })?;

    if config.workers == 0 {
        return Err(ConfigError::InvalidWorkers);
    }

    if config.port == 0 {
        return Err(ConfigError::InvalidPort);
    }

    Ok(config)
}

fn main() {
    match load_config("config.toml") {
        Ok(config) => {
            println!("配置加载成功:{config:#?}");
            println!(
                "监听地址:http://{}:{},工作线程:{}",
                config.host,
                config.port,
                config.workers
            );
        }

        Err(error) => {
            eprintln!("启动失败:{error}");

            let mut source = std::error::Error::source(&error);

            while let Some(cause) = source {
                eprintln!("底层原因:{cause}");
                source = cause.source();
            }
        }
    }
}

这个案例有三个层次:

系统错误
  └── std::io::Error

解析错误
  └── toml::de::Error

业务校验错误
  ├── InvalidWorkers
  └── InvalidPort

这正是 thiserror 比单纯字符串错误更有价值的地方:调用方可以根据错误变体进行精确处理。

十、根据错误变体执行不同逻辑

定义:

use thiserror::Error;

#[derive(Debug, Error)]
enum UserError {
    #[error("用户不存在:{0}")]
    NotFound(u64),

    #[error("用户已被禁用:{0}")]
    Disabled(u64),

    #[error("数据库错误:{0}")]
    Database(String),
}

处理:

fn handle_error(error: UserError) {
    match error {
        UserError::NotFound(user_id) => {
            println!("返回 HTTP 404,用户 ID:{user_id}");
        }

        UserError::Disabled(user_id) => {
            println!("返回 HTTP 403,用户 ID:{user_id}");
        }

        UserError::Database(message) => {
            eprintln!("返回 HTTP 500,数据库错误:{message}");
        }
    }
}

如果只返回:

Err("用户不存在".to_string())

调用方只能比较字符串:

if error == "用户不存在"

这非常脆弱。

而枚举可以:

match error {
    UserError::NotFound(id) => {}
    UserError::Disabled(id) => {}
    UserError::Database(message) => {}
}

十一、为错误增加业务上下文

只使用 #[from]

#[derive(Debug, thiserror::Error)]
enum AppError {
    #[error("I/O 错误:{0}")]
    Io(#[from] std::io::Error),
}

调用:

let content = std::fs::read_to_string(path)?;

最终只知道:

I/O 错误:No such file or directory

不知道是哪个文件。

更好的设计:

use std::path::PathBuf;
use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error("读取文件 `{path}` 失败")]
    ReadFile {
        path: PathBuf,

        #[source]
        source: std::io::Error,
    },
}

然后手动补充上下文:

fn read_file(path: &str) -> Result<String, AppError> {
    std::fs::read_to_string(path).map_err(|source| {
        AppError::ReadFile {
            path: PathBuf::from(path),
            source,
        }
    })
}

错误显示:

读取文件 `/data/app.toml` 失败

错误链:

No such file or directory (os error 2)

设计错误时可以遵循一个原则:

错误变体负责说明“业务操作失败”
source 负责保存“底层技术原因”

例如:

创建用户失败
└── 数据库唯一索引冲突

而不是只把所有错误原样向上传递。

十二、模块化项目中的错误设计

对于多模块项目,不建议所有错误都塞进一个巨大枚举:

enum AppError {
    UserNotFound,
    UserDisabled,
    OrderNotFound,
    OrderPaid,
    ConfigMissing,
    DatabaseError,
    RedisError,
    IoError,
    // 几百个变体……
}

更推荐每个模块有自己的错误类型。

1. 目录结构

src/
├── main.rs
├── error.rs
├── user/
│   ├──.rs
│   ├── error.rs
│   └── service.rs
└── order/
    ├──.rs
    ├── error.rs
    └── service.rs

Rust 2024 项目不必使用旧式 mod.rs

2. 用户模块错误

src/user/error.rs

use thiserror::Error;

#[derive(Debug, Error)]
pub enum UserError {
    #[error("用户不存在:{0}")]
    NotFound(u64),

    #[error("邮箱地址不合法:{0}")]
    InvalidEmail(String),

    #[error("用户已被禁用:{0}")]
    Disabled(u64),
}

src/user.rs

pub mod error;
pub mod service;

src/user/service.rs

use super::error::UserError;

pub fn find_user(user_id: u64) -> Result<String, UserError> {
    if user_id != 1 {
        return Err(UserError::NotFound(user_id));
    }

    Ok("Albert".to_string())
}

3. 应用层统一错误

src/error.rs

use crate::user::error::UserError;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum AppError {
    #[error(transparent)]
    User(#[from] UserError),

    #[error("系统内部错误:{0}")]
    Internal(String),
}

业务函数:

use crate::error::AppError;
use crate::user::service::find_user;

fn execute() -> Result<(), AppError> {
    let username = find_user(100)?;

    println!("用户:{username}");

    Ok(())
}

转换链:

UserError::NotFound(100)
        ↓ #[from]
AppError::User(...)

十三、Web 项目中的使用方式

以 Salvo、Axum、Actix Web 等 Web 框架为例,通常需要把业务错误转换为 HTTP 响应。

可以将错误类型分成两部分:

领域错误
    ↓
应用错误
    ↓
HTTP 响应

示例:

use thiserror::Error;

#[derive(Debug, Error)]
enum UserServiceError {
    #[error("用户不存在:{0}")]
    NotFound(u64),

    #[error("用户无权执行该操作")]
    PermissionDenied,

    #[error("数据库操作失败")]
    Database {
        #[source]
        source: std::io::Error,
    },
}

映射 HTTP 状态码:

impl UserServiceError {
    fn status_code(&self) -> u16 {
        match self {
            UserServiceError::NotFound(_) => 404,
            UserServiceError::PermissionDenied => 403,
            UserServiceError::Database { .. } => 500,
        }
    }

    fn public_message(&self) -> &'static str {
        match self {
            UserServiceError::NotFound(_) => "用户不存在",
            UserServiceError::PermissionDenied => "没有操作权限",
            UserServiceError::Database { .. } => "系统内部错误",
        }
    }
}

这里需要区分:

内部日志错误:
数据库操作失败:Connection refused

返回给前端:
系统内部错误

不要直接把数据库地址、SQL、文件路径等内部信息返回给客户端。

十四、thiserroranyhow 的区别

这是最容易混淆的地方。

thiserror

用于自定义错误类型

#[derive(Debug, thiserror::Error)]
enum UserError {
    #[error("用户不存在:{0}")]
    NotFound(u64),

    #[error("数据库错误")]
    Database,
}

特点:

  • 错误是结构化的;
  • 可以 match
  • 适合库、模块边界和领域层;
  • 适合公开 API;
  • 调用方可以判断具体错误类型。

anyhow

用于方便地传播和补充错误上下文

use anyhow::{Context, Result};

fn load_config() -> Result<String> {
    let content = std::fs::read_to_string("config.toml")
        .context("读取 config.toml 失败")?;

    Ok(content)
}

特点:

  • 统一使用 anyhow::Error
  • 使用方便;
  • 容易添加上下文;
  • 通常适合应用入口、命令行程序和不要求调用方匹配错误变体的上层代码。

thiserror 官方文档也将 anyhow 作为应用代码中“单一方便错误类型”的互补工具。(Docs.rs)

推荐配合方式

底层模块、领域模块、公共库
        使用 thiserror

应用入口、main、任务调度、CLI
        使用 anyhow

例如:

use anyhow::{Context, Result};
use thiserror::Error;

#[derive(Debug, Error)]
enum ConfigError {
    #[error("配置文件不存在")]
    NotFound,

    #[error("配置格式错误:{0}")]
    InvalidFormat(String),
}

fn parse_config(content: &str) -> Result<(), ConfigError> {
    if content.trim().is_empty() {
        return Err(ConfigError::InvalidFormat(
            "配置内容为空".to_string(),
        ));
    }

    Ok(())
}

fn run() -> Result<()> {
    let content = std::fs::read_to_string("config.toml")
        .context("读取主配置文件失败")?;

    parse_config(&content)
        .context("解析应用配置失败")?;

    Ok(())
}

fn main() {
    if let Err(error) = run() {
        eprintln!("{error:#}");
    }
}

这里:

parse_config()
    返回 ConfigError
    使用 thiserror

run()
    返回 anyhow::Result
    使用 anyhow 添加上下文

十五、多个相同底层错误时的处理

下面这种定义会产生冲突:

#[derive(Debug, thiserror::Error)]
enum AppError {
    #[error("读取配置失败:{0}")]
    ReadConfig(#[from] std::io::Error),

    #[error("写入日志失败:{0}")]
    WriteLog(#[from] std::io::Error),
}

原因是它相当于想生成两份:

impl From<std::io::Error> for AppError

Rust 无法根据同一个 std::io::Error 自动判断应该转换为:

ReadConfig

还是:

WriteLog

正确方式是去掉 #[from]

use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error("读取配置失败")]
    ReadConfig {
        #[source]
        source: std::io::Error,
    },

    #[error("写入日志失败")]
    WriteLog {
        #[source]
        source: std::io::Error,
    },
}

然后明确映射:

fn read_config() -> Result<String, AppError> {
    std::fs::read_to_string("config.toml")
        .map_err(|source| AppError::ReadConfig { source })
}

fn write_log(content: &str) -> Result<(), AppError> {
    std::fs::write("app.log", content)
        .map_err(|source| AppError::WriteLog { source })
}

十六、保存额外上下文数据

错误类型不仅用于打印,还可以保存程序处理所需的信息。

use thiserror::Error;

#[derive(Debug, Error)]
enum ApiError {
    #[error("远程服务请求失败,状态码:{status_code},请求 ID:{request_id}")]
    RemoteService {
        status_code: u16,
        request_id: String,
        response_body: String,
    },
}

调用方可以提取字段:

fn handle_error(error: ApiError) {
    match error {
        ApiError::RemoteService {
            status_code,
            request_id,
            response_body,
        } => {
            println!("状态码:{status_code}");
            println!("请求 ID:{request_id}");
            println!("响应内容:{response_body}");
        }
    }
}

注意:

#[error(...)]

只决定错误怎么显示,并不会影响字段本身。

十七、泛型错误类型

thiserror 也可以用于泛型:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum RepositoryError<E>
where
    E: std::error::Error + 'static,
{
    #[error("记录不存在")]
    NotFound,

    #[error("存储层错误:{0}")]
    Storage(E),
}

不过在实际业务项目中,泛型错误容易使类型签名变复杂。

通常更常见的是明确指定底层错误:

#[derive(Debug, thiserror::Error)]
enum UserRepositoryError {
    #[error("用户不存在")]
    NotFound,

    #[error("数据库错误:{0}")]
    Database(String),
}

或者:

#[derive(Debug, thiserror::Error)]
enum UserRepositoryError {
    #[error("数据库错误")]
    Database {
        #[source]
        source: sea_orm::DbErr,
    },
}

十八、SeaORM 项目示例

你的常用技术栈包括 SeaORM,因此可以这样设计。

use sea_orm::DbErr;
use thiserror::Error;

#[derive(Debug, Error)]
pub enum UserRepositoryError {
    #[error("用户不存在:{0}")]
    NotFound(i64),

    #[error("查询用户失败")]
    Query {
        #[source]
        source: DbErr,
    },

    #[error("创建用户失败")]
    Create {
        #[source]
        source: DbErr,
    },

    #[error("更新用户失败")]
    Update {
        #[source]
        source: DbErr,
    },
}

查询:

use sea_orm::{DatabaseConnection, EntityTrait};

async fn find_user(
    db: &DatabaseConnection,
    user_id: i64,
) -> Result<user::Model, UserRepositoryError> {
    let result = user::Entity::find_by_id(user_id)
        .one(db)
        .await
        .map_err(|source| UserRepositoryError::Query { source })?;

    result.ok_or(UserRepositoryError::NotFound(user_id))
}

这里没有直接写:

Database(#[from] DbErr)

而是区分成:

Query
Create
Update

这样发生错误时,能够知道具体哪个数据库操作失败。

简单项目也可以统一:

#[derive(Debug, thiserror::Error)]
pub enum RepositoryError {
    #[error("数据库操作失败:{0}")]
    Database(#[from] sea_orm::DbErr),
}

两种方式的区别是:

统一 Database
    代码简单,但业务上下文较少

Query/Create/Update
    代码稍多,但日志定位更准确

十九、Redis 错误示例

use thiserror::Error;

#[derive(Debug, Error)]
pub enum CacheError {
    #[error("Redis 连接失败")]
    Connection {
        #[source]
        source: redis::RedisError,
    },

    #[error("读取缓存失败,key:{key}")]
    Get {
        key: String,

        #[source]
        source: redis::RedisError,
    },

    #[error("写入缓存失败,key:{key}")]
    Set {
        key: String,

        #[source]
        source: redis::RedisError,
    },
}

使用:

async fn get_cache(
    connection: &mut redis::aio::MultiplexedConnection,
    key: &str,
) -> Result<Option<String>, CacheError> {
    use redis::AsyncCommands;

    connection
        .get(key)
        .await
        .map_err(|source| CacheError::Get {
            key: key.to_string(),
            source,
        })
}

二十、错误测试

建议同时测试错误变体和显示文本。

use thiserror::Error;

#[derive(Debug, Error)]
enum UserError {
    #[error("用户不存在:{0}")]
    NotFound(u64),

    #[error("邮箱不合法:{0}")]
    InvalidEmail(String),
}

fn find_user(id: u64) -> Result<(), UserError> {
    Err(UserError::NotFound(id))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn should_return_not_found() {
        let result = find_user(100);

        assert!(matches!(
            result,
            Err(UserError::NotFound(100))
        ));
    }

    #[test]
    fn should_format_error_message() {
        let error = UserError::NotFound(100);

        assert_eq!(
            error.to_string(),
            "用户不存在:100"
        );
    }
}

对于带 source 的错误:

#[cfg(test)]
mod tests {
    use std::error::Error;

    use super::*;

    #[test]
    fn should_keep_source_error() {
        let source = std::io::Error::new(
            std::io::ErrorKind::NotFound,
            "文件不存在",
        );

        let error = AppError::ReadFile {
            path: "config.toml".into(),
            source,
        };

        assert!(error.source().is_some());
    }
}

二十一、#[backtrace]

thiserror 支持 #[backtrace] 属性,用于将错误中的 backtrace 通过 Error::provide() 暴露出去。不过官方文档注明,该属性目前涉及 nightly 编译器能力;普通 stable 项目不要把它作为基础方案依赖。(crates.io)

形式类似:

use std::backtrace::Backtrace;
use thiserror::Error;

#[derive(Debug, Error)]
enum AppError {
    #[error("I/O 操作失败")]
    Io {
        source: std::io::Error,

        #[backtrace]
        backtrace: Backtrace,
    },
}

普通项目更常见的做法是:

  • 使用 anyhow 捕获和展示错误链;
  • 设置 RUST_BACKTRACE=1
  • 使用 tracing 记录错误上下文;
  • 在错误发生的位置记录调用参数。

Linux/macOS:

RUST_BACKTRACE=1 cargo run

PowerShell:

$env:RUST_BACKTRACE = "1"
cargo run

二十二、错误可见性设计

假设你正在开发一个库:

pub fn load_user() -> Result<User, UserError>

那么 UserError 就是公开 API 的一部分:

#[derive(Debug, thiserror::Error)]
pub enum UserError {
    #[error("用户不存在")]
    NotFound,

    #[error("数据库错误:{0}")]
    Database(String),
}

调用方可能这样写:

match load_user() {
    Err(UserError::NotFound) => {}
    Err(UserError::Database(_)) => {}
    Ok(user) => {}
}

因此,一旦新增错误变体,可能影响调用方的穷尽匹配。

公共库可以考虑添加:

#[non_exhaustive]
#[derive(Debug, thiserror::Error)]
pub enum UserError {
    #[error("用户不存在")]
    NotFound,

    #[error("数据库错误")]
    Database,
}

调用方必须增加兜底:

match error {
    UserError::NotFound => {}
    UserError::Database => {}
    _ => {}
}

这有利于库未来新增错误变体。

二十三、常见错误与注意事项

1. 忘记派生 Debug

错误类型必须满足 std::error::Error 所要求的调试能力。

错误写法:

#[derive(thiserror::Error)]
enum AppError {
    #[error("错误")]
    Failed,
}

推荐:

#[derive(Debug, thiserror::Error)]
enum AppError {
    #[error("错误")]
    Failed,
}

2. 错误类型中全部使用 String

不推荐:

#[derive(Debug, thiserror::Error)]
enum AppError {
    #[error("系统错误:{0}")]
    System(String),
}

然后什么错误都转字符串:

.map_err(|error| AppError::System(error.to_string()))

这会丢失:

  • 原始错误类型;
  • source() 错误链;
  • downcast 能力;
  • 部分调试信息。

更好:

#[derive(Debug, thiserror::Error)]
enum AppError {
    #[error("I/O 错误")]
    Io {
        #[source]
        source: std::io::Error,
    },
}

3. 错误文本缺少业务语义

不够好:

#[error("{0}")]
Database(#[from] sea_orm::DbErr)

稍好:

#[error("数据库操作失败:{0}")]
Database(#[from] sea_orm::DbErr)

更适合大型项目:

#[error("查询用户失败")]
QueryUser {
    #[source]
    source: sea_orm::DbErr,
}

4. 将敏感信息直接放入 Display

谨慎使用:

#[error("数据库连接失败:用户名={username},密码={password}")]
DatabaseConnection {
    username: String,
    password: String,
}

密码、Token、密钥不应出现在错误文本和日志中。

应该写:

#[error("数据库连接失败,主机:{host}")]
DatabaseConnection {
    host: String,

    #[source]
    source: DatabaseError,
}

5. 把所有错误都做成 #[from]

虽然这样很方便:

#[derive(Debug, thiserror::Error)]
enum AppError {
    #[error(transparent)]
    Io(#[from] std::io::Error),

    #[error(transparent)]
    Database(#[from] sea_orm::DbErr),

    #[error(transparent)]
    Redis(#[from] redis::RedisError),
}

但是它缺少业务上下文。

对于原型、小工具可以这样做。

对于正式业务系统,建议按操作分类:

ReadConfig
CreateUser
UpdateOrder
ReadCache
WriteCache

二十四、推荐的错误分层方案

结合 Rust Web 项目,可以使用四层错误设计:

基础设施错误
    SeaORM、Redis、I/O、HTTP 客户端错误
            ↓
仓储层错误
    UserRepositoryError
            ↓
服务层错误
    UserServiceError
            ↓
接口层响应
    HTTP 400 / 403 / 404 / 500

示例:

use thiserror::Error;

#[derive(Debug, Error)]
pub enum UserRepositoryError {
    #[error("用户记录不存在:{0}")]
    NotFound(i64),

    #[error("数据库查询失败")]
    Query {
        #[source]
        source: sea_orm::DbErr,
    },
}

#[derive(Debug, Error)]
pub enum UserServiceError {
    #[error("用户不存在:{0}")]
    NotFound(i64),

    #[error("用户已被禁用:{0}")]
    Disabled(i64),

    #[error("用户存储服务异常")]
    Repository {
        #[source]
        source: UserRepositoryError,
    },
}

转换:

impl From<UserRepositoryError> for UserServiceError {
    fn from(error: UserRepositoryError) -> Self {
        match error {
            UserRepositoryError::NotFound(user_id) => {
                UserServiceError::NotFound(user_id)
            }

            other => {
                UserServiceError::Repository {
                    source: other,
                }
            }
        }
    }
}

这里没有直接写:

Repository(#[from] UserRepositoryError)

是因为需要把仓储层的:

UserRepositoryError::NotFound

映射成服务层的:

UserServiceError::NotFound

而其他仓储错误统一包装。

这体现了一个重要原则:

#[from] 适合无条件的一对一转换。

手写 From 适合需要判断、分类或改变语义的转换。

二十五、完整可运行综合示例

use std::error::Error;
use std::fs;
use std::num::ParseIntError;
use std::path::{Path, PathBuf};
use thiserror::Error;

#[derive(Debug, Error)]
enum ConfigError {
    #[error("读取配置文件 `{path}` 失败")]
    ReadFile {
        path: PathBuf,

        #[source]
        source: std::io::Error,
    },

    #[error("配置文件内容为空")]
    Empty,

    #[error("端口格式错误:{0}")]
    InvalidPort(#[from] ParseIntError),

    #[error("端口不能为 0")]
    ZeroPort,
}

fn load_port(path: impl AsRef<Path>) -> Result<u16, ConfigError> {
    let path = path.as_ref();

    let content = fs::read_to_string(path).map_err(|source| {
        ConfigError::ReadFile {
            path: path.to_path_buf(),
            source,
        }
    })?;

    let content = content.trim();

    if content.is_empty() {
        return Err(ConfigError::Empty);
    }

    let port = content.parse::<u16>()?;

    if port == 0 {
        return Err(ConfigError::ZeroPort);
    }

    Ok(port)
}

fn print_error(error: &(dyn Error + 'static)) {
    eprintln!("操作失败:{error}");

    let mut index = 1;
    let mut source = error.source();

    while let Some(cause) = source {
        eprintln!("原因 {index}:{cause}");

        index += 1;
        source = cause.source();
    }
}

fn main() {
    match load_port("port.txt") {
        Ok(port) => {
            println!("服务端口:{port}");
        }

        Err(error) => {
            print_error(&error);

            match error {
                ConfigError::ReadFile { path, .. } => {
                    eprintln!("请检查文件是否存在:{}", path.display());
                }

                ConfigError::Empty => {
                    eprintln!("请在文件中填写端口,例如:8080");
                }

                ConfigError::InvalidPort(_) => {
                    eprintln!("端口必须是 0~65535 之间的整数");
                }

                ConfigError::ZeroPort => {
                    eprintln!("请使用非零端口");
                }
            }
        }
    }
}

测试不同情况:

echo 8080 > port.txt
cargo run

输出:

服务端口:8080

写入错误内容:

echo abc > port.txt
cargo run

输出类似:

操作失败:端口格式错误:invalid digit found in string
原因 1:invalid digit found in string
端口必须是 0~65535 之间的整数

删除文件:

rm port.txt
cargo run

输出类似:

操作失败:读取配置文件 `port.txt` 失败
原因 1:No such file or directory (os error 2)
请检查文件是否存在:port.txt

二十六、核心属性速查表

属性 作用 示例
#[derive(Error)] 自动实现 Error #[derive(Debug, Error)]
#[error("...")] 自动实现 Display #[error("用户不存在:{0}")]
#[from] 自动实现 From<T>,支持 ? 转换 Io(#[from] io::Error)
#[source] 指定底层错误来源 #[source] source: io::Error
#[error(transparent)] 透明转发底层错误显示和来源 Io(#[from] io::Error)
#[backtrace] 暴露 backtrace 当前需要谨慎考虑编译器支持

二十七、使用建议总结

小型工具可以直接统一包装:

#[derive(Debug, thiserror::Error)]
enum AppError {
    #[error(transparent)]
    Io(#[from] std::io::Error),

    #[error(transparent)]
    ParseInt(#[from] std::num::ParseIntError),
}

正式业务系统建议保存业务上下文:

#[derive(Debug, thiserror::Error)]
enum AppError {
    #[error("读取配置文件 `{path}` 失败")]
    ReadConfig {
        path: PathBuf,

        #[source]
        source: std::io::Error,
    },
}

底层模块、领域层和公共库优先使用:

thiserror

应用入口、CLI、任务执行器可以使用:

anyhow

最推荐的组合是:

thiserror:定义“这是什么错误”
anyhow:说明“执行什么操作时发生了这个错误”

例如:

解析应用配置失败
└── 端口格式错误
    └── invalid digit found in string

这能同时获得结构化处理能力、清晰的业务语义和完整的底层错误链。