Rust Fullstack: Rocket, SeaORM, Dioxus
Для написания фуллстек-приложений на Rust понадобится недюжиное понимание того, как устроены библиотеки и фреймворки и как удобно их использовать. Но есть и плюс: я уже для вас разобрался.
Полный список используемых крейтов для фронтенда и бэкенда - в конце статьи.
В листингах кода полно комментариев.
Структура репозитория
Начать следует с создания Cargo.toml
в корне репозитория:
[workspace] members = [ "./frontend", "./backend", "./backend/migration", "./data_types" ] resolver = "2" [profile.dev] incremental = true [profile.release] opt-level = 3 lto = true panic = 'abort'
Как видно, Cargo позволяет объединить несколько подпроектов в один через рабочие пространства. Помимо бэкенда и фронтенда, виден также подпроект миграций SQL и подпроект типов данных.
Строка resolver = "2"
нужна для корректного управления подпроектами.
Настройки профилей можете оставить без изменений, можете дописать под себя. Для разработки включена инкрементальная сборка, для выкатки в релиз - LTO-оптимизация.
Миграции SQL
В проекте используется SeaORM. Грубо говоря, это - способ управления сущностями через базу данных. Поэтому, чтобы начать разработку, нужно сперва определить типы данных, которые понадобятся для проекта.
Для этого в папке backend
выполните следующие команды:
cargo install sea-orm-cli # устанавливает клиент управления миграциями sea-orm-cli migrate init # инициализирует подпроект миграций
Миграции - это обновление структуры базы данных с целью её расширения без потерь информации в автоматическом режиме. Всё, что нужно, - это написать код.
Типичный файл миграции выглядит так:
use sea_orm_migration::prelude::*; #[derive(DeriveMigrationName)] pub struct Migration; #[async_trait::async_trait] impl MigrationTrait for Migration { /// Эта функция выполняется при запуске миграции. Если база данных пуста, /// и это первая миграция, - то в ней нужно создать таблицы для хранения /// информации. async fn up(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager .create_table( Table::create() .table(Users::Table) .if_not_exists() .col(ColumnDef::new(Users::Id) .big_integer().not_null().auto_increment() .primary_key().unique_key() ) .col(ColumnDef::new(Users::Login) .string().not_null().unique_key() ) .col(ColumnDef::new(Users::Salt).string().not_null()) .col(ColumnDef::new(Users::PHash).string().not_null()) .col(ColumnDef::new(Users::Settings).string().not_null()) .to_owned() ).await } /// Эта функция используется, чтобы откатить нежелательные изменения при /// необходимости. Грубо говоря, здесь нужно писать код, который вернёт /// всё, как раньше. Но не прям в 2007. async fn down(&self, manager: &SchemaManager) -> Result<(), DbErr> { manager.drop_table(Table::drop().table(Users::Table).to_owned()).await } } /// В перечислении мы определяем поля идентификаторов. #[derive(Iden)] enum Users { Table, // Да, среди полей таблицы затесалась сама таблица. // Подробнее: https://docs.rs/sea-query#iden Id, Login, Salt, PHash, Settings, }
Миграции перечисляются в backend/migrations/lib.rs
:
pub use sea_orm_migration::prelude::*; mod m20231007_000001_create_tables; // Указываем файл с миграцией pub struct Migrator; #[async_trait::async_trait] impl MigratorTrait for Migrator { fn migrations() -> Vec<Box<dyn MigrationTrait>> { vec![ // И добавляем миграции сюда. Box::new(m20231007_000001_create_tables::Migration), ] } }
Для запуска миграций вам необходимо создать пустую базу данных и из папки backend
выполнить команду:
sea-orm-cli migrate -u postgres://login:pass@localhost/dbname
Затем, чтобы сгенерировать сущности, которые вы сможете использовать в коде на бэкенде, необходимо выполнить следующую команду:
sea-orm-cli generate entity -o ./src/entities
Вуаля, вы можете теперь подключить сущности через mod entities;
в main.rs
и использовать их примерно следующим образом:
let new_user = users::ActiveModel { login: ActiveValue::Set(data.login.clone()), salt: ActiveValue::Set(salt.clone()), p_hash: ActiveValue::Set(base64.encode(p_hash)), settings: ActiveValue::Set(serde_json::to_string(&settings)?), ..Default::default() }; Users::insert(new_user).exec(db as &DbPool).await?;
Общие структуры данных
Чтобы использовать все преимущества строгой типизации в Rust, необходимо задать общие типы данных, которые планируется использовать на бэкенде и фронтенде.
use chrono::{DateTime, Utc, serde::ts_seconds}; use serde::{Serialize, Deserialize}; // Авторизация и управление аккаунтами: #[derive(Deserialize, Serialize)] pub struct SignUpRequestData { pub login: String, pub name: String, pub password: String, } #[derive(Deserialize, Serialize)] pub struct SignInRequestData { pub login: String, pub password: String, } #[derive(Serialize, Deserialize)] pub struct IsUserValid { pub valid: bool, } #[derive(Deserialize, Serialize)] pub struct UserToken { pub user_id: i64, pub token_str: String, #[serde(with = "ts_seconds")] pub birth: DateTime<Utc>, }
Чтобы использовать подпроект в бэкенде и фронтенде, укажите data_types
как зависимость в их Cargo.toml
:
data_types = { path = "../data_types" }
Структура бэкенда
Часть 1. Управление ошибками
Можно было бы использовать anyhow::Error
или подобные крейты, но самописные решения предоставляют определённую гибкость:
use base64::DecodeError; use bb8_redis::redis::RedisError; use bb8_redis::bb8::RunError; use sea_orm::DbErr; use rocket::{ request::Request, response::{self, Response, Responder}, http::{ContentType, Status}, }; use std::{io, fmt, num::ParseIntError, string::FromUtf8Error}; use serde::{Serialize, Deserialize}; use serde_json::Error as JsonError; /// Структура данных ошибок. #[derive(Serialize, Deserialize, Debug)] pub struct ErrorResponder { message: String, } /// Мы воплощаем трейт Responder, чтобы указать, /// как Rocket будет эту ошибку возвращать клиенту. #[rocket::async_trait] impl<'r> Responder<'r, 'static> for ErrorResponder { fn respond_to(self, _: &'r Request<'_>) -> response::Result<'static> { let json_answer = serde_json::to_string(&self).unwrap(); Response::build() .status(Status::Unauthorized) .header(ContentType::JSON) .sized_body(json_answer.len(), io::Cursor::new(json_answer)) .ok() } } impl fmt::Display for ErrorResponder { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { write!(f, "{}", self.message) } } impl From<DbErr> for ErrorResponder { fn from(err: DbErr) -> ErrorResponder { ErrorResponder { message: err.to_string() } } } impl From<String> for ErrorResponder { fn from(string: String) -> ErrorResponder { ErrorResponder { message: string } } } impl From<&str> for ErrorResponder { fn from(str: &str) -> ErrorResponder { str.to_owned().into() } } impl From<io::Error> for ErrorResponder { fn from(err: io::Error) -> ErrorResponder { err.to_string().into() } } impl From<JsonError> for ErrorResponder { fn from(err: JsonError) -> ErrorResponder { err.to_string().into() } } impl From<ParseIntError> for ErrorResponder { fn from(err: ParseIntError) -> ErrorResponder { err.to_string().into() } } impl From<RedisError> for ErrorResponder { fn from(err: RedisError) -> ErrorResponder { err.to_string().into() } } impl From<RunError<RedisError>> for ErrorResponder { fn from(err: RunError<RedisError>) -> ErrorResponder { err.to_string().into() } } impl From<DecodeError> for ErrorResponder { fn from(err: DecodeError) -> ErrorResponder { err.to_string().into() } } impl From<FromUtf8Error> for ErrorResponder { fn from(value: FromUtf8Error) -> Self { value.to_string().into() } } /// Делаем биндинг для удобного использования в функциях. pub type MResult<T> = Result<T, ErrorResponder>;
Использование MResult<T>
облегчает нашу жизнь таким образом:
pub async fn login(...) -> MResult<Json<UserToken>> { let user_data = find_by_login(&data.login, db).await?; let phash_db = base64.decode(user_data.p_hash.as_bytes())?; let salted_password = data.password.clone() + &user_data.salt; validate_hashes(salted_password.as_bytes(), &phash_db)?; let utl_name = get_user_tokens_list_name(&user_data.id); let cacher = cacher as &RedisPool; let mut cacher_conn = cacher.get().await?; let user_tokens_list_len: isize = cacher_conn.llen(&utl_name).await?; let token = generate_token(user_data.id)?; if user_tokens_list_len >= MAX_TOKENS_PER_USER { cacher_conn.ltrim(&utl_name, 0, MAX_TOKENS_PER_USER - 1).await?; } cacher_conn.lpush(&utl_name, &serde_json::to_string(&token)?).await?; Ok(Json(token)) }
Часть 2. Управление shared-объектами
Для начала загрузим в приложение всю необходимую информацию для его работы и инициализируем объекты для управления данными:
use bb8_redis::{bb8::Pool as Bb8Pool, RedisConnectionManager}; use dotenv::dotenv; use sea_orm::*; pub type RedisPool = Pool<RedisConnectionManager>; pub type DbPool = DatabaseConnection; pub struct AppState { pub db_url: String, pub redis_url: String, } pub(super) async fn load_app_state() -> MResult<(AppState, DbPool, RedisPool)> { dotenv().ok(); // Загружаем все переменные из файла `.env` в окружение let state = AppState { db_url: std::env::var("DATABASE_URL").expect("DATABASE_URL variable must be set"), redis_url: std::env::var("REDIS_URL").expect("REDIS_URL variable must be set"), }; let db = connect_db(&state.db_url).await?; let cacher = connect_redis(&state.redis_url).await?; Ok((state, db, cacher)) } async fn connect_db(db_url: &str) -> MResult<DbPool> { Ok(Database::connect(db_url).await?) } async fn connect_redis(redis_url: &str) -> MResult<RedisPool> { let manager = RedisConnectionManager::new(redis_url)?; Ok(Bb8Pool::builder().build(manager).await?) }
А теперь напишем функцию, которая полностью заменяет fn main()
:
#[launch] async fn rocket() -> _ { // 1. Установка уровня логгирования. if cfg!(debug_assertions) { simple_logger::init_with_level(log::Level::Info).unwrap(); } else { simple_logger::init_with_level(log::Level::Warn).unwrap(); } // 2. Загрузка основных параметров и установка соединений с базами данных. let (state, db, cacher) = match load_app_state().await { Ok((state, db, cacher)) => (state, db, cacher), Err(err) => panic!("{}", err), }; // 3. Запуск сервера. rocket::build() // перечисляем управляемые объекты: .manage(state) .manage(db) .manage(cacher) // перечисляем роуты .mount("/", routes![ ... ]) }
Часть 3. Роуты
В перечислении параметров функций-роутов можно использовать только те управляемые объекты, которые вам нужны.
pub use rocket::{State, serde::json::Json}; pub use rocket::post; /// Создаём метод POST для пути '/login', /// который принимает `application/json` в теле запроса. #[post("/login", format = "json", data = "<data>")] pub async fn login( data: Json<SignInRequestData>, // JSON из тела запроса db: &State<DbPool>, cacher: &State<RedisPool>, ) -> MResult<Json<UserToken>> { ... Ok(Json(token)) }
Чтобы запрашивать через параметры роута ещё и ключ аутентификации, используйте следующий код:
/// Ключ API через заголовок аутентификации. pub struct ApiToken(String); #[rocket::async_trait] impl<'r> FromRequest<'r> for ApiToken { type Error = ErrorResponder; async fn from_request(req: &'r Request<'_>) -> Outcome<Self, Self::Error> { /// Извлекаем токен из строки авторизации. fn extract_token(key: &str) -> String { key.to_owned().replace("Bearer ", "") } // Получаем текст заголовка `Authorization`: match req.headers().get_one("Authorization") { None => Outcome::Error((Status::Forbidden, "Authorization token is missing.".into())), Some(key) => Outcome::Success(ApiToken(extract_token(key))), } } }
Тогда роуты принимают подобный вид:
#[get("/project/<project_id>")] pub async fn get_project_info( project_id: i64, // Запрос `/project/2221` установит // в переменную `project_id` значение 2221. db: &State<DbPool>, cacher: &State<RedisPool>, token: ApiToken, // Токен, который клиент должен передавать // вместе с запросом. ) -> MResult<Json<ProjectCardInfo>> { ... }
Запуск
cd backend cargo run --release
Структура фронтенда
Часть 1. Конфигурация приложения
Создайте Dioxus.toml
с подобным содержимым:
[application] name = "app_name" default_platform = "web" out_dir = "../dist" asset_dir = "public" [web.app] title = "App Title" [web.watcher] watch_path = ["src", "public"] [web.resource] # Если требуется использовать TailwindCSS, # изучите https://dioxuslabs.com/learn/0.4/cookbook/tailwind style = ["tailwind.css"] script = [] [web.resource.dev] style = [] script = []
cargo install dioxus-cli
Часть 2. Облегчаем себе жизнь при работе с LocalStorage
Используем простейшие функции для получения и записи значений:
pub type MResult = Result<(), AppError>; pub static APP_TOKEN_LOCALSTORAGE_KEY: &str = "token"; /// Чтение из локального хранилища. pub fn get_from_storage(key: &str) -> Result<String, AppError> { let window = match web_sys::window() { None => return Err("Не удалось получить параметры окна".into()), Some(window) => window, }; let storage = match window.local_storage() { Err(_) => return Err("Не удалось получить доступ к LocalStorage".into()), Ok(None) => return Err("LocalStorage пуст".into()), Ok(Some(storage)) => storage, }; if let Ok(Some(value)) = storage.get_item(key) { Ok(value) } else { Err(format!("Не удалось получить значение по ключу \"{}\"", key).into()) } } /// Запись в локальное хранилище. pub fn put_in_storage(key: &str, val: &str) -> MResult { let window = match web_sys::window() { None => return Err("Не удалось получить параметры окна".into()), Some(window) => window, }; let storage = match window.local_storage() { Err(_) => return Err("Не удалось получить доступ к LocalStorage".into()), Ok(None) => return Err("LocalStorage пуст".into()), Ok(Some(storage)) => storage, }; if storage.set_item(key, val).is_err() { return Err("Не удалось сохранить значение в LocalStorage".into()) } Ok(()) } /// Получение заголовка авторизации. pub fn get_authorization_token() -> Result<String, AppError> { get_from_storage(APP_TOKEN_LOCALSTORAGE_KEY) }
Часть 3. Добавляем функции для запросов к серверу
pub static BASE_API_URL: &str = "schema://example.com/"; pub static LOGIN_API: &str = "login"; pub static AUTH_API: &str = "user"; pub static CHECK_USER_API: &str = "/check"; /// Осуществляет вход в аккаунт. pub async fn login(username: String, password: String) -> MResult { let token: UserToken = reqwest::Client::new() .post(format!("{}{}", BASE_API_URL, LOGIN_API)) .json(&SignInRequestData { login: username, password }) .send() .await? .json::<UserToken>() .await?; put_in_storage(APP_TOKEN_LOCALSTORAGE_KEY, &serde_json::to_string(&token).unwrap()) } /// Осуществляет проверку пользовательского токена. pub async fn check_user_token() -> MResult { let res: IsUserValid = reqwest::Client::new() .post(format!("{}{}{}", BASE_API_URL, AUTH_API, CHECK_USER_API)) .bearer_auth(get_authorization_token()?) .send() .await? .json() .await?; if !res.valid { return Err("Пользователь не авторизован".into()) } Ok(()) }
Часть 4. Подготовка реактивных состояний приложения
Чтобы Dioxus был способен динамически отрисовывать веб-страницу в зависимости от изменения данных, их необходимо задать заранее:
/// Структура данных, которая отвечает за отображение необходимого раздела. #[derive(Clone, Debug, PartialEq)] pub enum InAppLocation { Unset, Auth, Register, Projects, ProjectSurvey(ProjectID), PrepareRecommendationsByTest(ProjectID), // ... } /// Вспомогательная структура для принудительной отрисовки DOM. #[derive(Clone, Debug, PartialEq)] pub enum LocalUpdate { NotScheduled, Scheduled, }
Далее мы создаём структуру данных, которую будем передавать во все компоненты приложения по необходимости:
#[derive(Copy, Clone)] pub struct AppHooks<'a> { pub app_location: &'a UseState<InAppLocation>, pub update_scheduler: &'a UseState<LocalUpdate>, pub project_selected: &'a UseState<ProjectID>, pub get_summaries_fut: &'a UseFuture<Result<Vec<ProjectLineSummary>, AppError>>, pub get_project_card_fut: &'a UseFuture<Result<ProjectCardInfo, AppError>>, pub get_project_card_st: &'a UseState<ProjectID>, // ... } impl<'a> AppHooks<'a> { pub fn new(cx: Scope<'a>) -> Self { // Состояния приложения. let app_location = use_state(cx, || InAppLocation::Unset); let update_scheduler = use_state(cx, || LocalUpdate::NotScheduled); // Задание опционального хука let project_selected = use_state(cx, || 0); let get_project_card_fut = use_future(cx, (), |_| { let project_id = project_selected.to_owned(); async move { get_project_info(*project_id).await } }); // Задание опционального хука // Мы хотим, чтобы замыкание-футура выполнялась только тогда, // когда `get_project_survey_st` установлен в `true`. let get_project_survey_st = use_state(cx, || false); let get_project_survey_fut = use_future(cx, (), |_| { let survey_loaded = get_project_survey_st.to_owned(); async move { if survey_loaded == true { let res = get_project_survey().await; if res.is_ok() { survey_loaded.set(true); } res } else { Err("Ожидание запроса.".into()) } } }); // Отдаём хуки AppHooks { // Состояния app_location, update_scheduler, project_selected, // Исполняемые замыкания get_summaries_fut: use_future(cx, (), |_| async move { get_summaries().await }), get_project_card_fut, get_project_card_st: use_state(cx, || 0), // ... } } }
Часть 5. Подготавливаем главный компонент приложения и запуск
Главный компонент приложения играет роль роутера, раскидывая пользователя по компонентам в зависимости от состояния приложения AppLocation
.
fn main() { dioxus_web::launch(App); }
use dioxus::core::{Element, Scope}; use dioxus::prelude::*; /// Главный компонент веб-приложения. fn App(cx: Scope) -> Element { // Создание хранилища состояний let hx = AppHooks::new(cx); // Замыкание для проверки аутентификации let apploc_fut = use_future(cx, (), |_| async move { check_user_token().await }); // Раскладка для отображения состояния загрузки let loading_lt = rsx!( div { "Загрузка..." }); // Отрисовка приложения // Поскольку отрисовка условная, все состояния и хуки должны // выноситься в безусловный блок. Следовательно, данные до компонентов // должны прокидываться из главного компонента. cx.render({ // 1. Получение текущего местоположения let curr_location = (**hx.app_location).clone(); // 2. Отрисовка страницы в соответствии с местоположением match curr_location { // Если положение не установлено - // сравниваем с результатом валидации токена. InAppLocation::Unset => { match apploc_fut.value() { Some(Ok(_)) => { hx.app_location.set(InAppLocation::Projects); }, Some(Err(_)) => { hx.app_location.set(InAppLocation::Auth); }, None => {}, }; loading_lt }, // Страницы: InAppLocation::Auth => rsx!(Auth(cx, hx)), InAppLocation::Register => rsx!(Register(cx, hx.app_location)), InAppLocation::Projects => rsx!(Projects(cx, hx)), InAppLocation::ProjectSurvey(project_id) => rsx!(ProjectSurvey(cx, hx, project_id)), // ... } }) }
- После установки значений или обновления состояния замыканий-футур будет выполнен рендеринг VirtualDOM, и изменения будут при необходимости отрисованы.
- Внутри блока
cx.render({ ... })
необходимо писать код, который должен будет выполняться каждую перерисовку, а перед ним - код, который должен будет выполниться лишь один раз. - При этом нельзя в компонентах использовать любые хуки (функции Dioxus, которые начинаются на
use_
), если эти компоненты будут отрисованы условно (см. правила хуков по ссылке ниже). Например,InAppLocation::Projects
будет отрисован только после того, как отрисуетсяAuth
, и будет выполнена авторизация. Это - причина, почему хуки лучше выносить в отдельную структуру данных -AppHooks
.
Дополнительная информация про стейты, правила хуков, футуры, динамический рендеринг и футуры, которые будут выполняться по какому-то событию.
Часть 6. Компоненты
Код компонента страницы авторизации выглядит приблизительно так:
use dioxus::prelude::*; use dioxus_html::input_data::keyboard_types::Code as KeyCode; use log::{error, debug}; use crate::requests::auth::login; use crate::states::{AppHooks, InAppLocation, LocalUpdate}; /// Страница авторизации пользователя. pub fn Auth<'a>(cx: Scope<'a>, hx: AppHooks<'a>) -> Element<'a> { // Создаём хуки, которые будут использоваться только в этом компоненте let email = use_state(cx, || "".to_string()); let password = use_state(cx, || "".to_string()); // Вход в аккаунт // Это замыкание будет выполнено по нажатию на кнопку "Войти" let auth_fut = move |_| { cx.spawn({ to_owned![email, password]; let app_location = hx.app_location.to_owned(); let update_scheduler = hx.update_scheduler.to_owned(); async move { match login( email.current().to_string().clone(), password.current().to_string().clone() ).await { Ok(_) => { debug!("Токен получен."); app_location.set(InAppLocation::Projects); update_scheduler.set(LocalUpdate::Scheduled); }, Err(e) => error!("{}", e), } } }); }; // Вход в аккаунт // Это замыкание будет выполено по нажатию на кнопку `Enter` на поле пароля // // Q: Почему нужно два одинаковых внешне замыкания? // A: Из-за различия в типах событий по нажатию кнопки мыши или клавиатуры. let auth_by_enter_fut = move |_| { cx.spawn({ to_owned![email, password]; let app_location = hx.app_location.to_owned(); let update_scheduler = hx.update_scheduler.to_owned(); async move { match login( email.current().to_string().clone(), password.current().to_string().clone() ).await { Ok(_) => { debug!("Токен получен."); app_location.set(InAppLocation::Projects); update_scheduler.set(LocalUpdate::Scheduled); }, Err(e) => error!("{}", e), } } }); }; // Отрисовка страницы cx.render(rsx! { div { class: "flex mx-auto justify-center items-center h-screen w-2/5", div { p { class: "text-center font-medium mb-2", "Авторизация", }, input { class: "border-b-2 w-full hover:outline-2 outline-offset-2 mb-1 rounded-sm", placeholder: "Электронная почта", r#type: "email", // Так мы можем использовать зарезервированные // в языке Rust слова, которые используются в HTML. value: "{email}", // Значение будет соответствовать значению хука `email`. oninput: move |evt| email.set(evt.value.clone()), }, input { class: "border-b-2 w-full hover:outline-2 outline-offset-2 mb-1 rounded-sm", r#type: "password", placeholder: "Пароль", value: "{password}", oninput: move |evt| password.set(evt.value.clone()), onkeypress: move |evt| { if evt.code() == KeyCode::Enter { auth_by_enter_fut(evt); } } }, div { class: "flex flex-row columns-2 block gap-4", button { class: "w-full rounded-xl shadow-md px-4 py-1 text-white bg-orange-500", onclick: auth_fut, "Войти" }, button { class: "w-full rounded-xl shadow-md px-4 py-1 text-white bg-teal-800", onclick: move |_| { hx.app_location.set(InAppLocation::Register); }, "Зарегистрироваться" }, }, } } }) }
Код компонента страницы проектов:
pub fn Projects<'a>(cx: Scope<'a>, hx: AppHooks<'a>) -> Element<'a> { // Создание проекта let new_project_fut = move |_| { cx.spawn({ let update_scheduler = hx.update_scheduler.clone(); async move { match new_project().await { Ok(_) => { debug!("Проект создан."); update_scheduler.set(LocalUpdate::Scheduled); }, Err(e) => error!("{}", e), } } }); }; // Отрисовка страницы cx.render({ // 1. Проверка планировщика (если добавили/удалили проект) let curr_summaries_scheduler_state = hx.update_scheduler.clone(); if curr_summaries_scheduler_state == LocalUpdate::Scheduled { hx.update_scheduler.set(LocalUpdate::NotScheduled); hx.get_summaries_fut.restart(); // так можно перезапускать футуры // и ожидать от них новых значений } // 2. Обновление значений из корутины let mut summaries_list = Vec::new(); if hx.get_summaries_fut.value().is_some() { let res = hx.get_summaries_fut.value().unwrap(); if res.is_ok() { let summaries_vec = res.as_ref().unwrap(); for summary in summaries_vec { summaries_list.push(rsx!(ProjectLine(cx, summary.clone(), hx.update_scheduler, hx.project_selected))) } } } // 3. Отрисовка превью проекта let project_card = match **hx.project_selected { 0 => None, _ => Some(rsx!(ProjectCard(cx, hx))), }; // 4. Финальная отрисовка rsx!{ SideBar { app_location: hx.app_location.clone() }, div { class: "sm:pl-80 md:pl-80 lg:pl-80 pl-12 pr-12 pt-8 pb-8", p { class: "font-medium text-xl select-none pb-4", "Мои проекты" }, summaries_list.into_iter(), // будет последовательно отрисован // список компонентов div { class: "pt-4 py-4", button { class: "animate-slide_in pl-8 pr-8 shadow-md rounded-full px-4 py-1 text-white bg-[#607a79] hover:bg-[#719190]", onclick: new_project_fut, "Начать новый проект" } }, project_card, // отметим, что можно передавать Option<LazyNodes<..>> } } }) }
Сборка
Для сборки необходимо выполнить следующую команду:
dx build --release
Встраивание фронта в бэк
Добавьте следующие функции в backend/main.rs
:
// Файлы для фронтенда. #[get("/")] async fn frontend() -> Result<NamedFile, NotFound<String>> { let path = Path::new("../dist/index.html"); NamedFile::open(&path).await.map_err(|e| NotFound(e.to_string())) } // Если используете TailwindCSS #[get("/tailwind.css")] async fn get_tw_css() -> Result<NamedFile, NotFound<String>> { let path = Path::new("../dist/tailwind.css"); NamedFile::open(&path).await.map_err(|e| NotFound(e.to_string())) } #[get("/assets/<path..>")] async fn get_dx_app_internals(path: PathBuf) -> Option<NamedFile> { NamedFile::open(Path::new("../dist/assets/").join(path)).await.ok() }
И измените функцию async fn rocket()
:
rocket::build() // перечисляем роуты .mount("/", routes![ frontend, get_dx_app_internals, get_tw_css, // ... ])
Возможные проблемы
1. /favicon.ico
error
Проблема с favicon.ico
? Да, бывает. Измените frontend/index.html
:
<head> <!-- ... --> <link rel="shortcut icon" type="image/avif" href="favicon.avif"> <!-- ... --> </head>
Сконвертируйте иконку в AVIF и добавьте ещё один роут в backend
:
#[get("/favicon.avif")] async fn get_favicon() -> Result<NamedFile, NotFound<String>> { let path = Path::new("../dist/favicon.avif"); NamedFile::open(&path).await.map_err(|e| NotFound(e.to_string())) }
2. Разрешение проблем с CORS
Если вы хотите на сервере разрешить CORS, добавьте следующий код:
/// Разрешает CORS. pub struct CorsAllower; #[rocket::async_trait] impl Fairing for CorsAllower { fn info(&self) -> Info { Info { name: "Add CORS headers to responses", kind: Kind::Response } } async fn on_response<'r>(&self, _request: &'r Request<'_>, response: &mut Response<'r>) { response.set_header(Header::new("Access-Control-Allow-Origin", "*")); response.set_header(Header::new("Access-Control-Allow-Methods", "POST, PUT, DELETE, PATCH, GET, OPTIONS")); response.set_header(Header::new("Access-Control-Allow-Headers", "Authorization, Content-Type")); response.set_header(Header::new("Access-Control-Allow-Credentials", "true")); } } /// Разрешает запросы OPTIONS. #[allow(dead_code)] #[options("/<_path..>")] pub async fn cors_options_preflight(_path: std::path::PathBuf) -> MResult<()> { Ok(()) }
rocket::build() .mount("/", routes![ cors_options_preflight, // <- позволяет отвечать на OPTIONS-запросы // .. ]) .attach(CorsAllower); // <- добавляет разрешающие заголовки
Полезные библиотеки
1. Бэкенд
[dependencies] base64 = "0.21" bb8-redis = "0.13" chrono = { version = "0.4", features = ["serde"] } data_types = { path = "../data_types" } dotenv = "0.15.0" log = "0.4.20" passwords = "3.1" rocket = { version = "0.5.0-rc.4", features = ["tls", "json"] } sea-orm = { version = "0.12", features = ["sqlx-postgres", "sqlx-sqlite", "runtime-tokio-native-tls", "macros", "mock"] } sea-orm-migration = "0.12" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" sha3 = "0.10" tokio = { version = "1.33.0", features = ["sync"] } uuid = { version = "1.5.0", features = ["v4"] }
2. Фронтенд
[dependencies] async-recursion = "1.0" chrono = { version = "0.4", features = ["serde"] } console_log = "1" data_types = { path = "../data_types" } dioxus = "0.4" dioxus-free-icons = { version = "0.7", features = ["bootstrap"] } dioxus-html = "0.4" dioxus-web = "0.4" futures = "0.3" log = "0.4" reqwest = { version = "0.11", features = ["json"] } serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" web-sys = { version = "0.3", features = ["Storage", "Window"] }