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)),
};
执行顺序并不神秘:
- 先调用
parse::<u16>()。 - 如果得到
Ok(value),取出value,继续执行下一行。 - 如果得到
Err(error),当前函数立即返回。 - 当前函数的错误类型与底层错误类型不同时,使用
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。稳定工具链项目不应为了使用这个属性切换整个构建环境,除非确实评估过收益和维护成本。
多数应用应先把以下内容做好:
- 错误种类准确;
- 路径、行号和操作对象完整;
- source 链没有丢失或重复;
- 日志能关联到当前请求或任务。
这些信息缺失时,即使有 backtrace,也可能只看到"代码从哪里经过",仍然不知道"正在处理哪个文件、哪个任务、哪一项输入"。
十二、设计错误类型时的检查顺序
面对一个新模块,可以按下面的顺序设计错误:
- 列出调用者真正需要区分的失败。
- 为每种失败写明上层可能采取的动作。
- 确定当前层独有的上下文,例如路径、字段、行号、任务 ID。
- 判断是否存在应保留的底层错误。
- 没有额外上下文时考虑
#[from];需要上下文时使用map_err和#[source]。 - 为人类消息和机器分类分别设计接口,不解析
Display文本。 - 检查错误字段是否可能泄露敏感数据。
- 决定错误枚举是公开 API、
#[non_exhaustive],还是私有表示加透明包装。 - 测试变体、字段、source 和退出码。
- 最后再调整错误文字,使它简洁、准确且不重复底层原因。
顺序很重要。先写一批错误字符串,再尝试从字符串反推错误结构,通常会得到含义重叠的变体和不稳定的测试。
结语
thiserror 的作用可以概括为一句话:用声明式写法生成标准 Rust 错误实现。
它最常用的能力不多:
#[derive(Error)]生成std::error::Error实现;#[error(...)]定义当前层的Display;#[from]生成直接错误转换,并隐含#[source];#[source]保留底层原因;#[error(transparent)]在没有新错误语义时透明转发。
真正决定代码质量的,仍然是错误类型本身:变体是否反映业务事实,上下文是否在正确层补充,source 是否完整,显示文本是否重复,公开 API 是否承担了明确而稳定的承诺。
宏替开发者省下样板代码,省下来的注意力应该用在这些问题上。