亲爱的读者们,在前一章中,我们介绍了对新订阅者的电子邮件地址的验证——它们必须符合电子邮件格式。 现在我们拥有的电子邮件虽然在语法上是有效的,但我们仍然不确定这些邮箱是否真的存在:有人实际使用这些电子邮件地址吗?它们可以接收到邮件吗? 我们无法确定,而唯一的方法就是发送一封真正的确认邮件。
1. 订阅者同意
此时你可能会有疑虑——在这个阶段我们需要知道这一点吗?我们不能等到下一期通讯发布时再看他们是否能收到我们的邮件吗? 如果彻底验证是唯一的顾虑,我同意我们应该等到下一次发行而不是增加POST /subscriptions
端点的复杂性。 然而,我们还有另一个不能推迟的关注点:订阅者的同意。
电子邮件地址不像密码——如果你上网时间足够长,你的电子邮件很可能并不难获取。 某些类型的电子邮件地址(例如专业电子邮件)甚至是公开的。 这为滥用打开了可能性。 恶意用户可能会开始将一个电子邮件地址订阅到互联网上的各种通讯列表,导致受害者的收件箱被垃圾邮件淹没。 不道德的通讯所有者则可能开始从网络上抓取电子邮件地址并将其添加到自己的通讯列表中。 这就是为什么仅通过POST /subscriptions
请求不足以说明“这个人想要接收我的通讯内容!” 例如,如果你处理的是欧盟公民,法律要求获得用户的明确同意。 这也是为什么发送确认邮件已经成为常见做法的原因:在填写通讯HTML表单后,你会收到一封邮件,要求确认你确实想订阅该通讯。 这对我们来说非常好——我们可以保护用户免受滥用,并且可以在尝试发送通讯之前确认提供的电子邮件地址确实存在。
2. 用户确认流程
让我们从用户的角度来看看确认流程。 他们会收到一封包含确认链接的邮件。 一旦点击链接,就会发生一些事情,然后他们会被重定向到一个成功页面(“你现在是我们通讯的订阅者!耶!”)。从那时起,他们将在收件箱中收到所有的通讯期号。 后台是如何工作的呢? 我们将尽量保持简单——我们的版本不会在确认时执行重定向,只会给浏览器返回一个200 OK。 每当用户想要订阅我们的通讯时,他们会发起一个POST /subscriptions
请求。 我们的请求处理器将会:
- 将他们的详细信息添加到数据库中的
subscriptions
表,状态设置为pending_confirmation
; - 生成一个(唯一的)订阅令牌(subscription_token);
- 在
subscription_tokens
表中存储subscription_token
及其对应的订阅者ID; - 发送一封包含结构为
https://<我们的API域名>/subscriptions/confirm?token=<subscription_token>
链接的邮件给新订阅者; - 返回200 OK。
一旦他们点击链接,浏览器标签页会打开并发出一个到我们的GET /subscriptions/confirm
端点的GET请求。我们的请求处理器将会:
- 从查询参数中检索
subscription_token
; - 从
subscription_tokens
表中检索与subscription_token
关联的订阅者ID; - 更新
subscriptions
表中的订阅者状态从pending_confirmation
到active
; - 返回200 OK。
这里还有一些其他可能的设计(例如使用JWT代替唯一令牌),以及我们需要处理的一些边缘情况(例如,如果他们点击链接两次会发生什么?如果他们尝试重复订阅又会怎样?)——随着我们实施进度的推进,我们会在最合适的时间讨论这些问题。
3. 实施策略
这里有大量工作要做,所以我们将其分为三个概念性的部分:
- 编写一个模块来发送邮件;
- 适应现有
POST /subscriptions
请求处理器的逻辑以匹配新的规范; - 从零编写一个
GET /subscriptions/confirm
请求处理器。
让我们开始吧!
EmailClient,我们的邮件发送组件
如何发送电子邮件
要真正发送一封电子邮件,你需要了解SMTP——简单邮件传输协议。SMTP对于电子邮件的作用类似于HTTP对于网页的作用:它是一个应用层协议,确保不同的电子邮件服务器和客户端实现能够互相理解并交换消息。然而,我们不会构建自己的私有邮件服务器,因为这需要太多的时间且不会有太多的收益。我们将利用第三方服务。现代的邮件递送服务期望什么?我们需要通过SMTP与它们通信吗?
实际上,不一定需要。SMTP是一种专业化的协议,除非你之前有过处理电子邮件的经验,否则直接使用它是不太可能的。学习新协议需要时间,并且在过程中难免会犯错误。这就是为什么大多数提供商会暴露两种接口:SMTP接口和REST API。
如果你熟悉电子邮件协议或需要一些非传统的配置,可以选择SMTP接口。否则,大多数开发者会更快(也更可靠)地使用REST API来启动和运行。正如你可能已经猜到的,我们也将选择后者——我们将编写一个REST客户端。
选择一个邮件API
市场上不缺少邮件API提供商,原作者在这学习过程中使用了Postmark。
为了完成接下来的部分,你需要注册Postmark,并在登录其门户后授权一个单一的发件人邮箱。
实施细节
使用Postmark REST API发送邮件
编写EmailClient模块:
touch email_client.rs
//! src/lib.rs
//[..]
pub mod email_client;
Cargo.toml
reqwest = { version = "0.12", default-features = false, features = [
"json",
"rustls-tls",
] }
//! email_client.rs
use reqwest::Client;
use secrecy::{ExposeSecret, Secret};
use crate::domain::SubscriberEmail;
pub struct EmailClient {
http_client: Client,
base_url: String,
sender: SubscriberEmail,
authorization_token: Secret<String>,
}
impl EmailClient {
pub fn new(
base_url: String,
sender: SubscriberEmail,
authorization_token: Secret<String>,
timeout: std::time::Duration,
) -> Self {
let http_client = Client::builder().timeout(timeout).build().unwrap();
Self {
http_client,
base_url,
sender,
authorization_token,
}
}
pub async fn send_email(
&self,
recipient: SubscriberEmail,
subject: &str,
html_content: &str,
text_content: &str,
) -> Result<(), reqwest::Error> {
let url = format!("{}/email", self.base_url);
let request_body = SendEmailRequest {
from: self.sender.as_ref(),
to: recipient.as_ref(),
subject,
html_body: html_content,
text_body: text_content,
};
self.http_client
.post(&url)
.header(
"X-Postmark-Server-Token",
self.authorization_token.expose_secret(),
)
.json(&request_body)
.send()
.await?
.error_for_status()?;
Ok(())
}
}
#[derive(serde::Serialize)]
#[serde(rename_all = "PascalCase")]
struct SendEmailRequest<'a> {
from: &'a str,
to: &'a str,
subject: &'a str,
html_body: &'a str,
text_body: &'a str,
}
#[derive(serde::Serialize)]
:这是一个宏,告诉编译器为SendEmailRequest
自动生成实现Serialize
trait 的代码。这使得该结构体可以被序列化成 JSON 或其他格式的数据。#[serde(rename_all = "PascalCase")]
:这是另一个serde
属性,它指定了当结构体字段被序列化时,所有字段名都将转换为 PascalCase(即每个单词首字母大写)。例如,from
将变为From
,html_body
将变为HtmlBody
等等。SendEmailRequest<'a>
:定义了一个泛型生命周期参数'a
,这意味着所有的字符串切片(&str
)都必须至少和包含它们的SendEmailRequest
实例一样长。
字段
from
: 发件人的电子邮件地址。to
: 收件人的电子邮件地址。subject
: 电子邮件的主题。html_body
: 邮件正文的 HTML 版本。text_body
: 邮件正文的纯文本版本。
连接复用
在构建高性能的 HTTP 客户端应用程序时,连接池(Connection Pooling)是一个非常重要的优化措施。通过重用已建立的连接而不是为每个请求创建新的连接,可以显著提高性能并减少资源消耗。特别是在使用 HTTPS 的情况下,创建新连接的成本更高,因为它涉及到 SSL/TLS 握手过程。
reqwest
自动处理了连接池,当你创建一个 Client
实例时,它会在后台初始化一个连接池。为了充分利用这个特性,你应该在整个应用程序中重复使用同一个 Client
实例,而不是每次需要发起请求时都创建一个新的实例。这样做不仅可以节省时间,还可以避免高负载下可能出现的套接字耗尽(socket exhaustion)问题。
此外,值得注意的是,Client::clone
并不会创建一个新的连接池;相反,它只是克隆了一个指向底层连接池的指针。因此,你可以安全地在多个地方使用 Client
的克隆副本,而不会导致额外的资源开销。
在 actix-web 中重用 reqwest::Client
要在 actix-web 应用程序中跨多个请求重用同一个 reqwest::Client
,我们需要将客户端实例存储在应用程序上下文中。这样,我们就可以在请求处理器中通过提取器(例如 actix_web::web::Data
)来获取对 Client
的引用。
重构StartUp.rs
use crate::email_client::EmailClient;
use crate::routes::{health_check, subscribe};
use actix_web::dev::Server;
use actix_web::web::Data;
use actix_web::{web, App, HttpServer};
use sqlx::PgPool;
use std::net::TcpListener;
use tracing_actix_web::TracingLogger;
pub fn run(
listener: TcpListener,
db_pool: PgPool,
email_client: EmailClient,
) -> Result<Server, std::io::Error> {
let db_pool = Data::new(db_pool);
use crate::email_client::EmailClient;
use crate::routes::{health_check, subscribe};
use actix_web::dev::Server;
use actix_web::web::Data;
use actix_web::{web, App, HttpServer};
use sqlx::PgPool;
use std::net::TcpListener;
use tracing_actix_web::TracingLogger;
pub fn run(
listener: TcpListener,
db_pool: PgPool,
email_client: EmailClient,
) -> Result<Server, std::io::Error> {
let db_pool = Data::new(db_pool);
let email_client = Data::new(email_client);
let server = HttpServer::new(move || {
App::new()
.wrap(TracingLogger::default())
.route("/health_check", web::get().to(health_check))
.route("/subscriptions", web::post().to(subscribe))
.app_data(db_pool.clone())
.app_data(email_client.clone())
})
.listen(listener)?
.run();
Ok(server)
}
let server = HttpServer::new(move || {
App::new()
.wrap(TracingLogger::default())
.route("/health_check", web::get().to(health_check))
.route("/subscriptions", web::post().to(subscribe))
.app_data(db_pool.clone())
.app_data(email_client.clone())
})
.listen(listener)?
.run();
Ok(server)
}
在决定如何在应用程序中管理 EmailClient 时,确实需要考虑多个因素,包括性能、内存使用以及代码的可维护性。让我们分析一下两种策略的优缺点,并确定哪种方式更适合你的场景。
第一种策略:直接将 reqwest::Client 包装为 web::Data
优点:
避免双重封装:如果 EmailClient 只是简单地包装了 reqwest::Client,那么直接将 Client 包装进 web::Data 可以减少不必要的封装层级。
更少的间接层:减少了额外的数据结构带来的间接层,这可能对某些高性能要求的应用程序有帮助。
缺点:
无法携带其他数据:在这种情况下,EmailClient 的其他属性(如 base_url 和 sender)将无法与 Client 一起共享,导致每次创建 App 实例时都需要重新分配这些属性。
第二种策略:将整个 EmailClient 包装为 web::Data
优点:
共享数据:所有 App 实例可以共享同一个 EmailClient 实例及其所有属性,这意味着 base_url 和 sender 等信息只需要分配一次。
简化配置:可以在一个地方配置 EmailClient 的所有属性,然后在整个应用程序中重用它,这有助于提高代码的可维护性和一致性。
缺点:
每个线程都会创建一个新的 App 实例:虽然每个线程会创建一个新的 App 实例,但这并不会显著增加开销,因为字符串分配或指针克隆的成本相对较低。
稍微增加了一点复杂度:由于 EmailClient 携带了更多的信息,因此可能会略微增加一些复杂度,但这通常是可以接受的。
最佳实践
EmailClient 并不仅仅是一个简单的 reqwest::Client 包装器,它还包含了其他的字段(例如 base_url 和 sender)。因此,第二种策略——将整个 EmailClient 包装为 web::Data——是最合适的选择。这样做的好处在于它可以确保所有 App 实例都能够共享相同的 EmailClient 配置,而不需要为每个实例重新分配那些配置数据。
重构configuration.rs
#[derive(serde::Deserialize)]
pub struct Settings {
pub database: DatabaseSettings,
pub application: ApplicationSettings,
//新增
pub email_client: EmailClientSettings,
}
//[..]
//....
//[..]
#[derive(serde::Deserialize)]
pub struct EmailClientSettings {
pub base_url: String,
pub sender_email: String,
pub authorization_token: Secret<String>,
pub timeout_milliseconds: u64,
}
impl EmailClientSettings {
pub fn sender(&self) -> Result<SubscriberEmail, String> {
SubscriberEmail::parse(self.sender_email.clone())
}
pub fn timeout(&self) -> std::time::Duration {
std::time::Duration::from_millis(self.timeout_milliseconds)
}
}
重构main.rs
use sqlx::postgres::PgPoolOptions;
use std::net::TcpListener;
use zero2prod::{
configuration::get_configuration,
email_client::EmailClient,
startup::run,
telemetry::{get_subscriber, init_subscriber},
};
#[tokio::main]
async fn main() -> std::io::Result<()> {
let subscriber = get_subscriber("zero2prod".into(), "info".into(), std::io::stdout);
init_subscriber(subscriber);
let configuration = get_configuration().expect("Failed to read configuration.");
let connection_pool =
PgPoolOptions::new().connect_lazy_with(configuration.database.connect_options());
let sender_email = configuration
.email_client
.sender()
.expect("Invalid sender email address.");
let timeout = configuration.email_client.timeout();
let email_client = EmailClient::new(
configuration.email_client.base_url,
sender_email,
configuration.email_client.authorization_token,
timeout,
);
let address = format!(
"{}:{}",
configuration.application.host, configuration.application.port
);
let listener = TcpListener::bind(address)?;
run(listener, connection_pool, email_client)?.await?;
Ok(())
}
为了方便测试我们先把healt_check.rs简单化处理
use actix_web::HttpResponse;
pub async fn health_check() -> HttpResponse {
HttpResponse::Ok().finish()
}
执行cargo test 通过
running 10 tests
test domain::subscriber_email::tests::empty_string_is_rejected ... ok
test domain::subscriber_email::tests::email_missing_at_symbol_is_rejected ... ok
test domain::subscriber_name::tests::a_valid_name_is_parsed_successfully ... ok
test domain::subscriber_name::tests::empty_string_is_rejected ... ok
test domain::subscriber_name::tests::names_containing_an_invalid_character_are_rejected ... ok
test domain::subscriber_name::tests::a_name_longer_than_256_graphemes_is_rejected ... ok
test domain::subscriber_name::tests::whitespace_only_names_are_rejected ... ok
test domain::subscriber_name::tests::a_256_grapheme_long_name_is_valid ... ok
test domain::subscriber_email::tests::email_missing_subject_is_rejected ... ok
test domain::subscriber_email::tests::valid_emails_are_parsed_successfully ... ok