DBView 开发日志 ④ — 数据库驱动架构设计与实现
日期: 2026-06-11
项目: DBView — Database Visual Explorer(数据库可视化工具)
状态: v1.0 功能完善与修复阶段
一、本期概要
本期深入讲解 DBView 的数据库驱动层(Driver Layer),这是整个项目最核心的底层基础设施:
- DatabaseDriver 接口设计 — 接口的考量、演化与折衷
- 四种数据库驱动实现对比 — MySQL / PostgreSQL / SQLite / Oracle 的差异与共性
- 查询取消机制 — AbortSignal 模式在异步/同步驱动中的跨实现
- 驱动测试策略 — 当前测试覆盖情况与改进方向
所有代码分析均基于实际源码(src/main/db/ 目录下的 7 个文件),共约 1,200 行 TypeScript。
二、DatabaseDriver 接口设计
2.1 接口定义
DBView 中,所有数据库操作统一通过 DatabaseDriver 接口暴露:
1 | // src/main/db/db-driver.ts |
共 14 个方法,分为三大类:
| 类别 | 方法数 | 职责 |
|---|---|---|
| 连接生命周期 | 3 | createPool / closePool / isConnected |
| 元数据查询 | 10 | 数据库、表、视图、列、索引、主键、DDL、存储过程/函数、用户 |
| 数据操作 | 2 | executeQuery(原生 SQL)、queryPage(分页查询) |
2.2 接口设计的考量
设计原则一:最少惊讶(Least Surprise)
每个驱动对外表现一致的行为。用户(调用方)不需要知道底层是 MySQL 还是 Oracle——同样的调用链,同样的返回值类型。
为什么不用更通用的 API(如 pool.query)?
- 不同数据库的元数据查询方式差异巨大(MySQL 的
SHOW DATABASESvs PostgreSQL 的pg_databasevs Oracle 的all_users) - 接口封装了这些差异,让上层 IPC handler 和 UI 组件完全不受影响
设计原则二:Schema 参数可选
绝大多数元数据方法都接受可选的 schema 参数:
1 | getTables(schema?: string): Promise<TableInfo[]> |
这反映了不同数据库的层级模型差异:
- MySQL:DATABASE → TABLE(两层,
schema= database) - PostgreSQL:DATABASE → SCHEMA → TABLE(三层,
schema= schema,如public) - SQLite:单文件无层级(忽略
schema参数) - Oracle:SCHEMA(= USER) → TABLE(两层,
schema= user/owner)
演化过程: 最初版本只有 getTables() 无参数,后来发现 PostgreSQL 必须支持 schema 隔离,才加了 schema? 参数。这个变更影响了所有驱动,但好在接口声明在前、实现在后,改动成本可控。
设计原则三:接口风格统一
接口中 createPool / closePool 返回 Promise<void>,而 isConnected() 返回 boolean。这是有意为之——isConnected() 只是读取内部状态标志,不需要异步等待:
1 | isConnected(): boolean { |
不过这个不一致也确实带来了一点调用方的麻烦:调用者需要记住 isConnected() 不需要 await。如果未来统一为 Promise<boolean>,可以平滑升级支持真实心跳检测。
缺少什么?
回头看,这个接口遗漏了两个重要的方法:
testConnection()— 当前验证连接用createPool后立即getConnection()再release(),应该拆分为独立方法getSchemaNames()— PostgreSQL 需要额外查询pg_namespace,目前放在pg-driver.ts中作为私有方法getSchemas(),接口没有暴露它
2.3 DriverFactory 工厂模式
1 | // src/main/db/driver-factory.ts |
简单但有效。每次创建驱动都是全新的实例,没有复用或缓存——因为 Electron 应用中通常只有少量并发连接(几个到十几个),每次连接独立实例更安全。
如果未来需要连接池复用,可以在 ConnectionManager 层做缓存,而不是在工厂层。
三、四种数据库驱动实现对比
3.1 总体对比
| 维度 | MySQL | PostgreSQL | SQLite | Oracle |
|---|---|---|---|---|
| Node.js 库 | mysql2/promise |
pg |
better-sqlite3 |
oracledb |
| 连接模型 | 连接池 (Pool) | 连接池 (Pool) | 单连接 (Database) | 连接池 (Pool) |
| 异步/同步 | 异步 (Promise) | 异步 (Promise) | 同步 | 异步 (Promise) |
| 参数占位符 | ? |
$1, $2, ... |
? |
:1, :2, ... |
| SSL 支持 | 原生 | 原生 | N/A | 原生 |
| 行内编辑 | SHOW INDEX 解析 |
pg_indexes 视图 |
PRAGMA index_info() |
all_indexes 表 |
| DDL 获取 | SHOW CREATE TABLE |
From metadata | sqlite_master |
From metadata |
| 存储过程 | INFORMATION_SCHEMA.ROUTINES |
pg_proc |
❌ 不支持 | user_source |
| 用户查询 | mysql.user |
pg_user |
❌ 不支持 | all_users |
| 分页语法 | LIMIT ? OFFSET ? |
LIMIT $n OFFSET $n |
LIMIT ? OFFSET ? |
OFFSET ? ROWS FETCH NEXT ? ROWS ONLY |
3.2 MySQL 驱动 (mysql-driver.ts)
特点:最全面的实现,元数据查询最丰富。
使用 mysql2/promise 库的 Pool API。MySQL 的 INFORMATION_SCHEMA 提供了最统一、最完整的元数据查询方式:
1 | // 获取表信息 — 一次性拿到注释、引擎、行数、创建时间 |
关键设计:系统库排序
getDatabases() 将系统库排在末尾,而非过滤:
1 | const SYSTEM_DBS = ['information_schema', 'performance_schema', 'sys', 'mysql'] |
这是开发日志 03 中修复的内容——之前直接过滤系统库,导致用户无法浏览 information_schema。
查询取消: 通过 AbortSignal 监听取消事件,在取消时 conn.destroy() 强制中断。由于获取了独立连接执行查询,destroy() 不会影响连接池中的其他连接。
3.3 PostgreSQL 驱动 (pg-driver.ts)
特点:类型解析最精细,DDL 重建最复杂。
1 | // 类型解析器注册 — 将数字类型从字符串自动转为 number |
pg 库默认将所有类型返回为字符串,这导致数字列在前端显示为字符串。注册类型解析器后,前端不需要额外转换。
DDL 生成 vs MySQL 的 SHOW CREATE TABLE:
PostgreSQL 没有 SHOW CREATE TABLE 命令。PG 驱动实现了 DDL 重建:先查询 information_schema.columns 获取列信息,再查询 pg_indexes 获取索引,然后拼接为 DDL 字符串:
1 | let ddl = `CREATE TABLE "${schemaName}"."${table}" (\n` |
这条路上踩过的坑:
- 列的顺序必须与
ordinal_position一致 - 索引列名需要从
indexdef中用正则\(([^)]+)\)解析 - 注释需要通过
pg_description表查询
查询取消: pg 库原生支持 Query.abort() 方法,是最优雅的实现。
参数占位符: $1, $2, ... — 比 MySQL 的 ? 更麻烦,分页查询中需要动态计算参数索引:
1 | let paramIndex = 1 |
3.4 SQLite 驱动 (sqlite-driver.ts)
特点:同步执行,也是最大的挑战。
使用 better-sqlite3——这是唯一一个同步 API 的驱动。这带来了一系列设计挑战:
同步 vs 异步的张力:
DatabaseDriver 接口所有方法都是 async,但 SQLite 的底层操作是同步的。解决方法很简单——使用 async function 包装同步调用:
1 | async getTables(_schema?: string): Promise<TableInfo[]> { |
查询取消的难题:
SQLite 的同步 API 意味着无法中断一个正在执行的查询。当 AbortSignal 触发时,同步执行的 db.prepare(sql).all() 不会被中断。
采用的折衷方案:监听到 abort 信号后,关闭数据库连接(db.close()),后续操作会因”数据库已关闭”而失败:
1 | let interrupted = false |
这个方案的代价:取消查询后,用户需要重新连接才能再次使用。这是同步驱动不得已的折衷。
WAL 模式:
1 | this.db.pragma('journal_mode = WAL') |
SQLite 连接时自动启用 WAL(Write-Ahead Logging)模式,提升并发读取性能,同时开启外键约束。
3.5 Oracle 驱动 (oracle-driver.ts)
特点:连接管理最复杂,元数据查询模式最不同。
Oracle 的 oracledb 库与其他驱动有显著差异:
连接模型:
每次查询获取独立连接,用完立即释放:
1 | async getTables(schema?: string): Promise<TableInfo[]> { |
与其他三个驱动不同,Oracle 的 getTables、getColumns 等每个元数据方法都 getConnection() → execute() → close()。这比 MySQL/PG 的共享连接池模式开销大,但更安全——Oracle 连接可能因为查询超时等原因被服务端断开。
连接字符串构造:
1 | const connectString = config.oracleServiceName |
Oracle 的连接字符串有两种格式:SID 格式(host:port/dbname)和服务名格式(host:port/servicename)。兼容两种是早期用户反馈的结果。
元数据:
Oracle 的元数据分散在多张系统视图(all_tables、all_tab_columns、all_indexes、all_constraints、user_source 等),不像 MySQL 那样集中在 INFORMATION_SCHEMA。
分页语法:
Oracle 12c+ 使用 OFFSET FETCH 语法,与其他三个驱动的 LIMIT/OFFSET 不同:
1 | `SELECT * FROM ... OFFSET :1 ROWS FETCH NEXT :2 ROWS ONLY` |
查询取消: 通过 conn.break() 实现——Oracle 原生支持中断正在执行的 SQL 语句。
3.6 queryPage 分页查询的跨驱动统一
queryPage 是所有驱动中最复杂的方法之一,需要统一处理:
- 动态过滤条件 — 支持
=/!=/>/</>=/<=/LIKE/IS NULL/IS NOT NULL - 排序 — 自定义排序列和方向
- 分页 — 页号 + 每页条数
- 总行数 — 额外 COUNT 查询
所有驱动实现了完全相同的逻辑,但参数占位符语法不同:
| 驱动 | 占位符 | 示例 |
|---|---|---|
| MySQL | ? |
WHERE \col` = ?` |
| PostgreSQL | $n |
WHERE "col" = $1 |
| SQLite | ? |
WHERE "col" = ? |
| Oracle | :n |
WHERE "col" = :1 |
这一差异导致每种驱动的 queryPage 实现几乎是复制粘贴然后修改占位符——这是接口设计的一个痛点。如果未来重构,可以用抽象基类统一分页逻辑,只让子类提供占位符格式。
四、查询取消机制
4.1 架构:AbortSignal 模式
所有驱动通过 AbortSignal(标准 Web API)实现查询取消:
1 | executeQuery(sql: string, params?: unknown[], signal?: AbortSignal): Promise<SQLResult> |
前端 SQL 编辑器中,点击”执行”按钮时创建一个 AbortController,点击”取消”时调用 controller.abort()。信号通过 IPC 传递到主进程的驱动层。
4.2 四种实现的对比
| 驱动 | 取消方式 | 实现 | 优点 | 缺点 |
|---|---|---|---|---|
| MySQL | conn.destroy() |
独立连接上监听 abort → destroy | 立即中断 | 丢失该连接 |
| PostgreSQL | query.abort() |
pg 原生 Query 的 .abort() 方法 |
最优雅,不丢失连接 | 依赖库支持 |
| SQLite | db.close() |
同步模式下无法中断,关闭 DB | 唯一可行方案 | 需重新连接 |
| Oracle | conn.break() |
Oracle 原生连接中断 | 原生支持,可靠 | 需独立连接 |
4.3 核心代码对比
MySQL — destroy 连接:
1 | const conn = await pool.getConnection() |
PostgreSQL — abort query:
1 | const client = await pool.connect() |
Oracle — break 连接:
1 | const conn = await this.pool.getConnection() |
四种实现中,PostgreSQL 的 query.abort() 最理想——它只取消当前查询而不影响连接本身,后续查询可以正常执行。
4.4 前端联动
在前端 SQL 编辑器中,取消按钮的状态与查询执行状态同步:
1 | // 伪代码 — 渲染进程 |
五、项目源文件统计
截至本期,项目驱动层的完整文件清单:
1 | src/main/db/ |
| 文件 | 行数 | 核心复杂度 |
|---|---|---|
mysql-driver.ts |
~270 | 中等:INFORMATION_SCHEMA 丰富但简单 |
pg-driver.ts |
~310 | 高:DDL 重建、类型解析、$n 参数 |
sqlite-driver.ts |
~240 | 中高:同步 API 适配、取消折衷 |
oracle-driver.ts |
~350 | 高:连接管理、元数据分散、OFFSET FETCH |
总驱动层代码量:约 1,200 行(不含 driver-logger.ts)。
六、驱动测试策略
6.1 当前测试覆盖情况
现状:项目目前没有专用的驱动测试文件。
测试缺失的原因:
- 项目开发节奏 — v1.0 以功能交付为主,测试被推迟到后续迭代
- 数据库依赖的测试难点 — 四种驱动都需要真实数据库才能跑完整测试,Mock 成本高
- Electron 环境 — 驱动运行在 Electron 主进程中,常规 Node.js 测试框架需要额外配置
6.2 推荐的测试策略
如果现在补充测试,建议采用三层测试金字塔:
第一层:单元测试(最快,占 70%)
测试 DriverFactory 和接口契约:
1 | // driver-factory.test.ts |
所有驱动共享的接口契约可以用一个测试矩阵覆盖:
1 | const DRIVERS = ['mysql', 'postgresql', 'sqlite', 'oracle'] |
第二层:集成测试(需数据库,占 20%)
针对每种数据库启动真实实例(或用 Docker 容器),测试:
- 连接与断开(
createPool/closePool/isConnected) - 元数据查询返回正确的结构(列名、类型匹配)
- 基本 CRUD(执行 SQL → 验证结果)
- 分页查询(
queryPage的正确性) - 查询取消(发出取消请求 → 验证查询中断)
1 | // PostgreSQL 集成测试示例 |
第三层:端到端测试(占 10%)
通过 Electron 的 IPC 通道完整测试:渲染进程 → preload → main → driver → database → 返回数据。
6.3 通用测试基础设施
三种数据库(MySQL、PostgreSQL、Oracle)启动成本高,推荐用 Docker Compose 管理测试环境:
1 | # docker-compose.test.yml |
SQLite 不需要 Docker,直接用一个临时文件即可:
1 | import { tmpdir } from 'os' |
6.4 测试优先级
建议按以下优先级补充测试:
- DriverFactory — 最简单,覆盖率提升立竿见影
- SQLite 驱动 — 不需要外部数据库,测试最容易写
- MySQL 驱动 — 最常用,最需要保障
- PostgreSQL 驱动 — DDL 重建逻辑复杂,需要重点测试
- Oracle 驱动 — 使用场景少,但连接管理逻辑复杂
七、经验与反思
做得好的
- 接口先行 — 先定义
DatabaseDriver接口,再逐个实现驱动,确保了 API 一致性和新增驱动的可预测性 - 工厂模式简单可靠 —
DriverFactory虽然朴素,但零 bug,新增类型只需加一个case - Decorator 扩展性 —
DriverLogger装饰器证明接口可以零侵入地扩展功能(日志、未来可加缓存、监控等) - 独立连接的取消模式 — MySQL 和 Oracle 使用独立连接执行查询 +
destroy/break取消,避免了连接池被污染
可以改进的
- 抽象基类缺失 —
queryPage在四个驱动中有大量重复逻辑(过滤条件构建、参数映射),可以提取一个BaseDriver抽象类 - 缺少
testConnection()方法 — 当前用createPool+ 立即连接来验证,语义不清晰 - PostgreSQL Schema 扩展 —
getSchemas()是私有方法,接口未暴露,限制了 PG 用户切换 schema 的能力 - Oracle 连接开销 — 每次元数据操作都
getConnection/close,高频使用场景下性能不佳 - 错误处理粒度不够 — 有的驱动用
try/catch优雅降级(如 MySQL 的getIndexes对系统视图返回空数组),有的直接抛出,行为不一致
未来展望
如果项目继续演进,驱动层可以考虑:
- 连接池缓存 — 复用空闲连接,减少
createPool开销 - 事件驱动日志 — 通过事件发射器替代当前的日志 Service 引用,解耦更彻底
- 查询超时 — 在
AbortSignal基础上,支持timeout参数自动取消长时间查询 - 批量操作 — 为大量 INSERT/UPDATE 场景提供批量执行接口
- TTL 连接验证 — 定期心跳检测,避免用户操作时才发现连接已断开
DBView 开发日志系列 — 记录一个数据库可视化工具从 0 到 1 的完整历程