受够了 DBeaver 装驱动,我用 Rust + Tauri 手搓了一个数据库客户端
起因:一个让我抓狂的下午
说实话,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(PostgresDatabase、MysqlDatabase、SqliteDatabase 都没有字段),所有逻辑在模块级的 async 函数里。
上层的服务层完全不知道底下连的是什么数据库,它只跟
SchemaNode、QueryResult这些统一的数据结构打交道。
最初是想用 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 函数,先按关键字判断(select、with、show、describe、explain、values),再检查有没有 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 练练手,希望这篇文章里的设计思路和踩坑经验能给你一些参考。
完整的项目代码暂时不开源,但文章里贴的都是核心逻辑的关键片段,照着这个架构搭一个出来不难。
最爽的不是做出来了,是再也不用等驱动下载了。