Как стать автором
Поиск
Написать публикацию
Обновить

zero2prod (Rust)

Время на прочтение5 мин
Количество просмотров3.2K

Лет так много назад, если верить слухам того времени, питон был не зыбко популярен, flask был где-то в узких кругах, а за django продавцам нужно было замолвить слово. Все, конечно, понимали - за django будущее, и не только потому, что java всем поднадоела, но потому что было удобно и для бизнеса, и для кодинга. Что кривить, читая книгу zero2prod невольно вспоминаешь удовольствие от изучения django, удивления - "а что, так можно было", и пожалуй, глубину проработки деталей, которые обычный разработчик осилил бы самостоятельно, но обычно было лень.

Rust при всей своей скромности по скорости весьма удобен для day to day разработки, и книга (которая в тайтле) раскрывает детали этой парадоксальной особенности.

Хотелось бы имееть какую-никакую логику в построении приложения, но, к сожaлению или к счастью, в наше время при наличии широкого набора инструментов любой компонент будущей структуры выглядит наиболее подходящим на место первого к выполнению. Сходимость проекта, в свою очередь, будет определяться всем попало, и не последнюю роль в ней будет играть фантазия авторов и усидчивость коллег.

Начнем, пожалуй, с файлов конфигурации. В свое время Степанов говорил, что единственное применение inheritance есть в наследовании полей. У нас есть файлик базовой конфигурации base.yaml и много-много файлов конфигураций для различных сред. В коде это выглядит примерно так:

Config::builder()
    .add_source(File::new("configuration/base.yaml", FileFormat::Yaml))
    .add_source(File::new(&env_config, FileFormat::Yaml))
    .build()

На всякий случай, yaml формат - это такой удобный KV с отступами.

Миграции для базы данных, почему бы и нет. Ну то есть у нас еще нет базы данных, но есть знание, что персистентный стейт нужен, и каким-то образом нужно будет создать таблички или менять колонки. Для миграций важна очередность выполнения (к именам файлов иногда добавляют timestamp) и инструмент (command line tool). Автор zero2prod юзает миграции в баш-скрипте. Как-то так:

...
export DATABASE_URL=...
sqlx database create
sqlx migrate run

Лирическое отступление: забавно, что как киллер-фича наряду с async/await рассматривалась и rust cli. То есть как бы люди думали в 2018 куда приложить ресурсы и рассматривалось четыре направления: Embedded systems, WebAssembly, Command-line interfaces, Network services. И тут автор книги о расте на голубом глазу юзает баш.

Докер. Наверно, не очень распространенное, но очень удобное решение - two stage build. На первом шаге билдим код, на втором копируем и запускаем бинарник. Автор zero2prod придает большое значение размеру образа - там прям увлекательное чтиво. Далее автор деплоит приложение на облако, попутно описывая файл конфигурации для деплоя - где живет база, где application code, нужен ли load balancer и т.д. В целом это отдельная история, но как бы неплохо бы знать, как ваш код будет исполняться.

Пару слов об sqlx, который согласно докам "..is not an ORM, but compile-time checked queries". То есть ребята во время компиляции коннектятся к базе и проверяют структуру ваших sql запросов. Кажется, в какое-то время всех это задолбало, и ребята выпустили command line утилиту для генерации query oффлайн. Далее в книге идет не очень популярная, но весьма интересная аргументация о движке запросов. Если мне не изменяет память, суть ORM в достаточно легкой замене базы данных без изменения кода приложения (см. например django). Так вот, автор zero2prod предлагает юзать чистый sql, аргументируя это тем, что язык приложения может смениться, а sql запросы останутся эскуальными. Напомню, книга о языке программирования rust.

Лирическое отступление: популярный вопрос на интервью - какие либы вы юзали. Автор zero2prod сравнивает sqlx c двумя orm, и кажется, самый притязательный читатель найдет что-нибудь интересное для себя.

Итак, мы хотим уже написать код, но как? Автор книги предлагает через тесты, а сам проект разбить на клиентский код и библиотеку. Клиентский код - эт так, чтоб потыкаться курлом, ну или через браузер, - а библиотеку нужно покрыть тестами чуть более чем полностью - red green development, все дела. Тут, как говорится, есть нюанс. Пофиксить свои тесты это всегда ок. Пофиксить чужие тесты, если test case не больше десяти строк, тоже ок, а вот пофиксить абстракцию в тестах (потому что много тестов и нужны абстракции) - это, скорее, skip test, чем фикс. Спорить о тестах также бесполезно, как и о языках программирования (хотя все и так знают, что rust лучший :)

Все вебовские приложения в конце концов юзают "экстримли фаст" web framework, не исключая и zero2prod. Автор книги юзает actix-web с весьма интересной фичей powerful request routing. Тут проще кодом:

App::new()
    .route("/health_check", web::get().to(routes::health_check))
    .route("/subscriptions", web::post().to(routes::subscribe))    

Pучка routes::subscribe может иметь почти любую сигнатуру. По словам автора actix-web, это происходит благодаря системе типов раста, а не за счет магии макросов. Например,

pub async fn health_check() ...
pub async fn subscribe(form: web::Form<Email>, pool: web::Data<SqlitePool>) ...

// in the same time we can swap args if we want in subscribe(...)
pub async fn subscribe(pool: web::Data<SqlitePool>, form: web::Form<Email>) ...

Кажется, в динамических языках это сделать не очень тривиально.

Интересная структура кода у автора zero2prod - в одном файле и имплементация route, и запрос к базе данных. Кажется, все свалено в кучу, c другой стороны код получается очень компактным, поэтому и не хочется разделять на файлы, например:

pub async fn subscribe(...) -> Result<HttpResponse, SubscribeError> {
    let new_subscriber = form.0.try_into()?;
    let mut transaction = pool.begin().await?;
    insert_subscriber(&new_subscriber, &mut transaction).await?;
    transaction.commit().await?;
    Ok(HttpResponse::Ok().finish())
}

Вот эти знаки вопроса в коде - это про удобство (или эргономику) раста. Каждый такой знак на самом деле раскрывается в что-то типа такого:

if insert_subscriber(&new_subscriber, &mut transaction).await.is_err() {
    return HttpResponse::InternalServerError().finish();
}

Внимательный читатель тут же поинтересуется, каким образом SubscribeError связан с http ответом (ResponseError) - тут работает нативная фича раста - Trait (они же протоколы), как-то так:

impl From<sqlx::Error> for SubscribeError {
    fn from(e: sqlx::Error) -> Self {
        Self::DatabaseError(e)
    }
}

impl From<String> for SubscribeError {
    fn from(e: String) -> Self {
        Self::ValidationError(e)
    }
}

impl ResponseError for SubscribeError {
    fn status_code(&self) -> StatusCode {
        match self {
            SubscribeError::ValidationError(_) => StatusCode::BAD_REQUEST,
            SubscribeError::DatabaseError(_) => StatusCode::INTERNAL_SERVER_ERROR,
        }
    }
}

Немного многословно, можно сделать короче с помощью библиотек обработки ошибок: anyhow и thiserror. Тут идея, как мне кажется, в том, что ошибки лежат где-то в одном месте и не мешают чтению кода, основной логики программы (только ok path - класс!).

Валидации инпута. Автор книги предлагает считать String, они же подписчики "грязными" данными, а структурку SubscriberName(String) чистыми данными, соответственно переход между состояниями строго в одном месте:

impl SubscriberName {
    pub fn parse(s: String) -> Result<SubscriberName, String> { ... }
}

В идеальном мире, наверно, сработало бы, а так кажется маловероятным, что любые String инпута будут обернуты в структурки. Опять же есть трэйт AsRef - возможно и прокатит:

impl AsRef<str> for SubscriberName {
    fn as_ref(&self) -> &str {
        &self.0
    }
}
// e.g. we can use our SubscriberName here 
fn somewhere_inside_codebase(x: impl AsRef<str>) { ... }

Пару слов про telemetry. Наверно, не стоит все бросать и заменять логи на телеметрию уже сегодня. Идея вроде путная, и вроде уже много сервисов по сбору эвентов от приложений. Но хз. Мне зашла такая обертка (см. ниже) которая вроде как форсит вас юзать короткие функции и одновременно добавляет эвент на входе функции и эвент на выходе:

#[tracing::instrument(
    name = "Adding a new subscriber",
    skip(form, pool),
    fields(
        subscriber_email = %form.email,
        subscriber_name= %form.name
    )
)]
pub async fn subscribe(...) {}

В книге еще много чего интересного, поэтому приятного чтения.

Теги:
Хабы:
Всего голосов 8: ↑0 и ↓8-8
Комментарии5

Публикации

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