返回文章列表

受够了 DBeaver 装驱动,我用 Rust + Tauri 手搓了一个数据库客户端

510·4 分钟阅读
RustTauri数据库客户端NovaDB

起因:一个让我抓狂的下午

说实话,DBeaver 我用了好几年了。功能确实强大,支持的数据库也多。但有一件事一直让我如鲠在喉 ——

每次连一种新的数据库类型,它都要你手动下载驱动

你懂的,国内网络环境嘛。Maven 仓库慢得像蜗牛,有时候干脆连不上。一个下午能被"驱动下载失败"打断三四次,心态直接炸裂。我花钱花时间在这上面?不值。

后来我想明白了:AI 时代了,写个自己的能有多难?需求很简单 —— PostgreSQL、MySQL、SQLite 三种数据库够我日常用了,驱动直接编译进二进制,永远不存在"下载驱动"这回事

技术选型也很直接:Rust 做后端(编译出原生二进制,驱动全静态链接),Tauri 2 做桌面壳(比 Electron 省内存不是一点半点),React 做前端(生态成熟,写 UI 快)。项目叫 NovaDB,下面聊聊踩过的坑和关键设计决策。

第一个决策:怎么统一三种数据库

三种数据库,三种方言,三种系统表。如果每个功能都写三遍 if postgres ... else if mysql ...,代码很快就臭不可闻。

我的做法是定义一个 Database trait,把所有操作抽象成六个方法:

#[async_trait]
pub trait Database {
    async fn load_schema(&self, profile: &ConnectionProfile, password: Option<&str>) -> AppResult<Vec<SchemaNode>>;
    async fn get_table_info(&self, profile: &ConnectionProfile, password: Option<&str>, qualified_name: &str) -> AppResult<TableInfo>;
    async fn get_database_info(&self, profile: &ConnectionProfile, password: Option<&str>, database_name: &str) -> AppResult<DatabaseInfo>;
    async fn run_sql(&self, profile: &ConnectionProfile, password: Option<&str>, sql: &str) -> AppResult<QueryResult>;
    async fn test_connection(&self, profile: &ConnectionProfile, password: Option<&str>) -> AppResult<()>;
    async fn get_server_version(&self, profile: &ConnectionProfile, password: Option<&str>) -> AppResult<String>;
}

然后用一个工厂函数按数据库类型分发:

pub fn connect_database(profile: &ConnectionProfile) -> Box<dyn Database + Send + Sync> {
    match profile.db_type {
        DatabaseType::Sqlite => Box::new(sqlite::SqliteDatabase),
        DatabaseType::Postgres => Box::new(postgres::PostgresDatabase),
        DatabaseType::Mysql => Box::new(mysql::MysqlDatabase),
    }
}

每个 backend 是个零大小的 struct(PostgresDatabaseMysqlDatabaseSqliteDatabase 都没有字段),所有逻辑在模块级的 async 函数里。

上层的服务层完全不知道底下连的是什么数据库,它只跟 SchemaNodeQueryResult 这些统一的数据结构打交道。

最初是想用 enum 大法 match all the things,后来发现三种数据库的 schema 查询逻辑差异太大(PostgreSQL 有 schema 层级,MySQL 有 database 层级,SQLite 只有一个文件),用 trait 做多态反而更干净。

连接管理:一个反直觉的选择

这里有个设计决策可能让有经验的开发者觉得奇怪:

每次操作都新建连接池,操作完立刻关闭

async fn connect(profile: &ConnectionProfile, password: Option<&str>) -> AppResult<PgPool> {
    let url = connection_url(profile, password)?;
    common::connect_pool::<Postgres>(&url).await
}
pub(crate) async fn connect_pool<DB>(url: &str) -> AppResult<Pool<DB>>
where
    DB: Database,
{
    PoolOptions::<DB>::new()
        .max_connections(4)
        .connect(url)
        .await
        .map_err(Into::into)
}

为什么不保持长连接?因为这是个桌面客户端,不是 Web 服务器。用户可能切到别的应用干别的事,半小时后回来看一条 SQL。如果连接一直挂着,数据库那边可能早就超时断开了,用户回来执行 SQL 就会看到莫名其妙的错误。

每次操作新建连接,虽然多了几毫秒的连接开销,但换来的是每次操作都是确定能成功的。对于交互式客户端来说,这个 trade-off 完全值得。

值值解码:一个通用函数搞定三种方言

三种数据库返回的行数据格式不同,但最终都要转成 JSON 给前端。我写了一个通用的 decode_value 函数,按优先级依次尝试解码:

pub(crate) fn decode_value<DB, EV>(
    row: &<DB as sqlx::Database>::Row,
    col: &<DB as sqlx::Database>::Column,
    extra_value: EV,
) -> AppResult<serde_json::Value>
where
    DB: Database,
    EV: Fn(&<DB as sqlx::Database>::Row, usize) -> AppResult<Option<serde_json::Value>>,
{
    // 依次尝试:null → bool → i32 → i64 → extra → f64 → DateTime → ... → String → bytes
}

注意那个 extra_value 闭包参数——这是留给各个 backend 的"扩展点"。比如 MySQL 的 BOOL 实际上是 TINYINT,PostgreSQL 有 Decimal 类型,SQLite 的布尔值存储方式不同,这些特殊情况都通过这个闭包注入,主函数完全不用改

还有一个细节:

整数类型全部序列化为字符串。因为 JavaScript 的 Number 最大安全整数是 2^53,而数据库的 BIGINT 可以到 2^63。

不转字符串的话,大整数到前端就丢精度了。这个坑我是真踩过。

MySQL 的 BLOB 地狱

如果要评选"最让人头疼的数据库适配",MySQL 绝对排第一。它的 information_schema 有时候返回的元数据列不是 VARCHAR,而是 BLOB/BINARY 类型。你查个表名,它给你一个 Vec<u8>

我的解决方案是写了一组 helper 函数,先尝试正常类型,失败了再用 UTF-8 lossy 转换兜底:

fn get_mysql_string(row: &MySqlRow, idx: usize) -> AppResult<String> {
    // 先尝试 String
    // 失败了尝试 Vec<u8>,然后 String::from_utf8_lossy
}

还有 MySQL 的零日期 "0000-00-00 00:00:00"——这是个历史遗留问题,其他数据库都没有这种东西。我的日期解析器要尝试多种格式,遇到这个特殊值还得特殊处理。

MySQL 的兼容性代码比 PostgreSQL 和 SQLite 加起来还多。

如果你也要做多数据库支持,做好心理准备。

前端:让 SQL 编辑器"认识"你的数据库

CodeMirror 6 的 @codemirror/lang-sql 插件本身就支持 SQL 补全,但默认是静态的。我做了一件让它变"聪明"的事:把当前连接的 schema 实时喂给它

function buildSchemaFromTree(nodes: SchemaNode[]): Record<string, string[]> {
    const schema: Record<string, string[]> = {};
    for (const node of nodes) {
        if (node.type === 'relation') {
            // 同时注册 "public.users" 和 "users" 两种形式
            schema[node.qualified_name] = node.columns.map(c => c.name);
            schema[node.name] = node.columns.map(c => c.name);
        }
        if (node.children?.length) {
            Object.assign(schema, buildSchemaFromTree(node.children));
        }
    }
    return schema;
}

这个 schema map 传给 sql({ schema: sqlSchema }),编辑器就知道你有哪些表、哪些列。输入 SELECT * FROM us 它会补全 users,输入 users. 它会列出所有列名。

方言也是动态切换的 —— 连 MySQL 就用 MySQL 方言,连 PostgreSQL 就用 PostgreSQL 方言,标识符的引号方式、关键字都跟着变。

这个体验比 DBeaver 那种"万能但通用"的补全要精准得多。

密码存哪儿:系统钥匙链

密码明文存文件?那是上个时代的事了。NovaDB 用 keyring crate 对接 macOS 的 Keychain(Windows 上是 Credential Manager,Linux 上是 Secret Service):

use keyring::Entry;
 
fn get_entry(key: &str) -> AppResult<Entry> {
    Entry::new_with_target(key, "com.lispking.novadb", key)
        .map_err(|e| AppError::Security(e.to_string()))
}

每个连接用 UUID 作为 key 存密码,use_keychain 字段默认 true。用户也可以关掉钥匙链,密码就存在 JSON 配置文件里——但我会在 UI 上明确提示风险。

编辑连接时,前端会从后端取回密码预填到输入框,但用 type="password" 遮住。用户点眼睛图标切换显示/隐藏。这些细节看起来小,但密码管理的安全感就是靠这些小细节堆出来的

Tab 状态:每个连接有自己的"工作区"

用 DBeaver 的时候,切连接会丢掉当前的 SQL 编辑器状态,这点很烦。NovaDB 的做法是:

每个连接维护自己的一套 tab

切换连接时,当前连接的 tabs(查询 tab、表信息 tab、数据库信息 tab、活跃 tab ID)存到 IndexedDB,目标连接的 tabs 恢复出来。这意味着你可以在连接 A 写了半条 SQL,切到连接 B 看看表结构,再切回来,SQL 还在。

// 切换连接时保存/恢复 tabs
const savedTabs = await loadWorkspaceState(profileId);
if (savedTabs) {
    set({ tabs: savedTabs.tabs, activeTabId: savedTabs.activeTabId });
}

错误处理:thiserror 一把梭

Rust 的错误处理是个永恒的话题。我的选择是 thiserror 定义一个 AppError 枚举,然后 From trait 自动转换:

#[derive(Debug, thiserror::Error)]
pub enum AppError {
    #[error("{0}")]
    Message(String),
    #[error("Storage error: {0}")]
    Storage(String),
    #[error("Database error: {0}")]
    Database(String),
    #[error("Security error: {0}")]
    Security(String),
    #[error("Serialization error: {0}")]
    Serialization(String),
}

Tauri 的 command 函数要求返回 Result<T, String>,所以最后 AppError.to_string() 一下。这不是最优雅的方式,但对桌面客户端来说够用了。

没必要在客户端里搞 anyhow + context 的复杂错误链,用户看到错误消息能定位问题就行。

结果集检测:你怎么知道 SQL 会返回行?

一个容易忽略的细节:SELECT 返回行,INSERT 不返回。但 INSERT ... RETURNING 又返回行了。EXPLAIN 返回行,SHOW 也返回行。

我写了个 is_result_set_query 函数,先按关键字判断(selectwithshowdescribeexplainvalues),再检查有没有 RETURNING 子句:

fn is_result_set_query(sql: &str) -> bool {
    let trimmed = sql.trim_start().to_lowercase();
    // 检查关键字前缀...
    // 如果是 INSERT/UPDATE/DELETE,检查有没有 RETURNING
    if trimmed.starts_with("insert") || trimmed.starts_with("update") || trimmed.starts_with("delete") {
        let upper = sql.to_uppercase();
        return upper.contains("RETURNING");
    }
    false
}

根据返回值决定用 fetch_all 还是 execute。这个函数虽然简单,但写错一个分支用户就会看到"Query executed successfully, 0 rows affected"而看不到数据,体验直接崩掉。

右键菜单:小功能,大体验

Sidebar 的表名右键能"预览数据"(自动生成 SELECT * FROM table LIMIT 100),能"查看表信息"。Tab 右键能重命名、关闭、关闭其他。

这些功能代码量不大,但用起来就是比没有强太多。DBeaver 做这些是因为它迭代了几年,NovaDB 第一版就做是因为这些都是高频操作,不做的话用户每天都要手打 SELECT * FROM xxx LIMIT 100

结论

NovaDB 目前还是 0.1.0,功能跟 DBeaver 比差得远。但它解决了我最痛的问题:不用装驱动了sqlx 的 PostgreSQL、MySQL、SQLite 驱动在编译时就静态链接进二进制,分发出去就是一个文件,双击就能用。

技术栈总结一下:

选型 理由
桌面壳 Tauri 2 比 Electron 省内存,Rust 原生集成
后端 Rust + sqlx 三种驱动全静态链接,告别手动装驱动
前端 React 19 + Zustand 生态成熟,写 UI 快
SQL 编辑器 CodeMirror 6 轻量,schema 感知补全
密码存储 keyring 系统原生钥匙链,安全
状态持久化 IndexedDB 每连接独立工作区

如果你也被 DBeaver 的驱动问题折磨过,或者就是想用 Rust 练练手,希望这篇文章里的设计思路和踩坑经验能给你一些参考。

完整的项目代码暂时不开源,但文章里贴的都是核心逻辑的关键片段,照着这个架构搭一个出来不难。

最爽的不是做出来了,是再也不用等驱动下载了。