「深挖Rust」错误处理 — 10

112 阅读3分钟

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


避免错误枚举

SubscribeError 中,我们使用枚举变量有两个目的。

  • 确定应该返回给我们API的调用者的响应(ResponseError)。
  • 提供相关的诊断(Error::source, Debug, Display)。

SubscribeError,按照目前的定义,暴露了 subscribe 的很多实现细节:我们在请求处理程序中的每一个易错的函数调用都有一个变体!这不是一个可以扩展的策略。

我们需要从抽象层的角度来考虑:订阅的调用者需要知道什么?

他们应该能够确定向用户返回什么响应(通过ResponseError)。subscribe() 的调用者并不了解订阅流程的复杂性:他们对领域的了解,不足以让他们对 SendEmailErrorTransactionCommitError 有不同的行为。

subscribe() 应该返回一个正确的抽象层次上的错误类型。而理想的错误类型应该是这样的:

//! src/routes/subscriptions.rs

#[derive(thiserror::Error)]
pub enum SubscribeError {
    #[error("{0}")]
    ValidationError(String),
    #[error(/* */)]
    UnexpectedError(/* */),
}

ValidationError 对应于400错误请求,UnexpectedError 对应于500内部服务器错误。

那我们应该在 UnexpectedError 变量中存储什么?

我们需要将多种错误类型映射到其中 → sqlx::Error, StoreTokenError, reqwest::Error。 我们不想暴露那些通过 subscribe() 映射到 UnexpectedError 的易错程序的实现细节。它必须是不透明的。

我们在查看Rust标准库中的 Error Trait 时,碰到了一个满足这些要求的类型: Box<dyn std::error::Error>

让我们试一试:

//! src/routes/subscriptions.rs

#[derive(thiserror::Error)]
pub enum SubscribeError {
    #[error("{0}")]
    ValidationError(String),
    // Transparent delegates both `Display`'s and `source`'s implementation
    // to the type wrapped by `UnexpectedError`.
    #[error(transparent)]
    UnexpectedError(#[from] Box<dyn std::error::Error>),
}
//! src/routes/subscriptions.rs
// [...]

impl ResponseError for SubscribeError {
    fn status_code(&self) -> StatusCode {
        match self {
            SubscribeError::ValidationError(_) => StatusCode::BAD_REQUEST,
            SubscribeError::UnexpectedError(_) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

我们只需要调整 subscribe(),在使用 ? 操作符之前正确转换我们的错误。

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

pub async fn subscribe(/* */) -> Result<HttpResponse, SubscribeError> {
    // [...]
    let mut transaction = pool
        .begin()
        .await
        .map_err(|e| SubscribeError::UnexpectedError(Box::new(e)))?;
    let subscriber_id = insert_subscriber(/* */)
        .await
        .map_err(|e| SubscribeError::UnexpectedError(Box::new(e)))?;
    // [...]
    store_token(/* */)
        .await
        .map_err(|e| SubscribeError::UnexpectedError(Box::new(e)))?;
    transaction
        .commit()
        .await
        .map_err(|e| SubscribeError::UnexpectedError(Box::new(e)))?;
    send_confirmation_email(/* */)
        .await
        .map_err(|e| SubscribeError::UnexpectedError(Box::new(e)))?;
    // [...]
}

有一些代码是重复的,但现在先放一放。代码已经编译完毕,我们的测试也如期通过。

让我们改变到目前为止我们所使用的测试来检查我们的日志信息的质量:让我们在insert_subscriber 而不是 store_token 中触发失败。

//! tests/api/subscriptions.rs
// [...]

#[tokio::test]
async fn subscribe_fails_if_there_is_a_fatal_database_error() {
    // [...]
    // Break `subscriptions` instead of `subscription_tokens`
    sqlx::query!("ALTER TABLE subscriptions DROP COLUMN email;",)
        .execute(&app.db_pool)
        .await
        .unwrap();

    // [..]
}

测试通过了,但是发现我们的日志出现了退步。

INFO: [HTTP REQUEST - END]
    exception.details:
        "error returned from database: column 'email' of
         relation 'subscriptions' does not exist"
    exception.message:
        "error returned from database: column 'email' of
         relation 'subscriptions' does not exist"

我们看不到错误链。我们失去了之前通过 thiserror 附加到 InsertSubscriberError 上的操作方便的错误信息。

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

#[derive(thiserror::Error)]
pub enum SubscribeError {
    #[error("Failed to insert new subscriber in the database.")]
    InsertSubscriberError(#[source] sqlx::Error),
    // [...]
}

这在意料之中:我们现在将原始错误派发给 Display(通过 #[error(transparent)]),我们没有在 subscribe() 中给它附加任何额外的上下文。

我们可以解决这个问题 → 让我们给 UnexpectedError 添加一个新的String字段,将上下文信息附加到我们正在存储的不透明错误上。

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

#[derive(thiserror::Error)]
pub enum SubscribeError {
    #[error("{0}")]
    ValidationError(String),
    #[error("{1}")]
    UnexpectedError(#[source] Box<dyn std::error::Error>, String),
}

impl ResponseError for SubscribeError {
    fn status_code(&self) -> StatusCode {
        match self {
            // [...]
            // The variant now has two fields, we need an extra `_`
            SubscribeError::UnexpectedError(_, _) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

我们需要在 subscribe 中相应地调整我们的映射代码 → 我们将重新使用我们在重构SubscribeError 之前的错误描述。

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

pub async fn subscribe(/* */) -> Result<HttpResponse, SubscribeError> {
    // [..]
    let mut transaction = pool.begin().await.map_err(|e| {
        SubscribeError::UnexpectedError(
            Box::new(e),
            "Failed to acquire a Postgres connection from the pool".into(),
        )
    })?;
    let subscriber_id = insert_subscriber(/* */)
        .await
        .map_err(|e| {
            SubscribeError::UnexpectedError(
                Box::new(e),
                "Failed to insert new subscriber in the database.".into(),
            )
        })?;
    // [..]
    store_token(/* */)
        .await
        .map_err(|e| {
            SubscribeError::UnexpectedError(
                Box::new(e),
                "Failed to store the confirmation token for a new subscriber.".into(),
            )
        })?;
    transaction.commit().await.map_err(|e| {
        SubscribeError::UnexpectedError(
            Box::new(e),
            "Failed to commit SQL transaction to store a new subscriber.".into(),
        )
    })?;
    send_confirmation_email(/* */)
        .await
        .map_err(|e| {
            SubscribeError::UnexpectedError(
                Box::new(e),
                "Failed to send a confirmation email.".into()
            )
        })?;
    // [..]
}

虽然这有些丑陋,但它是有效的:

INFO: [HTTP REQUEST - END]
    exception.details=
        "Failed to insert new subscriber in the database.

        Caused by:
            error returned from database: column 'email' of
             relation 'subscriptions' does not exist"
    exception.message="Failed to insert new subscriber in the database."