Dioxus 接数据库最容易写歪的 3 个地方:sqlx + SQLite 怎么接才顺

前言

前面几篇更多还是在写页面。到了数据库这一篇,事情就开始变味了。

因为从这里往后,你很快会发现,判断一个 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 怎么更新

至于 INSERTSELECT、分页、排序、约束,这些该留在服务端。

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

这段配置看着普通,但有两个地方别放过去。

第一,tokiosqlx 都是 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 runmigrate!()、离线校验、查询宏全配上?

我的看法是,项目里当然要做,但这篇先别把重点带偏。

这篇重点是先把 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 本身,而是组织方式:

  1. 先拿共享连接池
  2. 再执行查询
  3. 最后只把业务结果返回给组件

组件没必要知道表名是什么,也没必要知道 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 接数据库,最容易出问题的地方,往往不是 SELECTINSERT 本身,而是边界到底有没有守住。

如果你读到最后只记住三件事,那就是:

  • sqlx 只应该活在服务端
  • 连接池应该初始化一次,而不是每次请求现连
  • 组件消费的是 Server Function 的结果,不是底层 SQL 细节

这三件事如果没写乱,sqlx + SQLite 在 Dioxus 0.7 里已经够你做一个像样的全栈 Demo,继续往小项目推也不会太吃力。

相关推荐
晴虹1 小时前
vue3-scroll-more:横向滚动条-元素或页签过多滚动显示处理的组件
前端·vue.js
独孤留白1 小时前
从C到Rust:移动语义、引用传递与生命周期——一次讲清楚
rust
代码搬运媛1 小时前
Claude 全栈开发专用 Rules 配置
前端
PedroQue991 小时前
uni-router v1.7.0重磅更新:守卫重定向自由掌控
前端·uni-app
逸铭1 小时前
Day 4:登录与 Token——桌面端怎么存密钥
前端·客户端
溯朢1 小时前
TokUI 流式渲染的 SSE 全链路拆解
前端
京东云开发者1 小时前
京东 Oxygen xLLM 大模型推理引擎正式捐赠开放原子开源基金会,共建国产 AI Infra 生态
前端
Csvn1 小时前
LLM 一把梭:从 Swagger 文档到类型安全 API 请求,再也不手写接口
前端