likes
comments
collection
share

「深挖Rust」错误处理 — 2

作者站长头像
站长
· 阅读数 21

「这是我参与2022首次更文挑战的第 3 天,活动详情查看:2022首次更文挑战」。


Troubleshoot

如果一个操作只有一种失败模式怎么办,我们应该只用 () 作为错误类型吗?

Err(()) 可能足以让调用者决定做什么。例如,向用户返回一个500内部服务器错误。

但是控制流并不是应用程序中错误的唯一目的。我们希望错误能够携带足够的关于故障的上下文,以便让开发人员生成一份 *“报告” *,其中包含足够的细节,以便去解决这个问题。

我们所说的 *“报告” *是什么意思?

  • 在像我们这样的后端API中,它通常是一个日志。
  • 在CLI中,当使用 --verbose 标志时,它可能是一个显示在终端的错误信息。

实现的细节可能有所不同,但目的是相同的:那就是帮助开发者了解什么地方出了什么问题。这正是我们在初始代码段中所做的。

//! src/routes/subscriptions.rs
// [...]

pub async fn store_token(/* */) -> Result<(), sqlx::Error> {
    sqlx::query!(/* */)
        .execute(transaction)
        .await
        .map_err(|e| {
            tracing::error!("Failed to execute query: {:?}", e);
            e
        })?;
    // [...]
}

如果查询失败,我们会抓取错误并生成一条日志,之后我们想追溯这个问题,就可以在调查数据库问题时去检查错误日志。

边缘错误

到目前为止,我们关注的是我们的API的内部,即:函数调用其他函数,以及操作者试图在错误发生后弄清其中的含义。但是用户呢?

就像操作者一样,用户希望API在遇到错误时能发出信号。

store_token() 失败时,API的用户会看到什么?我们看看请求处理 handler

//! src/routes/subscriptions.rs
// [...]

pub async fn subscribe(/* */) -> HttpResponse {
    // [...]
    if store_token(&mut transaction, subscriber_id, &subscription_token)
        .await
        .is_err()
    {
        return HttpResponse::InternalServerError().finish();
    }
    // [...]
}

他们会收到一个没有正文的HTTP响应和一个500内部服务器错误的状态码。

状态码的作用与 store_token() 中的错误类型相同:它是一个机器可解析的信息,调用者(如浏览器)可以用它来决定下一步该怎么做(例如,假设这是一个短暂的失败,请求重试即可)。

那么,浏览器背后的用户呢?我们要告诉他们什么?

什么都没有,响应体是空的。这实际上是一个很好的实现:用户不应该关心他们正在调用的API的内部情况。他们没有关于它的预期模型,也没有办法确定它为什么失败。那是操作者的范畴,我们在设计上省略了这些细节。

而在其他情况下,我们需要向用户传达额外的信息。让我们看看我们对同一个终端的输入验证:

//! src/routes/subscriptions.rs

#[derive(serde::Deserialize)]
pub struct FormData {
    email: String,
    name: String,
}

impl TryFrom<FormData> for NewSubscriber {
    type Error = String;

    fn try_from(value: FormData) -> Result<Self, Self::Error> {
        let name = SubscriberName::parse(value.name)?;
        let email = SubscriberEmail::parse(value.email)?;
        Ok(Self { email, name })
    }
}

API接收了一个电子邮件地址和一个名字,作为用户提交的表单的附件数据。这两个字段都要经过另外的验证:SubscriberName::parseSubscriberEmail::parse。这两个方法是容易出错的,它们返回一个String作为错误类型来解释出错的原因。

//! src/domain/subscriber_email.rs
// [...]

impl SubscriberEmail {
    pub fn parse(s: String) -> Result<SubscriberEmail, String> {
        if validate_email(&s) {
            Ok(Self(s))
        } else {
            Err(format!("{} is not a valid subscriber email.", s))
        }
    }
}

我必须承认,这不是最有用的错误信息:虽然我们告诉用户,他们输入的电子邮件地址是错误的,但我们没有帮助他们确定原因。 最后,这并不重要:我们没有把这些错误信息作为API响应的一部分发送给用户。而他们得到的是一个没有正文的 400 bad request

//! src/routes/subscription.rs
// [...]

pub async fn subscribe(/* */) -> HttpResponse {
    let new_subscriber = match form.0.try_into() {
        Ok(form) => form,
        Err(_) => return HttpResponse::BadRequest().finish(),
    };
    // [...]

这是一个糟糕的错误:用户被蒙在鼓里,不能按返回的信息调整他们的提交行为(也不懂错在哪)。

总结

让我们总结一下到目前为止我们发现的问题。错误主要有两个作用:

  1. 控制流(即决定下一步做什么)
  2. 报告(充当事后调查的凭证,追溯什么地方出错了)

我们还可以根据错误的位置进行区分:

  1. 内部错误(即一个函数在我们的应用程序中调用另一个函数)
  2. 边缘错误(即一个我们未能满足预期的API请求)

控制流是程序化的,决定下一步做什么所需的所有信息必须能够被机器访问:

  • 我们使用类型(例如枚举变体)、方法和字段来处理内部错误
  • 我们依靠状态代码来处理边缘的错误

相反,错误报告主要由人类来消费。内容必须根据用户的情况进行调整:

  • 开发者可以接触到系统的内部结构:他们应该被提供尽可能多的关于故障模式的背景。
  • 用户在应用程序之外:应用程序提供必要的信息来调整他们的行为(例如,修复错误的输入)。