Как стать автором
Обновить

Магическая система типов Rust

Уровень сложностиПростой
Время на прочтение9 мин
Количество просмотров2.6K
Автор оригинала: Bogdan Pshonyak
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 делает это особенно практичным благодаря своему устройству системы типов. В других языках эти подходы не всегда практичны, если вообще возможны.

Теги:
Хабы:
Если эта публикация вас вдохновила и вы хотите поддержать автора — не стесняйтесь нажать на кнопку
Всего голосов 10: ↑9 и ↓1+13
Комментарии62

Публикации

Истории

Работа

Rust разработчик
8 вакансий

Ближайшие события

15 – 16 ноября
IT-конференция Merge Skolkovo
Москва
22 – 24 ноября
Хакатон «AgroCode Hack Genetics'24»
Онлайн
28 ноября
Конференция «TechRec: ITHR CAMPUS»
МоскваОнлайн
25 – 26 апреля
IT-конференция Merge Tatarstan 2025
Казань