- 支持试读
内容介绍
通过《使用 axum 和 dioxus 构建全栈 Web 应用》专题的学习,相信你已经掌握了 dioxus 前端应用的开发,dioxus 还提供了 fullstack,可以快速的构建一个全栈应用,可喜的是,dioxus fullstack 的后端是由 axum 实现的。 - 支持试读
开发代办事项App
本章我们将代办事项App的开发,掌握 dioxus fullstack 的基本知识。 dioxus fullstack 文件上传
我们在《【加餐】dioxus 和 axum 实现文件上传》讲解过如果使用 dioxus 编写文件上传组件,该案例中,我们使用的是原生 axum 实现的后端。本章,我们将讨论使用 dioxus fullstack 实现文件上传。和原生 axum 相比,dioxus fullstack 实现文件上传非常容易。dioxus fullstack 使用 sqlx
在构建待办事项App时,我们讨论了如何在 dioxus 中,使用单例模式共享数据存储。本章我们讨论如何在 dioxus 中共享 sqlx 连接池。图床:上传图片并保存到 B2 存储桶
本章我们将开始实现图床。我们将从创建一个开启路由及 tailwind css 的 dioxus fullstack 项目开始,并在本章实现图片上传并保存到 B2 存储桶。图床:后台管理
本章我们将实现图床的后台管理功能,你将学习到:如何在 dioxus 自定义 AXUM、如何实现 dioxus fullstack JWT 鉴权。
开发代办事项App
本章我们将代办事项App的开发,掌握 dioxus fullstack 的基本知识。
创建项目
dx new todo-app
参照以下过程进行选择:
✔ 🤷 Which sub-template should be expanded? · Bare-Bones
✔ 🤷 Do you want to use Dioxus Fullstack? · true
✔ 🤷 Do you want to use Dioxus Router? · false
✔ 🤷 Do you want to use Tailwind CSS? · false
✔ 🤷 Which platform do you want DX to serve by default? · Web
dioxus fullstack 初印象
我们来看一下 dx
工具给我们生成的代码。我们聚焦到 Echo()
和 echo_server()
,它们实现了一个简单的回显功能,即用户通过前端的表单输入任何内容,后端都原样返回:
/// Echo component that demonstrates fullstack server functions.
#[component]
fn Echo() -> Element {
let mut response = use_signal(|| String::new());
rsx! {
div {
id: "echo",
h4 { "ServerFn Echo" }
input {
placeholder: "Type here to echo...",
oninput: move |event| async move {
let data = echo_server(event.value()).await.unwrap();
response.set(data);
},
}
if !response().is_empty() {
p {
"Server echoed: "
i { "{response}" }
}
}
}
}
}
/// Echo the user input on the server.
#[server(EchoServer)]
async fn echo_server(input: String) -> Result<String, ServerFnError> {
Ok(input)
}
Echo
组件。Echo()
是一个前端组件- 使用
use_signal()
定义了一个response
状态,用于维护从服务端返回的响应数据 input
的oninput
事件:let data = echo_server(event.value()).await.unwrap()
,调用后端函数echo_server()
- 将后端函数的返回结果,设置到
response
状态中
- 如果
response
状态不为空,则显示该状态的数据
- 使用
echo_server
API。它是一个后端函数,直接将传入的参数原样返回给客户端
dioxus fullstack 的实现原理
感觉很神奇不是吗?它是怎么做到在前端像调用普通函数那样,直接调用后端函数的?
- dioxus 前端代码最终编译为 WASM
- dioxus fullstack 是通过 RPC(远程过程调用)来实现前端代码调用后端函数的
编写UI
#[component]
pub fn Header() -> Element {
rsx! {
div { class: "flex justify-between items-center gap-x-2",
div { class: "grow",
input {
placeholder: "输入代办事项",
class: "block w-full px-3 py-1.5 outline-none ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-blue-500",
}
}
div { class: "shrink-0",
button { class: "px-3 py-1.5 text-white bg-blue-500 hover:bg-blue-600",
"添加"
}
}
}
}
}
#[component]
pub fn Body() -> Element {
rsx! {
TodoList {}
}
}
#[component]
fn TodoList() -> Element {
rsx! {
ul { class: "space-y-2",
for i in 0..10 {
TodoItem { key: "{i}" }
}
}
}
}
#[component]
fn TodoItem() -> Element {
rsx! {
li { class: "flex items-center justify-between px-2 py-1 group hover:bg-gray-300 rounded",
div { "TodoItem" }
div { class: "flex items-center justify-end gap-x-1 group-hover:opacity-100 group-hover:visible invisible opacity-0 transition-all",
button { class: "px-1 py-0.5 text-sm bg-green-600 text-white hover:bg-green-700",
"完成"
}
button { class: "px-1 py-0.5 text-sm bg-red-600 text-white hover:bg-red-700",
"删除"
}
}
}
}
}
没啥好讲的,最基本的 rsx。我们来看看后端的编写。
注意,我们手动添加了 tailwind css,具体操作请参见《计数器》章节
依赖管理
开始后端编写之前,我们要明确一点:很多 crate 在 dioxus 的前端中是不能使用的,只有 dioxus 后端才能使用,比如 tokio
、sqlx
等。这就要求我们需要对前后端依赖进行管理。
情景一:前后端共用的依赖
此情景中,就是添加普通依赖,比如 serde
。
可以使用以下命令添加:
cargo add serde --features derive
或者在 Cargo.toml
手动添加:
[dependencies]
serde = { version = "1.0.219", features = ["derive"] }
情景二:后端使用的依赖
此情景中,添加依赖时,需要加上 optional
,让其成为可选依赖,比如:
或者在 Cargo.toml
手动添加:
[dependencies]
xid = { version = "1.1.1", optional = true }
然后,需要添加一个 server
feature,并在该feature中,启用要使用的依赖:
[features]
server = ["dioxus/server", "dep:xid"]
情景三:前端使用的依赖
和情景二类似,不同的是,要做 web
feature 中启用:
[features]
web = ["dioxus/web", "dep:reqwest"]
对于本案例而言,前后端都需要 serde,服务端需要用到 tokio 和 xid,所以最终的 Cargo.toml
如下:
[dependencies]
dioxus = { version = "0.6.0", features = ["fullstack"] }
serde = { version = "1.0.219", features = ["derive"] }
tokio = { version = "1.45.0", features = ["full"], optional = true }
xid = { version = "1.1.1", optional = true }
[features]
default = ["web"]
web = ["dioxus/web"]
desktop = ["dioxus/desktop"]
mobile = ["dioxus/mobile"]
server = ["dioxus/server", "dep:tokio", "dep:xid"]
数据存储
为了不分散注意力,本案例不涉及数据库,而是使用内存进行存储。我们来看下数据存储的实现:
pub struct Store(Arc<Mutex<Vec<models::Todo>>>);
impl Store {
pub fn new() -> Self {
Self(Arc::new(Mutex::new(Vec::new())))
}
pub fn add(&self, todo: models::Todo) {
self.0.lock().unwrap().push(todo);
}
pub fn list(&self) -> Vec<models::Todo> {
self.0.lock().unwrap().clone()
}
pub fn find(&self, id: &str) -> Option<models::Todo> {
self.0
.lock()
.unwrap()
.iter()
.find(|todo| todo.id == id)
.cloned()
}
pub fn del(&self, id: &str) {
self.0.lock().unwrap().retain(|todo| todo.id != id);
}
pub fn clear(&self) {
self.0.lock().unwrap().clear();
}
pub fn len(&self) -> usize {
self.0.lock().unwrap().len()
}
pub fn done(&self, id: &str) {
self.0
.lock()
.unwrap()
.iter_mut()
.find(|todo| todo.id == id)
.map(|todo| todo.is_done = true);
}
}
-
我们使用
Store
包装了一个Arc<Mutex<Vec<models::Todo>>>
。它线程安全的、带互斥锁的列表 -
接下来我们为其实现方法。这些方法大部分使用 AI 辅助实现的,利用 IDE 的 AI 助手能大幅提升开发效率,这里推荐几个可以通过签到获取余额/积分,从而实现免费使用的 AI API(排名不分先后,建议都进行注册):
-
其中的
models::Todo
是数据模型,其定义如下:#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] pub struct Todo { pub id: String, pub title: String, pub is_done: bool, } #[cfg(feature = "server")] impl Todo { pub fn new(title: impl Into<String>) -> Self { let id = xid::new().to_string(); Self { id, title: title.into(), is_done: false, } } }
在 dioxus fullstack 开发中,#[cfg(feature = "server")]
非常重要!
- 它告诉 rust 编译器,该代码只在 server feature 中编译
- 它避免了 tokio 等,无法在 dioxus 前端使用的 crate 被错误的编译,造成整个应用无法运行
- 它避免了敏感数据暴露在前端的风险。诸如数据库连接信息、TOKEN 等敏感数据只能在后端使用,而不能暴露给前端
- 我们在《使用 axum 和 dioxus 构建全栈 Web 应用》专题中讲到,dioxus 前端是无法在运行期间获取到环境变量的(需要使用
env!
宏,在编译时指定 )。和 dioxus 前端不同,dioxus 后端是可以使用std::env::var()
,在运行期获取环境变量
- 我们在《使用 axum 和 dioxus 构建全栈 Web 应用》专题中讲到,dioxus 前端是无法在运行期间获取到环境变量的(需要使用
后端实现
是时候来实现后端功能了!
#[server]
async fn add_todo(title: String) -> Result<String, ServerFnError> {
if title.is_empty() {
return Ok("".to_string());
}
if title.len() > 50 {
return Ok("".to_string());
}
let todo = models::Todo::new(title);
let id = todo.id.clone();
let db = db::get_db().await;
if db.len() >= 10 {
return Ok("".to_string());
}
db.add(todo);
Ok(id)
}
#[server]
pub async fn delete_todo(id: String) -> Result<(), ServerFnError> {
let db = db::get_db().await;
db.del(&id);
Ok(())
}
#[server]
pub async fn done_todo(id: String) -> Result<(), ServerFnError> {
let db = db::get_db().await;
db.done(&id);
Ok(())
}
#[server]
pub async fn list_todos() -> Result<Vec<models::Todo>, ServerFnError> {
let db = db::get_db().await;
let mut todos = db.list();
todos.sort_by(|a, b| b.id.cmp(&a.id));
Ok(todos)
}
add_todo
:添加一个待办事项- 为了限制演示站点的资源(因为我们使用内存做存储),这里对代办事项进行了限制:单条代办事项不能超过50个字符,所以待办事项不能超过10条。
delete_todo
:删除指定的代办事项done_todo
:将指定的待办事项设置为完成list_todos
:获取待办事项列表
以上所有函数都调用了 db::get_db()
来获取数据存储,我们来看一下它是怎么实现的
虽然 dioxus fullstack 的后端使用 axum 实现,但并不能像常规 axum 那样,通过 State
或者 Extension
来共享状态。你可以通过自定义 extract
来共享状态,但实现起来略显复杂。
如果你使用过 Go/java 等语言,你会看到,它们通常使用单例模式来共享某一个资源,比如数据库连接池。rust 当然也支持这种方式,而本专题也采用这种方式。我们来看看怎么实现的:
use tokio::sync::OnceCell;
static DB: OnceCell<Store> = OnceCell::const_new();
pub async fn init_db() -> Store {
Store::new()
}
pub async fn get_db() -> &'static Store {
DB.get_or_init(init_db).await
}
- 使用
OnceCell
定义了静态变量DB
- 它将在整个应用中共享(通过
get_db()
函数) OnceCell
可以确保只初始化一次,并且是线程安全的
- 它将在整个应用中共享(通过
init_db()
用于初始化操作,对于本案例而言,就是调用Store::new()
方法get_db()
获取OnceCell
维护的对象的引用,本案例中,获取的是数据存储的引用- 有个
OnceCell
的详细介绍,请参阅其官方文档
集成前后端
万事俱备,只欠集成。是时候进行前后端集成了:
#[component]
pub fn TodoApp() -> Element {
let todo_list = use_resource(list_todos);
rsx! {
Header { todo_list }
TodoList { todo_list }
}
}
#[component]
fn TodoList(todo_list: Resource<Result<Vec<models::Todo>, ServerFnError>>) -> Element {
match &*todo_list.read_unchecked() {
Some(Ok(list)) => {
if list.is_empty() {
rsx! {
div { "暂无代办事项" }
}
} else {
rsx! {
ul { class: "space-y-2",
for item in list {
TodoItem {
key: "{item.id}",
item: item.clone(),
todo_list,
}
}
}
}
}
}
Some(Err(e)) => rsx! {
div { "发生错误: {e}" }
},
None => rsx! {
div { "正在加载..." }
},
}
}
#[component]
fn TodoItem(
item: models::Todo,
todo_list: Resource<Result<Vec<models::Todo>, ServerFnError>>,
) -> Element {
let item_done = item.clone();
rsx! {
li { class: "flex items-center justify-between px-2 py-1 group hover:bg-gray-300 rounded",
div { class: if item.is_done { "line-through" }, "{item.title}" }
div { class: "flex items-center justify-end gap-x-1 group-hover:opacity-100 group-hover:visible invisible opacity-0 transition-all",
if !item.is_done {
button {
class: "px-1 py-0.5 text-sm bg-green-600 text-white hover:bg-green-700",
onclick: move |_| {
let id = item_done.id.clone();
async move {
let _ = done_todo(id).await.unwrap();
todo_list.restart();
}
},
"完成"
}
}
button {
class: "px-1 py-0.5 text-sm bg-red-600 text-white hover:bg-red-700",
onclick: move |_| {
let id = item.id.clone();
async move {
let _ = delete_todo(id).await.unwrap();
todo_list.restart();
}
},
"删除"
}
}
}
}
}
#[component]
pub fn Header(mut todo_list: Resource<Result<Vec<models::Todo>, ServerFnError>>) -> Element {
let mut title = use_signal(|| String::new());
rsx! {
div { class: "flex justify-between items-center gap-x-2",
div { class: "grow",
input {
placeholder: "输入代办事项",
value: "{title}",
oninput: move |e| title.set(e.value()),
class: "block w-full px-3 py-1.5 outline-none ring-1 ring-inset ring-gray-300 focus:ring-2 focus:ring-blue-500",
}
}
div { class: "shrink-0",
button {
class: "px-3 py-1.5 text-white bg-blue-500 hover:bg-blue-600",
onclick: move |_| async move {
if title().is_empty() {
return;
}
if title().len() > 50 {
return;
}
let _ = add_todo(title()).await.unwrap();
title.set(String::new());
todo_list.restart();
},
"添加"
}
}
}
}
}
TodoApp
组件:整个应用入口主键- 使用
use_resource(todo_lists)
定义了一个,到todo_list()
后端程序的资源 - 调用
Header
和TodoList
组件,并将上一步定义的资源传递给两个子组件
- 使用
TodoList
组件:渲染待办事项列表- 使用
for
来遍历所以待办事项 - 具体待办事项由
TodoItem
组件渲染
- 使用
TodoItem
组件:渲染单个待办事项Header
组件:渲染头部,用于添加代办事项- 所有组件都共享了
use_resource(todo_lists)
,原因在于,无论是添加、删除或者将某个待办事项设定为完成之后,都要重新获取代办事项列表,通过共享该资源,就能在操作完成后,重新获取列表数据。不知道你是否记得,dioxus 有全局状态,你可以试试看,能否使用全局状态,消除每个组件都要通过 Props 来传递该资源的问题。
lib.rs
和 main.rs
最后,我们看一下 lib.rs
:
pub mod components;
#[cfg(feature = "server")]
pub mod db;
pub mod models;
我们可以看到,db
模块被 #[cfg(feature = "server")]
修饰,也就是说,该模块对前端不可见。
main.rs
也很干净:
use dioxus::prelude::*;
use todo_app::components::TodoApp;
const FAVICON: Asset = asset!("/assets/favicon.ico");
const MAIN_CSS: Asset = asset!("/assets/main.css");
fn main() {
dioxus::launch(App);
}
#[component]
fn App() -> Element {
rsx! {
document::Link { rel: "icon", href: FAVICON }
document::Link { rel: "stylesheet", href: MAIN_CSS }
document::Stylesheet { href: MAIN_CSS }
div { class: "container mx-auto max-w-lg shadow-lg p-3 space-y-3", TodoApp {} }
}
}
编译和部署
编译
使用以下命令编译:
dx bundle --release --platform web
编译成功后,将在 target/dx/todo-app/release
生成 web
目录,该目录下将包含:
public
:前端代码server
:后端可执行文件
部署到 nginx
将编译成功后的文件(上一步生成的 web
目录)上传到服务器上,然后运行 ./server
即可,它会自动处理前端代码(就是说,上一步生成的public
目录要同时上传到服务器,但不需要使用 nginx 来为它创建站点)。而 nginx 只需要反代它监听的端口(dioxus 默认端口是 8080
,我们将在下一个案例中讲解如何自定义端口)
server {
listen [::]:80;
server_name dx-todo.ace.dpdns.org;
location / {
return 301 https://$http_host$request_uri;
}
}
server {
listen [::]:443 ssl http2;
server_name dx-todo.ace.dpdns.org;
ssl_certificate /opt/ssl/dx-todo.ace.dpdns.org.pem;
ssl_certificate_key /opt/ssl/dx-todo.ace.dpdns.org.key;
location / {
proxy_redirect off;
proxy_pass http://127.0.0.1:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $http_host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}