if !is_valid_email(&form.email) || !is_valid_password(&form.password) {
return HttpResponse::BadRequest().finish();
}
Этот код — кусок дерьма; кошмар, который вот-вот случится. Чтобы понять, почему и как это исправить, сначала нужно понять главный урок, который мне преподал Rust: силу использования системы типов для обеспечения инвариантов.
Давайте разбираться. В программировании инвариант — это правило или условие, которое всегда должно быть истинным. Например, если мы пишем программное обеспечение для управления банковскими счетами, один из инвариантов может заключаться в том, что баланс никогда не должен быть меньше нуля (предполагая, что овердрафт не разрешен).
struct BankAccount {
// in cents
balance: i32,
}
Но как мы можем соблюсти этот инвариант?
Есть несколько подходов, которые можно разделить на две категории: ручное соблюдение инварианта и его автоматическое соблюдение.
Ручное соблюдение инварианта
Ручное соблюдение инварианта включает в себя:
Код-ревью
Комментарии в коде
Документацию
Проектные документы
Даже устные договоренности, разделяемые между членами команды
Как вы можете представить, такой способ обеспечения довольно хрупок, но у него есть свои применения. Например, представьте себе UI-инвариант, согласно которому любое действие удаления требует подтверждения пользователя. Это было бы очень сложно обеспечить автоматически, поэтому вы просто описываете эти правила в документации и пытаетесь контролировать их соблюдение на код-ревью. Нарушение этого инварианта не будет катастрофическим, поэтому, возможно, в этом случае этого достаточно.
Однако, если мы будем использовать ручной подход в нашем примере с банковским балансом, это быстро приведет нас к краху.
struct BankAccount {
// in cents
// should never be less than 0!
balance: i32,
}
Мы не можем здесь позволить себе нарушение инварианта; нам нужен более надежный метод его обеспечения.
Автоматическое соблюдение инварианта
Включает в себя:
Ассерты в рантайме
Проверки в рантайме
Тестирование
Валидацию ввода
Использование системы типов
Мы затронем все эти подходы, уделяя особое внимание использованию системы типов, который является самым надежным способом соблюдения инвариантов.
Ассерты
Начнем с ассертов как метода автоматического соблюдения инварианта.
struct BankAccount {
balance: i32,
}
impl BankAccount {
fn new(initial_balance: i32) -> Self {
assert!(initial_balance >= 0, "Initial balance cannot be negative");
Self {
balance: initial_balance,
}
}
fn deposit(&mut self, amount: i32) {
assert!(amount >= 0, "Deposit amount cannot be negative");
self.balance += amount;
}
fn withdraw(&mut self, amount: i32) {
assert!(amount >= 0, "Withdrawal amount cannot be negative");
assert!(self.balance >= amount, "Insufficient funds");
self.balance -= amount;
}
}
Мы утверждаем, что начальный баланс должен быть больше или равен нулю. И такие же ассерты ставим в методах deposit
и withdraw
.
Наш инвариант теперь автоматически проверяется через код, но есть несколько проблем. Ассерты проверяются в рантайме, это означает, что разработчики все равно могут написать неправильный код. Кроме того, если ассерт сработает, наша программа вызовет панику и завершится аварийно.
Использование системы типов Rust
Давайте улучшим этот код, используя систему типов Rust. Мы изменим тип баланса с 32-битного знакового целого числа на 32-битное беззнаковое целое число, и баланс теперь в принципе не может быть отрицательным числом. Теперь можно удалить ассерты в функциях new
и deposit
, а также первый ассерт в функции withdraw
.
impl BankAccount {
fn new(initial_balance: u32) -> Self {
Self {
balance: initial_balance,
}
}
fn deposit(&mut self, amount: u32) {
self.balance += amount;
}
fn withdraw(&mut self, amount: u32) {
assert!(self.balance >= amount, "Insufficient funds");
self.balance -= amount;
}
}
Однако нам все еще нужно убедиться, что на счету достаточно средств.
Здесь мы можем воспользоваться важной особенностью системы типов Rust. Мы изменим возвращаемое значение на тип Result
, чтобы учесть эту потенциальную ошибку.
fn withdraw(&mut self, amount: u32) -> Result<u32, String> {
if self.balance >= amount {
self.balance -= amount;
Ok(self.balance)
} else {
Err("Insufficient funds".to_string())
}
}
Затем внутри функции мы выполним простую рантайм-проверку. Поскольку функция withdraw
возвращает тип Result
, она заставит вызывающий код обработать потенциальную ошибку. Мы также можем добавить тесты, чтобы убедиться, что withdraw
работает правильно.
С этим подходом наш код не скомпилируется, или наши тесты завершатся с ошибкой, если инвариант будет нарушен. Это делает наш код гораздо более надежным.
Этот мощный метод проектирования программного обеспечения, использующий систему типов для обеспечения инвариантов, называется type-driven design. Хотя наш предыдущий пример был простым, система типов может обеспечивать соблюдение очень сложных инвариантов, особенно если язык статически типизирован и имеет выразительную систему типов, как Rust.
Почему этот код — кошмар?
В начале видео я сказал, что этот код — кошмар, который вот-вот произойдет:
if !is_valid_email(&form.email) || !is_valid_password(&form.password) {
return HttpResponse::BadRequest().finish();
}
Почему?
#[post("/user/register")]
pub async fn register_user(
form: web::Form<FormData>,
pool: web::Data<PgPool>
) -> HttpResponse {
if !is_valid_email(&form.email) || !is_valid_password(&form.password) {
return HttpResponse::BadRequest().finish();
}
let user = User {
email: form.email.clone(),
password: form.password.clone(),
};
match insert_user(&pool, &user).await {
Ok(_) => HttpResponse::Ok().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
У нас есть API-эндпоинт для создания новых пользователей. Инвариант заключается в том, что электронная почта и пароль должны быть всегда действительными. Мы обеспечиваем это через валидацию ввода: пользователи предоставляют непроверенные данные, и мы вызываем несколько функций валидации, чтобы убедиться, что введенные данные соответствуют нашим требованиям. Только после этого мы сохраняем данные в БД.
Проблема в том, что эти проверки выполняются только один раз — в начале обработчика запросов. Так может ли функция insert_user
безопасно полагать, что электронная почта и пароль действительны?
#[derive(Debug)]
struct User {
pub email: String,
pub password: String,
}
async fn insert_user(pool: &PgPool, user: &User) -> Result<Uuid, sqlx::Error> {
let user_id = Uuid::new_v4();
let password = hash_password(user.password.as_str());
// insert user into database ...
Ok(user_id)
}
Если мы посмотрим на сигнатуру функции в изоляции, нет никакой информации, которая гарантировала бы, что электронная почта и пароль действительны; они определены как простые строки. Эта функция должна верить на слово, что вызывающий код правильно выполнил валидацию перед передачей ввода, и это — рецепт катастрофы.
По мере роста и изменения кода вы можете представить, как проверка валидации случайно удаляется или данные каким-то образом изменяются, и вот мы уже приплыли:
#[post("/user/register")]
pub async fn register_user(form: web::Form<FormData>, pool: web::Data<PgPool>) -> HttpResponse {
if !is_valid_email(&form.email) || !is_valid_password(&form.password) {
return HttpResponse::BadRequest().finish();
}
let user = User {
email: form.email.clone(),
password: form.password.clone(),
};
+ // remove sensitive data before logging
+ user.password.clear();
+ dbg!("Registering user: {:?}", &user);
match insert_user(&pool, &user).await {
Ok(_) => HttpResponse::Ok().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
Один из способов предотвратить это — снова выполнить валидацию внутри функции insert_user
:
async fn insert_user(pool: &PgPool, user: &User) -> Result<Uuid, sqlx::Error> {
+ if !is_valid_email(&user.email) || !is_valid_password(&user.password) {
+ // return error...
+ }
let user_id = Uuid::new_v4();
let password = hash_password(user.password.as_str());
// insert user into database ...
Ok(user_id)
}
Однако это вводит ненужную избыточность и чревато ошибками.
Принцип «Не валидировать но парсить»
Вместо этого мы можем воспользоваться принципом проектирования на основе типов: не валидировать но парсить. Вместо того чтобы разбросать функции валидации по всему коду, мы можем парсить пользовательский ввод в новые типы, которые гарантированно будут соблюдать наши инварианты.
Сначала мы создадим два новых типа: Email
и Password
.
pub struct Email(String);
pub struct Password(String);
Оба они являются структурными кортежами, которые оборачивают строковое значение. Оборачивание встроенных типов с нестрогими требованиями в пользовательские типы с более строгими требованиями называется newtype pattern в Rust. В данном случае наши требования (или инварианты) заключаются в том, чтобы электронная почта была правильно отформатирована, а пароль соответствовал требованиям к длине.
Чтобы обеспечить это, мы добавим функцию parse
, которая принимает непроверенную строку в качестве ввода и парсит ее в тип Email
или Password
. Операция парсинга может завершиться неудачей, поэтому мы будем возвращать тип Result
.
impl Email {
pub fn parse(email: String) -> Result<Email, AuthError> {
if !is_valid_email(&email) {
Err(AuthError::ValidationError("Email must be valid".to_string()))
} else {
Ok(Email(email))
}
}
}
impl Password {
pub fn parse(password: String) -> Result<Password, AuthError> {
if !is_valid_password(&password) {
Err(AuthError::ValidationError("Password must be valid".to_string()))
} else {
Ok(Password(password))
}
}
}
Здесь мы используем несколько уникальных особенностей системы типов Rust. Из-за правил видимости Rust внутренняя строка является приватной и недоступной за пределами структуры. И поскольку в Rust нет встроенных или стандартных конструкторов, единственный способ создать экземпляр Email
или Password
— через функцию parse
.
Мы по-прежнему используем те же функции валидации, что и раньше, но теперь логика валидации содержится внутри типа, а состояние валидации сохраняется внутри типа. Мы также можем добавить метод as_str
, чтобы предоставить доступ только для чтения к внутренним строковым данным.
impl Email {
pub fn parse(email: String) -> Result<Email, AuthError> {
// ...
}
pub fn as_str(&self) -> &str {
&self.0
}
}
impl Password {
pub fn parse(password: String) -> Result<Password, AuthError> {
// ...
}
pub fn as_str(&self) -> &str {
&self.0
}
}
Теперь мы можем обновить структуру User
, чтобы использовать наши новые типы:
#[derive(Debug)]
struct User {
pub email: Email,
pub password: Password,
}
и обновить функцию-регистратор:
#[post("/user/register")]
pub async fn register_user(
form: web::Form<FormData>,
pool: web::Data<PgPool>
-) -> HttpResponse {
- if !is_valid_email(&form.email) || !is_valid_password(&form.password) {
- return HttpResponse::BadRequest().finish();
- }
- let user = User {
- email: form.email.clone(),
- password: form.password.clone(),
- };
+) -> Result<HttpResponse, AuthError> {
+ let email = Email::parse(form.email.clone())?;
+ let password = Password::parse(form.password.clone())?;
+ let user = User::new(email, password);
match insert_user(&pool, &user).await {
Ok(_) => HttpResponse::Ok().finish(),
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
Теперь любой последующий код может быть уверен, что электронная почта и пароль действительны.
Продвинутые подходы в проектировании, основанном на типах
Проектирование на основе типов — большая тема, и это только верхушка айсберга. Мы только что говорили о принципе не валидировать но парсить и о том, как реализовать его, используя подход нового типа в Rust. Мы также можем использовать более сложные подходы, такие как type state pattern, который позволяет определить различные состояния, в которых может находиться объект, определить конкретные действия для каждого состояния и обеспечить допустимые переходы между состояниями.
Например, пользователь в нашем API может находиться в одном из трех состояний: зритель, редактор или администратор. Сначала мы создадим структуру, представляющую каждое состояние, а затем определим структуру User
, которая является обобщенной по UserRole
, по умолчанию являющейся зрителем.
pub struct Viewer;
pub struct Editor;
pub struct Admin;
pub struct User<UserRole = Viewer> {
pub email: Email,
pub password: Password,
state: PhantomData<UserRole>,
}
impl User {
pub fn new(email: Email, password: Password) -> Self {
Self {
email,
password,
state: PhantomData,
}
}
}
Мы будем хранить обобщение в поле state
, которое использует PhantomData
, чтобы избежать ненужного выделения памяти.
Затем мы можем определить методы, доступные для всех состояний, и методы, специфичные для состояний, такие как метод edit
для редакторов. Мы также можем обеспечить правильные переходы между состояниями: зрители могут быть повышены до редакторов, редакторы до администраторов, а администраторы могут быть понижены до редакторов.
impl User<Viewer> {
pub fn promote(self) -> User<Editor> { /*...*/ }
}
impl User<Editor> {
pub fn edit(&self) { /*...*/ }
pub fn promote(self) -> User<Admin> { /*...*/ }
}
impl User<Admin> {
pub fn demote(self) -> User<Editor> { /*...*/ }
}
Обратите внимание, что мы используем модель владения Rust: эти функции перехода состояния принимают self
в качестве ввода, что перемещает экземпляр в функцию и делает его недоступным в дальнейшем. Это означает, что если экземпляр зрителя будет повышен до редактора, старый экземпляр пользователя более не может быть использован.
fn main() {
let viewer = User::new(
Email::parse("bogdan@email.com".to_string()).unwrap(),
Password::prase("password".to_string()).unwrap(),
);
let editor = viewer.promote();
viewer.get_email(); // error: borrow of moved value 'viewer'
}
Итак, как начать использовать проектирование на основе типов в ваших собственных проектах на Rust? Есть много способов реализовать этот мощный метод проектирования программного обеспечения в Rust. Теоретически вы можете применить некоторые из этих паттернов и в других языках, но Rust делает это особенно практичным благодаря своему устройству системы типов. В других языках эти подходы не всегда практичны, если вообще возможны.