存储数据的验证
cargo check 现在应该返回一个错误:
error[E0308]: mismatched types
--> src/routes/newsletters.rs:53:17
|
52 | .send_email(
| ---------- arguments to this method are incorrect
53 | subscriber.email,
| ^^^^^^^^^^^^^^^^ expected `SubscriberEmail`, found `String`
|
我们没有对从数据库中检索的数据进行任何验证 - ConfirmedSubscriber::email 是字符串类型。
相反, EmailClient::send_email 需要一个经过验证的电子邮件地址 - 一个 SubscriberEmail 实例。
我们可以先尝试一个简单的解决方案 - 将 ConfirmedSubscriber::email 更改为 SubscriberEmail 类型。
//! src/routes/newsletters.rs
// [...]
struct ConfirmedSubscriber {
email: SubscriberEmail,
}
sqlx 似乎不是很喜欢这个类型, 它不知道怎么把 TEXT 转换为 SubscriberEmail
error[E0277]: the trait bound `SubscriberEmail: From<String>` is not satisfied
--> src/routes/newsletters.rs:70:16
|
70 | let rows = sqlx::query_as!(
| ________________^
71 | | ConfirmedSubscriber,
72 | | r#"
73 | | SELECT email
... |
76 | | "#,
77 | | )
| |_____^ unsatisfied trait bound
|
我们可以浏览 sqlx 的文档,寻找实现自定义类型支持的方法——虽然麻烦不少,但好处却不多。
我们可以采用与 POST /subscriptions 端点类似的方法—— 我们使用两个结构体:
- 一个结构体编码了我们期望传输的数据布局 (FormData);
- 另一个结构体通过使用我们的域类型解析原始表示来构建(
NewSubscriber)。
对于我们的查询,它看起来像这样:
//! src/routes/newsletters.rs
// [...]
#[tracing::instrument(name = "Get confirmed subscribers", skip(pool))]
async fn get_confirmed_subscribers(
pool: &PgPool,
) -> Result<Vec<ConfirmedSubscriber>, anyhow::Error> {
// We only need `Row` to map the data coming out of this query.
// Nesting its definition inside the function itself is a simple way
// to clearly communicate this coupling (and to ensure it doesn't
// get used elsewhere by mistake).
struct Row {
email: String,
}
let rows = sqlx::query_as!(
Row,
r#"
SELECT email
FROM subscriptions
WHERE status = 'confirmed'
"#,
)
.fetch_all(pool)
.await?;
// Map into the domain type
let confirmed_subscribers = rows
.into_iter()
.map(|r| ConfirmedSubscriber {
email: SubscriberEmail::parse(r.email).unwrap(),
})
.collect();
Ok(confirmed_subscribers)
}
SubscriberEmail::parse(r.email).unwrap() 是个好主意吗?
所有新订阅者的邮件都会经过 SubscriberEmail::parse 中的验证逻辑——这是我们第六章重点讨论的主题。
那么,你可能会争辩说,我们数据库中存储的所有邮件必然都是有效的——这里无需考虑验证失败的情况。直接将它们全部解包就很安全了,因为它永远不会崩溃。
假设我们的软件永远不会更改,这种推理是合理的。但我们正在针对高部署频率进行优化!
存储在 Postgres 实例中的数据会在应用程序的新旧版本之间创建时间耦合。
我们从数据库中检索的邮件已被应用程序的先前版本标记为有效。当前版本可能不同意。
例如,我们可能会发现我们的邮件验证逻辑过于宽松——一些无效的邮件漏掉了,导致在尝试发送新闻通讯时出现问题。我们实施了更严格的验证例程,并部署了修补版本,突然间,电子邮件投递完全失效了!
get_confirmed_subscribers 在处理之前被认为有效但现在已经失效的已存储电子邮件时会引发 panic。
那么,我们该怎么办?
从数据库检索数据时,是否应该完全跳过验证?
没有一刀切的答案。
您需要根据域的需求,逐个评估问题。
有时处理无效记录是不可接受的——例程应该失败,并且操作员必须介入以纠正损坏的记录。
有时我们需要处理所有历史记录(例如分析数据),并且应该对数据做出最少的假设——String 是我们最安全的选择。
在我们的例子中,我们可以折中一下: 在获取下一期新闻通讯的收件人列表时,我们可以跳过无效的电子邮件。我们将对发现的每个无效地址发出警告,以便操作员识别问题并在未来的某个时间点更正存储的记录。
//! src/routes/newsletters.rs
// [...]
async fn get_confirmed_subscribers(
pool: &PgPool,
) -> Result<Vec<ConfirmedSubscriber>, anyhow::Error> {
// [...]
// Map into the domain type
let confirmed_subscribers = rows
.into_iter()
.filter_map(|r| match SubscriberEmail::parse(r.email) {
Ok(email) => Some(ConfirmedSubscriber { email }),
Err(error) => {
tracing::warn!(
"A confirmed subscriber is using an invalid email address.\n{error}."
);
None
},
})
.collect();
Ok(confirmed_subscribers)
}
filter_map 是一个方便的组合器——它返回一个新的迭代器,其中只包含我们的闭包返回 Some 变量的项。
责任边界
我们可以避免这种情况,但值得花点时间思考一下这里谁在做什么。
当遇到无效的电子邮件地址时, get_confirmed_subscriber 是否是选择跳过或中止的最合适位置?
这感觉像是一个业务层面的决策,最好放在 publish_newsletter 中,它是我们交付工作流的驱动程序。
get_confirmed_subscriber 应该简单地充当存储层和领域层之间的适配器。它处理数据库特定的部分(即查询)和映射逻辑,但它将映射或查询失败时的处理决定委托给调用者。
让我们重构一下:
//! src/routes/newsletters.rs
// [...]
async fn get_confirmed_subscribers(
pool: &PgPool,
// We are returning a `Vec` of `Result`s in the happy case.
// This allows the caller to bubble up errors due to network issues or other
// transient failures using the `?` operator, while the compiler
// forces them to handle the subtler mapping error.
// See http://sled.rs/errors.html for a deep-dive about this technique.
) -> Result<Vec<Result<ConfirmedSubscriber, anyhow::Error>>, anyhow::Error> {
// We only need `Row` to map the data coming out of this query.
// Nesting its definition inside the function itself is a simple way
// to clearly communicate this coupling (and to ensure it doesn't
// get used elsewhere by mistake).
struct Row {
email: String,
}
let rows = sqlx::query_as!(
Row,
r#"
SELECT email
FROM subscriptions
WHERE status = 'confirmed'
"#,
)
.fetch_all(pool)
.await?;
// Map into the domain type
let confirmed_subscribers = rows
.into_iter()
.map(|r| match SubscriberEmail::parse(r.email) {
Ok(email) => Ok(ConfirmedSubscriber { email }),
Err(error) => Err(anyhow::anyhow!(error)),
})
.collect();
Ok(confirmed_subscribers)
}
我们现在在调用点收到编译器错误:
error[E0609]: no field `email` on type `Result<ConfirmedSubscriber, anyhow::Erro
r>`
--> src/routes/newsletters.rs:53:28
|
53 | subscriber.email,
| ^^^^^ unknown field
|
我们可以立即修复:
//! src/routes/newsletters.rs
// [...]
pub async fn publish_newsletter(
body: web::Json<BodyData>,
pool: web::Data<PgPool>,
email_client: web::Data<EmailClient>,
) -> Result<HttpResponse, PublishError> {
let subscribers = get_confirmed_subscribers(&pool).await?;
for subscriber in subscribers {
// The compiler forces us to handle both the happy and unhappy case!
match subscriber {
Ok(subscriber) => {
email_client
.send_email(
subscriber.email,
&body.title,
&body.content.html,
&body.content.text,
)
.await
.with_context(|| {
format!(
"Failed to send newsletter issue to {}",
subscriber.email
)
})?;
}
Err(error) => {
tracing::warn!(
// We record the error chain as a structured field
// on the log record.
error.cause_chain = ?error,
// Userin `\` to split a long string literal over
// two lines, without creating a `\n` character.
"Skipping a confirmed subscriber. \
Their stored contact details are invalid",
)
}
}
}
Ok(HttpResponse::Ok().finish())
}
关注编译器
编译器几乎可以正常工作:
error[E0277]: `SubscriberEmail` doesn't implement `std::fmt::Display`
--> src/routes/newsletters.rs:64:29
|
63 | ... "Failed to send newsletter issue to {}",
| -- required by th
is formatting parameter
64 | ... subscriber.email
| ^^^^^^^^^^^^^^^^ `SubscriberEmail` cannot be formatte
d with the default formatter
|
这是因为我们将 ConfirmedSubscriber 中的电子邮件类型从 String 更改为了 SubscriberEmail。
让我们为新类型实现 Display:
//! src/domain/subscriber_email.rs
// [...]
impl std::fmt::Display for SubscriberEmail {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
// We just forward to the Display implementation of
// the wrapped String
self.0.fmt(f)
}
}
进展顺利! 又一个编译器错误,这次是借用检查器的错误!
error[E0382]: borrow of moved value: `subscriber.email`
--> src/routes/newsletters.rs:61:35
|
55 | subscriber.email,
| ---------------- value moved here
...
61 | .with_context(|| {
| ^^ value borrowed here after move
...
64 | subscriber.email
| ---------------- borrow occurs due to use in cl
osure
|
我们可以在第一次使用时直接添加一个 .clone() 函数,然后就完事了。
但让我们更复杂一点:我们真的需要在 Email Client::send_email 中获取订阅者电子邮件的所有权吗?
//! src/email_client.rs
// [...]
pub async fn send_email(
&self,
recipient: SubscriberEmail,
subject: &str,
html_content: &str,
text_content: &str,
) -> Result<(), reqwest::Error> {
// [...]
let request_body = SendEmailRequest {
from: self.sender.as_ref(),
to: recipient.as_ref(),
subject: subject,
html_body: html_content,
text_body: text_content,
};
// [...]
}
我们只需要能够调用 as_ref 即可—— &SubscriberEmail 就可以了。
让我们相应地更改签名:
//! src/email_client.rs
// [...]
pub async fn send_email(
&self,
recipient: &SubscriberEmail,
// [...]
) -> Result<(), reqwest::Error> {
// [...]
}
有几个调用点需要更新——编译器会很温柔地指出它们。我会把修复留给读者,作为练习。
完成后, 测试套件应该会通过。
移除一些模板代码
在继续之前,让我们最后看一下 get_confirmed_subscribers:
//! src/routes/newsletters.rs
// [...]
async fn get_confirmed_subscribers(
pool: &PgPool,
) -> Result<Vec<Result<ConfirmedSubscriber, anyhow::Error>>, anyhow::Error> {
struct Row {
email: String,
}
let rows = sqlx::query_as!(
Row,
r#"
SELECT email
FROM subscriptions
WHERE status = 'confirmed'
"#,
)
.fetch_all(pool)
.await?;
// Map into the domain type
let confirmed_subscribers = rows
.into_iter()
.map(|r| match SubscriberEmail::parse(r.email) {
Ok(email) => Ok(ConfirmedSubscriber { email }),
Err(error) => Err(anyhow::anyhow!(error)),
})
.collect();
Ok(confirmed_subscribers)
}
Row 有什么用吗?
其实不然——查询本身就很简单,用一个专门的类型来表示返回的数据并没有什么好处。
我们可以切换回 query! 并完全移除 Row:
//! src/routes/newsletters.rs
// [...]
async fn get_confirmed_subscribers(
pool: &PgPool,
) -> Result<Vec<Result<ConfirmedSubscriber, anyhow::Error>>, anyhow::Error> {
let rows = sqlx::query!(
r#"
SELECT email
FROM subscriptions
WHERE status = 'confirmed'
"#,
)
.fetch_all(pool)
.await?;
// Map into the domain type
let confirmed_subscribers = rows
.into_iter()
.map(|r| match SubscriberEmail::parse(r.email) {
Ok(email) => Ok(ConfirmedSubscriber { email }),
Err(error) => Err(anyhow::anyhow!(error)),
})
.collect();
Ok(confirmed_subscribers)
}
我们甚至不需要触及剩余的代码 - 它可以直接编译。