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

相关推荐
不爱学英文的码字机器1 小时前
重塑 Web 性能:用 Rust 与 WASM 构建“零开销”图像处理器
前端·rust·wasm
不光头强3 小时前
Spring框架的事务管理
数据库·spring·oracle
百***92025 小时前
【MySQL】MySQL库的操作
android·数据库·mysql
q***76665 小时前
Spring Boot 从 2.7.x 升级到 3.3注意事项
数据库·hive·spring boot
信仰_2739932435 小时前
Redis红锁
数据库·redis·缓存
人间打气筒(Ada)5 小时前
Centos7 搭建hadoop2.7.2、hbase伪分布式集群
数据库·分布式·hbase
国服第二切图仔5 小时前
Rust开发之Trait 定义通用行为——实现形状面积计算系统
开发语言·网络·rust
心灵宝贝5 小时前
如何在 Mac 上安装 MySQL 8.0.20.dmg(从下载到使用全流程)
数据库·mysql·macos
奋斗的牛马6 小时前
OFDM理解
网络·数据库·单片机·嵌入式硬件·fpga开发·信息与通信
忧郁的橙子.7 小时前
一、Rabbit MQ 初级
服务器·网络·数据库