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 делает это особенно практичным благодаря своему устройству системы типов. В других языках эти подходы не всегда практичны, если вообще возможны.
Комментарии (26)
Vladime
04.09.2024 08:49Пример из "Parse don't validate" подан неверно уже в источнике, не сказано, например, что
password
должен быть приватным. Видимо, первоисточник: Parse, Don't validate: An Effective Error Handling Strategy, там изложено правильно.AskePit Автор
04.09.2024 08:49Спасибо за приведенную вами статью, я искренне удивился, что перевожу даже не первичный контент, а вторичный :)
По поводу приватного password: я прочитал первоисточник, но не смог заметить принципиально новой информации о приватности password. В данной статье и оригинальном видео сказано:
Из-за правил видимости Rust внутренняя строка является приватной и недоступной за пределами структуры.
Наверное, вы что-то иное имели в виду? Можете показать конкретный тест в первоисточнике, которого идеологически нехватает в видео Богдана?
Vladime
04.09.2024 08:49Я не знаю, какой из текстов первичен. Но у вас (а также на видео), есть код:
#[derive(Debug)] struct User { pub email: Email, pub password: Password, }
А дальше идет
user.password.clear();
, что также может быть опасно (если реализовать методclear()
и случайно сделатьmut user
). Лучше, в любом случае, поля инкапсулировать. Хотя да, согласен, и по моей ссылке этого нет, приходится додумывать.
Revertis
04.09.2024 08:49Но ведь здесь проблема не решена от слова совсем:
AskePit Автор
04.09.2024 08:49Дико прощу прощения — это кривые руки автора-переводчика. Поправил это, спасибо за указание
Revertis
04.09.2024 08:49То есть убрали код, который мог привести к ошибкам, и решили, что проблема решена???
AskePit Автор
04.09.2024 08:49Этот код не скомпилировался бы, т.к. к
password
теперь нельзя достучаться напрямую — он теперь исключительно readonly черезas_str(&self) -> &str
.Поэтому формально да — все как вы сказали, мы убираем код, который теперь не компилируется, и проблема действительно решена. И даже недосмотр автора перевода не может сломать программу
Revertis
04.09.2024 08:49Так ведь далее возникнет так же задача вывести в лог данные юзера с санитацией. Как будет решаться ЭТА проблема?
AskePit Автор
04.09.2024 08:49Я не вижу принципиальной проблемы в ЭТОЙ проблеме. Если вы грамотно простроите интерфейс класса юзера, вы прекрасно сможете справиться с поставленной задачей.
Вы, конечно можете спросить меня: "А как тогда будет решаться проблема X?", "А проблема Y???", "А проблема Z???!!!1!!1!", и мы с вами сможем в прямом эфире написать прямо здесь, в хабра-комментариях полноценное приложение — благо проблем можно придумать на ходу сколько пожелается. Но неизменным будет, что не всегда решение одной проблемы будет универсально покрывать и решать все остальные проблемы разрабатываемого приложения.
Revertis
04.09.2024 08:49Так изначальный посыл автора был в том, что если кто-то захочет вывести в лог данные юзера, то их надо будет санитайзить, и тут-то возникнет проблема.
Но в итоге никто не показал как решить именно эту проблему. Просто выкинули код санитайзинга и вывода в лог, и решили, что проблема решена, и разработчики никогда не будут с этим сталкиваться.
0x1b6e6
04.09.2024 08:49+2Но в итоге никто не показал как решить именно эту проблему.
Тогда я напишу. Это можно решить переопределением трейта
Debug
для типаPassword
:#[derive(Debug)] struct User { email: Email, password: Password, } #[derive(Debug)] struct Email(String); struct Password(String); impl std::fmt::Debug for Password { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str("[hidden]") } } fn main() { let user = User { email: Email("mail@example.com".to_string()), password: Password("my p@$$w0rD".to_string()), }; println!("user = {user:?}"); // user = User { email: Email("mail@example.com"), password: [hidden] } }
Revertis
04.09.2024 08:49Вооот! То есть можно убрать
parse()
и вообще добавить реализациюDebug
дляUser
, а не только дляPassword
.Хотя да, для валидации данных
parse()
лучше оставить.
ionicman
Я не силен в Rust, подскажите насчет баланса - вот если оно беззнаковое uin32 и = 0, что будет, если его уменьшить на, например, 5? Ибо в C это приведет просто к переполнению, что будет еще хуже, чем отрицательный баланс (там хоть понятно будет, на сколько ушло).
Ну и проверка типов тоже сомнительна - вы переложили валидацию в тип - окей, можно было, как было сказано выше, поставить валидацию в саму функцию - и это тоже не проблема (не надо вот только про лишние проверки, можно подумать что с типом их не будет на этапе парсинга). Но суть проблемы здесь - отсуствие класса user, где все это должно быть инкапсулировано, те это архитектурная, а не синтаксическая проблема, ИМХО.
PrinceKorwin
Паника в текущем треде исполнения.
Немного не так. Не валидацию, а контроль. Это немного рвзные вещи. К сожалению статья мало этот аспект раскрывает.
И типы это не классы с инкапсулированной валидацией.
ionicman
Естественно, но сути архитектурной проблемы с моей точки зрения это не меняет.
PrinceKorwin
Просто пример с email/password не очень показательный.
Можно привести другой пример. Пусть, для упрощения, мы имеем корзину с товарами. Каждый товар может быть в разном количестве в корзинке. У товаров есть цена. И есть общая цена корзинки.
Здесь мы можем выделить следующие типы:
Корзина
Товар
Кол-во товаров одного типа
Цена за 1 штуку товара
Теперь можно определить возможные отношения и операции между этими типами.
Например:
тип Корзина может содержать в себе N тип Товары
тип Кол-во товаров одного типа можно складывать только с таким-же типом (вычитать и другие мат. операции нельзя, например)
тип Цена может быть суммирован только с таким же типом и может быть умножен на тип Кол-во товаров одного типа
Благодаря этому компилятор не позволит скомпилировать код который случайно попытается умножить цену товара на просто число где бы вы не попытались это сделать. И для этого не нужно будет ни энкапсулировать логику в отдельном классе и следить чтобы эта логика не "утекла" из него. Ни надо расставлять нигде валидации. Компилятор всё проверит за вас.
Имея такие типы в Persistence Layer у вас есть маппинг, что на что. И это дает гарантию, что никто не сможет случайно записать в поле цена число которое не было подсчитано как цена товара.
ionicman
Можно так. Но опять-же это лишь перекладывание сложности.
В приведенном вами случае, вы переложили ее в типы, если сделать класс "Корзина" - то сложность уйдет в нее.
Что до меня - я выберу последнее, так как в жизни корзина куда сложнее устроена (скидки, акции, товары бандлом, огрничение на товарные позиции не больше/не меньше, товарная позиция, которая идет только бандлом с дургим товаром и тд). ИМХО класс в этом случае будет куда более удобным.
Типы, естественно, никто не отменяет, но их я бы сделал просто наиболее универсальными и с ограничением только по edge-случаям.
Попытаюсь переформулировать все мной сказанное - засовывать сильные ограничения в типы и потом с ними танцевать куда сложнее, чем делать проверку при нормальном подходе к ООП.
Но да, в случае с типами будет доп. проверка на уровне компилятора - это плюс. Однако тесты все равно писать)
Сейчас крайности в томже TSe - народ пытается все в типы запихать, везде юзать дженерики - это приводит к такой жести при поддержке потом, что это целая тема для отдельной статьи - не надо так.
PrinceKorwin
А сложность никогда никуда не девается. Просто типы предлагают переложить сложность на:
первичную проработку архитектуры типов
на момент компиляции
Давая при этом гораздо больше гарантий правильности исполнения.
Не совсем. Вам будет не достаточно класса "Корзина". Типы и их гарантии они сквозные - от обработки HTTP запроса, по бизнес-логике и до persist в БД. Вам нужно будет инкапсилуровать логику валидации в класс "Корзина" и в другие классы через которые потенциально эта Корзина ходит.
Каждому своё. Типы и их возможности тоже куда сложнее и многограннее чем моя жалкая попытка их описать.
Полностью согласен. Сделать согласованное непротиворечивое описание типов в большом проекте - это тот еще челендж. Но и бенефита вы получаете гораздо больше чем "делать проверку при нормальном подходе к ООП".
Я много пишу на Java и на Rust. И система типов в Rust меня не один раз спасала от ошибок которые бы Java пропустила. И я больше рад иметь возможность описывать типы тогда когда это имеет смысл, чем не иметь такой возможности.
Ну нет. Вы же объявляя аргумент функции как int не будете делать тест проверяющий, что туда на вход пытаются передать String, это не имеет смысла - компилятор проверит. Вот и с типами так же.
С другой стороны. Имея persistence для корзинки. Как вы напишете тест, что поле total amount корректно во всех сценариях было посчитано? Надо обкладывать тестами и следить чтобы ничего не пропустили в будущем.
В случае с типами вам для этого вообще не нужно будет писать тестов. Гарантии даст компилятор. И эти гарантии будут распространяться также на будущий код.
domix32
Собственно, к языку тут отношения на самом деле примерно никакого. С тем же успехом можно было б взять условный C# и также с голыми интами и строками напилить if ради кривых примеров - суть не поменяется.
Переполнение контролируется пользователем. Если выражение вычислимо на этапе компилияции, то оно не даст скомпилироваться без спецфлагов и будет ругаться на переполнение.
Для динамических данных в дебаге оно будет паниковать, в релизе - переполняться. Для знающих есть wrapping/saturating операции.
ionicman
У меня как раз и был вопрос - в рантайме в раст будет ли паника при уменьшении uint32 ниже нуля, как я понял из ответа - паника будет. Если не будет - тогда зачем все это делать ).
В Си ничего не будет - просто будет число = макисмальное число от типа переменной - ( то что вычли - 1 ).
PrinceKorwin
Код на Rust:
Результат компиляции и исполнения - DEBUG:
Результат компиляции и исполнения - RELEASE:
domix32
В релизе будет переполнение, в дебаге - паника.
AskePit Автор
Про вопрос о вычитании: обычное вычитание через
-
запаникует. При желании в Rust можно воспользоваться встроенными вu32
(и в другие целочисленные типы) методами:Вот как эти виды вычитаний будут себя вести для случаев переполнения:
PrinceKorwin
Только в debug сборке. В release отработает без паники. Пример кода и результата выше по треду.
aiscy
Может быть не совсем верно привязываться к профилю. Все же никто не помешает нам сделать
PrinceKorwin
Да, конечно! Но про такую особенность нужно, конечно знать заранее.