为了增强您的POST /subscriptions端点的安全性和可靠性,确保输入数据的质量和有效性是非常重要的。当前的实现似乎只做了最基础的验证------即检查name和email字段是否存在。这样的做法可能会让系统暴露于各种潜在的问题之下,例如恶意用户提交无效或格式不正确的数据,或者导致数据库中存储了低质量的数据。
改进输入验证
- 验证数据类型 :确保
name
和email
字段是字符串类型。 - 验证长度 :为
name
和email
字段设定合理的最小和最大长度限制。 - 格式验证 :特别是对于
email
字段,使用正则表达式或其他方法来验证电子邮件地址的格式是否正确。 - 字符集验证:根据需要限制允许使用的字符,防止特殊字符导致问题。
- 唯一性验证 :如果需要,可以检查
email
是否已经存在于系统中,以避免重复订阅。 - 空格处理:移除前导和尾随空格,防止用户意外地在输入前后加入不必要的空格。
Cargo.toml
[dependencies]
//[..]
unicode-segmentation = "1.7.1"
首先做一个集成测试
rust
//!tests/health_check.rs
use secrecy::Secret;
use sqlx::{Connection, Executor, PgConnection, PgPool};
use std::net::TcpListener;
use std::sync::LazyLock;
use uuid::Uuid;
use zero2prod::configuration::{get_configuration, DatabaseSettings};
use zero2prod::startup::run;
use zero2prod::telemetry::{get_subscriber, init_subscriber};
static TRACKING: LazyLock<()> = LazyLock::new(|| {
let default_filter_level = "info".to_string();
let subscriber_name = "test".to_string();
if std::env::var("TEST_LOG").is_ok() {
let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::stdout);
init_subscriber(subscriber);
} else {
let subscriber = get_subscriber(subscriber_name, default_filter_level, std::io::sink);
init_subscriber(subscriber);
};
});
pub struct TestApp {
pub address: String,
pub db_pool: PgPool,
}
async fn spawn_app() -> TestApp {
LazyLock::force(&TRACKING);
let listener = TcpListener::bind("127.0.0.1:0").expect("Failed to bind random port");
let port = listener.local_addr().unwrap().port();
let address = format!("http://127.0.0.1:{}", port);
let mut configuration = get_configuration().expect("Failed to read configuration.");
configuration.database.database_name = Uuid::new_v4().to_string();
let connection_pool = configure_database(&configuration.database).await;
let server = run(listener, connection_pool.clone()).expect("Failed to bind address");
let _ = tokio::spawn(server);
TestApp {
address,
db_pool: connection_pool,
}
}
pub async fn configure_database(config: &DatabaseSettings) -> PgPool {
let maintenance_settings = DatabaseSettings {
database_name: "newsletter".to_string(),
username: "postgres".to_string(),
password: Secret::new("postgres".to_string()),
..config.clone()
};
let mut connection = PgConnection::connect_with(&maintenance_settings.connect_options())
.await
.expect("Failed to connect to Postgres");
connection.execute(format!(r#"CREATE DATABASE "{}";"#, config.database_name).as_str())
.await
.expect("Failed to create database.");
let connection_pool = PgPool::connect_with(config.connect_options())
.await
.expect("Failed to connect to Postgres.");
sqlx::migrate!("./migrations").run(&connection_pool)
.await.expect("Failed to migrate the database");
connection_pool
}
#[tokio::test]
async fn subscribe_returns_a_200_when_fields_are_present_but_empty() {
let app = spawn_app().await;
let client = reqwest::Client::new();
let body = "name=zhangsan&email=zhangsan%40126.com";
//Act
let response = client.post(&format!("{}/subscriptions", &app.address))
.header("Content-Type", "application/x-www-form-urlencoded")
.body(body)
.send()
.await
.expect("Failed to execute request.");
// Assert
assert_eq!(200, response.status().as_u16());
let saved = sqlx::query!("SELECT email, name FROM subscriptions",)
.fetch_one(&app.db_pool)
.await
.expect("Failed to fetch saved subscription.");
assert_eq!(saved.email, "zhangsan@126.com");
assert_eq!(saved.name, "zhangsan");
}
执行结果
Testing started at 14:34 ...
warning: unused manifest key: bin.0.plugin
warning: unused manifest key: lib.plugin
warning: unused manifest key: test.0.plugin
Finished `test` profile [unoptimized + debuginfo] target(s) in 0.37s
Running tests/health_check.rs (target/debug/deps/health_check-6616c6a4e3cd7fb3)
域约束
域约束(Domain Constraints)是指在软件开发和数据库设计中,对数据值或行为施加的限制条件。这些约束确保了应用程序中的数据保持有效、一致且符合业务规则。它们可以应用于不同的层面,例如数据库层、业务逻辑层或者用户界面层。以下是关于域约束的一些重要概念:
1. 数据库层的域约束
在数据库设计中,域约束通常通过定义表结构时指定字段的数据类型、长度、格式等来实现。常见的域约束包括但不限于:
- 非空约束 (NOT NULL):确保字段不能包含NULL值。
- 唯一性约束 (UNIQUE):保证字段中的每个值都是唯一的,常用于主键或需要唯一性的列。
- 检查约束 (CHECK):允许你定义一个表达式,该表达式的值必须为真,否则插入或更新操作将被拒绝。
- 外键约束 (FOREIGN KEY):用来维护两个表之间的参照完整性,确保子表中的记录对应于父表中存在的记录。
- 默认值约束 (DEFAULT):当没有提供具体值时,自动为字段设置默认值。
2. 业务逻辑层的域约束
在应用层面上,域约束可能涉及到更复杂的业务规则,而不仅仅是简单的数据验证。例如:
- 数值范围:如年龄必须介于0到120之间。
- 格式验证:如电子邮件地址必须遵循特定格式。
- 复杂依赖关系:如订单状态改变后,某些操作是否被允许。
3. 用户界面层的域约束
在UI层面,可以通过前端验证来防止无效输入直接提交给服务器。这包括但不限于:
- 即时反馈:用户输入时立即显示错误提示信息。
- 控件属性:利用HTML5内置的input类型(如email, url, number等),以及min/max/maxlength等属性来进行基本的输入限制。
- JavaScript验证:编写脚本进行更加灵活和复杂的验证逻辑。
采用名字相同策略
从本结开始,我们可以通过创建一个新的类型SubscriberEmail来定义我们的不变量("这个字符串代表一个有效的电子邮件")。这样做有几个好处:
- 提高代码的可读性和意图表达:通过定义一个特定类型的SubscriberEmail,我们可以更清晰地传达该值应该是一个有效的电子邮件地址。这使得代码更容易理解,因为类型本身就在说明它的用途。
- 增强编译时检查:如果使用的是静态类型语言如Java或Kotlin,自定义类型可以在编译阶段就帮助捕获错误。例如,如果尝试将非电子邮件格式的字符串赋值给SubscriberEmail类型的变量,编译器可以立即报错。
- 简化业务逻辑:在应用中传递和处理SubscriberEmail对象而不是原始字符串,可以确保所有地方都遵循同样的规则,减少了在不同位置重复实现验证逻辑的需求。
- 促进不可变性:一旦创建了SubscriberEmail对象,就可以保证它是有效且不可更改的,从而减少意外修改的风险。
- 支持领域驱动设计(DDD) :这种做法符合DDD的原则,即通过引入丰富的领域模型来更好地捕捉业务规则和约束条件。
rust
#[allow(clippy::async_yields_async)]
#[tracing::instrument(name = "Adding a new subscriber",
skip(form, pool),
fields(subscriber_email=%form.email,subscriber_name=%form.name))]
pub async fn subscribe(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
let name = match SubscriberName::parse(form.0.name) {
Ok(name) => name,
Err(_) => return HttpResponse::BadRequest().finish(),
};
let new_subscriber = NewSubscriber {
email: form.0.email,
name,
};
match insert_subscriber(&pool, new_subscriber).await {
Ok(_) => HttpResponse::Ok().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
#[tracing::instrument(name = "Save new subscriber detial in database",
skip(new_subscriber, pool))]
pub async fn insert_subscriber(pool: &PgPool, new_subscriber: NewSubscriber) -> Result<(), sqlx::Error> {
sqlx::query!(
r#"insert into subscriptions (id,email,name,subscribed_at) values($1,$2,$3,$4)"#,
Uuid::new_v4(),
new_subscriber.email,
new_subscriber.name.as_ref(),
Utc::now()
)
.execute(pool)
.await
.map_err(|e| {
tracing::error!("Failed to execute query :{:?}", e);
e
})?;
Ok(())
}
Type-Driven Development
Type-Driven Development (TDD) 是一种软件开发方法,但它与更常见的 Test-Driven Development(测试驱动开发)不同。更强调类型系统在开发过程中的作用。Type-Driven Development 强调利用强大的静态类型系统来指导代码的设计和编写,确保程序的正确性和可靠性。
类型驱动开发的核心理念
使用类型作为设计工具:
- 在类型驱动开发中,开发者首先考虑的是如何通过类型系统表达业务逻辑和数据结构。这意味着在编写实现之前,先定义接口、函数签名以及数据类型的形状。
编译器辅助开发:
- 强大的静态类型检查可以在编译时捕捉到许多潜在错误,如类型不匹配、空值引用等。这减少了运行时错误的发生几率,并提高了代码质量。
提高代码可读性和维护性:
- 清晰的类型信息使得代码更容易被理解和维护。其他开发者可以快速了解函数接收什么参数、返回什么结果,而无需深入阅读实现细节。
促进模块化和解耦:
- 通过精心设计的类型接口,可以使各个组件之间的依赖关系更加明确,从而降低系统的耦合度,增加灵活性。
支持重构:
- 当进行大规模重构时,静态类型系统可以帮助识别受影响的部分,确保修改不会破坏现有功能。
实践类型驱动开发的方法
- 从类型开始:在编码之前,优先考虑类型的设计。定义好所有必要的数据类型、枚举、联合类型等。
- 让编译器引导你:当遇到编译错误时,不要急于解决问题,而是思考这些错误是否反映了设计上的不足。调整类型定义以更好地匹配需求。
- 保持类型安全:尽量避免使用动态类型或任何形式的类型转换,除非绝对必要。如果必须这样做,请确保有足够的理由,并且已经充分评估了风险。
- 利用高级类型特性 :现代编程语言提供了丰富的类型构造,如泛型、代数数据类型(ADTs)、模式匹配等。合理运用这些特性可以让代码更加简洁且富有表现力。
- 文档即类型:良好的类型注释本身就是一种文档形式。它不仅帮助团队成员理解代码意图,也使得未来维护变得更加容易。
新增domain模块
cargo.toml 增加配置
[dev-dependencies]
claims = "0.7"
rust
//!src/lib.rs
pub mod domain;
rust
use unicode_segmentation::UnicodeSegmentation;
pub struct NewSubscriber {
pub email: String,
pub name: SubscriberName,
}
#[derive(Debug)]
pub struct SubscriberName(String);
impl SubscriberName {
pub fn parse(s: String) -> Result<SubscriberName, String> {
let is_empty_or_whitespace = s.trim().is_empty();
let is_too_long = s.graphemes(true).count() > 256;
let forbidden_characters = ['/', '(', ')', '"', '<', '>', '\\', '{', '}'];
let contains_forbidden_characters = s.chars().any(|g| forbidden_characters.contains(&g));
if is_empty_or_whitespace || is_too_long || contains_forbidden_characters {
Err(format!("{} is not a valid subscriber name.", s))
} else {
Ok(Self(s))
}
}
}
impl AsRef<str> for SubscriberName {
fn as_ref(&self) -> &str {
&self.0
}
}
#[cfg(test)]
mod tests {
use crate::domain::SubscriberName;
use claims::{assert_err, assert_ok};
#[test]
fn a_256_grapheme_long_name_is_valid() {
let name = "a̐".repeat(256);
assert_ok!(SubscriberName::parse(name));
}
#[test]
fn a_name_longer_than_256_graphemes_is_rejected() {
let name = "a".repeat(257);
assert_err!(SubscriberName::parse(name));
}
#[test]
fn whitespace_only_names_are_rejected() {
let name = " ".to_string();
assert_err!(SubscriberName::parse(name));
}
#[test]
fn empty_string_is_rejected() {
let name = "".to_string();
assert_err!(SubscriberName::parse(name));
}
#[test]
fn names_containing_an_invalid_character_are_rejected() {
for name in &['/', '(', ')', '"', '<', '>', '\\', '{', '}'] {
let name = name.to_string();
assert_err!(SubscriberName::parse(name));
}
}
#[test]
fn a_valid_name_is_parsed_successfully() {
let name = "Ursula Le Guin".to_string();
assert_ok!(SubscriberName::parse(name));
}
}
parse
方法接收一个String
类型的参数s
,并返回一个Result<SubscriberName, String>
,即如果成功则返回一个SubscriberName
实例,否则返回一个错误信息字符串。- 方法内部进行了三个主要检查:
- 非空或空白 :通过
trim().is_empty()
检查字符串是否为空或仅包含空白字符。 - 长度限制 :使用
graphemes(true).count()
来计算图形单元的数量(考虑组合字符),确保不超过256个图形单元。 - 禁止字符:检查字符串中是否包含了预定义的禁止字符列表中的任何一个字符。
- 非空或空白 :通过
如果任意一项检查失败,则构造一个错误消息并返回 Err
;否则返回 Ok
包含一个新的 SubscriberName
实例。
此函数实现了如下:
-
- 编写了多个单元测试来验证
SubscriberName::parse
方法的行为: - 测试最大允许长度(256个图形单元)的名字是否有效。
- 测试超过256个图形单元的名字是否被拒绝。
- 测试仅由空白字符组成的名字是否被拒绝。
- 测试空字符串是否被拒绝。
- 测试包含禁止字符的名字是否被拒绝。
- 测试一个有效的名字能否成功解析。
- 编写了多个单元测试来验证
总结
rust是一门不太容易掌握的语言,在实际学习过程中遇到了很多问题,很不可思议。不过大多数情况下都可以在网上找到答案,希望正在学习的朋友们不要放弃