Rust 2026 经验谈 - 错误处理体系 2026
错误处理是 Rust 的核心设计优势之一——Result<T, E> 和 ? 操作符的组合让错误传播既安全又优雅。但”用什么类型做 E”这个问题的答案在 2026 年已经丰富到需要一篇专题来梳理。本文将从库/应用分界的架构视角出发,深入 thiserror 2.0 + anyhow 1.0 的最佳实践、Error source chain 的遍历、? 操作符的推导细节、Provider API 的前沿进展,以及 eyre vs anyhow 的选型。
thiserror 2.0 + anyhow 1.0:架构分工
Rust 错误处理的黄金法则是:库用 thiserror,应用用 anyhow。这不是教条,而是有深层原因的架构决策。
库的错误类型:thiserror 2.0
库的 Error 类型是公共 API 的一部分。调用者需要:
- 匹配特定错误:
match err { MyError::NotFound => ... } - 提取错误详情:
if let MyError::Timeout { duration } = err { ... } - 错误链追踪:从底层错误向上传播
thiserror 2.0 通过 derive 宏自动实现 std::error::Error、Display 和 From:
use thiserror::Error;
#[derive(Error, Debug)]pub enum DataStoreError { #[error("data store disconnected")] Disconnect(#[from] io::Error),
#[error("the data for key `{0}` is not available")] Redaction(String),
#[error("invalid header (expected {expected:?}, found {found:?})")] InvalidHeader { expected: String, found: String, },
#[error("unknown data store error")] Unknown,}thiserror 2.0 的新特性:
use thiserror::Error;
#[derive(Error, Debug)]pub enum AppError { // #[error(transparent)] 透传底层错误(1.0 已支持,2.0 改进与 Provider API 协作) #[error(transparent)] Io(#[from] std::io::Error),
#[error(transparent)] Parse(#[from] std::num::ParseIntError),
// 2.0 改进的 #[source] 与 Display 格式化协作 #[error("configuration error at {path}:{line}")] Config { path: String, line: usize, #[source] source: ConfigParseError, },}
#[derive(Error, Debug)]#[error("invalid config value: {msg}")]struct ConfigParseError { msg: String,}应用的错误类型:anyhow 1.0
应用的错误处理不需要匹配具体类型——你只需要:
- 记录/展示错误(附带上下文)
- 尽早退出(
?传播) - 偶尔 downcast 检查特定错误
anyhow 1.0 提供这一切:
use anyhow::{Context, Result, anyhow, ensure};
fn read_config(path: &str) -> Result<Config> { let content = std::fs::read_to_string(path) .with_context(|| format!("failed to read config from {}", path))?;
let config: Config = toml::from_str(&content) .context("failed to parse config")?;
ensure!(config.port > 0, "port must be positive, got {}", config.port);
Ok(config)}with_context 的威力:它在错误链上附加上下文信息,让”配置文件不存在”这样的底层错误变成”failed to read config from /etc/app.toml: No such file or directory”——带有完整的因果链。
为什么不能混用
// ❌ 库返回 anyhow::Errorpub fn parse(data: &[u8]) -> anyhow::Result<Parsed> { // ...}
// 问题:调用者无法匹配具体错误fn caller() { match parse(&data) { Ok(parsed) => { /* ... */ }, Err(e) => { // e 是 anyhow::Error,只能 downcast if e.is::<std::io::Error>() { // 但这很脆弱,且丧失了穷尽性检查 } }, }}
// ✅ 库返回具体错误类型pub fn parse(data: &[u8]) -> Result<Parsed, ParseError> { // ...}
fn caller() { match parse(&data) { Ok(parsed) => { /* ... */ }, Err(ParseError::InvalidFormat(line)) => { /* 处理格式错误 */ }, Err(ParseError::Overflow) => { /* 处理溢出 */ }, }}分工原则:
- 库(library crate):定义具体
Error枚举(thiserror),让调用者可以模式匹配 - 应用(binary crate):用
anyhow::Result收集所有错误,附加上下文,记录日志 - 库的内部:可以用 anyhow 做快速原型,但公共 API 必须用具体类型
Error source chain:遍历因果链
std::error::Error::source() 方法返回错误的原因(cause),形成一条链。这是错误报告和日志系统的核心。
手动遍历 source chain
use std::error::Error;
fn print_error_chain(err: &dyn Error) { let mut level = 0; let mut source = Some(err); while let Some(e) = source { println!("{:width$}→ {}", level, e, width = level * 2); source = e.source(); level += 1; }}
// 输出示例:// → failed to read config from /etc/app.toml// → No such file or directory (os error 2)thiserror 的 #[from] 自动实现 source
#[derive(Error, Debug)]pub enum StoreError { #[error("database query failed")] Query(#[from] sql::Error), // #[from] 自动实现 From<sql::Error> 和 source()
#[error("connection pool exhausted")] PoolExhausted,}
// sql::Error 作为 StoreError::Query 的 sourcefn demo(err: StoreError) { if let Some(source) = err.source() { println!("caused by: {}", source); // 打印 sql::Error }}anyhow 的错误链
anyhow 的 context() 和 with_context() 在错误链上插入上下文节点:
use anyhow::{Context, Result};
fn deep_operation() -> Result<()> { std::fs::read_to_string("missing.txt") .context("reading config")?; // 错误链:reading config → No such file or directory Ok(())}anyhow 的 Chain 迭代器:
fn print_anyhow_chain(err: &anyhow::Error) { for (i, cause) in err.chain().enumerate() { println!("{:2}: {}", i, cause); }}? 操作符类型推导细节
? 操作符的行为比大多数人理解的更微妙。它不仅传播错误,还执行 From 转换。
核心机制
fn foo() -> Result<i32, AppError> { let n: i32 = "42".parse()?; // parse 返回 Result<i32, ParseIntError> // ? 做了两件事: // 1. 如果 Err(e),调用 From::<ParseIntError>::from(e) 转为 AppError // 2. 提前返回 Err(AppError::...) Ok(n)}推导步骤:
- 编译器知道函数返回
Result<_, AppError> ?遇到Result<_, ParseIntError>的Err分支- 查找
From<ParseIntError>forAppError的实现 - 如果找到,调用
from()转换;如果没找到,编译错误
From vs FromIterator 的坑
fn process(items: &[&str]) -> Result<Vec<i32>, AppError> { // ❌ 常见错误:试图对 Iterator<Item=Result<_, _>> 使用 ? // items.iter().map(|s| s.parse::<i32>()).collect()? // ^^^^^^^^ 返回 Result<i32, ParseIntError> // collect() 对 Iterator<Item=Result<i32, ParseIntError>> 的行为是: // 如果任何元素是 Err,返回 Err(第一个错误) // 否则返回 Ok(Vec<i32>) // 但 ? 作用于 collect 的结果,类型是 Result<Vec<i32>, ParseIntError> // 需要 From<ParseIntError> for AppError
// ✅ 正确写法 items.iter() .map(|s| s.parse::<i32>().map_err(AppError::from)) .collect::<Result<Vec<_>, _>>()}关键区别:
collect::<Result<Vec<T>, E>>()利用FromIteratorforResult——任何一个元素失败则整体失败?利用From<E1>forE2——把一种错误类型转为另一种
混用时,? 作用于 collect() 的结果(Result<Vec<T>, E1>),不是作用于每个元素。
? 在 Option 中的行为
fn find_and_process(data: &[i32]) -> Option<i32> { let first = data.first()?; // ? 在 Option 上下文中:None 则提前返回 Some(*first * 2)}? 在 Option 上下文中传播 None,不做 From 转换。注意:Result 和 Option 之间的 ? 不能混用——Result 中的 ? 不能传播到返回 Option 的函数,反之亦然(除非有 From 实现)。
fn mixed() -> Option<i32> { // ❌ "42".parse::<i32>().ok()? 可以工作,但这是手动转换 // 直接用 ? 不行:Result 和 Option 的 ? 不互通 "42".parse::<i32>().ok()? // ✅ ok() 将 Result 转为 Option}Provider API:替代 Error::source 获取上下文
Provider API(std::any::Provider / std::any::Demand)是 Rust 1.86 引入的类型按需提供机制,比 Error::source() 更灵活。但作为错误上下文提取的完整 API,其稳定化仍在推进中。
问题:source() 只返回一个原因
Error::source() 只能返回一个源错误。但有时候错误有多个相关信息:
#[derive(Error, Debug)]#[error("request to {url} failed with status {status}")]struct RequestError { url: String, status: u16, source: std::io::Error,}
// source() 只返回 std::io::Error// url 和 status 丢失了——它们不在 source chain 中传统方案是把所有信息塞进 Display 消息中,但这不可编程——你只能得到字符串,无法提取结构化数据。
Provider API 的解决方案
Provider API 允许错误按类型提供上下文值:
use std::any::{Demand, Provider};
impl Provider for RequestError { fn provide<'a>(&'a self, demand: &mut Demand<'a>) { demand.provide_ref::<String>(&self.url); demand.provide_ref::<u16>(&self.status); demand.provide_ref::<std::io::Error>(&self.source); }}
// 消费者按类型提取上下文fn inspect(err: &dyn Provider) { if let Some(url) = demand.provide_ref::<String>() { println!("failed URL: {}", url); } // 注意:此处展示的是 Provider API 的核心思路 // 完整的 demand 提取 API 在稳定化过程中可能有所调整}vs Error::source():
| 维度 | source() | Provider API |
|---|---|---|
| 返回值数量 | 1 个 | 任意多个 |
| 类型安全 | &dyn Error | 按请求类型返回 |
| 向后兼容 | 是 | 是(Provider 有默认空实现) |
| 提取方式 | 遍历链 | 按类型 demand |
稳定化状态:Provider API 的核心 trait(Provider/Demand)已在 Rust 1.86 稳定,但用于错误上下文提取的便捷 API(如 demand_ref 等函数级接口)仍在完善中。anyhow 和 eyre 已部分采用 Provider 思路,等完整 API 稳定后会逐步迁移。
thiserror 2.0 对 Provider 的支持
thiserror 2.0 已经为 Provider API 做了准备:#[source] 标注的字段会同时实现 provide 方法,让你可以通过 Provider API 提取 source 而不仅靠 source() 方法。
eyre vs anyhow 选型
eyre 是 anyhow 的 fork,核心差异在于错误报告(error report)的自定义能力。
anyhow 的报告格式
Error: failed to read config from /etc/app.toml
Caused by: No such file or directory (os error 2)eyre 的报告格式
Error: 0: failed to read config from /etc/app.toml 1: No such file or directory (os error 2)
Location: src/main.rs:15:5eyre 的报告包含源码位置(文件名、行号、列号),这在调试时非常有用。
核心对比
| 维度 | anyhow | eyre |
|---|---|---|
| 错误报告 | 简洁,无位置信息 | 详细,含位置信息 |
| 自定义报告 | 不可 | 可(通过 Handler trait) |
| 性能 | 稍快(不捕获位置) | 稍慢(捕获位置 + backtrace) |
| 依赖 | 仅 thiserror(可选) | color-eyre(推荐) |
| 生态成熟度 | 更成熟,更多文档 | 活跃但较小 |
选型建议
// 场景 1:库的内部 / 简单应用use anyhow::Result; // anyhow 足够:轻量、快速
// 场景 2:需要详细错误报告的应用(CLI、服务端)use eyre::Result; // eyre + color-eyre:彩色报告 + backtrace + 位置
// 场景 3:嵌入式 / 极端性能敏感// 手动定义 Error 枚举,不用 anyhow/eyre,避免分配color-eyre 的配置
use color_eyre::{eyre::eyre, Help, Result};
fn read_config() -> Result<Config> { let content = std::fs::read_to_string("config.toml") .with_suggestion(|| "try creating config.toml with default settings")?; // 帮助建议
Ok(toml::from_str(&content)?)}
fn main() -> Result<()> { color_eyre::install()?; // 初始化 color-eyre 的错误处理器
let config = read_config()?; Ok(())}with_suggestion 是 color-eyre 的特色功能——在错误报告中附加可操作的建议,这在 CLI 工具中特别有用。
实战:完整的错误处理架构
以一个 HTTP 服务为例,展示 thiserror + anyhow 的分层架构:
// ===== 库:db crate =====use thiserror::Error;
#[derive(Error, Debug)]pub enum DbError { #[error("connection failed to {host}:{port}")] ConnectionFailed { host: String, port: u16, #[source] source: std::io::Error, },
#[error("query execution failed")] Query(#[from] sql::Error),
#[error("record not found: {table}/{id}")] NotFound { table: String, id: i64 },
#[error("pool exhausted, max: {max}")] PoolExhausted { max: usize },}
// ===== 库:api crate =====#[derive(Error, Debug)]pub enum ApiError { #[error("database error")] Db(#[from] DbError),
#[error("invalid request: {0}")] Validation(String),
#[error("unauthorized")] Unauthorized,
#[error("rate limited, retry after {retry_after_secs}s")] RateLimited { retry_after_secs: u64 },}
// ===== 应用:server binary =====use anyhow::{Context, Result};
fn handle_request(req: Request) -> Result<Response> { let user = authenticate(&req) .context("authentication failed")?;
let data = query_database(&user) .with_context(|| format!("query failed for user {}", user.id))?;
Ok(Response::ok(data))}关键设计:
DbError和ApiError是具体类型,库的用户可以模式匹配- 应用层用
anyhow::Result收集,context()附加业务上下文 #[from]让?自动转换:DbError→ApiError::Db- 错误链:
anyhow context→ApiError→DbError→std::io::Error
小结
Rust 的错误处理体系在 2026 年已经形成清晰的最佳实践:
- 库/应用分界:库用 thiserror 定义具体 Error 枚举,应用用 anyhow/eyre 收集并附加上下文
- Error source chain:
source()方法形成因果链,是错误报告的核心 ?操作符:通过From自动转换错误类型,理解其推导机制才能写出正确的错误转换链- Provider API:
provide/demand机制比source()更灵活,允许错误按类型提供多个上下文值 - eyre vs anyhow:简单场景用 anyhow,需要详细报告(位置、backtrace、建议)用 eyre + color-eyre
最核心的一条原则:错误类型是你的 API 的一部分。对库来说,错误枚举的设计和函数签名的设计同等重要——它决定了调用者能多精细地处理异常情况。对应用来说,错误信息的质量直接决定了运维排障的效率。
支持与分享
如果这篇文章对你有帮助,欢迎分享给更多人或赞助支持!
TinyZ's Blog