5

2022-46: OpenDAL 的错误处理实践

 1 year ago
source link: https://xuanwo.io/reports/2022-46/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

2022-46: OpenDAL 的错误处理实践

最近在为 OpenDAL 实现新功能的时候越来越感觉现有的错误处理逻辑能力捉襟见肘,于是花了不少时间重新设计了一套全新的错误处理逻辑。今天这篇周报就跟大家分享一下我在 OpenDAL 中的错误处理实践,希望为大家在 Rust 中构建自己的错误处理体系提供一些思路。

任何实践都不能脱离具体的场景。

在开始介绍实践之前,先了解一下 OpenDAL 这个项目以及他需要处理的哪些错误。OpenDAL 是一个旨在实现自由、无痛、高效访问数据的 Rust 库,用起来大概是这样:

// Init Operator
let op = Operator::from_env(Scheme::Fs)?;
// Create object handler.
let o = op.object("test_file");
// Read data from object;
let bs = o.read().await?;

作为一个统一的存储抽象,OpenDAL 需要返回统一的 Error 结构体,让用户不需要针对不同的服务来写逻辑。以文件不存在为例,我们需要允许用户写出这样的代码:

if let Err(e) = op.object("test").metadata().await {
    if e.kind() == ErrorKind::ObjectNotFound {
      // logic if file not exist.
    } else {
      // logic if we meet other errors.
    }
}

也就是说 OpenDAL 要允许用户知道发生了什么错误,使得用户可以根据 Error 的具体类型来分别处理;不仅如此,OpenDAL 对接了海量的存储服务,只返回 Error Code 是远远不够的,OpenDAL 还需要提供足够的上下文,让用户能够根据 Error 快速定位到具体发生了什么。

总结一下 OpenDAL 的需求,它需要一个这样的错误处理框架:

  • 知道发生了什么错误
  • 能够决定如何处理它
  • 辅助定位错误出现的原因

在本次重新设计之前,OpenDAL 使用 std::io::Error 来返回错误。这一设计的好处是最大限度的符合了用户使用 std::fs 的习惯,降低了学习成本。

为了能够返回错误的上下文,OpenDAL 在 io::Error 中携带了 ObjectErrorBackendError

pub struct BackendError {
    context: HashMap<String, String>,
    source: anyhow::Error,
}

#[derive(Error, Debug)]
#[error("object error: (op: {op}, path: {path}, source: {source})")]
pub struct ObjectError {
    op: Operation,
    path: String,
    source: anyhow::Error,
}

但是在实际运用上,这样的设计非常痛苦。

原始错误泄漏

前面我们总结过,OpenDAL 的一大需求就是提供错误的上下文,因为一个简单的 UnexpectedEof 对用户调试问题没有任何帮助。但是使用 std::io::Error 会导致我们代码中非常容易出现原始错误泄漏的问题,比如:

impl Object {
  pub async fn range_read(&self, range: impl RangeBounds<u64>) -> Result<Vec<u8>> {
      ...

      io::copy(s, &mut bs).await?;

      Ok(bs.into_inner())
  }
}

此处的 io::copy 中出现的 io 错误会在没有任何上下文的情况下被泄漏出去。

错误上下文构建困难

std::io::Error 是一个外部类型,我们能做的事情很少。为了携带 context,我们必须要在构建 Error 时就把所有的上下文传递过去,这让我们实现时不得不写很多重复的逻辑。OpenDAL 中有大量此类帮助函数:

pub fn new_other_object_error(
    op: Operation,
    path: &str,
    source: impl Into<anyhow::Error>,
) -> io::Error {
    io::Error::new(io::ErrorKind::Other, ObjectError::new(op, path, source))
}

pub fn new_response_consume_error(op: Operation, path: &str, err: Error) -> Error {
    Error::new(
        err.kind(),
        ObjectError::new(op, path, anyhow!("consuming response: {err:?}")),
    )
}

设计目标不一致

std::io::Error 被设计用来返回底层的 IO 错误,它的设计目标与 OpenDAL 的需求是不一致的,用户无法通过 ErrorKind::NotFound 来判断是 Object 不存在还是对应的 Bucket 不存在。不仅如此,std::io::Error 作为标准的一部分,与它相关的改进推动起来都比较困难。时至今日,OpenDAL 一直在等待的 io_error_more feature 还没有被 stable。这意味着,OpenDAL 无法返回 ErrorKind::IsADirectoryErrorKind::NotADirectory,进而导致用户无法处理相关错误。

综合考虑以上各种因素之后,我决定重新设计 OpenDAL 的 Error 类型。

ErrorKind

ErrorKind 基本上参考 io::ErrorKind,但是只选取了 OpenDAL 需要使用的部分:

#[non_exhaustive]
pub enum ErrorKind {
    Unexpected,
    Unsupported,

    BackendConfigInvalid,

    ObjectNotFound,
    ObjectPermissionDenied,
    ObjectIsADirectory,
    ObjectNotADirectory,
}

ErrorStatus

为了能够更好地支持用户实现错误重试等逻辑(这是 OpenDAL 用户的常见需求),我在 Error 中引入了 ErrorStatus 的概念:

enum ErrorStatus {
    Permanent,
    Temporary,
    Persistent,
}
  • Permanent 表示错误是永久的,在没有外部变化的情况下,用户不应该重试这个错误
  • Temporary 表示错误是临时的,用户可以尝试重试这个请求来解决它
  • Persistent 表示错误曾经是临时的,但是在重试之后还在持续出错,不鼓励用户再尝试这个请求

Error Operation

在错误定位中最有帮助的就是知道这个错误发生在什么样的操作中,为此 OpenDAL 加入了 Error Operation:这是一个 &‘static str,OpenDAL 的实现者可以通过 with_operation() 来追加这一信息:

pub fn with_operation(mut self, operation: &'static str) -> Self {
    if !self.operation.is_empty() {
        self.context.push(("called", self.operation.to_string()));
    }

    self.operation = operation;
    self
}

特别的,如果这个 Error 过去被设置过 operation,我们会在 context 中追加一个新的 called context,来标记这个错误都在哪些操作中调用。

Error Context

OpenDAL 在错误中增加了 Error Context 来携带上下文的相关信息:

pub struct Error {
    ...
    context: Vec<(&'static str, String)>,
}

impl Error {
    pub fn with_context(mut self, key: &'static str, value: impl Into<String>) -> Self {
        self.context.push((key, value.into()));
        self
    }
}

一般的,我们会需要支持这个错误来自哪个服务,正在请求哪个路径等。OpenDAL 通过实现了一个 Error Context Wrapper 来为所有的 Service 自动实现这一功能:

pub struct ErrorContextWrapper<T: Accessor + 'static> {
    meta: AccessorMetadata,
    inner: T,
}

#[async_trait]
impl<T: Accessor + 'static> Accessor for ErrorContextWrapper<T> {
    async fn read(&self, path: &str, args: OpRead) -> Result<ObjectReader> {
        let br = args.range();
        self.inner.read(path, args).await.map_err(|err| {
            err.with_operation(Operation::Read.into_static())
                .with_context("service", self.meta.scheme())
                .with_context("path", path)
                .with_context("range", br.to_string())
        })
    }
}

得益于这一设计,OpenDAL 从 Services 实现中删除了海量 context 相关的代码。

Error Source

显然的,OpenDAL 需要能够把底层的错误也暴露出来。这里 OpenDAL 没有使用 thiserror 来自动为所有的 error 实现 #[from],因为对用户来说,没有办法处理的错误是没有意义的。即使 OpenDAL 处理并细化的返回 IoError,XmlError,JsonError,用户也没有办法根据这个错误知道他们应该做什么。OpenDAL 的选择是统一使用 ErrorKind 来返回错误类型,通过 ErrorStatus 来返回错误状态,除此以外,用户只能直接把错误返回给更上层。

OpenDAL 选择使用 anyhow::Error 来携带错误源:

pub struct Error {
    ...
    source: Option<anyhow::Error>,
}

impl Error {
    pub fn set_source(mut self, src: impl Into<anyhow::Error>) -> Self {
        debug_assert!(self.source.is_none(), "the source error has been set");

        self.source = Some(src.into());
        self
    }
}

开发者可以通过 set_source 来设置错误源,并通过 debug_assert 来防止 source 被多次设置。

这里有一个很容易忽略的点:我们不需要为所有的 Error 生成 backtrace。很多业务上预期的错误,比如 ObjectNotFound,完全可以不需要携带额外的错误源和 Backtrace 信息。

综合上面的所有特性,我们得到了这样一个 Error:

pub struct Error {
    kind: ErrorKind,
    message: String,

    status: ErrorStatus,
    operation: &'static str,
    context: Vec<(&'static str, String)>,
    source: Option<anyhow::Error>,
}

在 OpenDAL 有这样一些使用原则:

  • 所有的函数都应当返回 Result<T, opendal::Error>
  • 来自外部库的错误使用 set_source(err) 封装为 opendal::Error 并返回
  • 谨慎实现 From<OtherError> for opendal::Error,防止原始错误泄漏
  • 同一个错误只处理一次,后续的操作只追加 context,不重复 wrap

OpenDAL 学习自 anyhow,为 Error 分别实现了 DisplayDebug

其中 Display 会展示紧凑的错误信息,不展示 source,也没有 backtrace:

ObjectNotFound (permanent) at stat, context { service: s3, path: x/x/y } => status code: 404, headers: {"x-amz-request-id": "TTD9EWB9NZ4ZF1DP", "x-amz-id-2": "ch/MHMf/zwPLxWgtBBY7fw9i9K+FGxDRzx3sxrbQKbtl21SONzTpNvs1IrFt2OjhAexcEB3Oo+c=", "c***tent-type": "applicati***/xml", "date": "M***, 21 Nov 2022 15:31:06 GMT", "server": "Amaz***S3"}, body: ""

Debug 则会展示完整的错误信息:

Unexpected (temporary) at write => send async request

    Context:
        called: http_util::Client::send_async
        service: s3
        path: c40634f8-4a4b-479e-b98f-1ee0d7f1041b

    Source: error sending request for url (https://s3.us-west-1.amazonaws.com/***/***84551d50-811f-4614-87cb-ede43447dfbf/c40634f8-4a4b-479e-b98f-1ee0d7f1041b): user body write aborted: early end, expected 2621254 more bytes

    Caused by:
        0: user body write aborted: early end, expected 2621254 more bytes
        1: early end, expected 2621254 more bytes

本文分享了 OpenDAL 的错误实践,基本思想是用户的角度出发区分预期错误和非预期错误。对预期错误,给定明确的错误类型,帮助用户写出清晰的错误处理逻辑;对非预期错误,使用同一个错误类型,比如 Unexpected 来封装,并辅以错误状态帮助用户决策是否进行重试。不要滥用 thiserror 以及 From<OtherError> for Error 等机制,不要盲目的给用户返回大量没有操作空间的错误码。

此外,设计一个良好的错误上下文机制,确保每个错误只处理一次,避免一个错误被反复封装好几层。一方面有额外的性能影响:对 OpenDAL 来说,错误分支可能是少数的 case,但是用户的逻辑中完全有可能重度依赖 OpenDAL 返回的错误;另一方面不利于用户阅读并调试错误:重复的封装让开发者一眼找不到重点,在一层又一层的结构体中迷失了上下文。

总之,一定要从用户体验角度出发设计接口~


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK