Rust 错误处理最佳实践

Rust 的错误处理机制是其最出色的设计之一。Result<T, E> 类型强制开发者在编译期就考虑错误路径,避免了运行时意外的 panic。但随着项目规模增长,如何在库代码和应用代码中合理地组织错误类型,成了一个值得深入探讨的话题。

本文将结合我在 MarkdownKit(一个 Rust Markdown 解析库)开发过程中的实际经验,分享 Rust 错误处理的最佳实践。

基础:自定义错误类型

在小型库或模块中,推荐使用 thiserror 来定义错误类型。它提供了 #[derive(Error)] 宏,让你用声明式的方式定义错误:

use thiserror::Error;

#[derive(Error, Debug)]
pub enum MarkdownError {
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),

    #[error("Parse error at line {line}: {message}")]
    Parse { line: usize, message: String },

    #[error("Invalid heading level: {0} (must be 1-6)")]
    InvalidHeadingLevel(u8),

    #[error("Unclosed delimiter: {0}")]
    UnclosedDelimiter(String),
}

thiserror 的几个关键优势:

  • #[from] 自动生成 From 实现,方便使用 ? 操作符传播上游错误
  • #[error("...")] 自动生成 Displaystd::error::Error 实现
  • 支持结构体变体和元组变体,灵活携带上下文信息

库 vs 应用:两种不同的哲学

这是 Rust 社区中一个经典的区分:

📦 库代码(Library)

暴露明确的、结构化的错误类型,让调用者可以精确地 match 和处理每种错误。使用 thiserror 定义枚举,作为公开 API 的一部分。

🖥️ 应用代码(Application)

关注的是"把错误信息清晰地报告给用户或日志",而非让调用者进行细粒度匹配。使用 anyhow 简化错误传播链。

实际案例:MarkdownKit 的解析流程

在 MarkdownKit 中,解析过程涉及多个阶段——词法分析、块解析、行内解析、HTML 渲染。每个阶段都可能出错。我采用了分层错误类型的设计:

// 公开 API 错误类型
#[derive(Error, Debug)]
pub enum MarkdownError {
    #[error("Parse error: {0}")]
    Parse(#[from] ParseError),

    #[error("Render error: {0}")]
    Render(#[from] RenderError),

    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
}

// 内部解析错误(不公开)
#[derive(Error, Debug)]
enum ParseError {
    #[error("{0}")]
    Message(String),
}

// 内部渲染错误(不公开)
#[derive(Error, Debug)]
enum RenderError {
    #[error("Unsupported node type at position {pos}")]
    UnsupportedNode { pos: usize },
}

这样设计的好处是:外部调用者只需要关心 MarkdownError,而内部的解析和渲染实现可以自由演进而不影响公开 API。

使用 anyhow 处理"足够好"的错误

在 CLI 工具或一次性脚本中,anyhow 是更好的选择。它让你无需定义自己的错误类型:

use anyhow::{Context, Result};

fn read_config(path: &str) -> Result<Config> {
    let content = std::fs::read_to_string(path)
        .with_context(|| format!("Failed to read config file: {}", path))?;

    let config: Config = toml::from_str(&content)
        .with_context(|| "Failed to parse config TOML")?;

    Ok(config)
}

.with_context() 是 anyhow 最强大的功能之一。它为错误链添加人类可读的上下文,形成清晰的错误追踪:

Error: Failed to read config file: /etc/myapp/config.toml

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

何时选择什么?

场景 推荐方案
公开 Rust 库 thiserror + 枚举
CLI 工具 anyhow
Web 服务(如 Axum/Actix) 自定义错误 + IntoResponse
混合场景 库用 thiserror,应用层用 anyhow 包装

总结

Rust 的错误处理生态已经非常成熟。记住这条黄金法则:

库代码暴露类型化的错误(thiserror),应用代码关注错误报告(anyhow)。两种方案不是互斥的——你可以在同一个项目的不同层次使用它们。

最终目标是让你的代码既健壮又易于维护。错误处理本身也是一种 API 设计——好的错误信息能让调用者快速定位问题,而不必深入你的实现细节。