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); // 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(), } }
Теперь любой последующий код может быть уверен, что электронная почта и пароль действительны.
Продвинутые подходы в проектировании, основанном на типах
Проектирование на основе типов — большая тема, и это только верхушка айсберга. Мы только что говорили о принципе не валидировать но парсить и о том, как реализовать его, используя подход нового типа в 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 делает это особенно практичным благодаря своему устройству системы типов. В других языках эти подходы не всегда практичны, если вообще возможны.
ссылка на оригинал статьи https://habr.com/ru/articles/840692/
Добавить комментарий