- 支持试读
内容介绍
通过《使用 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 的基本知识。
创建项目
创建一个名为 todo-app 的 dioxus 项目:
参照以下过程进行选择:
✔ 🤷   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_serverAPI。它是一个后端函数,直接将传入的参数原样返回给客户端
dioxus fullstack 的实现原理
感觉很神奇不是吗?它是怎么做到在前端像调用普通函数那样,直接调用后端函数的?
- dioxus 前端代码最终编译为 WASM
 - dioxus fullstack 是通过 RPC(远程过程调用)来实现前端代码调用后端函数的
 
编写UI
现在开始编写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 add xid --optional
[dependencies]
xid = { version = "1.1.1", optional = true }
然后,需要添加一个 server feature,并在该feature中,启用要使用的依赖:
[features]
server = ["dioxus/server", "dep:xid"]
情景三:前端使用的依赖
和情景二类似,不同的是,要做 web feature 中启用:
对于本案例而言,前后端都需要 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:
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;
  }
}
