构建一个rust生产应用读书笔记6-拒绝无效订阅者01

为了增强您的POST /subscriptions端点的安全性和可靠性,确保输入数据的质量和有效性是非常重要的。当前的实现似乎只做了最基础的验证------即检查name和email字段是否存在。这样的做法可能会让系统暴露于各种潜在的问题之下,例如恶意用户提交无效或格式不正确的数据,或者导致数据库中存储了低质量的数据。

改进输入验证

  1. 验证数据类型 :确保nameemail字段是字符串类型。
  2. 验证长度 :为nameemail字段设定合理的最小和最大长度限制。
  3. 格式验证 :特别是对于email字段,使用正则表达式或其他方法来验证电子邮件地址的格式是否正确。
  4. 字符集验证:根据需要限制允许使用的字符,防止特殊字符导致问题。
  5. 唯一性验证 :如果需要,可以检查email是否已经存在于系统中,以避免重复订阅。
  6. 空格处理:移除前导和尾随空格,防止用户意外地在输入前后加入不必要的空格。

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来定义我们的不变量("这个字符串代表一个有效的电子邮件")。这样做有几个好处:

  1. 提高代码的可读性和意图表达:通过定义一个特定类型的SubscriberEmail,我们可以更清晰地传达该值应该是一个有效的电子邮件地址。这使得代码更容易理解,因为类型本身就在说明它的用途。
  2. 增强编译时检查:如果使用的是静态类型语言如Java或Kotlin,自定义类型可以在编译阶段就帮助捕获错误。例如,如果尝试将非电子邮件格式的字符串赋值给SubscriberEmail类型的变量,编译器可以立即报错。
  3. 简化业务逻辑:在应用中传递和处理SubscriberEmail对象而不是原始字符串,可以确保所有地方都遵循同样的规则,减少了在不同位置重复实现验证逻辑的需求。
  4. 促进不可变性:一旦创建了SubscriberEmail对象,就可以保证它是有效且不可更改的,从而减少意外修改的风险。
  5. 支持领域驱动设计(DDD) :这种做法符合DDD的原则,即通过引入丰富的领域模型来更好地捕捉业务规则和约束条件。

subscriptions.rs 重构

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;

domain.rs

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是一门不太容易掌握的语言,在实际学习过程中遇到了很多问题,很不可思议。不过大多数情况下都可以在网上找到答案,希望正在学习的朋友们不要放弃

相关推荐
阿落ovo43 分钟前
访问控制列表ACL
java·运维·服务器·网络·数据库·华为·智能路由器
凡人的AI工具箱2 小时前
每天40分玩转Django:Django中间件
开发语言·数据库·后端·python·中间件·django
老码GoRust2 小时前
Rust中自定义Debug调试输出
服务器·开发语言·后端·rust
SomeB1oody2 小时前
【Rust自学】3.2. 数据类型:标量类型
开发语言·后端·rust
编码浪子2 小时前
构建一个rust生产应用读书笔记四(实战3)
开发语言·后端·rust
SomeB1oody2 小时前
【Rust自学】3.4. 函数和注释
开发语言·后端·rust
幽兰的天空3 小时前
MySQL面试题大全及答案解析(进阶篇)
数据库·mysql
Lian_Aseubel3 小时前
更新数据时Redis的操作
数据库·redis·缓存
编程、小哥哥3 小时前
Redis 缓存、加锁(独占/分段)、发布/订阅,常用特性的使用和高级编码操作
数据库·redis·缓存