前言
前面几篇更多还是在写页面。到了数据库这一篇,事情就开始变味了。
因为从这里往后,你很快会发现,判断一个 Dioxus Demo 像不像项目,不是看按钮会不会亮,也不是看路由能不能跳,而是看数据库这一层写得拧不拧巴。
很多代码一开始看着都挺正常:
- 页面能渲染
- Server Function 能调通
- SQLite 也确实写进去了
但只要顺着业务链路再往前走一步,问题马上就会出来:
sqlx该放哪- 连接池到底是不是每次请求都重新建
- 组件该知道多少数据库细节
- 现在图省事的写法,后面会不会把自己坑住
真写起来,最容易歪的还不是 SQL 语法,而是边界。
我见过不少第一次写 Dioxus fullstack 的代码,一上来就是这几种路数:
- 在组件附近顺手引
sqlx - 每个
#[server]函数里都connect("sqlite://...")一次 - 表单校验、查询语句、UI 刷新全搅在一个函数里
这些代码不是完全不能跑。问题是,它们特别像那种"今天能交差,过两天自己都不想再看"的写法。
所以这篇我不想按 API 顺序往下念,直接挑 3 个最容易写歪的地方说:
Dioxus 接数据库,麻烦不在"能不能连上 SQLite",而在这 3 件事有没有分清:
sqlx只能活在服务端- 连接池要初始化一次,别每次现连
- Server Function 负责"收参数 + 调用查询 + 返回结果",别把数据库细节撒进组件里
官方在数据库教程里用的是 rusqlite,没问题,做最小 demo 也很直接。这里改用 sqlx + SQLite,主要是因为它更接近后面往 Postgres 或 MySQL 扩的时候那套写法。
1. 先把坑点说在前面
先别急着往下贴查询代码。数据库这块,有几个判断最好一开始就立住。
1.1 数据库代码应该只存在于服务端
Dioxus 0.7 的 Fullstack 本质上还是双目标构建:
- 客户端 bundle:Web、桌面、移动
- 服务端 binary:SSR + Server Functions
官方文档明确提醒过,像 tokio 这种依赖如果不做 feature 隔离,直接进 wasm 构建就会炸。sqlx 也是同一类问题。
所以数据库代码最好先接受一个事实:
它不是"应用里任何地方都能用的工具函数",而是 server-only 代码。
1.2 连接池是应用级资源,不是函数级临时变量
举个例子,下面这种写法从直觉上很顺,但我不建议:
rust
#[server]
async fn create_note(title: String, content: String) -> Result<(), String> {
let db = sqlx::SqlitePool::connect("sqlite://data/app.db")
.await
.map_err(|e| e.to_string())?;
sqlx::query("INSERT INTO notes (title, content) VALUES (?, ?)")
.bind(title)
.bind(content)
.execute(&db)
.await
.map_err(|e| e.to_string())?;
Ok(())
}
这段不是不能跑,但问题也很直接:
- 每次请求都重新建连接
- 初始化逻辑无处安放
- 后面加建表、迁移、日志、测试都不好接
数据库连接池更像应用级资源,不适合跟某一个 server function 绑死。
1.3 组件只该关心"拿到了什么数据",不该关心"SQL 怎么写"
这件事我后面还会反复提。
如果组件里开始出现这些东西:
- 表名
- 字段名
- SQL 片段
bind顺序
那基本说明边界已经开始串了。
组件真正该关心的是:
- 提交什么数据
- 成功后拿回什么结果
- UI 怎么更新
至于 INSERT、SELECT、分页、排序、约束,这些该留在服务端。
2. 第一步先别写查询,先把 Cargo.toml 分干净
这一段有点枯燥,但说实话,躲不过去。你不先把它分干净,后面多半会在 wasm 构建时吃亏。
按 Dioxus 0.7 官方 Fullstack Project Setup 的建议,服务端依赖应该挂到 server feature 下。这个习惯最好早点养,不然后面很容易一边修 feature 一边骂人。
一个比较稳的 Cargo.toml 可以先长这样:
toml
[dependencies]
dioxus = { version = "0.7", features = ["fullstack", "router"] }
serde = { version = "1", features = ["derive"] }
tokio = { version = "1", features = ["full"], optional = true }
sqlx = { version = "0.8", features = ["sqlite", "runtime-tokio-rustls"], optional = true }
[features]
default = []
web = ["dioxus/web"]
desktop = ["dioxus/desktop"]
mobile = ["dioxus/mobile"]
server = ["dioxus/server", "dep:tokio", "dep:sqlx"]
这段配置看着普通,但有两个地方别放过去。
第一,tokio 和 sqlx 都是 optional dependency。
第二,真正需要它们的时候,只在 server feature 里启用。
这事看着像样板,其实绕不过去。官方文档已经说明:server function 的函数体会自动只在 server feature 下编译,但依赖声明和普通的 server-only 模块,还是得自己收拾干净。
说白了就是:
#[server]能帮你兜住函数体- 但兜不住你乱放的依赖和 import
3. 连接池别每次现建,用 OnceCell + SqlitePool 先落到一个可维护解
接下来这块,我觉得基本决定了你后面是在写项目,还是在继续堆 demo。
如果你现在写的是一个 Dioxus 单体 fullstack 项目,真没必要一上来就把 Axum state、模块注入、仓储层全搬出来。先把最短那条路走通,比什么都强。
先有一个只在服务端编译的数据库模块,把 SqlitePool 初始化一次,已经够用了。
比如:
rust
// src/server/db.rs
#[cfg(feature = "server")]
use sqlx::{sqlite::SqlitePoolOptions, SqlitePool};
#[cfg(feature = "server")]
use tokio::sync::OnceCell;
#[cfg(feature = "server")]
static DB: OnceCell<SqlitePool> = OnceCell::const_new();
#[cfg(feature = "server")]
pub async fn pool() -> Result<&'static SqlitePool, sqlx::Error> {
DB.get_or_try_init(|| async {
let pool = SqlitePoolOptions::new()
.max_connections(5)
.connect("sqlite://data/notes.db")
.await?;
sqlx::query(
r#"
CREATE TABLE IF NOT EXISTS notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
content TEXT NOT NULL DEFAULT '',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
"#,
)
.execute(&pool)
.await?;
Ok(pool)
})
.await
}
这段代码没什么花活,但我觉得它顺手,恰恰是因为职责清楚:
- 连接池只初始化一次
- 建表逻辑只放一次
- 后面的 server functions 不需要再知道数据库怎么连
3.1 为什么这里用 OnceCell
因为你现在最需要的,不是什么看起来很漂亮的架构,而是一个:
- 生命周期明确
- 能异步初始化
- 不会每次请求重新连库
的共享连接池。
tokio::sync::OnceCell 刚好能把这件事兜住。
3.2 为什么这里先不急着上 migration 宏
很多人学 sqlx 时第一反应就是:
那我是不是应该立刻
sqlx migrate run、migrate!()、离线校验、查询宏全配上?
我的看法是,项目里当然要做,但这篇先别把重点带偏。
这篇重点是先把 Dioxus 里的数据库边界搭顺,所以我先用 CREATE TABLE IF NOT EXISTS 把链路写清楚。
真到项目里,我的建议是:
- 开发早期:先把
pool()、Server Function、UI 提交链路跑通 - 结构稳定后:迁移到
migrations/目录,别长期把建表 SQL 写在初始化函数里
不是说 migration 不重要,而是别一开始就让工具链抢了正题。
4. 查询模型和输入模型分开,别让数据库结构直接压到表单上
到了这一步,很多人会图省事,直接把表单结构拿去落库,再把数据库那一行原样回给前端。
小 demo 可以,真写项目我不太建议。
举个最小例子:
rust
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct NewNote {
pub title: String,
pub content: String,
}
#[derive(Clone, Debug, Serialize, Deserialize, sqlx::FromRow, PartialEq)]
pub struct Note {
pub id: i64,
pub title: String,
pub content: String,
pub created_at: String,
}
这里故意拆成两个类型:
NewNote:前端提交时用Note:数据库查询和前端展示时用
这点现在看着不大,但后面真的会省你不少事。
因为一旦你加上这些需求:
- 自动生成 ID
- 默认值
- 服务端补时间戳
- 后端内部字段不想直接暴露给 UI
"输入模型"和"输出模型"天然就不是一回事。
别让数据库表结构反过来决定表单怎么长,不然后面很难改。
5. Server Function 里别做花活,老老实实把 CRUD 链路写顺
下面直接看最核心的这组代码。
5.1 查询列表
rust
use dioxus::prelude::*;
#[server]
pub async fn list_notes() -> Result<Vec<Note>, String> {
let db = crate::server::db::pool()
.await
.map_err(|e| e.to_string())?;
let notes = sqlx::query_as::<_, Note>(
r#"
SELECT id, title, content, created_at
FROM notes
ORDER BY id DESC
"#,
)
.fetch_all(db)
.await
.map_err(|e| e.to_string())?;
Ok(notes)
}
这里想看的不是 SELECT 本身,而是组织方式:
- 先拿共享连接池
- 再执行查询
- 最后只把业务结果返回给组件
组件没必要知道表名是什么,也没必要知道 created_at 在 SQLite 里是怎么存的。
5.2 新建一条笔记
rust
#[server]
pub async fn create_note(input: NewNote) -> Result<Note, String> {
let title = input.title.trim();
if title.is_empty() {
return Err("标题不能为空".into());
}
let db = crate::server::db::pool()
.await
.map_err(|e| e.to_string())?;
let result = sqlx::query(
r#"
INSERT INTO notes (title, content)
VALUES (?, ?)
"#,
)
.bind(title)
.bind(input.content.trim())
.execute(db)
.await
.map_err(|e| e.to_string())?;
let note = sqlx::query_as::<_, Note>(
r#"
SELECT id, title, content, created_at
FROM notes
WHERE id = ?
"#,
)
.bind(result.last_insert_rowid())
.fetch_one(db)
.await
.map_err(|e| e.to_string())?;
Ok(note)
}
这里我故意没让 create_note 返回一个空的 Ok(())。
原因很简单,刚创建出来的那条记录,最好直接回给前端。
这样前端立刻就能选两种做法:
- 重新拉列表
- 直接把返回值并进本地状态
比起"写成功了,你自己再查一遍",这条路顺手得多。
5.3 为什么我这里用 query_as::<_, Note>,没直接上 query_as!
这个问题很实战,而且我觉得很多人都会卡在这。
sqlx 的查询宏当然很香,编译期能校验列名和类型。但它也会带来两个明显前提:
- 你得给好
DATABASE_URL - 你得接受编译期和 schema 更强绑定
如果你现在还在快速迭代表结构,我反而建议先这样写:
rust
#[derive(sqlx::FromRow)]
struct Note {
id: i64,
title: String,
content: String,
created_at: String,
}
然后配合:
rust
sqlx::query_as::<_, Note>("SELECT ...")
它没有宏那么"强校验",但阻力更小,尤其适合系列前期的 demo 和 MVP。
等 schema 稳定了,再切到 query! / query_as! 完全来得及。
我对 sqlx 的看法一直差不多:不用第一天就把所有高级姿势配满,能跟着项目成熟度一点点加严,反而更实用。
6. 前端页面只做提交和刷新,别跟 SQL 绑在一起
后端这条线通了,前端这边反而没那么麻烦。
举个例子,一个最小的笔记页大概可以长这样:
rust
use dioxus::prelude::*;
#[component]
pub fn NotesPage() -> Element {
let mut title = use_signal(String::new);
let mut content = use_signal(String::new);
let mut refresh = use_signal(|| 0_u64);
let mut message = use_signal(|| None::<String>);
let notes = use_resource(move || {
let _version = refresh();
async move { list_notes().await }
});
let submit = move |_| async move {
let input = NewNote {
title: title(),
content: content(),
};
match create_note(input).await {
Ok(_) => {
refresh += 1;
title.set(String::new());
content.set(String::new());
message.set(None);
}
Err(err) => {
message.set(Some(err));
}
}
};
rsx! {
div {
h1 { "我的笔记" }
input {
value: "{title}",
oninput: move |evt| title.set(evt.value()),
placeholder: "标题"
}
textarea {
value: "{content}",
oninput: move |evt| content.set(evt.value()),
placeholder: "内容"
}
button { onclick: submit, "保存笔记" }
if let Some(msg) = message() {
p { class: "error", "{msg}" }
}
ul {
match &*notes.read_unchecked() {
Some(Ok(items)) => rsx! {
for note in items.iter() {
li { key: "{note.id}", "{note.title}" }
}
},
Some(Err(err)) => rsx! {
li { "加载失败:{err}" }
},
None => rsx! {
li { "加载中..." }
}
}
}
}
}
}
这段代码我更在意的,不是用了哪个 hook,而是前后端边界没串味。
- 组件知道
NewNote - 组件知道
Note - 组件知道要调
list_notes()和create_note()
但它完全不知道:
- 数据库地址
- 表结构怎么创建
- SQL 语句怎么拼
- 连接池怎么初始化
所以组件应该消费的是服务端能力,不是数据库细节。这个边界一旦守住,后面很多代码会轻松不少。
7. 和 Next.js + Prisma 的差距,到底该怎么看
这个话题题纲里提到了,我觉得还是得正面说,不然容易写成自嗨。
我的结论是:
Dioxus + sqlx 的数据库体验,没有 Next.js + Prisma 那么"自动挡",但它也没那么虚。
7.1 它确实没有 Prisma 那种一把梭体验
比如你不会天然拿到这些东西:
- schema DSL
- 自动生成 client
- 很顺手的 migration 命令流
- 一套几乎不用碰 SQL 的模型操作体验
如果你习惯了:
ts
await prisma.note.create({ data: { title, content } })
那你刚切到 sqlx 时,第一反应大概率是:
怎么还得自己写 SQL?
没错,就是要自己写。
7.2 但它也换来了更清楚的边界
sqlx 这套东西的好处是,它从头到尾都在提醒你:
- 数据库就是数据库
- 查询就是查询
- 服务端边界就是服务端边界
没有额外生成一层神秘 client,也没有把数据访问包装得像本地对象一样无感。
对喜欢显式控制的 Rust 项目来说,这反而是优点。
尤其在 Dioxus 这种 fullstack 场景里,你会明显感觉到它的思路是:
- 前端组件负责交互
- Server Function 负责接口
sqlx负责数据访问
三层不算重,边界也清楚。
7.3 真正的差距,不是"能不能用",而是"默认帮你做了多少"
所以别把这个对比理解成"谁更高级"。
我会这么看:
- Next.js + Prisma:默认帮你做得更多,上手更像自动挡
- Dioxus + sqlx:默认更显式,更像手动挡,但也更好知道车是怎么开的
你喜不喜欢这套体验,最后还是看团队习惯。
如果你本来就接受:
- 手写 SQL
- 自己管 feature
- 自己管迁移
- 自己管错误边界
那 Dioxus + sqlx 完全是能用的,而且不少地方比我一开始预想的顺。
8. 真要往项目走,接下来优先补这三件事
写到这里,这篇主线其实差不多闭环了。
但如果你准备把 Demo 往项目推进,我建议下一步优先补下面三件事。
8.1 把建表 SQL 迁到 migrations/
初始化函数里写 CREATE TABLE IF NOT EXISTS 适合示范,不适合长期维护。
一旦字段开始变:
- 新增索引
- 补默认值
- 改唯一约束
你就会很快需要正式迁移。
8.2 给查询结果和错误打日志
数据库这一层出了问题,不打日志很难查。
尤其是这些场景:
- 某条插入为什么失败
- 某个参数为什么查不到
- SQLite 文件到底落在哪
工程化那篇我会再展开讲 tracing,这里先把这个意识放在脑子里就够了。
8.3 给 Server Function 做最小单测
哪怕你还没上完整集成测试,也建议先测两类东西:
- 空标题会不会被挡住
- 插入后能不能查回那条记录
数据库代码最怕的就是页面看着没事,数据其实悄悄写歪了。这种事,测试比肉眼靠谱。
总结
Dioxus 接数据库,最容易出问题的地方,往往不是 SELECT 或 INSERT 本身,而是边界到底有没有守住。
如果你读到最后只记住三件事,那就是:
sqlx只应该活在服务端- 连接池应该初始化一次,而不是每次请求现连
- 组件消费的是 Server Function 的结果,不是底层 SQL 细节
这三件事如果没写乱,sqlx + SQLite 在 Dioxus 0.7 里已经够你做一个像样的全栈 Demo,继续往小项目推也不会太吃力。