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"] }