RUST:错误处理 thiserror 详解

RUST:错误处理 thiserror 详解

Rust 把可恢复错误放在返回值里。一个函数如果可能失败,通常返回 Result<T, E>:成功时得到 T,失败时得到 E。错误不是从函数内部突然抛出的控制信号,而是调用者必须面对的一种返回结果。

这种设计让错误路径很明确,但也带来了一项重复工作:每个错误类型都要实现 Debug、Display 和 std::error::Error,需要包装底层错误时还要实现 From。这些实现大多遵循固定模式,手写不难,却容易把真正重要的错误分类和上下文设计淹没在样板代码里。

thiserror 解决的就是这部分重复。它提供一个派生宏,根据错误类型上的属性生成标准 trait 实现。宏展开以后,得到的仍然是普通 Rust 类型和标准库 trait。它不会引入新的异常机制,不会改变 Result 的含义,也不会改变 ? 的执行过程。

以下示例使用 Rust 1.94.0、Rust 2024 edition 和 thiserror 2.0.21。依赖只需写成:

toml 复制代码
[dependencies]
thiserror = "=2.0.21"

一、thiserror 解决什么问题

先看一个不使用 thiserror 的错误类型:

rust 复制代码
use std::error::Error;
use std::fmt;
use std::path::PathBuf;

#[derive(Debug)]
struct ReadConfigError {
    path: PathBuf,
}

impl fmt::Display for ReadConfigError {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(formatter, "无法读取配置文件 {}", self.path.display())
    }
}

impl Error for ReadConfigError {}

这里真正属于业务设计的内容只有两项:错误保存一个路径,显示时说明该路径无法读取。其余代码只是把类型接入标准错误体系。

使用 thiserror 后,同一件事可以写成:

rust 复制代码
use std::path::PathBuf;
use thiserror::Error;

#[derive(Debug, Error)]
#[error("无法读取配置文件 {}", .path.display())]
struct ReadConfigError {
    path: PathBuf,
}

#[derive(Error)] 会生成 std::error::Error 实现;#[error(...)] 会生成 Display 实现。Debug 仍由标准的 #[derive(Debug)] 生成。

这三者不要混为一谈:

  • Debug 展示类型的内部结构,适合开发、测试和调试;
  • Display 展示当前这一层面向人的错误说明;
  • Error 把类型接入标准错误接口,并允许通过 source() 取得底层原因。

thiserror 官方文档特意说明,它不会成为公共 API 的一部分。公共函数返回的是开发者定义的错误类型,调用者依赖的是 std::error::Error 等标准接口。以后把宏生成的实现换成手写实现,或者反过来换,并不会仅仅因为这项变化就破坏调用者代码。

这也说明了 thiserror 的边界:它负责生成实现,不负责替开发者决定错误应该分成几类、保存哪些上下文、哪些错误可以恢复。宏可以减少重复,不能代替错误建模。

错误类型同时服务三类使用者

设计错误时很容易只盯着终端上打印的那一句话。实际项目中,同一个错误至少会被三类代码使用。

第一类是当前程序的控制流。调用者需要判断是否重试、是否改用默认值、是否回滚已经完成的操作。这一层依赖错误变体和字段,而不是中文句子。

第二类是开发者的诊断工具。日志、测试和错误报告器需要知道错误发生在哪个操作、哪个文件、哪一行,以及更底层的原因是什么。这一层依赖上下文字段和 source 链。

第三类是最终用户。用户需要的是可以理解、可以采取行动、又不会泄露内部信息的提示。这一层通常由 CLI、HTTP 接口或界面代码决定,不一定直接等于底层错误的 Display。

例如,配置读取错误可以在内部保存完整路径和 io::Error。本地 CLI 可以显示这条路径,HTTP 服务对外却可能只返回稳定错误码和"服务器配置不可用",把绝对路径留在受控日志中。错误类型保存了哪些事实,与哪些事实最终对外显示,是两个不同问题。

thiserror 主要帮助前两层建立标准错误类型。它不会自动完成用户文案、本地化、日志脱敏或者 HTTP 状态码映射。这些工作仍然属于应用边界。

宏生成代码的意义

过程宏在编译期读取类型和属性,生成普通 Rust 实现。程序运行时不会为了显示一个错误再启动宏,也不存在"thiserror 运行时"。最终二进制执行的仍是编译后的 Display::fmt、Error::source 和 From::from。

这点对理解依赖边界很重要。thiserror 不是一个全局错误容器,也不要求所有错误继承某个框架基类。一个模块可以用它生成实现,另一个模块可以手写同样的标准 trait;两种错误仍能通过 Result 和 std::error::Error 正常协作。

二、先理解 Rust 的错误模型

1. Result<T, E> 是普通枚举

Result 的定义可以简化理解为:

rust 复制代码
enum Result<T, E> {
    Ok(T),
    Err(E),
}

T 是成功值类型,E 是错误值类型。下面这个函数的签名已经说明了全部结果:

rust 复制代码
fn load_config(path: &Path) -> Result<Config, LoadConfigError>

调用成功时返回 Ok(Config),失败时返回 Err(LoadConfigError)。Err 不会自动打印,也不会自动终止进程。调用者可以重试、改用默认值、继续向上返回,或者在程序入口把它转换成非零退出码。

错误是否可恢复,不由 thiserror 决定。真正的决定发生在调用错误函数的那一层。例如,命令行指定的配置文件不存在时,CLI 可能打印错误并退出;桌面应用则可能弹出文件选择框。底层读取函数只应准确返回发生了什么,不应替所有调用场景决定处理方式。

2. ? 是提前返回,不是抛异常

这行代码很常见:

rust 复制代码
let value = text.parse::<u16>()?;

只看 Result 的常见场景,它可以近似理解成:

rust 复制代码
let value = match text.parse::<u16>() {
    Ok(value) => value,
    Err(error) => return Err(From::from(error)),
};

执行顺序并不神秘:

  1. 先调用 parse::<u16>()。
  2. 如果得到 Ok(value),取出 value,继续执行下一行。
  3. 如果得到 Err(error),当前函数立即返回。
  4. 当前函数的错误类型与底层错误类型不同时,使用 From::from(error) 转换。

因此,? 不会进行 C++ 异常式的栈搜索,也不会寻找某个远处的 catch。它只是把一段常见的 match 和提前返回写短了。返回类型仍受编译器检查;没有合适的错误转换时,代码无法通过编译。

3. panic! 不等于 Err

Err 表示调用者可以处理的失败,例如文件不存在、输入格式错误、权限不足或者服务超时。panic! 表示程序内部约束被破坏,或者程序选择不再恢复。

两者的传播方式也不同:

  • Err 是普通返回值,沿函数调用显式返回;
  • panic! 会按构建配置展开栈或直接中止进程。

配置内容非法通常应该返回 Err。如果把这类情况写成 panic!,上层就很难提供重试、用户提示或替代配置。

4. 与 C++ 错误处理的有限比较

C++ 可以使用返回码、异常和 std::variant 表达失败。Rust 的 Result<T, E> 同时具有"显式返回值"和"可携带不同错误变体"两个特点。

与整数错误码相比,错误枚举能保存路径、行号、字段名和底层错误,调用者也不必另外查询全局状态。与 C++ 异常相比,Rust 函数是否可能返回某类错误直接写在返回类型中,? 也不会绕过函数签名。与 std::variant 相比,Result 固定区分成功和失败,标准库围绕这种区分提供了大量组合方法。

这些机制没有绝对优劣。对 Rust 代码而言,重要的是不要把 ? 按照"类似 throw"草率理解。两者表面上都能减少逐层转发代码,但类型约束和控制流程并不相同。

三、#[derive(Error)] 与 #[error(...)]

thiserror 支持枚举、具名字段结构体、元组结构体和单元结构体。业务代码最常见的是错误枚举,因为一次操作往往有多种失败原因。

下面的启动检查可能遇到三类失败:队列不可用、缺少参数、端口超出范围。三种失败需要保存的数据不同,正好对应三种枚举变体形式。

rust 复制代码
use thiserror::Error;

#[derive(Debug, Error, PartialEq, Eq)]
enum StartupError {
    #[error("任务队列暂时不可用")]
    QueueUnavailable,

    #[error("缺少必填参数:{0}")]
    MissingArgument(&'static str),

    #[error("端口 {port} 超出允许范围 {min}..={max}")]
    PortOutOfRange { port: u16, min: u16, max: u16 },
}

fn validate_port(port: u16, min: u16, max: u16) -> Result<(), StartupError> {
    if (min..=max).contains(&port) {
        Ok(())
    } else {
        Err(StartupError::PortOutOfRange { port, min, max })
    }
}

fn main() {
    let examples = [
        StartupError::QueueUnavailable,
        StartupError::MissingArgument("config"),
        StartupError::PortOutOfRange {
            port: 80,
            min: 1024,
            max: 65535,
        },
    ];

    for error in examples {
        println!("{error}");
    }

    if let Err(error) = validate_port(80, 1024, 65535) {
        println!("业务检查失败:{error}");
    }
}

运行:

powershell 复制代码
cargo run

运行结果:

text 复制代码
任务队列暂时不可用
缺少必填参数:config
端口 80 超出允许范围 1024..=65535
业务检查失败:端口 80 超出允许范围 1024..=65535

1. 单元、元组和具名字段变体怎样选择

QueueUnavailable 是单元变体,不保存额外数据。它适合"错误种类本身已经包含全部含义"的情况。要谨慎使用这种形式:如果排查时还需要队列名称、节点地址或操作阶段,就应该保存这些信息。

MissingArgument(&'static str) 是元组变体。字段少、含义直观时,这种写法很紧凑。{0} 表示元组变体的第一个字段。这里使用 &'static str,因为参数名来自程序内固定字符串,不需要分配 String。

PortOutOfRange { port, min, max } 是具名字段变体。字段超过一个,或者同类型字段容易混淆时,具名字段更清楚。三个字段都是 u16,如果写成 PortOutOfRange(u16, u16, u16),调用处很难一眼看出哪个值是实际端口,哪个值是上下界。

错误类型首先服务于程序判断和排查,其次才是少写字符。变体形式应根据需要保存的信息选择,不必追求全部统一成一种写法。

2. #[error] 使用标准格式化规则

#[error(...)] 的内容最终用于生成 Display::fmt。常见简写有:

rust 复制代码
#[error("{name}")]       // 相当于 Display 格式化具名字段
#[error("{name:?}")]     // 相当于 Debug 格式化具名字段
#[error("{0}")]          // Display 格式化第一个元组字段
#[error("{0:?}")]        // Debug 格式化第一个元组字段

Display 和 Debug 的选择会直接影响输出。路径是典型例子:

rust 复制代码
#[error("无法读取配置文件 {path:?}")]

{:?} 会使用 PathBuf 的 Debug 表示,通常带引号,适合诊断。如果希望按面向用户的路径形式显示,可以写:

rust 复制代码
#[error("无法读取配置文件 {}", .path.display())]

.path 表示访问错误值的字段,display() 返回适合格式化路径的包装值。格式化表达式应该保持简单。复杂的本地化、脱敏和多行报告不宜全部塞进 #[error];错误类型负责保存结构,最终展示可以交给更外层的诊断代码。

3. 为什么测试不能只比较显示文本

Display 是给人看的说明,不是理想的控制流协议。下面的测试比比较完整字符串更稳定:

rust 复制代码
assert_eq!(
    validate_port(80, 1024, 65535),
    Err(StartupError::PortOutOfRange {
        port: 80,
        min: 1024,
        max: 65535,
    })
);

它验证的是错误种类和三个字段。以后调整中文措辞,不会让业务测试无意义地失败。只有在错误文本本身就是对外承诺时,才需要单独测试 to_string()。

4. Display 应该描述当前层,而不是写一篇日志

一个好的 Display 通常是一句简洁说明。它应该回答"当前操作为什么失败",而不是一次性输出时间、线程、调用栈、全部参数和所有底层错误。

原因有两点。第一,Display 可能被不同错误报告器重复使用,过多格式会让日志系统难以控制布局。第二,错误值中的某些字段可能只适合内部判断,不适合直接展示。

例如,PortOutOfRange 保存 port、min 和 max,这些值都是解释错误所必需的,可以直接进入 Display。如果错误还保存内部节点 ID、认证上下文或原始请求,就不应因为字段存在而全部插入消息。

结构化字段负责保留事实,Display 负责给出默认说明,最终界面负责选择展示范围。三层职责分开后,错误类型既能被程序处理,也不会被固定成某一种输出格式。

四、#[from] 与 ?:适合直接转换的错误

底层函数与上层函数往往使用不同错误类型。字符串解析返回 ParseIntError,业务函数希望返回 WorkerCountError。如果某个业务变体只需要保存底层错误,不需要路径、行号等额外信息,就可以使用 #[from]。

rust 复制代码
use std::error::Error as _;
use std::num::ParseIntError;
use thiserror::Error;

#[derive(Debug, Error)]
enum WorkerCountError {
    #[error("工作线程数量必须是 1..=32 的整数")]
    InvalidNumber(#[from] ParseIntError),

    #[error("工作线程数量 {value} 超出允许范围 1..=32")]
    OutOfRange { value: u8 },
}

fn parse_worker_count(input: &str) -> Result<u8, WorkerCountError> {
    let value = input.parse::<u8>()?;

    if (1..=32).contains(&value) {
        Ok(value)
    } else {
        Err(WorkerCountError::OutOfRange { value })
    }
}

fn main() {
    let input = std::env::args().nth(1).unwrap_or_else(|| "many".to_owned());

    match parse_worker_count(&input) {
        Ok(value) => println!("工作线程数量:{value}"),
        Err(error) => {
            println!("解析失败:{error}");
            if let Some(source) = error.source() {
                println!("底层原因:{source}");
            }
        }
    }
}

分别运行正常输入和非法输入:

powershell 复制代码
cargo run -- 8
cargo run -- many

成功路径:

text 复制代码
工作线程数量:8

非法数字:

text 复制代码
解析失败:工作线程数量必须是 1..=32 的整数
底层原因:invalid digit found in string

1. #[from] 实际生成什么

InvalidNumber(#[from] ParseIntError) 会生成从 ParseIntError 到 WorkerCountError 的 From 实现。语义大致相当于:

rust 复制代码
impl From<ParseIntError> for WorkerCountError {
    fn from(source: ParseIntError) -> Self {
        Self::InvalidNumber(source)
    }
}

因此,input.parse::<u8>()? 失败时,? 可以把 ParseIntError 转成当前函数声明的 WorkerCountError。

#[from] 还隐含 #[source]。同一个字段不需要同时写:

rust 复制代码
InvalidNumber(#[from] #[source] ParseIntError)

后一个属性是多余的。使用 #[from] 后,标准 Error::source() 已经会返回这个底层错误。

From 的转换方向也值得注意。这里实现的是:

text 复制代码
ParseIntError -> WorkerCountError

它表示任何一个 ParseIntError 都能无条件转换成 WorkerCountError::InvalidNumber。这种转换不接收额外参数,也不允许失败,所以只适合明确、无歧义的包装关系。

如果同一个底层类型在不同位置代表不同业务含义,例如端口解析失败和重试次数解析失败,就不应该让一个全局 From<ParseIntError> 猜测用途。应在两个调用点分别构造具体变体。#[from] 的便利建立在"仅凭底层错误就能决定目标变体"这个前提上。

2. #[from] 为什么不能随便增加字段

带 #[from] 的变体不能再加入普通上下文字段;除底层错误外,只允许官方说明的 backtrace 特例。下面这种设计无法自动生成 From:

rust 复制代码
#[derive(Debug, Error)]
enum ConfigError {
    #[error("{path:?} 中的整数无效")]
    InvalidNumber {
        path: PathBuf,
        #[from]
        source: ParseIntError,
    },
}

原因不是宏能力不足,而是转换本身缺少信息。From<ParseIntError>::from 只收到一个 ParseIntError,它不知道 path 应该取什么值。随意填默认路径会制造错误诊断。

需要额外上下文时,应在知道上下文的调用位置使用 map_err 手工构造错误。这正是下一节的主题。

3. 解析成功不等于业务有效

输入 0 可以成功解析成 u8,所以不会产生 ParseIntError。但业务规则要求线程数至少是 1,因此函数返回 OutOfRange { value: 0 }。

这两个错误不应该合并:

  • InvalidNumber 表示输入无法解释为目标数据类型;
  • OutOfRange 表示数据类型正确,但违反当前业务约束。

区分二者后,上层才能给出准确提示,也能决定是否允许自动修正。例如,格式错误通常要求用户重新输入;超出范围则可能提示允许区间。

五、#[source]:在抽象边界上保留底层原因

std::error::Error::source() 返回导致当前错误的更底层错误。标准库文档把它放在"跨抽象边界"的场景中解释:上层模块可以返回自己的错误,同时保留对下层原因的访问。

假设配置解析器处理的是 config/task.conf 第 7 行。底层 ParseIntError 只知道字符不能解析成整数,并不知道文件路径、行号和这个值在业务中的含义。如果直接把 ParseIntError 返回到 CLI,信息不够;如果只返回一句自己拼的字符串,又会丢失底层错误类型。

合适的做法是同时保留两类信息:

  • 当前层上下文:路径、行号、原始值;
  • 底层原因:ParseIntError。
rust 复制代码
use std::error::Error as _;
use std::num::ParseIntError;
use std::path::{Path, PathBuf};
use thiserror::Error;

#[derive(Debug, Error)]
enum ConfigLineError {
    #[error("配置文件 {path:?} 第 {line} 行的值 {value:?} 不是有效整数")]
    InvalidInteger {
        path: PathBuf,
        line: usize,
        value: String,
        #[source]
        source: ParseIntError,
    },
}

fn parse_integer_at(path: &Path, line: usize, raw: &str) -> Result<u16, ConfigLineError> {
    let value = raw.trim();

    value
        .parse::<u16>()
        .map_err(|source| ConfigLineError::InvalidInteger {
            path: path.to_path_buf(),
            line,
            value: value.to_owned(),
            source,
        })
}

fn main() {
    let error = parse_integer_at(Path::new("config/task.conf"), 7, "many")
        .expect_err("示例输入应当解析失败");

    println!("顶层错误:{error}");
    if let Some(source) = error.source() {
        println!("底层原因:{source}");
    }
}

运行:

powershell 复制代码
cargo run

输出:

text 复制代码
顶层错误:配置文件 "config/task.conf" 第 7 行的值 "many" 不是有效整数
底层原因:invalid digit found in string

1. 为什么这里使用 map_err

parse::<u16>() 返回 Result<u16, ParseIntError>。map_err 只改变错误分支:成功值原样保留,失败时执行闭包,把 ParseIntError 转成 ConfigLineError。

闭包运行时仍处在 parse_integer_at 内部,所以能够取得 path、line 和 value。这是添加上下文最准确的位置。等错误继续向上传递后,上层通常已经不知道底层解析的是哪一行。

这里发生了几次有意的所有权转换:

  • path: &Path 只是借用,失败时用 to_path_buf() 复制成错误自己拥有的 PathBuf;
  • value: &str 指向输入字符串,失败时用 to_owned() 复制成错误自己拥有的 String;
  • ParseIntError 由闭包取得所有权,直接移动进 source 字段。

错误值因此不再借用函数参数。它可以离开当前函数,被记录、装箱或者继续向上传递,而不需要给错误类型增加生命周期参数。

并不是所有上下文都必须复制。高频失败路径、超大缓冲区或嵌入式环境可能需要更谨慎的内存设计。但配置解析错误通常不在性能热点上,清楚、完整的诊断比省下几次小分配更重要。

2. #[source] 与字段名 source

官方文档规定,两种写法都能让宏生成 source():

rust 复制代码
#[source]
cause: ParseIntError

或者:

rust 复制代码
source: ParseIntError

字段直接命名为 source 时,属性可以省略。显式写 #[source] 的好处是阅读代码时不需要记住命名约定,也允许使用别的字段名。团队应选择一种容易识别的风格,重点是错误链不能在跨模块时无意丢失。

3. 不要同时在 Display 里重复打印 source

标准库文档给出了一条很重要的规则:包装底层错误时,要么通过 source() 暴露底层错误,要么把底层错误写进当前层 Display,不应两边都做。

下面的写法会重复:

rust 复制代码
#[error("无法读取 {path:?}: {source}")]
Read {
    path: PathBuf,
    #[source]
    source: io::Error,
}

如果错误报告器先打印当前错误,再遍历 source(),输出可能变成:

text 复制代码
无法读取 "task.conf": 系统找不到指定的文件
caused by: 系统找不到指定的文件

更清楚的写法是让当前层只说明当前层的操作:

rust 复制代码
#[error("无法读取任务配置 {path:?}")]

底层操作系统消息由 source 链单独展示。这样每一层都增加新的事实,而不是重复下一层已经提供的句子。

4. 哪些错误不应该有 source

source 表示因果关系,不是为了让输出显得完整而添加的装饰。

"重试次数 20 超过最大值 10"是当前业务规则直接产生的错误,没有更底层错误导致它。此时 source() 返回 None 才是准确设计。把一条字符串或人为构造的占位错误塞进 source,只会让错误链失真。

5. source() 返回的是借用

标准签名的核心部分是:

rust 复制代码
fn source(&self) -> Option<&(dyn Error + 'static)>

返回值是对底层错误的共享借用。调用 source() 不会把内部错误移出去,也不会复制错误。借用的有效期受外层错误的 &self 约束,因此只要外层错误仍然存在,就可以继续沿链读取。

dyn Error 表示具体错误类型在这一处被擦除,调用者通过标准接口处理它。'static 约束的是底层错误对象不能借用短生命周期的临时数据;它不表示这次返回的引用可以永远使用。返回引用本身仍然不能比 &self 活得更久。

遍历错误链时,可以反复调用 source:

rust 复制代码
let mut current = error.source();

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

循环中的 source 只是借用。外层错误仍然拥有整条链。这个所有权关系使同一个错误既可以先打印顶层消息,又可以继续检查底层类型或写入诊断报告。

六、#[error(transparent)]:透明转发而不是重复包装

有些错误边界需要一个新的 Rust 类型,却不需要一条新的显示消息。#[error(transparent)] 用于这种情况:外层错误把 Display 和 source 行为转发给内部错误,不增加额外文本。

它常见于两种场景。

第一种是错误枚举中的兜底变体:已经精确建模的错误使用明确变体,其余错误暂时透明包装。第二种是公开不透明错误类型:公共 API 只暴露一个稳定包装,内部错误表示保持私有,可以在不暴露所有实现细节的情况下继续调整。

下面的例子采用第二种设计:

rust 复制代码
use std::error::Error as _;
use std::fs;
use std::io;
use std::path::{Path, PathBuf};
use thiserror::Error;

#[derive(Debug, Error)]
#[error(transparent)]
pub struct ConfigError(#[from] ConfigErrorKind);

#[derive(Debug, Error)]
enum ConfigErrorKind {
    #[error("无法读取配置文件 {path:?}")]
    Read {
        path: PathBuf,
        #[source]
        source: io::Error,
    },

    #[error("配置文件 {path:?} 必须以 `name=` 开头")]
    MissingNamePrefix { path: PathBuf },
}

impl ConfigError {
    pub fn kind(&self) -> &'static str {
        match &self.0 {
            ConfigErrorKind::Read { .. } => "read",
            ConfigErrorKind::MissingNamePrefix { .. } => "missing-name-prefix",
        }
    }
}

pub fn load_name(path: &Path) -> Result<String, ConfigError> {
    let content = fs::read_to_string(path).map_err(|source| ConfigErrorKind::Read {
        path: path.to_path_buf(),
        source,
    })?;

    let name = content
        .trim()
        .strip_prefix("name=")
        .ok_or_else(|| ConfigErrorKind::MissingNamePrefix {
            path: path.to_path_buf(),
        })?;

    Ok(name.to_owned())
}

fn main() {
    let path = std::env::args()
        .nth(1)
        .map(PathBuf::from)
        .unwrap_or_else(|| PathBuf::from("missing.conf"));

    match load_name(&path) {
        Ok(name) => println!("任务名:{name}"),
        Err(error) => {
            println!("kind={}", error.kind());
            println!("error={error}");

            if let Some(source) = error.source() {
                println!("source={source}");
            }
        }
    }
}

运行:

powershell 复制代码
cargo run

Windows 上读取不存在的文件时,输出类似:

text 复制代码
kind=read
error=无法读取配置文件 "missing.conf"
source=系统找不到指定的文件。 (os error 2)

最后一行来自操作系统,Linux 和 macOS 的文本会不同。

1. 公开类型和内部表示的职责

ConfigError 是公开类型,但它的元组字段没有 pub,字段类型 ConfigErrorKind 也是私有的。外部代码可以接收、打印和作为标准错误传递 ConfigError,却不能穷举匹配内部变体。

这种边界允许库以后调整内部错误结构。例如,把一个读取变体拆成"文件不存在"和"权限不足",不会直接迫使所有外部调用者修改穷举 match。如果确实需要向调用者承诺某些稳定分类,可以像例子一样提供 kind(),或者提供更具体的查询方法。

代价也很明确:调用者失去了直接匹配全部内部变体的能力。如果调用者必须针对"缺字段""权限不足""格式错误"采用完全不同的恢复策略,公开错误枚举可能更合适。

2. transparent 不等于"隐藏一切"

透明转发只处理错误语义:外层不增加新的显示消息,并把来源关系继续交给内部错误。它不会自动决定可见性,也不会自动提供稳定错误码。

是否公开字段、是否提供 kind()、是否允许向下转换,仍然是 API 设计的一部分。不能因为加了 transparent 就认为兼容性问题已经全部解决。

3. 什么时候不该使用透明包装

如果这一层有新的业务含义,就应该增加自己的消息和上下文。例如,"启动任务失败"由"读取配置失败"导致,而上层还知道任务 ID,那么上层错误可以写成:

rust 复制代码
#[error("任务 {task_id} 启动失败")]
StartTask {
    task_id: String,
    #[source]
    source: ConfigError,
}

这里不应透明转发,因为"哪个任务启动失败"是新的有效信息。判断标准不是"能不能少写一层",而是这一层有没有需要保留的新语义。

七、完整案例:任务配置加载器

前面的例子分别解释了宏属性。实际项目不会为了演示某个属性而单独定义错误,错误类型必须围绕一条真实业务路径设计。

下面实现一个本地任务配置加载器。配置包含任务名、重试次数和输出路径:

text 复制代码
name = nightly-index
retries = 3
output = build/report.json

加载过程分成三层:

text 复制代码
命令行入口
  └─ load_task_config:读取文件
       └─ parse_task_config:解析字段并检查业务规则

分层的目的不是让文件数量变多,而是让失败发生在哪一层、由谁补充什么信息变得明确。

  • 命令行入口知道如何展示错误、使用什么退出码;
  • 加载函数知道正在读取哪个路径;
  • 解析函数知道具体行号、字段名和原始值。

1. 先列出错误,而不是先写解析循环

配置加载器需要区分六类失败:

错误 表示什么 是否有底层 source
Read 文件系统没有返回配置内容 有,io::Error
InvalidLine 行结构不符合 key = value 规则,或出现未知字段 没有
DuplicateField 同一字段被定义两次 没有
MissingField 文件结束后仍缺少必填字段 没有
InvalidRetries retries 无法解析成无符号整数 有,ParseIntError
RetriesOutOfRange 数字解析成功,但超过业务上限 没有

这张表先确定了错误边界。读取失败和数字解析失败来自底层 API,因此保留 source;其余错误由本模块的规则直接产生,没有必要伪造底层原因。

2. 库代码

src/lib.rs:

rust 复制代码
use std::fs;
use std::io;
use std::num::ParseIntError;
use std::path::{Path, PathBuf};

use thiserror::Error;

const MAX_RETRIES: u16 = 10;

/// 成功加载后得到的配置。
/// 所有字段都拥有自己的数据,不再借用文件内容。
#[derive(Debug, PartialEq, Eq)]
pub struct TaskConfig {
    pub name: String,
    pub retries: u8,
    pub output: PathBuf,
}

#[derive(Debug, Error)]
pub enum LoadTaskError {
    #[error("无法读取任务配置 {path:?}")]
    Read {
        path: PathBuf,
        #[source]
        source: io::Error,
    },

    #[error("任务配置 {path:?} 第 {line} 行非法:{reason};原文为 {content:?}")]
    InvalidLine {
        path: PathBuf,
        line: usize,
        content: String,
        reason: String,
    },

    #[error("任务配置 {path:?} 第 {line} 行重复定义字段 {field:?}")]
    DuplicateField {
        path: PathBuf,
        line: usize,
        field: &'static str,
    },

    #[error("任务配置 {path:?} 缺少必填字段 {field:?}")]
    MissingField {
        path: PathBuf,
        field: &'static str,
    },

    #[error("任务配置 {path:?} 第 {line} 行的 retries 值 {value:?} 不是无符号整数")]
    InvalidRetries {
        path: PathBuf,
        line: usize,
        value: String,
        #[source]
        source: ParseIntError,
    },

    #[error("任务配置 {path:?} 第 {line} 行的 retries 值 {value} 超出允许范围 0..={max}")]
    RetriesOutOfRange {
        path: PathBuf,
        line: usize,
        value: u16,
        max: u16,
    },
}

impl LoadTaskError {
    /// 返回稳定的机器分类。显示文本可以调整,分类字符串应保持稳定。
    pub fn kind(&self) -> &'static str {
        match self {
            Self::Read { .. } => "read",
            Self::InvalidLine { .. } => "invalid-line",
            Self::DuplicateField { .. } => "duplicate-field",
            Self::MissingField { .. } => "missing-field",
            Self::InvalidRetries { .. } => "invalid-retries",
            Self::RetriesOutOfRange { .. } => "retries-out-of-range",
        }
    }
}

/// 从磁盘读取配置,再把内容交给纯解析函数。
pub fn load_task_config(path: &Path) -> Result<TaskConfig, LoadTaskError> {
    let content = fs::read_to_string(path).map_err(|source| LoadTaskError::Read {
        path: path.to_path_buf(),
        source,
    })?;

    parse_task_config(path, &content)
}

/// 解析内存中的配置文本。这个函数不执行 I/O,便于精确测试内容错误。
fn parse_task_config(path: &Path, content: &str) -> Result<TaskConfig, LoadTaskError> {
    let mut name: Option<String> = None;
    let mut retries: Option<u8> = None;
    let mut output: Option<PathBuf> = None;

    for (index, raw_line) in content.lines().enumerate() {
        // enumerate 从 0 开始,面向用户的行号从 1 开始。
        let line_number = index + 1;
        let line = raw_line.trim();

        if line.is_empty() || line.starts_with('#') {
            continue;
        }

        // 只切分第一个等号,因此值中仍然可以包含等号。
        let (raw_key, raw_value) =
            line.split_once('=')
                .ok_or_else(|| LoadTaskError::InvalidLine {
                    path: path.to_path_buf(),
                    line: line_number,
                    content: raw_line.to_owned(),
                    reason: "缺少 `=` 分隔符".to_owned(),
                })?;

        let key = raw_key.trim();
        let value = raw_value.trim();

        if key.is_empty() || value.is_empty() {
            return Err(LoadTaskError::InvalidLine {
                path: path.to_path_buf(),
                line: line_number,
                content: raw_line.to_owned(),
                reason: "字段名和值都不能为空".to_owned(),
            });
        }

        match key {
            "name" => {
                ensure_not_duplicate(path, line_number, "name", name.is_some())?;
                name = Some(value.to_owned());
            }
            "retries" => {
                ensure_not_duplicate(path, line_number, "retries", retries.is_some())?;

                // 先解析为 u16,再执行当前应用的 0..=10 范围检查。
                let parsed = value.parse::<u16>().map_err(|source| {
                    LoadTaskError::InvalidRetries {
                        path: path.to_path_buf(),
                        line: line_number,
                        value: value.to_owned(),
                        source,
                    }
                })?;

                if parsed > MAX_RETRIES {
                    return Err(LoadTaskError::RetriesOutOfRange {
                        path: path.to_path_buf(),
                        line: line_number,
                        value: parsed,
                        max: MAX_RETRIES,
                    });
                }

                // parsed 已经证明不超过 10,转换为 u8 不会截断数据。
                retries = Some(parsed as u8);
            }
            "output" => {
                ensure_not_duplicate(path, line_number, "output", output.is_some())?;
                output = Some(PathBuf::from(value));
            }
            _ => {
                return Err(LoadTaskError::InvalidLine {
                    path: path.to_path_buf(),
                    line: line_number,
                    content: raw_line.to_owned(),
                    reason: format!("未知字段 `{key}`"),
                });
            }
        }
    }

    let name = name.ok_or_else(|| LoadTaskError::MissingField {
        path: path.to_path_buf(),
        field: "name",
    })?;
    let retries = retries.ok_or_else(|| LoadTaskError::MissingField {
        path: path.to_path_buf(),
        field: "retries",
    })?;
    let output = output.ok_or_else(|| LoadTaskError::MissingField {
        path: path.to_path_buf(),
        field: "output",
    })?;

    Ok(TaskConfig {
        name,
        retries,
        output,
    })
}

fn ensure_not_duplicate(
    path: &Path,
    line: usize,
    field: &'static str,
    already_seen: bool,
) -> Result<(), LoadTaskError> {
    if already_seen {
        Err(LoadTaskError::DuplicateField {
            path: path.to_path_buf(),
            line,
            field,
        })
    } else {
        Ok(())
    }
}

这段代码值得按实际执行顺序阅读。

load_task_config 首先调用 fs::read_to_string。如果读取失败,map_err 把文件路径加入 Read,并把 io::Error 放进 source。只有读取成功,程序才会进入 parse_task_config。

解析函数逐行处理内容。空行和整行注释直接跳过;其他行必须包含等号。split_once('=') 只切第一个等号,避免输出路径或其他值中出现等号时被切成多段。

字段名和值经过 trim() 后仍然是借用切片,没有发生分配。只有确认字段有效并准备保存,才调用 to_owned() 或 PathBuf::from() 创建拥有所有权的数据。

每个字段在写入前调用 ensure_not_duplicate。重复检查必须发生在覆盖旧值之前,否则第二个值会悄悄替换第一个值,配置文件中的歧义也会被隐藏。

retries 先解析成 u16,而不是直接解析成最终字段类型 u8。这是为了分开"整数表示范围"和"业务允许范围":

  • many 不能成为整数,返回 InvalidRetries;
  • 300 超过 u8,但仍是一段合法无符号整数文本;
  • 20 可以成为 u8,却超过当前业务上限 10。

示例选择 u16 作为中间类型,让一部分较大数值可以被明确归入 RetriesOutOfRange。如果配置协议允许更大的整数,还可以先解析成 u64。错误分类应服务于配置语义,而不是机械服从最终字段的存储类型。

文件遍历结束后,三个 Option 分别转成必填值。此时才报告 MissingField,因为只有读完整个文件才能确定某字段确实没有出现。

3. CLI 入口

库返回结构化错误,命令行入口负责把它转换成终端输出和退出状态。

src/main.rs:

rust 复制代码
use std::error::Error as _;
use std::path::PathBuf;
use std::process::ExitCode;

use task_config_loader::{LoadTaskError, load_task_config};

fn main() -> ExitCode {
    // args_os 保留操作系统原生字符串,路径不必先转换成 UTF-8。
    let Some(path) = std::env::args_os().nth(1).map(PathBuf::from) else {
        eprintln!("用法:task-config-loader <配置文件>");
        return ExitCode::from(2);
    };

    match load_task_config(&path) {
        Ok(config) => {
            println!("配置加载成功");
            println!("name={}", config.name);
            println!("retries={}", config.retries);
            println!("output={}", config.output.display());
            ExitCode::SUCCESS
        }
        Err(error) => {
            print_error(&error);
            ExitCode::FAILURE
        }
    }
}

fn print_error(error: &LoadTaskError) {
    eprintln!("配置加载失败");
    eprintln!("kind={}", error.kind());
    eprintln!("error={error}");

    let mut current = error.source();
    while let Some(source) = current {
        eprintln!("caused by: {source}");
        current = source.source();
    }
}

入口没有把 LoadTaskError 再包装成另一段字符串。它先输出稳定分类和顶层 Display,然后逐层遍历 source。

这里使用 args_os(),因为文件路径不保证能转换成 UTF-8。只有面向人的提示需要字符串;真正交给文件系统的值应尽量保持 OsString/PathBuf 形式。

退出码也属于程序接口:

  • 0:配置加载成功;
  • 1:程序成功识别并报告了配置加载错误;
  • 2:调用方式错误,没有提供配置路径。

返回非零退出码不等于 panic。进程仍按程序写明的路径正常结束,只是告诉父进程本次操作没有成功。

4. 运行结果与错误链

在项目目录准备 fixtures 中的配置文件后,可以分别运行:

powershell 复制代码
cargo run -- fixtures/valid.task
cargo run -- fixtures/invalid-number.task
cargo run -- fixtures/out-of-range.task
cargo run -- fixtures/does-not-exist.task

正常配置:

text 复制代码
配置加载成功
name=nightly-index
retries=3
output=build/report.json

非法数字:

text 复制代码
配置加载失败
kind=invalid-retries
error=任务配置 "fixtures/invalid-number.task" 第 2 行的 retries 值 "many" 不是无符号整数
caused by: invalid digit found in string

超出业务范围:

text 复制代码
配置加载失败
kind=retries-out-of-range
error=任务配置 "fixtures/out-of-range.task" 第 2 行的 retries 值 20 超出允许范围 0..=10

文件不存在时,Windows 输出类似:

text 复制代码
配置加载失败
kind=read
error=无法读取任务配置 "fixtures/does-not-exist.task"
caused by: 系统找不到指定的文件。 (os error 2)

四段输出反映了前面设计的分层:

  • kind 是稳定机器分类;
  • error 是当前业务层说明;
  • caused by 只在确实存在底层原因时出现;
  • 操作系统错误文本允许随平台和系统语言变化。

5. 测试结构,而不是把中文句子当协议

解析失败测试应直接匹配错误变体和字段:

rust 复制代码
match &error {
    LoadTaskError::InvalidRetries {
        line, value, path, ..
    } => {
        assert_eq!(*line, 2);
        assert_eq!(value, "many");
        assert!(path.ends_with("invalid-number.task"));
    }
    other => panic!("预期 InvalidRetries,实际得到 {other:?}"),
}

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

读取失败测试应检查 io::ErrorKind:

rust 复制代码
match &error {
    LoadTaskError::Read { path, source } => {
        assert!(path.ends_with("missing.task"));
        assert_eq!(source.kind(), io::ErrorKind::NotFound);
    }
    other => panic!("预期 Read,实际得到 {other:?}"),
}

不要固定断言"系统找不到指定的文件"这句话。换到英文 Windows、Linux 或 macOS 后,文本会变化,但 ErrorKind::NotFound 的语义仍然稳定。

CLI 还应启动真实子进程检查退出码。只测试库函数,不能证明 main 是否把失败错误地转换成了退出码 0。

八、错误类型应该按什么边界拆分

thiserror 很容易使用,真正困难的是决定错误类型的边界。错误枚举太小,会把本来不同的失败混在一起;错误枚举太大,又会让整个应用依赖同一个不断膨胀的类型。

一个实用判断方法是看"谁负责处理"。

配置模块的调用者关心读取、格式和字段校验,可以定义 LoadTaskError。HTTP 客户端的调用者关心连接、状态码和响应解析,可以定义 ClientError。任务执行器关心取消、超时和工具失败,可以定义 RunTaskError。

这些模块到了应用入口才汇合。入口可以逐一包装,也可以使用 anyhow 等类型擦除错误统一记录和退出。没有必要因为它们最终都可能导致程序失败,就提前塞进一个巨大的 AppError。

1. 一个错误变体应表示一种可说明的失败

变体划分不是越细越好。是否拆分,取决于以下问题:

  • 调用者是否需要采取不同动作;
  • 给用户的修复方法是否不同;
  • 需要保存的上下文字段是否不同;
  • 日志和监控是否需要稳定地区分。

例如,"配置文件不存在"和"配置文件权限不足"都来自 io::Error。如果上层统一提示"无法读取配置"并退出,一个 Read 变体加 io::ErrorKind 已经足够。如果产品允许文件不存在时自动创建默认配置,但权限不足必须停止,就值得拆成不同业务分支,或者至少在处理层检查 ErrorKind。

反过来,也不要给每一种操作系统错误单独复制一个变体。底层类型已经提供稳定分类时,应先考虑保留它,而不是在业务枚举中重新造一套不完整的映射。

2. 上下文应在第一次知道它的地方添加

错误从底层向上返回时,上下文会逐渐增加:

text 复制代码
ParseIntError
  -> 第 7 行的 retries 无法解析
  -> 配置文件 config/task.conf 无法加载
  -> 任务 nightly-index 无法启动

每一层只添加自己知道的新信息。解析器知道行号和字段名;配置加载器知道路径;任务服务知道任务 ID。底层解析器不应该接收任务 ID,只为了将来可能打印错误。上层任务服务也不应该通过解析一段错误字符串来猜行号。

这种分层能保持模块职责清楚,也能避免重复消息。判断某层是否应该包装错误,可以问一句:这一层能否提供新的、排查或处理所需的信息?如果不能,直接传播或透明包装通常更合适。

3. 错误字段应该保存原始事实

错误字段应尽量保存可判断的原始数据,而不是只保存已经格式化的句子。

更好的设计:

rust 复制代码
RetriesOutOfRange {
    value: u16,
    max: u16,
}

较弱的设计:

rust 复制代码
InvalidConfig {
    message: String,
}

前一种设计允许测试字段、生成不同语言的提示、统计错误种类,甚至由上层自动把值限制到允许范围。后一种设计只剩一段文字,任何程序判断都只能依赖字符串内容。

这不表示错误中永远不能有 String。无法预先枚举的外部诊断、用户提供的字段值、协议返回的安全摘要都可能需要字符串。关键是不要把本来有明确类型的数据过早格式化。

4. 错误也有数据安全边界

路径、请求参数、服务端响应和用户输入都可能包含敏感信息。把它们存入错误后,错误可能进入日志、终端、遥测或崩溃报告。

设计错误字段时应同时考虑诊断价值和暴露范围:

  • 本地 CLI 可以显示用户刚刚传入的配置路径;
  • 公共 HTTP 响应通常不应返回服务器绝对路径;
  • 认证错误不应保存或显示完整 token;
  • 大段响应正文不应不加限制地塞进错误和日志。

thiserror 不会自动脱敏。#[error] 中写了哪个字段,Display 就可能输出哪个字段。敏感数据最好从错误类型设计阶段就避免保存,或者只保存经过控制的摘要。

5. 错误类型不应该同时承担日志职责

错误值描述失败事实,日志记录某个时间点发生的事件。两者有关联,但不是同一件事。

一个 LoadTaskError::Read 可以保存路径和 io::Error。日志系统还可能记录时间、任务 ID、进程版本和请求关联 ID。这些运行环境信息不必全部成为错误字段,否则错误类型会依赖日志系统,并在每一层反复携带相同数据。

常见做法是在错误产生处保存无法从其他地方恢复的事实,在错误被处理或越过进程边界时记录日志。不要每经过一层就先记录一次再返回同一个错误,否则一次失败可能出现多条内容相近的日志,让人误以为发生了多次故障。

到底在哪一层记录取决于系统结构。库通常只返回错误,不擅自打印;CLI 可以在 main 附近统一打印;服务器可以在请求边界记录一次完整错误链,同时把受控响应发给客户端。thiserror 负责让错误链可用,不负责决定日志次数。

6. "是否可重试"不总是错误类型自身的属性

网络超时常被称为"可重试错误",但是否真的能重试还取决于当前操作。

读取请求超时,重试通常没有副作用。创建订单请求超时却可能意味着服务端已经创建成功,只是响应没有返回;盲目重试可能产生重复订单。相同的底层超时错误,在不同操作中具有不同恢复条件。

因此,不要仅凭 io::ErrorKind::TimedOut 就在所有位置自动重试。错误类型可以保存超时事实,当前业务层还要结合操作是否幂等、是否已经产生部分副作用、是否有幂等键和剩余重试预算作决定。

配置加载器的范围错误也是同样道理。交互式界面可以让用户修改后重试,批处理任务可能必须失败退出。错误描述发生了什么,恢复策略由掌握业务状态的上层选择。

7. 稳定分类与人类消息要分开演进

错误文本会调整。最初的"重试次数无效"可能改成"retries 必须是 0 到 10 的整数",也可能根据界面语言本地化。如果脚本通过匹配整句文字判断错误种类,每次改文案都可能破坏自动化。

配置案例的 kind() 提供了一个简单分层:

text 复制代码
invalid-retries       // 稳定机器分类
任务配置第 2 行......     // 面向人的默认说明

公共协议还可以把分类定义成专门枚举或错误码,而不是字符串。重点是机器分类需要明确的兼容性承诺;Display 则可以在不改变错误语义的情况下改善措辞。

反过来,稳定分类也不应该塞入所有内部细节。调用者如果只需要知道"配置不可用",就没有必要让公共错误码暴露内部解析器的每个分支。公开分类的粒度应由外部需要决定,内部错误仍可以更精确。

8. 错误类型会随模块一起演进

错误设计很少一次完成。模块新增缓存、远端读取或格式版本后,错误种类也会变化。为了让变化可控,可以把错误类型视为模块接口的一部分:

  • 新增内部实现细节时,先判断调用者是否真的需要知道;
  • 新增公开变体前,检查外部穷举匹配的兼容性;
  • 删除 source 前,确认诊断工具是否依赖底层类型;
  • 修改字段类型前,检查测试、日志和序列化边界;
  • 修改显示文本时,不顺便改变机器分类含义。

私有错误枚举可以较自由地重构。公开错误枚举则需要像其他公共类型一样维护兼容性。thiserror 让实现代码很短,不能因此忽略类型本身已经成为合同。

九、常见误用

1. 所有错误都返回 String

rust 复制代码
type AppResult<T> = Result<T, String>;

这种写法看起来省事,但上层无法可靠区分读取失败和格式失败,无法取得 io::ErrorKind,也无法沿 source 链诊断。只要错误需要跨模块传递、测试或决定恢复动作,就应该保留类型。

2. 看见底层错误就机械使用 #[from]

#[from] 适合"底层错误本身已经足够表达当前变体"的情况。读取文件时通常还需要路径,解析字段时通常还需要字段名和行号。这时多写一个 map_err 是正确成本,不是多余代码。

3. 顶层消息重复底层消息

当前错误的 Display 已经包含 {source},错误报告器又遍历 source(),同一句话就会出现两遍。当前层只写当前操作,让底层原因留在错误链里。

4. 把可预期输入错误写成 panic!

用户提供的路径不存在、配置格式不对、远端超时,都属于运行时正常失败。它们应该返回 Result。panic! 更适合表示程序内部不变量被破坏,而不是代替普通错误处理。

5. 依赖完整错误文本做业务判断

rust 复制代码
if error.to_string().contains("找不到") {
    // ...
}

这段逻辑会受到措辞、系统语言和平台变化影响。程序判断应使用枚举变体、稳定 kind()、io::ErrorKind 或明确的查询方法。Display 留给人读。

6. 同一个 source 类型生成多个 From 路径

假设两个变体都包装 ParseIntError:

rust 复制代码
enum Error {
    ParsePort(#[from] ParseIntError),
    ParseRetries(#[from] ParseIntError),
}

宏无法为同一个 ParseIntError 同时生成两个不同的 From<ParseIntError> 实现,因为 From 调用时没有信息判断应该选择哪个变体。正确做法是在两个解析位置分别使用 map_err,明确构造 ParsePort 或 ParseRetries,同时补上字段名等上下文。

7. 公开枚举以后仍随意修改

公共错误枚举是 API。外部调用者可能穷举匹配它的变体。新增变体、修改字段类型或改变可见性,都可能影响兼容性。

常见选择有三种:

  • 公开完整枚举,把细粒度恢复能力作为正式承诺;
  • 给枚举加 #[non_exhaustive],要求外部调用者保留兜底分支;
  • 公开透明包装类型,只通过稳定方法暴露有限分类。

选择取决于调用者需要什么,不取决于哪一种写法看起来更"高级"。

十、thiserror 与 anyhow 的分工

thiserror 和 anyhow 经常一起出现,但职责不同。

thiserror 用来定义专门的错误类型。调用者知道可能得到哪些错误,可以匹配变体、读取字段并决定恢复动作。它适合库、业务模块和需要稳定错误合同的边界。

anyhow 提供一个方便的统一错误类型。函数可以返回来源不同的错误,并继续添加上下文,调用者通常负责记录、报告或退出,而不是穷举所有底层类型。它常见于应用入口、一次性任务和内部工具。

一种常见结构是:

text 复制代码
配置模块       -> Result<Config, LoadConfigError>   // thiserror
HTTP 模块      -> Result<Response, ClientError>      // thiserror
任务执行模块   -> Result<Report, RunTaskError>       // thiserror
CLI main       -> anyhow::Result<()>                  // 应用入口统一报告

不能把这理解成"库只能用 thiserror,应用只能用 anyhow"。应用内部同样需要结构化错误;库的内部工具也可能使用类型擦除错误。判断依据仍然是调用者是否需要依赖具体错误类型。

如果调用者需要根据错误执行不同逻辑,就保留具体类型。如果调用者只需要增加上下文、记录后结束当前操作,统一错误类型通常更省事。

十一、Backtrace 与 source 链不是一回事

source 链记录因果关系:任务启动失败,是因为配置加载失败;配置加载失败,是因为文件不存在。

backtrace 记录执行路径:程序经过哪些函数调用到达错误捕获位置。它用于定位代码路径,却不会自动解释业务因果。

thiserror 2.0.21 可以识别 Backtrace 字段,并通过 Error::provide 提供它。不过当前标准库的 provide 相关接口仍是 nightly 能力,#[backtrace] 也要求 nightly。稳定工具链项目不应为了使用这个属性切换整个构建环境,除非确实评估过收益和维护成本。

多数应用应先把以下内容做好:

  1. 错误种类准确;
  2. 路径、行号和操作对象完整;
  3. source 链没有丢失或重复;
  4. 日志能关联到当前请求或任务。

这些信息缺失时,即使有 backtrace,也可能只看到"代码从哪里经过",仍然不知道"正在处理哪个文件、哪个任务、哪一项输入"。

十二、设计错误类型时的检查顺序

面对一个新模块,可以按下面的顺序设计错误:

  1. 列出调用者真正需要区分的失败。
  2. 为每种失败写明上层可能采取的动作。
  3. 确定当前层独有的上下文,例如路径、字段、行号、任务 ID。
  4. 判断是否存在应保留的底层错误。
  5. 没有额外上下文时考虑 #[from];需要上下文时使用 map_err 和 #[source]。
  6. 为人类消息和机器分类分别设计接口,不解析 Display 文本。
  7. 检查错误字段是否可能泄露敏感数据。
  8. 决定错误枚举是公开 API、#[non_exhaustive],还是私有表示加透明包装。
  9. 测试变体、字段、source 和退出码。
  10. 最后再调整错误文字,使它简洁、准确且不重复底层原因。

顺序很重要。先写一批错误字符串,再尝试从字符串反推错误结构,通常会得到含义重叠的变体和不稳定的测试。

结语

thiserror 的作用可以概括为一句话:用声明式写法生成标准 Rust 错误实现。

它最常用的能力不多:

  • #[derive(Error)] 生成 std::error::Error 实现;
  • #[error(...)] 定义当前层的 Display;
  • #[from] 生成直接错误转换,并隐含 #[source];
  • #[source] 保留底层原因;
  • #[error(transparent)] 在没有新错误语义时透明转发。

真正决定代码质量的,仍然是错误类型本身:变体是否反映业务事实,上下文是否在正确层补充,source 是否完整,显示文本是否重复,公开 API 是否承担了明确而稳定的承诺。

宏替开发者省下样板代码,省下来的注意力应该用在这些问题上。

参考资料

相关推荐
Source.Liu6 小时前
【A11】拥有者模式平台架构笔记
windows·笔记·架构·rust
sayang_shao7 小时前
用 Rust 开发 STM32H743:从零搭建一个能烧录、能看日志的项目
stm32·单片机·rust
qq_406981037 小时前
大白话rust系列02原始数据类型
rust
遇码9 小时前
macOS 触摸板捏合在 GPUI 里无响应:vendor 打补丁,把手势合成为 cmd+滚轮事件
rust·图形渲染·策略模式·桌面应用·gpui
geovindu10 小时前
rust: Chain of Responsibility Pattern
开发语言·后端·设计模式·rust·责任链模式·行为模式
geovindu11 小时前
rust: Chain of Responsibility Pattern (续)
开发语言·后端·设计模式·rust·责任链模式·行为模式
Amos_Web12 小时前
Rspack 源码解析(二十四):AssetPlugin 如何让图片同时是「URL 字符串」和「真实文件」
前端·rust·前端框架
wflynn1 天前
GitHub 今日推荐|solidcraft:Rust 跨平台参数化 CAD,支持 AI 代理通过 MCP 协议驱动建模
rust·开源·github·cad·3d-modeling·brep