rust 全栈应用框架dioxus server
接上一篇文章dioxus
全栈应用框架的基本使用,支持web、desktop、mobile
等平台。
可以先查看上一篇文章rust 全栈应用框架dioxus👈
既然是全栈框架,那肯定是得有后端服务的,之前创建的服务没有包含后端服务包,我们修改Cargo.toml
,增加后端服务包,
指定依赖dioxus
包含特性fullstack
dioxus = { version = "0.6.0", features = ['fullstack'] }
并且需要去掉当前默认的default
平台设置,增加server
服务端功能。之后需要重启项目,并且需要指定平台dx serve --platform web
[features]
default = ["web"] // 移除
web = ["dioxus/web"]
desktop = ["dioxus/desktop"]
mobile = ["dioxus/mobile"]
server = ["dioxus/server"] // 增加
重新启动服务后,类似本地mock服务,可以像调用接口一样在前端组件逻辑中调用。
启动之后可以看到和仅前端服务不同的是多了一个fullstack
.这样我们可以开始编写服务端代码了。
以一个简单的记录信息的服务为例,保存用户输入的信息,并展示用户已经保存的信息数据。
内连服务RPC
内连服务功能函数定义和界面定义代码没有分离。通过#[server]
定义服务端功能函数,函数是异步async
的。
我们定义保存用户输入信息的函数,返回值为Result<(), ServerFnError>
,请求参数会自动被序列化,响应参数也必须可被序列化。
#[server]
async fn save_note(content:String) -> Result<(), ServerFnError> {Ok(())
}
在页面上通过点击事件,调用后端服务,dioxus
使用axum
来处理后端服务,前端则可以通过reqwest
库请求服务。内连服务则可以让我们直接在事件处理方法中调用服务端函数。
axusm
是一个web服务框架,集成了tokio
异步运行时;tower
构建客户端和服务端;hyper
http服务库。
定义好了服务端功能函数,在客户端通过点击事件进行调用。dioxus
会自动处理调用到的服务函数,注册服务,建立调用关系。
#[component]
fn App() -> Element {let mut content = use_signal(|| "".to_string());let handle_input = move |event: FormEvent| {content.set(event.value());};let handle_submit = move |_| async move {save_note(content()).await.unwrap();};rsx! {input { value:"{content}", oninput:handle_input }button { onclick:handle_submit,"submit" }}
}
用户输入点击提交,然后调用服务端函数save_note
保存信息。完善一下服务端功能,将输入的信息存储到当前目录文件中note.txt
。
#[server]
async fn save_note(content:String) -> Result<(), ServerFnError> {println!("received note {}", content);// 存储到当前目录文件中 note.txtstd::fs::write("note.txt", content).unwrap();Ok(())
}
运行测试dx serve --platform web
启动服务时,发现报错了
根据问题查询是因为在web平台要构建server
服务时,中间依赖的mio
库是一个服务端网络库,不能编译为WASM。web
端不能运行服务端程序,所以改换平台测试dx serve --platform desktop
改用desktop
平台,运行成功,按照功能测试了输入内容并点击保存,项目根目录下出现了文件note.txt
文件,内容正是我们输入的内容。
手动注册服务
通过#[server]
定义的服务端功能函数,在运行时会启动注册服务,这就限制了无法在不能运行服务端程序的平台上运行。比如不能在web
平台上执行,我们可以手动注册服务,并通过运行环境判断执行前端服务还是后端服务。
在自定义服务时,需要添加依赖axum \ tokio
,使用了optional
来标记依赖,这是因为某些依赖只在特定的平台中运行,然后在特定的平台特性中指定需要的依赖。
[dependencies]
axum = { version = "0.7.9", optional = true }
tokio = { version = "1.44.2", features = ["full"], optional = true }[features]
server = ['dioxus/server', "dep:axum", "dep:tokio"]
定义服务注册函数launch_server
,通过#[cfg(feature = "server")]
条件判断只有在features
为server
时才编译代码,这样在启动web
平台时不会自动将服务端代码进行编译,达到web和server分离的目的。
#[cfg(feature = "server")]
async fn launch_server() {// 获取到服务ip 端口// 这里依赖了`dioxus`的features cli_configlet addr = cli_config::fullstack_address_or_localhost();// 自定义axum 路由let router = axum::Router::new().serve_dioxus_application(ServeConfigBuilder::new(), App).into_make_service();// 监听端口let listener = tokio::net::TcpListener::bind(addr).await.unwrap();// 启动服务axum::serve(listener, router).await.unwrap();
}
dioxus::fullstack
提供了与axum
集成的服务能力。提供了serve_dioxus_application
方法,它提供完整的服务端渲染应用的能力。
注意:目前我使用的
dioxus
版本是0.6.3
,对应的axum
版本是0.7
。最新的axum
版本是0.8
,不支持serve_dioxus_application
,这可能会在dioxus
新版本0.7
中解决。
定义好了服务端运行函数,我们修改主函数main.rs
,通过条件编译#[cfg(feature = "server")]
只有在特性sever时执行我们定义的launch_server
,其它时执行前端运行函数dioxus::launch(App)
。
fn main() {#[cfg(feature = "server")]tokio::runtime::Runtime::new().unwrap().block_on(launch_server());#[cfg(not(feature = "server"))]dioxus::launch(App);
}
现在可以直接运行dx serve --platform web
,现在是服务端渲染,我们可以正常的访问前端页面,并且通过接口调用到了后端服务。
可以看到默认转换的服务API地址,前缀默认是api
,请求地址、类型等设置可以通过#[server]
参数进行设置,这里暂不涉及,需要的可以去查看文档。
在前后端混合开发时,注意一些服务端需要的静态变量,比如密码,数据库连接等,不能直接定义变量,通过条件编译#[cfg(feature = "server")]
进行处理。
分离服务
我们可以采用rust
工作区来管理项目,区分服务端server
和前端,然后前端又可以区分为web
、mobile
、desktop
,将公共的页面逻辑放在app
中,这样我们的目录就变成了
那么之前的区分server
的入口执行代码存放在server
目录中
use dioxus::prelude::*;// 不同平台的页面入口组件
use web::App;#[cfg(feature = "server")]
#[tokio::main]
async fn main() {let addr = dioxus::cli_config::fullstack_address_or_localhost();let router = axum::Router::new().serve_dioxus_application(ServeConfigBuilder::new(), App).into_make_service();let listener = tokio::net::TcpListener::bind(addr).await.unwrap();axum::serve(listener, router).await.unwrap();
}#[cfg(not(feature = "server"))]
fn main() {dioxus::launch(App);
}
这里还是依赖了dioxus
提供的服务端能力,也可以自己使用axum
自定义服务端功能,在调用#[server]
定义的服务端函数的地方改为传统接口请求方式。
将通用的组件,包括服务端函数等放在子包app
中,然后在不同的平台的引入并使用。
use dioxus::prelude::*;use app::App as BaseApp;#[component]
pub fn App() -> Element {rsx! {h2 { "Hello, web!" }BaseApp {}}
}
明确不同平台、不同能力划分的子包,可以更方便的管理,也能更好的针对不同平台进行定制化处理。
官方并没有给出一个标准示例,这方面还需要继续探索。
#[server]
可以将前后端写在一起,再区分模块是否不妥,还需实践。
路由
路由在业务开发中必不可少,dioxus
提供了特性router
支持路由配置,我们修改依赖增加特性支持
[workspace.dependencies]
dioxus = { version = "0.6.3", features = ["fullstack", "router"] }
diosux
提供了派生宏Routable
使得我们通过枚举定义路由:
#[derive(Routable, Clone, PartialEq)]
enum Route {#[route("/")]Home,
}
定义了默认导航路由地址/
渲染组件Home
,在需要渲染路由的地方使用Router::<Route> {}
来占位路由渲染。修改组件App
增加路由渲染占位:
#[component]
pub fn App() -> Element {rsx! {Router::<Route> {}}
}
我们定义Home
组件,默认可以展示来自不同平台的平台名称,在各个平台中通过use_context_provider
hook提供了平台名称。比如在web
平台中:
use dioxus::prelude::*;use app::App as BaseApp;#[component]
pub fn App() -> Element {use_context_provider(|| "dioxus-web".to_string());rsx! {BaseApp {}}
}
在Home
组件中获取并展示,上下文变量共享可以作为不同平台的环境变量来处理一些特定的逻辑。
#[component]
pub fn Home() -> Element {let platform_name: String = use_context();rsx! {h2{"{platform_name}"}}
}
我们将原来的输入信息保存的功能提取成一个组件Note
,并默认初始渲染这个组件,我们还希望Home
组件也能展示,也就是Home
组件是父组件,Note
组件是子组件。
通过Outlet::<Route> {}
将路由匹配的组件渲染到指定的位置,修改组件Home
use dioxus::prelude::*;use crate::Route;#[component]
pub fn Home() -> Element {let platform_name: String = use_context();rsx! {h2{"{platform_name}"}// 渲染子组件Outlet::<Route> {}}
}
路由/
默认渲染Note
,调整路由定义,通过#[layout]
定义组件嵌套关系,在父组件内部可通过Outlet
渲染匹配到的子组件。
#[derive(Routable, Clone, PartialEq)]
enum Route {#[layout(Home)]#[route("/")]AddNote,
}
上面默认路由/
渲染了Note
,在日常开发中/
路由渲染可能会发生改变,为了方便灵活配置,指定跳转到其他路由。新增一个路由/note
,用来访问Note
组件,然后路由/
重定向到/note
。
#[derive(Routable, Clone, PartialEq)]
#[rustfmt::skip]
enum Route {#[redirect("/",|| Route::AddNote)]#[layout(Home)]#[route("/note")]AddNote,#[end_layout]
}
为了标识嵌套关系,使用#[rustfmt::skip]
保持手动缩进格式,使用#[redirect]
重定向路由,第一个参数指定路径;第二个闭包函数返回渲染的路由(已经定义了的路由)。
当前重定向目标路由时,浏览器的访问路径并不会由
/
更改为/note
动态路由
动态路由包括动态路径和查询参数。
动态路径就是通过:name
表示参数name
可以是任意值。
#[derive(Routable, Clone, PartialEq)]
#[rustfmt::skip]
enum Route {#[route("/note/:id")]ViewNote {id:String},
}
查询参数则是在路径后面加上?
,后面跟:name
,多个参数用&
连接。
#[derive(Routable, Clone, PartialEq)]
#[rustfmt::skip]
enum Route {#[route("/note?:name&:id")]ViewNote { name:String, id:String },
}
在传递参数时,需要按照参数顺序定义,比如name
字段必须在id
前面。{ id:String, name:String }
这样写是错的。
路由嵌套
通过#[layout]
实现组件嵌套。实现路由嵌套可以减少路径重复书写,通过#[nest]
标识上级路径
#[derive(Routable, Clone, PartialEq)]
#[rustfmt::skip]
enum Route {#[redirect("/",|| Route::AddNote)]#[layout(Home)]#[nest("/note")]#[route("/")]AddNote,#[route("/view?:name&:id")]ViewNote { name:String,id:String },#[end_nest]#[end_layout]
}
路由嵌套中也可以使用动态路径,它可以将参数传递给所有子路由。
404页面
路由404页面,当匹配不到所有的路由定义时,渲染指定的页面,在路由配置最后新增路由兜底,通过:..segments
匹配所有路径段:
#[derive(Routable, Clone, PartialEq)]
enum Route {// ... routes#[route("/:..segments")]NotFound { segments: Vec<String> },
}
也可通过redirect
重定向到首页去,我们在初始默认/
渲染的Home
组件,可以在路由重定向,当匹配不到其他路由路径时,渲染Home
组件。
#[derive(Routable, Clone, PartialEq)]
enum Route {#[route("/")]#[redirect("/:..segments",|segments:Vec<String>| Route::Home {})]Home,
}
对于处理路由匹配不到的处理只能选择其中一个配置,不能同时设置。
对于:..routes
捕获剩余路由路径也可用于路由的嵌套路由中,比如#[route("/note/:..routes")]
路由导航
dioxus
提供了组件Link
用来跳转到指定路由。
#[component]
pub fn Home() -> Element {rsx! {Link { to:Route::AddNote {} , "Add Note" },Link { to:"https://www.baidu.com", "Baidu" },}
}
也支持直接跳转第三方链接。还可以通过navigator
全局函数获取到导航实例,通过方法手动跳转指定路由
push
跳转到指定路由replace
替换当前路由,路由历史丢失,不能回退。go
跳转到指定路由,路由历史保留,可以回退。go_back
返回上一级路由。go_forward
返回下一级路由。
#[component]
pub fn Home() -> Element {let router = navigator();rsx! {button {onclick: move |_| {router.push(Route::AddNote {});},"Add Note"}}
}
虽然功能上navigator
和Link
类似,但是对于外部链接navigator
并不保证跳转成功。
为了方便路由的前进、后退,dioxus
提供了全局组件GoBackButton
和GoForwardButton
直接使用,避免了通过点击事件处理函数手动跳转。
#[component]
pub fn Add() -> Element {rsx! {GoBackButton {"back"}}
}
连接数据库
现在能用的数据库很多了,这里找一个简单的数据库测试存储。不需要额外安装的嵌入式数据库,比如SQLite
安装依赖rusqlite
cargo add rusqlite --optional
新增一个db.rs
用于管理操作数据库,数据库操作只能在服务端运行, 我们需要使用#[cfg(feature = "server")]
#[cfg(feature = "server")]
thread_local! {pub static DB: rusqlite::Connection = {println!("DB init");let conn = rusqlite::Connection::open("note.db").unwrap();conn.execute_batch("CREATE TABLE IF NOT EXISTS notes (id INTEGER PRIMARY KEY,content TEXT NOT NULL)",).unwrap();conn};
}
修改我们之前保存信息的服务端方法,把保存到文件改为存储到数据库表中。
#[server]
pub async fn save_note(content: String) -> Result<(), ServerFnError> {println!("received note {}", content);// 存储到当前目录文件中 note.txt// std::fs::write("note.txt", content).unwrap();#[cfg(feature = "server")]{use crate::db::DB;let inserted = DB.with(|f| f.execute("INSERT INTO notes (content) VALUES (?1)", [&content])).expect("failed to insert into notes");println!("inserted {} rows", inserted);}Ok(())
}
同样的,在操作数据库也应该保证是在服务端运行#[cfg(feature = "server")]
,启动我们的程序dx serve --platform web
,在交互接口调用时,同时完成了数据初始化,并保存了数据到note.db
中。
可以看到当前服务目录下自动生成了note.db
文件,并且可以查看到数据已经保存到数据库中。
引用
-
dioxus
-
dioxus-doc