RUST:错误处理 thiserror + anyhow 工程化

RUST:错误处理 thiserror + anyhow 工程化详解

Rust 项目中的错误处理,真正困难的部分通常不是写出 Result<T, E>,也不是记住 ? 的语法,而是决定下面这些问题:

  • 哪一层需要知道错误的具体种类?
  • 哪些上下文应该作为结构化字段保存?
  • 哪些错误只需要交给最终操作人员阅读?
  • 底层错误应该怎样保留,而不是被一段新字符串覆盖?
  • 一个错误应该在哪里记录,才能既不丢信息,也不重复打印?
  • 库接口应该返回自己的错误类型,还是直接返回 anyhow::Error?

thiserror 和 anyhow 正好解决这条错误路径上的两个不同问题。

thiserror 适合定义错误。它帮助我们为枚举或结构体实现 Display、std::error::Error、source() 和 From,但最终得到的仍然是普通 Rust 错误类型。

anyhow 适合汇总和报告错误。它把满足约束的具体错误装进统一的 anyhow::Error,允许应用层继续添加上下文、遍历原因链,并在最终处理边界统一输出。

它们不是竞争关系。常规工程中更常见的组合是:

text 复制代码
可复用库、领域层、适配层
    返回 thiserror 定义的具体错误
                │
                │ `?` / From / map_err
                ▼
应用编排层、CLI main、一次性后台任务
    使用 anyhow 汇总并补充任务上下文
                │
                ▼
最终处理边界
    记录一次、映射退出码或生成安全响应

本文使用一个完整的小项目贯穿这条路径:扫描一组插件 JSON 清单,校验内容,检查重复 ID,最后生成稳定排序的插件目录文件。项目虽小,却包含普通命令行工具和服务启动代码都会遇到的文件 I/O、反序列化、业务校验、聚合、临时文件、错误链和退出码。

一、版本与依赖

示例使用 Rust 1.95.0 和 Rust 2024 edition。核心依赖版本如下:

toml 复制代码
thiserror = "=2.0.21"
anyhow = "=1.0.104"
serde = { version = "=1.0.229", features = ["derive"] }
serde_json = "=1.0.151"
tempfile = "=3.27.0"

这里没有展示完整 Cargo.toml。依赖的职责分别是:

  • thiserror:生成标准错误 trait 的实现。
  • anyhow:在可执行程序边界汇总错误并增加上下文。
  • serde、serde_json:读取和生成 JSON。
  • tempfile:在输出目录中安全创建有名字的临时文件,并在成功后提交。

根据 thiserror 2.0.21 文档,这个 crate 提供的是 std::error::Error 的派生宏。是否使用 thiserror 不会改变错误类型的公开语义:手写同样的 trait 实现与使用派生宏,对调用者而言没有本质差别。

anyhow 1.0.104 文档把 anyhow::Error 定义为面向 Rust 应用的动态错误类型。它接受实现了标准 Error trait,且满足线程安全和生命周期要求的错误,并保留错误原因链。

二、先决定错误边界,再选择错误库

下面两种函数看起来都很合理:

rust 复制代码
fn load_manifest(path: &Path) -> Result<PluginManifest, ManifestError>;

fn run_application(options: BuildOptions) -> anyhow::Result<BuildReport>;

区别不在函数长短,而在调用者要做什么。

load_manifest 是可复用的库接口。它的调用者可能需要区分:

  • 文件读不到;
  • JSON 语法或字段类型错误;
  • 插件 ID 不符合规则;
  • 入口路径是绝对路径或包含 ..。

调用者可能对不同错误采取不同动作。例如,编辑器可以把 JSON 错误定位到具体文件,批处理工具可以把非法 ID 汇总成报告,测试可以直接检查 io::ErrorKind::NotFound。因此,这个接口应该返回结构化的 ManifestError。

run_application 已经位于可执行程序上层。它只执行一次"从某个目录生成某个输出"的任务。失败后不会由同一进程里的另一段业务代码继续恢复;它需要做的是补充本次任务的上下文,然后把完整原因交给最终处理边界。因此,这一层使用 anyhow::Result 更合适。

一个简单判断方法是:

问题 更适合的做法
调用者需要 match 不同失败并采取不同动作 thiserror 定义具体错误
错误属于公开库 API 的一部分 thiserror 或手写错误类型
当前函数位于 CLI、服务启动、迁移程序等最上层 anyhow::Result
需要把多种下层错误统一向人报告 anyhow
需要稳定错误码、HTTP 状态码或 RPC 状态码 先保留结构化错误,再显式映射
只是嫌枚举麻烦,想把所有库函数都改成 anyhow::Result 通常不合适

这不是硬性规定。例如,一个只在单个二进制内部使用、永远不会被其他代码匹配的模块,可以直接使用 anyhow。反过来,即使位于应用层,只要需要稳定分支、重试策略或协议映射,也应该保留具体错误类型。

三、普通 Result 错误、panic、取消和退出不是同一种状态

在继续写代码前,需要先把几个经常混淆的概念分开。

3.1 Result::Err

Err 表示函数合同内已经预期到的失败。文件不存在、JSON 不合法、ID 重复、输出目录不可写,都可以用 Result 表达。调用者可以检查、转换、重试或报告这些错误。

3.2 panic

panic 通常表示程序内部假设被破坏,例如本应永远存在的状态却不存在,或者数组索引越界。它会开始栈展开或直接终止进程,取决于编译设置。不要用 panic 代替普通输入校验:一个用户写错 JSON,不应该被描述成程序内部崩溃。

测试中的 expect 和 panic! 是另一回事。测试代码可以用它们表明"若这个准备步骤失败,测试本身没有继续执行的意义"。生产路径仍然必须返回结构化错误。

3.3 取消与超时

异步 Future 被丢弃、任务收到取消信号或等待超时,都不自动等于业务操作已经撤销。若文件已经写入、数据库事务已经提交或远端请求已经发送,停止等待不会让副作用倒退。取消和超时应该有自己的状态或错误变体,不能伪装成普通业务校验失败。

本文项目是同步 CLI,没有异步取消,但同样的错误分层规则可以直接用于 Tokio 程序。

3.4 进程退出码

退出码是进程对调用者提供的粗粒度结果,不是 Rust 错误类型。本文规定:

  • 0:构建成功;
  • 2:命令行使用方式错误;
  • 1:已经进入构建流程,但扫描、解析、校验或写入失败。

把所有错误都返回 1 并不意味着错误类型也应该合并成一段字符串。进程外只看到粗粒度退出码,进程内仍然需要准确错误。

四、主线项目:插件清单构建器

每个插件使用一个 JSON 清单:

json 复制代码
{
  "id": "search",
  "name": "Search Documents",
  "version": "1.2.0",
  "entry": "bin/search"
}

构建器执行下面的步骤:

text 复制代码
命令行参数
  │
  ├─ manifest 目录
  └─ catalog 输出文件
          │
          ▼
扫描目录中直接存在的普通 .json 文件
          │
          ▼
读取字节 → JSON 反序列化 → 业务校验
          │
          ▼
检查重复 ID,并按 ID 排序
          │
          ▼
先完成 JSON 序列化
          │
          ▼
在输出目录创建临时文件
          │
          ▼
write_all → flush → sync_all → persist
          │
          ▼
正式 catalog.json

业务规则很明确:

  1. id 不能为空,只能包含 ASCII 小写字母、数字和连字符。
  2. entry 不能为空,必须是普通相对路径,不能包含根目录、Windows 驱动器前缀或 ..。
  3. 未知 JSON 字段必须报错,防止拼错字段后被静默忽略。
  4. 同一个目录不能出现重复插件 ID。
  5. 输出按 ID 排序,不能依赖 read_dir 的遍历顺序。
  6. 只有所有清单都成功时才提交新输出。
  7. 构建器只验证入口路径的表示形式,不执行插件,也不声称该路径已经存在或获得执行授权。

最后一条尤其重要。PathBuf 只是拥有路径数据的类型。一个值能被解析成 PathBuf,不证明目标存在、是普通文件、没有符号链接跳转、属于授权目录或可以安全执行。

五、项目分层与依赖方向

项目拆成三个生产代码文件:

text 复制代码
src/lib.rs
  ├─ 公开数据类型
  ├─ ManifestError
  └─ CatalogError

src/catalog.rs
  ├─ load_manifest
  ├─ build_catalog
  ├─ 业务校验
  └─ 临时文件提交

src/main.rs
  ├─ 参数解析
  ├─ anyhow 上下文
  ├─ 最终错误输出
  └─ 退出码

依赖方向是单向的:命令行层调用库层,库层不知道命令行如何打印错误,也不知道最终进程要返回哪个退出码。

这条边界有两个直接好处。

第一,库层可以被测试、桌面界面、HTTP 服务或其他 Rust 程序复用。调用者能够匹配 CatalogError,而不必解析中文错误消息。

第二,输出策略不会污染业务代码。将来如果 CLI 改成结构化日志,或者 HTTP 服务要把 DuplicateId 映射为 409,库层无需修改。

六、thiserror 定义的是标准 Rust 错误

错误类型的核心形式如下:

rust 复制代码
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ManifestError {
    #[error("无法读取插件清单 {path:?}")]
    Read {
        path: PathBuf,
        #[source]
        source: io::Error,
    },

    #[error("插件清单 {path:?} 不是符合要求的 JSON")]
    Decode {
        path: PathBuf,
        #[source]
        source: serde_json::Error,
    },

    #[error("插件清单 {path:?} 的 id {id:?} 非法:只能包含 ASCII 小写字母、数字和连字符")]
    InvalidId { path: PathBuf, id: String },

    #[error("插件清单 {path:?} 的入口路径 {entry:?} 不安全:{reason}")]
    UnsafeEntry {
        path: PathBuf,
        entry: PathBuf,
        reason: &'static str,
    },
}

#[derive(Debug, Error)] 中的 Error 来自 thiserror。它会生成标准 std::error::Error 实现;Debug 仍然由标准派生宏生成。

#[error("...")] 生成 Display。这里的字符串面向阅读错误的人,字段仍然保存在枚举内部。显示文本不是结构化字段的替代品。

以 InvalidId 为例,调用者可以准确取得 path 和 id:

rust 复制代码
match error {
    ManifestError::InvalidId { path, id } => {
        // 可以把 path 定位到编辑器,把 id 放入结构化诊断。
    }
    _ => {
        // 处理未来新增或当前不关心的错误。
    }
}

不要反过来解析 error.to_string()。显示文本可能改写、翻译或增加标点,依赖它做分支会让程序行为与文案耦合。

6.1 为什么错误要拥有路径

ManifestError 保存 PathBuf,不是 &Path。原因是错误会离开产生它的函数,甚至可能被放进任务结果、日志队列或动态错误容器。若错误借用局部路径或临时字符串,生命周期会迅速变得复杂。

构造错误时调用 path.to_path_buf() 会复制路径数据。这个开销只发生在失败路径,换来的是简单而可靠的所有权。

6.2 #[non_exhaustive] 保护什么

公开错误枚举也是 API。没有 #[non_exhaustive] 时,下游 crate 可以穷尽匹配全部变体;库增加一个新变体可能导致下游编译失败。

加上 #[non_exhaustive] 后,外部调用者必须保留通配分支。这为新增错误种类留出空间。代价是调用者不能用穷尽匹配证明"所有错误都已经处理"。对于会持续演进的公共库,这通常是合理取舍;只在单个二进制内部使用的私有枚举则不一定需要它。

6.3 kind() 与人类可读消息

示例为错误提供稳定种类:

rust 复制代码
pub fn kind(&self) -> &'static str {
    match self {
        Self::Read { .. } => "manifest-read",
        Self::Decode { .. } => "manifest-decode",
        Self::InvalidId { .. } => "manifest-invalid-id",
        Self::UnsafeEntry { .. } => "manifest-unsafe-entry",
    }
}

这种短标识适合结构化日志、统计和测试。它不应该替代枚举本身,也不必包含路径和完整原因。公开协议若需要长期稳定的错误码,应另外明确版本兼容规则,不能因为返回了 &'static str 就自动获得协议稳定性。

七、#[source] 保留原因,#[from] 生成转换

这两个属性经常一起出现,但职责并不相同。

7.1 #[source]

下面的 source 字段保存底层 io::Error:

rust 复制代码
Read {
    path: PathBuf,
    #[source]
    source: io::Error,
}

生成的 std::error::Error::source() 会返回这个底层错误。于是上层既能看到"无法读取哪个插件清单",又能继续看到操作系统原因,例如文件不存在、权限拒绝或路径指向目录。

如果只写:

rust 复制代码
return Err(ManifestError::InvalidId { ... });

这个错误没有底层来源,因为它本身就是当前层根据业务规则得出的结论。并非每个错误都必须有 source。

7.2 #[from]

CatalogError 中有两个适合自动转换的变体:

rust 复制代码
#[error("插件清单处理失败")]
Manifest(#[from] ManifestError),

#[error("无法把插件目录序列化为 JSON")]
Serialize(#[from] serde_json::Error),

#[from] 会生成对应的 From 实现,而且同时把这个字段作为错误来源。于是:

rust 复制代码
let manifest = load_manifest(&path)?;

可以把 ManifestError 自动转换为 CatalogError::Manifest。原来的 ManifestError 没有被改成字符串,仍然可以通过错误链访问。

官方文档规定,带 #[from] 的变体不能再随意增加其他上下文字段。原因很直接:自动 From<Source> 只拿到了一个 Source,它不知道额外的路径、行号或业务 ID 应该从哪里来。

八、什么时候必须使用 map_err

读取清单的代码没有使用裸 ?:

rust 复制代码
let bytes = fs::read(path).map_err(|source| ManifestError::Read {
    path: path.to_path_buf(),
    source,
})?;

fs::read 只返回 io::Error。这个错误包含操作系统种类和文本,却不知道应用正在读取哪个插件清单。若写成:

rust 复制代码
let bytes = fs::read(path)?;

就要求 ManifestError 存在 From<io::Error>。即使增加这样的转换,错误中也只有底层 I/O 信息,没有 path。

map_err 的闭包借用输入 path,只有在 fs::read 返回错误时才执行。闭包把路径复制成 PathBuf,并移动底层 io::Error 到 source 字段。随后 ? 提前返回已经构造好的 ManifestError。

JSON 解析同理:

rust 复制代码
let manifest: PluginManifest =
    serde_json::from_slice(&bytes).map_err(|source| ManifestError::Decode {
        path: path.to_path_buf(),
        source,
    })?;

工程中可以采用一条简单规则:

  • 当前层只是接受下层错误类型,不需要新增事实:用 #[from] 和 ?。
  • 当前层知道文件路径、操作阶段、记录 ID、远端地址等下层不知道的事实:用具名字段和 map_err。

不要为了减少几行代码而丢掉唯一能定位问题的上下文。

九、? 实际做了什么

在下面这行中:

rust 复制代码
let manifest = load_manifest(&path)?;

load_manifest 返回 Result<PluginManifest, ManifestError>,而当前函数返回 Result<BuildReport, CatalogError>。

若结果是 Ok(manifest),? 取出 PluginManifest,继续执行。

若结果是 Err(error),? 大致执行下面的逻辑:

rust 复制代码
let manifest = match load_manifest(&path) {
    Ok(manifest) => manifest,
    Err(error) => return Err(CatalogError::from(error)),
};

真正的语言实现通过 Try 和残差转换完成,但对工程理解而言,上面的展开足够准确。

? 不会自动完成以下事情:

  • 不会自动记录日志;
  • 不会自动添加文件路径;
  • 不会把错误显示给用户;
  • 不会重试;
  • 不会回滚已经发生的外部副作用;
  • 不会触发 C++ 异常式的栈展开。

它只是根据 Result 的状态继续或提前返回,并在需要时做错误类型转换。

十、错误枚举应该按职责分层

把项目里的所有错误塞进一个巨大枚举,短期看起来省事,长期会混合不相关职责。

本项目使用两层错误:

text 复制代码
ManifestError
  ├─ Read
  ├─ Decode
  ├─ InvalidId
  └─ UnsafeEntry

CatalogError
  ├─ Scan / ReadDirectoryEntry / InspectEntry
  ├─ Manifest(ManifestError)
  ├─ DuplicateId
  ├─ Serialize
  ├─ Create / Write / Flush / Sync temporary
  └─ Commit

ManifestError 描述一个文件为什么不能成为合法插件清单。CatalogError 描述整个目录构建为什么失败。目录层不需要把 ManifestError 的四个变体再复制一遍;它通过一个 Manifest 变体保留下层错误。

这种分层不是要求每个函数都创建新错误。只有当一层形成了新的职责边界、调用者需要新的分类,或者这一层掌握新的上下文时,才值得增加错误类型。

错误包装也不是越多越好。下面的链条没有提供有效信息:

text 复制代码
处理失败
  → 操作失败
    → 执行失败
      → 文件不存在

有效链条应该说明不同抽象层次的事实:

text 复制代码
无法从 manifests 构建插件目录 catalog.json
  → 插件清单处理失败
    → 插件清单 broken.json 不是符合要求的 JSON
      → trailing comma at line 6 column 1

十一、anyhow 放在应用边界

命令行层的核心函数只有一段:

rust 复制代码
fn run_application(options: BuildOptions) -> anyhow::Result<BuildReport> {
    let manifest_dir = options.manifest_dir.clone();
    let output_file = options.output_file.clone();

    build_catalog(&options).with_context(|| {
        format!(
            "无法从 {} 构建插件目录 {}",
            manifest_dir.display(),
            output_file.display()
        )
    })
}

库函数仍返回 CatalogError。进入 run_application 后,本次操作失败只需要向最终边界报告,不再由更高业务层精细恢复,因此这里把具体错误转换成 anyhow::Error。

转换不是丢弃原错误。CatalogError 被保存在 anyhow::Error 的原因链里,仍可遍历,也可以在诊断场景中 downcast。

11.1 context 与 with_context

Context trait 提供两个常用方法:

rust 复制代码
result.context("固定上下文")?;

result.with_context(|| format!("读取配置 {}", path.display()))?;

context 适合静态字符串或已经存在的值。

with_context 接收闭包,只在原结果是 Err 时构造上下文。若格式化需要路径、任务 ID 或其他分配,闭包可以避免成功路径上的无用工作。

上下文应该回答"当前正在做什么",而不是重复底层错误。例如:

  • 好:无法从 manifests 构建插件目录 catalog.json;
  • 差:发生错误;
  • 差:构建失败:插件清单处理失败,因为下层已经会显示后半句;
  • 危险:把完整配置、token、密码或用户正文拼进上下文。

11.2 为什么公共库不默认返回 anyhow::Result

如果 build_catalog 直接返回 anyhow::Result<BuildReport>,调用者仍能打印错误,但失去了清晰的编译期合同。它不知道可能出现 DuplicateId,也不能自然读取 first 和 second 路径。

downcast 可以补救一部分信息,但如果正常业务分支处处依赖 downcast,就说明错误在过早的位置被类型擦除了。

应用内部的私有辅助函数不必机械遵守这一条。如果一个函数只由 main 调用,失败后只有统一报告一种行为,返回 anyhow::Result 完全合理。关键不是"库文件还是 main 文件",而是调用者是否需要稳定、结构化地理解错误。

十二、最终边界只记录一次

库层发生错误时不打印:

rust 复制代码
let manifest = load_manifest(&path)?;

它只把错误交给调用者。run_application 也不打印,只增加一次任务上下文。最后由 main 统一处理:

rust 复制代码
eprintln!("错误:{error}");
for cause in error.chain().skip(1) {
    eprintln!("  原因:{cause}");
}

若每一层都先 eprintln! 再返回,同一个错误会出现多次,而且上层无法判断下层是否已经记录过。服务项目中同样如此:Repository、Service、Handler 和中间件不应全部打印同一个数据库错误。一般选择最了解请求身份、任务 ID、最终状态和日志策略的处理边界记录一次。

有些错误会在下层记录局部事件,例如一次重试失败。那条日志应明确表示"第 1 次尝试失败,准备重试",而不是把它冒充最终失败。最终失败仍由最终处理边界记录。

十三、错误的四种显示方式

假设 error 是 anyhow::Error。

13.1 {error}

只显示最外层 Display:

text 复制代码
无法从 manifests 构建插件目录 catalog.json

适合先给出简洁结论,但单独使用时可能缺少根因。

13.2 {error:#}

anyhow 的 alternate Display 会把原因链组合成一行。它适合简短终端工具,但长路径和复杂错误可能让一行难以阅读。

13.3 {error:?}

Debug 通常包含错误链,并在启用时包含 backtrace。它更适合开发诊断,不适合直接作为稳定用户协议。

13.4 error.chain()

显式遍历原因链可以控制布局、编号和脱敏。本文 CLI 先打印外层错误,再逐层打印 原因。skip(1) 跳过已经单独打印的最外层。

对于 thiserror 的具体错误,可以从 std::error::Error::source() 开始自行遍历。anyhow::chain() 则直接提供包含最外层错误的迭代器。

十四、downcast 的正确边界

本文 CLI 包含:

rust 复制代码
if let Some(catalog_error) = error.downcast_ref::<CatalogError>() {
    eprintln!("错误种类:{}", catalog_error.kind());
}

这里的 downcast 只补充诊断字段。无论是否成功,退出码都是 1,也不会由此决定是否重试或修改业务状态。

这类用法比较安全:

  • 崩溃报告或日志系统想提取一个已知错误种类;
  • 迁移旧接口时暂时兼容具体错误;
  • 测试确认添加 Context 后原始错误仍然存在。

如果代码变成下面这样,就应该重新考虑错误边界:

rust 复制代码
if error.downcast_ref::<DuplicateIdError>().is_some() {
    // 业务分支 A
} else if error.downcast_ref::<PermissionError>().is_some() {
    // 业务分支 B
}

正常业务控制流需要这些分支时,应让函数返回一个明确的错误枚举,而不是先擦除类型再尝试恢复。

十五、安全提交输出不是错误库自动提供的

错误类型设计正确,不等于外部副作用自动安全。若程序直接打开正式 catalog.json 并截断写入,写到一半失败时,旧文件已经损坏。

本文采用:

  1. 先扫描、解析、校验、去重和序列化;
  2. 在正式输出所在目录创建临时文件;
  3. 写入全部字节;
  4. 调用 flush;
  5. 对文件调用 sync_all;
  6. 使用 persist 替换正式路径。

同目录临时文件避免跨文件系统移动。NamedTempFile::persist 的文档说明,目标已存在时会进行原子替换;跨文件系统不能持久化。文档也明确指出:persist 本身不会同步文件内容或父目录。因此示例先对文件执行 sync_all,但仍没有同步父目录,不能把它描述成应对所有掉电时刻的完整持久化事务。严格耐久性要求还需要按目标操作系统和文件系统处理目录同步等问题。

tempfile 的 Drop 清理同样不是崩溃恢复机制。正常返回和栈展开会运行析构;process::exit、进程被强制终止或机器掉电时,不保证析构执行。长期运行程序仍应有临时文件命名和启动清理策略。

错误处理库负责表达失败,不负责撤销副作用。事务、幂等、补偿和安全文件替换必须由业务代码实现。

十六、用户提示、日志和内部错误不是同一份文本

这个 CLI 是开发工具,因此可以在终端显示本地路径和 JSON 行列。但若相同库被 HTTP 服务调用,公开响应不应直接返回:

text 复制代码
C:\service\private\plugins\search.json 读取失败

更合理的分层是:

  • 内部错误保存真实路径、底层 io::Error 和源链;
  • 结构化日志记录允许公开的路径标识、错误种类和请求 ID;
  • 对外响应使用稳定错误码和安全说明;
  • token、密码、Authorization、完整用户正文和敏感文件内容不进入任何默认错误文本。

Display 并不天然安全。把字段写进 #[error("...")] 就意味着它可能进入终端、日志、测试快照或 HTTP 响应。设计错误类型时要同时考虑"需要调查什么"和"允许向谁显示什么"。

十七、测试错误行为,而不是只比较整段字符串

下面的测试检查目录不存在:

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

它验证了三个稳定事实:错误变体是 Scan,保存了正确路径,底层 I/O 种类是 NotFound。操作系统的完整错误文本可能因语言和版本变化,因此不适合做稳定断言。

重复 ID 测试直接检查 id、first 和 second。旧输出保护测试先写入 known-good,再注入非法清单,最后确认原文件没有变化。

anyhow 边界测试检查:

rust 复制代码
let error = build_catalog(&options)
    .with_context(|| "构建测试插件目录失败")
    .expect_err("不存在的目录必须失败");

assert_eq!(error.to_string(), "构建测试插件目录失败");
assert!(error.downcast_ref::<CatalogError>().is_some());
assert_eq!(error.chain().count(), 3);

这里证明三件事:最外层上下文存在,具体 CatalogError 没有丢失,错误链还包含底层 I/O 原因。

进程级测试则真正启动二进制,检查退出码、标准输出和标准错误。直接调用 main 附近的辅助函数不能证明进程退出码正确。

十八、完整生产代码

下面给出项目的全部生产代码。测试代码只展示关键断言;运行行为由后文的命令和结果说明。

18.1 src/lib.rs:公开类型和结构化错误

rust 复制代码
//! 本地插件清单构建器的库层,也是理解 `thiserror` 职责的阅读入口。
//!
//! 正常路径读取一个目录中的 JSON 清单,完成业务校验和重复 ID 检查,
//! 再把排序后的目录写入输出文件。故意失败路径由测试和 fixtures 覆盖:
//! 目录不存在、JSON 错误、非法 ID、不安全入口路径、重复 ID 和输出失败。
//!
//! 库层使用 `thiserror` 定义可以被调用者匹配的具体错误类型。这里不会使用
//! `anyhow::Error` 抹去错误的静态类型,因为上层可能需要根据变体决定退出码、
//! 重试方式或用户提示。`anyhow` 只在 `main.rs` 的应用边界负责汇总和报告。

// `catalog` 是当前 crate 的私有实现模块。外部调用者不能写
// `plugin_catalog_builder::catalog::...`,只能使用下面显式公开的函数和类型。
mod catalog;

// `io` 是 `std::io` 模块的短名称,供错误变体保存具体的 `io::Error`。
use std::io;
// `PathBuf` 拥有路径数据;错误离开函数后仍要保存路径,所以不能只存临时的 `&Path`。
use std::path::PathBuf;

// `Deserialize` 让 JSON 可以生成 Rust 值,`Serialize` 让目录可以写回 JSON。
// derive 宏在编译期生成 trait 实现,不会在运行时使用反射。
use serde::{Deserialize, Serialize};
// `Error` 是 thiserror 提供的派生宏;它生成 Display、Error 和必要的 From 实现。
use thiserror::Error;

// `pub use` 把私有模块中的两个函数重新导出到 crate 根部。
// 因此调用者只需导入 `plugin_catalog_builder::build_catalog`,不必知道内部模块布局。
pub use catalog::{build_catalog, load_manifest};

/// 单个插件清单,对应一个 JSON 文件成功反序列化后的线格式数据。
///
/// `deny_unknown_fields` 让拼错的字段直接失败,避免错误输入被静默忽略。
/// 四个字段都拥有自己的数据,因此返回值不借用读取文件时使用的字节缓冲区。
// `Debug` 用于诊断,`Clone` 允许显式复制,`PartialEq + Eq` 便于测试完整比较,
// `Serialize + Deserialize` 分别支持写出与读入 JSON。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
// 这个 Serde 容器属性把未知字段归类为反序列化错误,而不是忽略它们。
#[serde(deny_unknown_fields)]
pub struct PluginManifest {
    // `String` 拥有插件 ID;构建器会校验字符集合,并把它用作排序和去重键。
    pub id: String,
    // 显示名称目前只负责进入输出目录,不参与 ID 唯一性判断。
    pub name: String,
    // 版本保持为字符串;本案例不把它解析成语义化版本,也不暗示已经验证版本规则。
    pub version: String,
    // `PathBuf` 保存入口路径的原生平台表示;后续只校验其表示,不执行入口文件。
    pub entry: PathBuf,
}

/// 最终写入磁盘的插件目录。
///
/// `plugins` 在 `build_catalog` 中按 ID 排序,使输出不受文件系统遍历顺序影响。
// 派生的 trait 与 `PluginManifest` 相同,便于把整个目录序列化并在测试中读回比较。
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct PluginCatalog {
    // `Vec` 拥有全部清单;目录离开构建函数后不依赖扫描阶段的任何局部变量。
    pub plugins: Vec<PluginManifest>,
}

/// 一次构建所需的输入路径和输出路径。
///
/// 两个 `PathBuf` 都由调用者构造并移动到该结构中;`build_catalog` 只共享借用
/// `&BuildOptions`,因此执行期间不会取得或销毁调用者拥有的配置对象。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BuildOptions {
    // 包含各个 JSON 清单的目录;扫描只查看这一层,不递归进入子目录。
    pub manifest_dir: PathBuf,
    // 最终 `catalog.json` 的目标路径;临时文件会创建在它的父目录中。
    pub output_file: PathBuf,
}

/// 构建成功后交给命令行层显示的摘要。
///
/// 失败时不会产生该类型;错误会以 `CatalogError` 从库层返回。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct BuildReport {
    // 成功写入的清单数量;`usize` 与内存集合长度类型一致。
    pub manifest_count: usize,
    // 克隆一份最终输出路径,让 CLI 无需继续借用原始 `BuildOptions`。
    pub output_file: PathBuf,
}

/// 读取和校验单个清单时可能发生的错误。
///
/// `thiserror` 适合这里,因为调用者需要区分"读文件失败""JSON 错误"和
/// "业务字段非法"。每个变体保存机器可匹配的结构,而不是只保存一段字符串。
///
/// `#[non_exhaustive]` 允许库将来增加错误变体。外部调用者匹配时必须保留
/// 通配分支,不能假定当前列出的变体永远完整。
// `Debug` 供诊断和测试使用;`Error` 生成 `Display` 与 `std::error::Error` 实现。
#[derive(Debug, Error)]
// 这个属性影响外部 crate 的模式匹配,是公开 API 的兼容性选择。
#[non_exhaustive]
pub enum ManifestError {
    // `#[error(...)]` 只定义面向人的顶层显示文本;字段仍可被程序直接读取。
    #[error("无法读取插件清单 {path:?}")]
    Read {
        // 保存发生失败的路径,因为单独的 `io::Error` 不保证携带业务对象路径。
        path: PathBuf,
        // `#[source]` 告诉标准错误链:真正的底层原因是这个 I/O 错误。
        #[source]
        source: io::Error,
    },

    // JSON 的语法、类型或未知字段错误统一属于单文件解码阶段。
    #[error("插件清单 {path:?} 不是符合要求的 JSON")]
    Decode {
        // 路径由当前适配层补充;serde_json 自身只知道输入字节和行列位置。
        path: PathBuf,
        // 保留原始 `serde_json::Error`,调用者仍可检查类别、行号和列号。
        #[source]
        source: serde_json::Error,
    },

    // 这个变体没有底层库错误,因为失败来自本项目自己的 ID 业务规则。
    #[error("插件清单 {path:?} 的 id {id:?} 非法:只能包含 ASCII 小写字母、数字和连字符")]
    InvalidId {
        // 保存来源文件,便于用户定位具体输入。
        path: PathBuf,
        // 保存被拒绝的 ID,便于测试结构化字段和生成有针对性的提示。
        id: String,
    },

    // 入口路径语法不安全时,同时报告文件、入口值和稳定原因。
    #[error("插件清单 {path:?} 的入口路径 {entry:?} 不安全:{reason}")]
    UnsafeEntry {
        // 指明是哪一个清单声明了不安全入口。
        path: PathBuf,
        // 拥有入口路径,错误离开校验函数后仍然有效。
        entry: PathBuf,
        // 原因来自代码中的固定规则,因此使用无需分配的静态字符串切片。
        reason: &'static str,
    },
}

/// 为单清单错误提供稳定分类。
///
/// `self` 通过共享借用传入,函数不会移动或修改错误。返回的字符串拥有
/// `'static` 生命周期,因为它们都是编译进程序的字面量。日志、指标和测试可以
/// 使用该值,而不必解析可能为了可读性而调整的 `Display` 文本。
impl ManifestError {
    // `&self` 表示只读借用;`-> &'static str` 表示返回固定类别名。
    pub fn kind(&self) -> &'static str {
        // `match` 必须覆盖当前 crate 内的所有变体,新增变体时编译器会提醒补充分支。
        match self {
            // `..` 忽略当前分类不需要读取的字段,错误本身仍保持完整。
            Self::Read { .. } => "manifest-read",
            Self::Decode { .. } => "manifest-decode",
            Self::InvalidId { .. } => "manifest-invalid-id",
            Self::UnsafeEntry { .. } => "manifest-unsafe-entry",
        }
    }
}

/// 扫描目录、组合清单和提交输出时可能发生的错误。
///
/// 该枚举是构建流程的公共错误协议。它既能保存当前层掌握的目录、条目和输出
/// 路径,也能把 `ManifestError` 作为下一层原因继续挂在错误链中。
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum CatalogError {
    // `read_dir` 建立目录迭代器失败时使用该变体。
    #[error("无法扫描插件清单目录 {dir:?}")]
    Scan {
        // 当前层知道正在扫描哪个目录,因此在这里补充该上下文。
        dir: PathBuf,
        // 原始 I/O 错误保留错误种类和操作系统信息。
        #[source]
        source: io::Error,
    },

    // 已经得到目录迭代器,但读取某个条目失败时使用该变体。
    #[error("读取目录 {dir:?} 中的条目失败")]
    ReadDirectoryEntry {
        // 目录路径说明失败发生在哪一次扫描中。
        dir: PathBuf,
        // 具体失败仍由底层 I/O 错误解释。
        #[source]
        source: io::Error,
    },

    // 读取条目成功,但查询它是文件、目录还是符号链接时可能失败。
    #[error("检查目录条目 {path:?} 的文件类型失败")]
    InspectEntry {
        // 保存具体条目路径,而不是只保存父目录。
        path: PathBuf,
        // 让 `Error::source()` 能继续访问 `io::Error`。
        #[source]
        source: io::Error,
    },

    // 透明包装一个单清单错误。`#[from]` 同时生成
    // `From<ManifestError> for CatalogError`,因此 `load_manifest(...)?` 可以自动转换。
    #[error("插件清单处理失败")]
    Manifest(#[from] ManifestError),

    // 去重失败是构建层业务错误,不对应某个外部库的 source。
    #[error("插件 id {id:?} 重复:第一次出现在 {first:?},再次出现在 {second:?}")]
    DuplicateId {
        // 保存发生冲突的稳定业务键。
        id: String,
        // 保存排序后第一次遇到该 ID 的清单路径。
        first: PathBuf,
        // 保存再次声明同一 ID 的清单路径。
        second: PathBuf,
    },

    // 这里只有一个底层错误且无需新增上下文字段,所以元组变体配合 `#[from]` 最简洁。
    #[error("无法把插件目录序列化为 JSON")]
    Serialize(#[from] serde_json::Error),

    // 创建临时文件失败时,正式输出尚未被修改。
    #[error("无法在输出目录 {dir:?} 中创建临时文件")]
    CreateTemporary {
        // 保存目标父目录,说明权限或路径错误发生在哪里。
        dir: PathBuf,
        #[source]
        source: io::Error,
    },

    // `write_all` 失败时保存临时文件的实际路径。
    #[error("无法写入插件目录临时文件 {path:?}")]
    WriteTemporary {
        path: PathBuf,
        #[source]
        source: io::Error,
    },

    // `flush` 把用户态缓冲交给操作系统;此阶段失败与写入阶段分开分类。
    #[error("无法刷新插件目录临时文件 {path:?}")]
    FlushTemporary {
        path: PathBuf,
        #[source]
        source: io::Error,
    },

    // `sync_all` 请求把文件内容和元数据同步到存储设备。
    #[error("无法把插件目录临时文件同步到存储设备 {path:?}")]
    SyncTemporary {
        path: PathBuf,
        #[source]
        source: io::Error,
    },

    // `persist` 把已经写好的临时文件提交到正式输出路径。
    #[error("无法把临时文件提交为正式插件目录 {output:?}")]
    Commit {
        // 保存用户要求的正式输出路径,而不是只显示随机临时文件名。
        output: PathBuf,
        #[source]
        source: io::Error,
    },
}

/// 为整个构建过程提供稳定的顶层错误种类。
///
/// 单个清单的详细种类仍可从 `ManifestError::kind` 得到;这里不把所有下层
/// 细节重复展开成顶层枚举变体。`anyhow` 包装该错误后,仍可通过 downcast
/// 找回 `CatalogError` 并调用这个方法。
impl CatalogError {
    pub fn kind(&self) -> &'static str {
        match self {
            Self::Scan { .. } => "catalog-scan",
            Self::ReadDirectoryEntry { .. } => "catalog-read-entry",
            Self::InspectEntry { .. } => "catalog-inspect-entry",
            Self::Manifest(_) => "catalog-manifest",
            Self::DuplicateId { .. } => "catalog-duplicate-id",
            Self::Serialize(_) => "catalog-serialize",
            Self::CreateTemporary { .. } => "catalog-create-temporary",
            Self::WriteTemporary { .. } => "catalog-write-temporary",
            Self::FlushTemporary { .. } => "catalog-flush-temporary",
            Self::SyncTemporary { .. } => "catalog-sync-temporary",
            Self::Commit { .. } => "catalog-commit",
        }
    }
}

18.2 src/catalog.rs:读取、校验、组合与提交

rust 复制代码
//! 插件目录的读取、校验、排序和文件提交过程。
//!
//! 初次阅读建议依次看 `load_manifest`、`build_catalog`、`write_catalog_file`。
//! 正常路径会把全部清单读入内存,验证完成后才提交输出;失败路径通过
//! `ManifestError` 或 `CatalogError` 返回,不在库层打印日志,也不终止进程。

// `BTreeMap` 同时完成 ID 去重和按 ID 排序;它按键的全序保存元素。
use std::collections::BTreeMap;
// `fs` 提供同步文件系统操作。本案例是短命令行工具,所以没有引入异步运行时。
use std::fs;
// 导入 `Write` trait 才能对临时文件调用 `write_all` 和 `flush`。
// `as _` 让 trait 进入方法查找范围,但不在当前模块绑定名称 `Write`。
use std::io::Write as _;
// `Path` 是借用的路径视图;`PathBuf` 是拥有所有权、可存入结构和错误的路径值。
// `Component` 用于逐段检查路径中是否出现根目录、Windows 前缀或 `..`。
use std::path::{Component, Path, PathBuf};

// `NamedTempFile` 在目标目录创建唯一临时文件,并在未持久化时通过 Drop 清理。
use tempfile::NamedTempFile;

// 从 crate 根导入公共输入、输出和错误类型。实现模块使用这些类型完成业务流程。
use crate::{
    BuildOptions, BuildReport, CatalogError, ManifestError, PluginCatalog, PluginManifest,
};

/// 从磁盘读取并校验一个插件清单。
///
/// - `path: &Path` 是共享借用,函数不会取得调用者路径的所有权。
/// - 成功时返回完全拥有数据的 `PluginManifest`,不再借用文件字节。
/// - I/O 和 JSON 错误保存为 `source`;业务校验错误保存路径和非法值。
/// - 失败由调用者 `build_catalog` 使用 `?` 转换为 `CatalogError`。
pub fn load_manifest(path: &Path) -> Result<PluginManifest, ManifestError> {
    // `fs::read` 同步打开并完整读取文件,成功值 `Vec<u8>` 拥有全部字节。
    // `map_err` 的闭包只在读取失败时执行,并取得底层 `io::Error` 的所有权。
    let bytes = fs::read(path).map_err(|source| ManifestError::Read {
        // `path` 只是借用,错误必须在函数返回后继续存在,因此复制成 `PathBuf`。
        path: path.to_path_buf(),
        // 把原始 I/O 错误移动进 `source` 字段,保留 ErrorKind 和操作系统错误。
        source,
        // `?` 在成功时取出 `Vec<u8>`,失败时立即返回刚构造的 `ManifestError::Read`。
    })?;

    // `from_slice` 共享借用 `bytes`,并根据 `Deserialize` 实现创建拥有数据的清单。
    // 显式类型标注告诉 Serde 目标类型是 `PluginManifest`。
    let manifest: PluginManifest =
        serde_json::from_slice(&bytes).map_err(|source| ManifestError::Decode {
            // serde_json 知道行列却不知道文件名,因此当前文件适配层补充路径。
            path: path.to_path_buf(),
            // 原始 JSON 错误被移动到 source,错误链不会退化成普通字符串。
            source,
            // `?` 让解码失败直接离开本函数;成功值赋给 `manifest`。
        })?;

    // 同时借用路径和清单执行本项目的业务规则;校验函数不会取得二者所有权。
    // 校验失败由 `?` 原样返回,成功的 `()` 被丢弃后继续执行下一行。
    validate_manifest(path, &manifest)?;
    // 把拥有全部字段的清单移动给调用者;局部 `bytes` 随函数结束释放。
    Ok(manifest)
}

/// 扫描全部清单并生成正式目录文件。
///
/// `options` 通过共享借用传入,调用者仍拥有配置。该函数依次完成扫描、读取、
/// 校验、去重、序列化和提交。前四个阶段失败时尚未创建临时输出;写入阶段失败
/// 时,未持久化的 `NamedTempFile` 会在 Drop 时尝试清理,原正式输出保持不变。
pub fn build_catalog(options: &BuildOptions) -> Result<BuildReport, CatalogError> {
    // 共享借用输入目录;`?` 会把 `collect_manifest_paths` 的 CatalogError 原样返回。
    let manifest_paths = collect_manifest_paths(&options.manifest_dir)?;
    // 键拥有插件 ID;值同时保存来源路径和清单。类型标注明确集合中的所有权结构。
    let mut manifests_by_id: BTreeMap<String, (PathBuf, PluginManifest)> = BTreeMap::new();

    // `for` 消耗 `manifest_paths`,每次把一个 `PathBuf` 移动到局部变量 `path`。
    for path in manifest_paths {
        // `load_manifest` 只借用当前路径。其错误通过 thiserror 生成的
        // `From<ManifestError> for CatalogError` 被 `?` 转为 `CatalogError::Manifest`。
        // 转换不会格式化成字符串,原始变体和 source 链仍然存在。
        let manifest = load_manifest(&path)?;
        // 复制 ID 是有意的:一个副本成为 BTreeMap 的键,原清单仍保留自己的 id 字段。
        let id = manifest.id.clone();

        // `get` 共享借用 `id` 查找已有条目;匹配只借用第一次出现的路径和清单。
        if let Some((first, _)) = manifests_by_id.get(&id) {
            // 发现重复后立即返回结构化业务错误,不进入序列化或文件写入阶段。
            return Err(CatalogError::DuplicateId {
                // `id` 不再需要作为新键,因此把其所有权移动进错误。
                id,
                // `first` 是 map 内 PathBuf 的借用,错误需要拥有路径,所以显式克隆。
                first: first.clone(),
                // 当前循环拥有 `path`,可直接移动进错误,避免额外复制。
                second: path,
            });
        }

        // 唯一 ID 通过检查后,把 ID、来源路径和清单一起移入 BTreeMap。
        // 移动后本轮循环不能再使用这三个局部值。
        manifests_by_id.insert(id, (path, manifest));
    }

    // `into_values` 取得 BTreeMap 所有权,并按键顺序产出每个 `(PathBuf, PluginManifest)`。
    // `map` 丢弃仅用于诊断的来源路径,把清单逐个移动进最终 Vec,没有复制正文数据。
    let catalog = PluginCatalog {
        plugins: manifests_by_id
            .into_values()
            .map(|(_, manifest)| manifest)
            .collect(),
    };

    // `to_vec_pretty` 只借用 catalog,并返回拥有编码结果的 `Vec<u8>`。
    // `CatalogError::Serialize(#[from])` 生成 From 实现,所以 `?` 可以转换 JSON 错误。
    let mut encoded = serde_json::to_vec_pretty(&catalog)?;
    // 在 JSON 后追加换行,方便终端查看和文本工具处理;这会修改拥有的 Vec。
    encoded.push(b'\n');

    // 同时共享借用输出路径和编码字节;写入失败以 CatalogError 返回。
    write_catalog_file(&options.output_file, &encoded)?;

    // 只有正式文件提交成功后才构造成功报告,避免调用者误认为半成品已经可用。
    Ok(BuildReport {
        // `len` 只读取 Vec 长度;catalog 仍在当前作用域中拥有全部清单。
        manifest_count: catalog.plugins.len(),
        // options 只是借用,报告要独立拥有输出路径,因此克隆 PathBuf。
        output_file: options.output_file.clone(),
    })
}

/// 校验与 JSON 和文件写入方式无关的业务规则。
///
/// `path` 与 `manifest` 都是共享借用。成功返回 `Ok(())`,失败返回拥有路径和非法
/// 值的 `ManifestError`。这里不检查入口文件是否存在,也不授予执行权限。
fn validate_manifest(path: &Path, manifest: &PluginManifest) -> Result<(), ManifestError> {
    // ID 必须非空,并且每个 UTF-8 字节都属于限定的 ASCII 字符集合。
    // `bytes()` 借用 String;`all` 会在第一个非法字节处短路。
    let valid_id = !manifest.id.is_empty()
        && manifest
            .id
            .bytes()
            .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit() || byte == b'-');

    // 布尔值为 false 时返回业务校验错误;这不是 I/O 或 serde_json 的 source 错误。
    if !valid_id {
        return Err(ManifestError::InvalidId {
            // 两个输入都是借用,因此错误需要分别复制路径和 ID 才能离开函数。
            path: path.to_path_buf(),
            id: manifest.id.clone(),
        });
    }

    // `as_os_str` 不要求路径是 UTF-8;空原生字符串表示没有入口路径。
    if manifest.entry.as_os_str().is_empty() {
        return Err(ManifestError::UnsafeEntry {
            path: path.to_path_buf(),
            entry: manifest.entry.clone(),
            // 原因是编译期字面量,可以作为 `&'static str` 存入错误。
            reason: "入口路径不能为空",
        });
    }

    // `components` 按平台语义解析路径。RootDir 覆盖 `/` 或 `\` 一类根目录,
    // Prefix 覆盖 Windows 盘符和 UNC 前缀;`any` 在第一次匹配时停止。
    let has_root_or_prefix = manifest
        .entry
        .components()
        .any(|component| matches!(component, Component::RootDir | Component::Prefix(_)));
    // 同时使用 `is_absolute` 和显式组件检查,拒绝绝对路径及带平台前缀的路径。
    if manifest.entry.is_absolute() || has_root_or_prefix {
        return Err(ManifestError::UnsafeEntry {
            path: path.to_path_buf(),
            entry: manifest.entry.clone(),
            reason: "入口路径不能包含根目录或平台路径前缀",
        });
    }

    // 相对路径仍可能用 `..` 逃离预期目录,因此单独遍历组件并拒绝 ParentDir。
    if manifest
        .entry
        .components()
        .any(|component| matches!(component, Component::ParentDir))
    {
        return Err(ManifestError::UnsafeEntry {
            path: path.to_path_buf(),
            entry: manifest.entry.clone(),
            reason: "入口路径不能包含 `..`",
        });
    }

    // 所有规则都通过后返回空成功值;调用者随后可以把清单加入目录。
    Ok(())
}

/// 收集直接位于目标目录中的普通 `.json` 文件。
///
/// `dir` 是共享借用。符号链接和子目录不会被跟随;返回的 Vec 拥有各路径。
/// 路径最终排序,确保重复 ID 报错中的 first/second 在同一输入上保持稳定。
fn collect_manifest_paths(dir: &Path) -> Result<Vec<PathBuf>, CatalogError> {
    // `read_dir` 成功返回惰性的目录迭代器;失败时当前层保存目录路径和 io::Error。
    let entries = fs::read_dir(dir).map_err(|source| CatalogError::Scan {
        dir: dir.to_path_buf(),
        source,
    })?;

    // 类型从后续 `push(path)` 推导为 `Vec<PathBuf>`;`mut` 允许循环追加元素。
    let mut paths = Vec::new();
    // 迭代器的每一项自身也是 Result,因为打开目录成功不代表每个条目都能读取。
    for entry in entries {
        // 条目读取失败时补充父目录;成功时 `DirEntry` 被移动进局部变量。
        let entry = entry.map_err(|source| CatalogError::ReadDirectoryEntry {
            dir: dir.to_path_buf(),
            source,
        })?;
        // `entry.path()` 创建拥有所有权的完整 PathBuf,供后续检查、收集或错误保存。
        let path = entry.path();
        // 查询文件类型可能单独失败;错误要保存当前条目路径,所以克隆一份。
        let file_type = entry
            .file_type()
            .map_err(|source| CatalogError::InspectEntry {
                path: path.clone(),
                source,
            })?;

        // `extension` 返回 Option<&OsStr>;无法无损转成 UTF-8 时按非 JSON 处理。
        // `is_some_and` 只在扩展名存在且等于 json(忽略 ASCII 大小写)时返回 true。
        let is_json = path
            .extension()
            .and_then(|extension| extension.to_str())
            .is_some_and(|extension| extension.eq_ignore_ascii_case("json"));

        // 只有普通文件且扩展名符合时才收集。目录和符号链接不会进入读取阶段。
        if file_type.is_file() && is_json {
            // 把 PathBuf 所有权移动进 Vec;本轮之后不再使用局部 `path`。
            paths.push(path);
        }
    }

    // PathBuf 的全序使同一目录内容产生稳定扫描顺序,也固定重复 ID 的先后路径。
    paths.sort();
    // 把拥有全部路径的 Vec 移动给调用者。
    Ok(paths)
}

/// 在输出目录写临时文件,并在内容刷新、同步成功后提交为正式文件。
///
/// `output` 与 `bytes` 都是共享借用;函数不会取得调用者缓冲区所有权。任何一步
/// 失败都返回带阶段和路径的 `CatalogError`。成功只表示本函数完成了文件同步与
/// 提交,不承诺目录项已经在所有平台上完成额外的目录级持久化。
fn write_catalog_file(output: &Path, bytes: &[u8]) -> Result<(), CatalogError> {
    // `parent` 可能返回空路径(例如仅传 `catalog.json`);这种情况使用当前目录 `.`。
    // `filter` 排除空父路径,`unwrap_or_else` 的闭包只在没有可用父目录时执行。
    let parent = output
        .parent()
        .filter(|parent| !parent.as_os_str().is_empty())
        .unwrap_or_else(|| Path::new("."));

    // 临时文件必须和正式输出位于同一目录,减少跨文件系统移动失败的可能。
    // 错误保存父目录;成功的 NamedTempFile 由当前函数独占拥有。
    let mut temporary =
        NamedTempFile::new_in(parent).map_err(|source| CatalogError::CreateTemporary {
            dir: parent.to_path_buf(),
            source,
        })?;

    // 临时文件名由库生成。后续多个错误阶段都要使用,因此先克隆成独立 PathBuf。
    let temporary_path = temporary.path().to_path_buf();
    // `write_all` 循环处理可能的部分写入,直到全部字节写完或遇到 I/O 错误。
    temporary
        .write_all(bytes)
        .map_err(|source| CatalogError::WriteTemporary {
            // 后续 flush 和 sync 仍要使用路径,所以这里克隆而不是移动原值。
            path: temporary_path.clone(),
            source,
        })?;
    // `flush` 请求把 Rust 缓冲中的数据交给下层写入对象,但不等同于持久化到设备。
    temporary
        .flush()
        .map_err(|source| CatalogError::FlushTemporary {
            path: temporary_path.clone(),
            source,
        })?;
    // `as_file` 共享借用内部 File;`sync_all` 请求同步文件内容和相关元数据。
    temporary
        .as_file()
        .sync_all()
        .map_err(|source| CatalogError::SyncTemporary {
            // 这是使用 temporary_path 的最后一个错误分支,可以把所有权直接移入错误。
            path: temporary_path,
            source,
        })?;

    // `persist` 消耗 NamedTempFile,把临时文件提交到正式路径。
    // 失败值同时带回底层 io::Error 和临时文件句柄;闭包取出 error 字段,
    // 其余失败值随后销毁并触发临时文件清理。
    temporary
        .persist(output)
        .map_err(|error| CatalogError::Commit {
            // output 只是借用,CatalogError 必须拥有一份路径副本。
            output: output.to_path_buf(),
            // 移动出 PersistError 中的底层 io::Error,作为标准 source 保存。
            source: error.error,
        })?;

    // 到达这里表示临时文件已经成功提交,没有资源需要显式 close。
    Ok(())
}

18.3 src/main.rs:anyhow 应用边界与退出码

rust 复制代码
//! 插件清单构建器的命令行边界,也是理解 `anyhow` 职责的阅读入口。
//!
//! 参数错误在进入应用流程前处理并返回退出码 2;库层错误由 `anyhow::Context`
//! 增加一次任务级上下文,最终只在 `main` 打印一次并返回退出码 1。
//! 正常路径返回退出码 0。这里不重新定义文件和业务错误,那些具体错误由
//! 库层的 `ManifestError` 与 `CatalogError` 负责表达。

// `OsString` 可以保存操作系统原生参数,即使路径不是合法 UTF-8 也不会丢失数据。
use std::ffi::OsString;
// `PathBuf` 拥有路径参数;解析后可以把它移动进 BuildOptions。
use std::path::PathBuf;
// `ExitCode` 让 main 明确返回 0、1 或 2,而不是在各层直接调用 process::exit。
use std::process::ExitCode;

// `Result` 是 `anyhow::Result<T>` 的别名,错误类型固定为 `anyhow::Error`。
// `Context as _` 把扩展 trait 放进方法查找范围,却不在本文件占用名称 Context。
use anyhow::{Context as _, Result};
// CLI 使用库层的输入、输出和具体错误。`CatalogError` 只用于最终诊断 downcast,
// 业务构建仍由 `build_catalog` 完成。
use plugin_catalog_builder::{BuildOptions, BuildReport, CatalogError, build_catalog};

/// 启动命令行程序,并把不同完成状态转换成进程退出码。
///
/// `main` 没有参数;它从当前进程环境取得命令行参数。返回的 `ExitCode` 由 Rust
/// 运行时交给操作系统。这里是唯一打印最终错误的位置,避免同一失败被多层重复记录。
fn main() -> ExitCode {
    // `args_os` 产生拥有 OsString 的迭代器;parse_options 消耗它并构造 BuildOptions。
    // `match` 明确区分"参数使用错误"和已经进入业务流程后的构建错误。
    let options = match parse_options(std::env::args_os()) {
        // 成功分支取得 BuildOptions 所有权,供后面的 run_application 使用。
        Ok(options) => options,
        // 失败分支取得静态错误说明,不会开始任何文件扫描或写入。
        Err(message) => {
            // `eprintln!` 写标准错误,便于脚本把诊断和正常标准输出分开处理。
            eprintln!("参数错误:{message}");
            // 用法是稳定的人类提示;程序逻辑依靠退出码而不是解析这段文本。
            eprintln!("用法:plugin-catalog-builder <manifest-dir> <output-file>");
            // 退出码 2 专门表示命令行参数数量或格式错误。
            return ExitCode::from(2);
        }
    };

    // 把 options 的所有权移动给应用函数;运行结束后 main 根据 Result 统一处理。
    match run_application(options) {
        // 成功分支取得 BuildReport,说明正式输出文件已经完成提交。
        Ok(report) => {
            // 成功摘要写标准输出。`display` 只借用 PathBuf,并按平台路径格式显示。
            println!(
                "构建成功:{} 个插件 -> {}",
                report.manifest_count,
                report.output_file.display()
            );
            // `SUCCESS` 等价于平台成功退出状态,通常是数值 0。
            ExitCode::SUCCESS
        }
        // 失败分支取得 `anyhow::Error` 所有权,其中保存上下文和原始错误链。
        Err(error) => {
            // `downcast_ref` 沿 anyhow 的错误链查找 CatalogError,并返回共享借用。
            // 它只用于输出稳定诊断类别,不把动态类型检查当成库内业务分支机制。
            if let Some(catalog_error) = error.downcast_ref::<CatalogError>() {
                // `kind` 来自具体错误类型,避免脚本解析可能变化的人类可读文本。
                eprintln!("错误种类:{}", catalog_error.kind());
            }

            // 默认 Display 只打印 anyhow 最外层上下文,也就是当前任务失败的说明。
            eprintln!("错误:{error}");
            // `chain` 从最外层开始遍历 Context、CatalogError 和更底层的 source。
            // `skip(1)` 跳过刚刚已经打印的最外层错误,防止首行重复。
            for cause in error.chain().skip(1) {
                // 每个原因只通过共享借用格式化;错误链仍由 error 统一拥有。
                eprintln!("  原因:{cause}");
            }
            // 构建阶段失败统一返回退出码 1,与参数错误的退出码 2 区分。
            ExitCode::FAILURE
        }
    }
}

/// 解析程序名之后的两个路径参数。
///
/// `arguments` 接受任何产生 `OsString` 的集合或迭代器,函数会取得并消耗这些
/// 字符串的所有权。成功返回拥有两个路径的 `BuildOptions`;失败只返回固定用法
/// 说明,还没有执行文件 I/O。这里使用具体的小错误类型而不是 `anyhow`,因为
/// 参数错误在 `main` 中有独立退出码 2。
fn parse_options(
    // `impl IntoIterator` 让生产代码传 args_os,测试传数组,而不暴露具体迭代器类型。
    arguments: impl IntoIterator<Item = OsString>,
) -> Result<BuildOptions, &'static str> {
    // `into_iter` 消耗传入集合,得到逐个产生 OsString 所有权的迭代器。
    let mut arguments = arguments.into_iter();
    // 第一个参数是可执行文件名。前导下划线表示有意不再使用该值,避免警告。
    let _program = arguments.next();

    // `next` 返回 Option<OsString>;存在时用 PathBuf::from 移动字符串缓冲并构造路径。
    let manifest_dir = arguments
        .next()
        .map(PathBuf::from)
        // `ok_or` 把 None 转成 Result::Err;`?` 在缺失时立即返回固定说明。
        .ok_or("缺少 manifest 目录")?;
    // 第二个业务参数同样保留原生路径表示,不先转换成可能失败的 UTF-8 String。
    let output_file = arguments.next().map(PathBuf::from).ok_or("缺少输出文件")?;

    // 再读取一次用于确认没有第三个业务参数;取得的多余 OsString 会在分支内销毁。
    if arguments.next().is_some() {
        // 参数过多仍属于用法错误,不进入 anyhow 的应用错误链。
        return Err("参数过多");
    }

    // 把两个 PathBuf 移入配置对象,并把配置所有权返回给 main。
    Ok(BuildOptions {
        manifest_dir,
        output_file,
    })
}

/// 执行一次完整构建,并在可执行程序边界添加任务级上下文。
///
/// `options` 按值传入,因此该函数取得配置所有权。库层继续返回具体
/// `CatalogError`;应用层只负责报告,无需为每种错误暴露不同返回类型,所以
/// 返回 `anyhow::Result<BuildReport>`。`Context` 只增加"这次整体任务是什么",
/// 不重复库层已经保存的目录扫描、JSON 解码和写入阶段信息。
fn run_application(options: BuildOptions) -> Result<BuildReport> {
    // 上下文闭包稍后需要输入目录,但 build_catalog 也要借用 options,
    // 因此先克隆一份独立 PathBuf,避免闭包捕获 options 造成借用或移动冲突。
    let manifest_dir = options.manifest_dir.clone();
    // 同理复制输出路径;复制的是路径数据,不是文件内容或文件句柄。
    let output_file = options.output_file.clone();

    // `build_catalog` 返回 Result<BuildReport, CatalogError>。
    // `with_context` 把错误装入 anyhow::Error 并在外层增加惰性生成的说明;成功时
    // 闭包完全不会执行,因此不会创建 format! 字符串。原 CatalogError 和 source
    // 仍保存在链中,可由 main 的 downcast_ref 和 chain 访问。
    build_catalog(&options).with_context(|| {
        // 闭包共享借用两个克隆后的 PathBuf,生成一次任务级可读文本。
        format!(
            "无法从 {} 构建插件目录 {}",
            manifest_dir.display(),
            output_file.display()
        )
    })
}

// 单元测试放在同一文件中,因此可以直接调用私有的 parse_options。
#[cfg(test)]
mod tests {
    // 导入父模块全部名称,包括私有函数;该导入只在测试构建中存在。
    use super::*;

    /// 验证两个业务路径会移动进 BuildOptions,而不要求先转换成 UTF-8 字符串。
    #[test]
    fn parses_two_paths_without_requiring_utf8_conversion() {
        // 数组按值交给 parse_options;三个 OsString 随迭代逐个移动。
        let options = parse_options([
            OsString::from("program"),
            OsString::from("manifests"),
            OsString::from("catalog.json"),
        ])
        // 本测试的固定输入必须成功;失败表示实现回归,所以用 expect 触发测试 panic。
        .expect("两个路径参数应解析成功");

        // 分别断言输入目录和输出文件,没有依赖 Debug 或 Display 文本。
        assert_eq!(options.manifest_dir, PathBuf::from("manifests"));
        assert_eq!(options.output_file, PathBuf::from("catalog.json"));
    }

    /// 故意注入缺少参数和多余参数,验证两者都在 I/O 前被拒绝。
    #[test]
    fn rejects_missing_and_extra_arguments() {
        // 只有程序名时缺少两个业务参数,返回 Err;测试不关心此处的具体文案。
        assert!(parse_options([OsString::from("program")]).is_err());
        // 四个元素表示程序名加三个业务参数,最后一个应触发"参数过多"。
        assert!(
            parse_options([
                OsString::from("program"),
                OsString::from("manifests"),
                OsString::from("catalog.json"),
                OsString::from("extra"),
            ])
            .is_err()
        );
    }
}

十九、运行成功路径

在 Cargo workspace 根目录执行:

powershell 复制代码
$runRoot = Join-Path $env:TEMP 'thiserror-anyhow-blog-run'
New-Item -ItemType Directory -Force -Path $runRoot | Out-Null
$out = Join-Path $runRoot 'catalog.json'
cargo +1.95.0 run --quiet -p plugin-catalog-builder -- `
  plugin-catalog-builder/fixtures/valid $out
Get-Content $out

本机实际输出:

text 复制代码
构建成功:2 个插件 -> C:\Users\gophe\AppData\Local\Temp\thiserror-anyhow-blog-run\catalog.json

输出文件内容:

json 复制代码
{
  "plugins": [
    {
      "id": "search",
      "name": "Search Documents",
      "version": "1.2.0",
      "entry": "bin/search"
    },
    {
      "id": "summarize",
      "name": "Summarize Document",
      "version": "2.0.1",
      "entry": "bin/summarize"
    }
  ]
}

临时目录路径会随用户和系统变化。稳定结果是退出码为 0、插件数量为 2,并且输出按 search、summarize 排序。

二十、运行失败路径

20.1 JSON 语法错误

powershell 复制代码
cargo +1.95.0 run --quiet -p plugin-catalog-builder -- `
  plugin-catalog-builder/fixtures/invalid-json `
  (Join-Path $runRoot 'invalid.json')

本机实际输出:

text 复制代码
错误种类:catalog-manifest
错误:无法从 plugin-catalog-builder/fixtures/invalid-json 构建插件目录 C:\Users\gophe\AppData\Local\Temp\thiserror-anyhow-blog-run\invalid.json
  原因:插件清单处理失败
  原因:插件清单 "plugin-catalog-builder/fixtures/invalid-json\\broken.json" 不是符合要求的 JSON
  原因:trailing comma at line 6 column 1

退出码为 1。四层信息分别来自 anyhow 应用上下文、CatalogError、ManifestError 和 serde_json::Error。

20.2 重复插件 ID

text 复制代码
错误种类:catalog-duplicate-id
错误:无法从 plugin-catalog-builder/fixtures/duplicate 构建插件目录 C:\Users\gophe\AppData\Local\Temp\thiserror-anyhow-blog-run\duplicate.json
  原因:插件 id "search" 重复:第一次出现在 "plugin-catalog-builder/fixtures/duplicate\\01-search.json",再次出现在 "plugin-catalog-builder/fixtures/duplicate\\02-search.json"

这个错误没有底层 source。重复 ID 是目录构建层通过比较得出的业务事实,不是 I/O 或 JSON 库返回的错误。

20.3 不安全入口路径

text 复制代码
错误种类:catalog-manifest
错误:无法从 plugin-catalog-builder/fixtures/unsafe-entry 构建插件目录 C:\Users\gophe\AppData\Local\Temp\thiserror-anyhow-blog-run\unsafe.json
  原因:插件清单处理失败
  原因:插件清单 "plugin-catalog-builder/fixtures/unsafe-entry\\escape.json" 的入口路径 "../outside.exe" 不安全:入口路径不能包含 `..`

构建器拒绝 ..,但这个检查仍不等于完整路径授权。若程序以后真的执行插件,还必须把路径解析到授权根目录内,处理符号链接、文件类型、权限和竞态条件。

20.4 输出目录不存在

text 复制代码
错误种类:catalog-create-temporary
错误:无法从 plugin-catalog-builder/fixtures/valid 构建插件目录 C:\Users\gophe\AppData\Local\Temp\thiserror-anyhow-blog-run\missing\catalog.json
  原因:无法在输出目录 "C:\\Users\\gophe\\AppData\\Local\\Temp\\thiserror-anyhow-blog-run\\missing" 中创建临时文件
  原因:系统找不到指定的路径。 (os error 3) at path "C:\\Users\\gophe\\AppData\\Local\\Temp\\thiserror-anyhow-blog-run\\missing\\.tmpbbvwp1"

临时文件名和操作系统本地化文本不稳定。测试只断言 CreateTemporary 以及底层 ErrorKind::NotFound。

20.5 参数不足

text 复制代码
参数错误:缺少 manifest 目录
用法:plugin-catalog-builder <manifest-dir> <output-file>

退出码为 2。参数解析失败发生在构建流程之前,没有必要转换成 CatalogError 或 anyhow::Error。

二十一、公共错误类型的兼容性

一旦库对外返回 CatalogError,变体名称和字段就可能被下游代码使用。修改时要区分以下情况:

  • 修改 Display 文本通常不属于 Rust 类型层面的破坏,但可能影响错误快照或错误地解析字符串的调用者。
  • 删除变体、重命名字段或改变字段类型,通常会破坏源码兼容。
  • 增加 #[non_exhaustive] 本身可能影响已有穷尽匹配,最好在首次公开时就决定。
  • 新增变体在非穷尽枚举中更容易兼容,但调用者仍需要决定通配分支的行为。
  • 把具体错误改成 anyhow::Error 会丢失公开的结构化合同。

如果不希望公开内部错误表示,可以定义一个公开的不透明错误结构体,内部保存私有枚举,并只提供需要长期承诺的访问方法。thiserror 的 #[error(transparent)] 可以帮助转发 Display 和来源,但是否隐藏字段、暴露错误码仍是 API 设计问题,不是宏自动决定的。

二十二、异步、线程与 Send + Sync + 'static

anyhow::Error 面向常规应用时,通常要求被包装的错误满足 Send + Sync + 'static。这样错误可以安全地在线程和异步任务之间移动,并且不借用短生命周期数据。

这会影响自定义错误字段:

  • 优先保存 String、PathBuf 等拥有所有权的数据;
  • 不要在需要跨任务返回的错误中保存短生命周期引用;
  • 第三方错误若不是 Send 或 Sync,不能直接装进需要这些约束的动态错误;
  • tokio::spawn 的任务结果还要满足任务自身的 Send + 'static 约束。

不要为了满足约束就把一切改成字符串。字符串化会丢失错误类型、source 和底层结构。更好的做法是检查错误为何不是线程安全的,在合适边界提取稳定且有意义的拥有数据。

二十三、Backtrace 的使用边界

anyhow 1.0.104 在底层错误没有提供 backtrace 时可以捕获 backtrace。需要通过环境变量启用显示:

powershell 复制代码
$env:RUST_LIB_BACKTRACE = '1'
cargo +1.95.0 run --quiet -p plugin-catalog-builder -- missing output.json

RUST_BACKTRACE=1 同时影响 panic 和错误;RUST_LIB_BACKTRACE=1 可以只开启库错误 backtrace。捕获和符号化 backtrace 有成本,生产环境应根据调查需求决定是否启用。

thiserror 2.0.21 对错误类型中 Backtrace 字段的自动 provide() 支持,官方当前仍注明需要 nightly。本文不让生产代码依赖这项能力。稳定工具链上最重要的仍是:保存结构化上下文、保留 source 链,并在最终边界选择合适的诊断输出。

Backtrace 也不能替代业务上下文。调用栈可以告诉你代码经过哪些函数,却不一定告诉你正在处理哪个插件、哪个请求或哪个文件。

二十四、常见错误设计问题

24.1 所有失败都写成字符串

rust 复制代码
Result<T, String>

这种形式不能自然保留 source,调用者只能解析文案。只在非常小、完全私有且确实不需要错误链的局部代码中考虑它。

24.2 所有库接口都返回 anyhow::Result

这会让上层难以实现稳定重试、协议映射和字段级测试。库接口是否需要具体错误,要看调用者是否需要理解失败。

24.3 为每个函数都建立错误枚举

错误类型太碎会产生大量没有新信息的包装层。按职责边界设计,不按函数数量设计。

24.4 只保存底层错误,不保存操作对象

io::Error 说"文件不存在",却不告诉你哪个文件。路径、记录 ID 和操作阶段通常应由知道这些事实的上层补充。

24.5 先把底层错误格式化进字符串

rust 复制代码
MyError::Message(format!("读取失败:{source}"))

这样会切断标准来源链。应把底层错误放在 #[source] 字段中,让显示文本和错误结构各司其职。

24.6 每一层都记录日志

同一个失败会重复出现,且难以关联哪一条是最终结果。通常只在最终处理边界记录一次;重试等中间事件必须明确其临时性质。

24.7 依赖完整错误文本判断行为

操作系统文字、第三方 crate 文案和路径格式都会变化。程序分支应匹配变体、错误码或 ErrorKind。

24.8 把用户输入错误写成 panic

用户可控输入失败属于正常错误路径。panic 留给内部不变量破坏,且仍需要在任务或进程边界决定如何处理。

24.9 认为 ? 会添加上下文

? 只传播或转换错误。需要上下文时必须使用结构化字段、map_err、context 或 with_context 明确添加。

24.10 认为错误返回会撤销副作用

Err 只是返回值。文件已经写入、消息已经发布、远端请求已经执行时,返回错误不会自动回滚。副作用安全要靠事务、临时文件、幂等键或补偿逻辑。

二十五、工程中的选择准则

可以把整套方法压缩为下面几条:

  1. 先画出谁产生错误、谁需要处理、谁最终报告,再选择错误类型。
  2. 可复用层返回结构化错误;最终应用边界可以使用 anyhow。
  3. 下层不知道的路径、ID 和操作阶段,由知道这些事实的当前层补充。
  4. 纯错误类型转换用 #[from];增加上下文字段用 map_err。
  5. 保留 source,不要提前字符串化底层错误。
  6. Context 说明当前任务,不重复底层原因。
  7. 只在最终处理边界记录一次,避免同一错误逐层打印。
  8. 测试错误变体、字段和 ErrorKind,不要只比较整段文案。
  9. 错误返回不会自动撤销副作用;单独设计提交与恢复过程。
  10. 用户提示、结构化日志、内部诊断和公开协议应分别设计。

thiserror 让具体错误类型容易维护,anyhow 让应用边界容易汇总和报告。二者组合的关键不在宏本身,而在错误信息始终沿正确方向流动:下层保留事实,上层补充语义,最终边界决定怎样处理。

参考资料

相关推荐
SomeOtherTime4 小时前
Rust生命周期介绍(AI回答)
开发语言·后端·rust
SomeOtherTime17 小时前
Rust所有权(AI回答)
开发语言·后端·rust
SomeOtherTime1 天前
Rust 面向对象介绍(AI回答)
开发语言·python·rust
西风测评2 天前
安全的自动更新怎么做:minisign 签名、先验签后解析与防回滚
rust·ssh
Delightrs2 天前
Rust LeetCode238 算法题解
开发语言·算法·rust
奶油话梅糖2 天前
快过 IDM 5 倍、开源免费:4,200 Star 的 Rust 下载器用 7 组对照实验“单挑“行业老大
rust·开源·idm
Zan3 天前
Tauri 2 实战:为什么一个可靠的桌面应用,应该由 Rust Host 管理生命周期?
前端·架构·rust
RobinDevNotes3 天前
从 Electron 到 GPUI,9 个跨平台桌面方案该怎么选
前端·javascript·rust·electron·openresty·前端开发工具
灿飞屋4 天前
Rust 编程其实很简单
开发语言·rust