DBView 开发日志 ④ — 数据库驱动架构设计与实现

DBView 开发日志 ④ — 数据库驱动架构设计与实现

日期: 2026-06-11
项目: DBView — Database Visual Explorer(数据库可视化工具)
状态: v1.0 功能完善与修复阶段


一、本期概要

本期深入讲解 DBView 的数据库驱动层(Driver Layer),这是整个项目最核心的底层基础设施:

  1. DatabaseDriver 接口设计 — 接口的考量、演化与折衷
  2. 四种数据库驱动实现对比 — MySQL / PostgreSQL / SQLite / Oracle 的差异与共性
  3. 查询取消机制 — AbortSignal 模式在异步/同步驱动中的跨实现
  4. 驱动测试策略 — 当前测试覆盖情况与改进方向

所有代码分析均基于实际源码(src/main/db/ 目录下的 7 个文件),共约 1,200 行 TypeScript。


二、DatabaseDriver 接口设计

2.1 接口定义

DBView 中,所有数据库操作统一通过 DatabaseDriver 接口暴露:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
// src/main/db/db-driver.ts
export interface DatabaseDriver {
// 连接生命周期
createPool(config: ConnectionConfig): Promise<void>
closePool(): Promise<void>
isConnected(): boolean

// 元数据查询(11 个方法)
getDatabases(): Promise<string[]>
getTables(schema?: string): Promise<TableInfo[]>
getViews(schema?: string): Promise<ViewInfo[]>
getColumns(table: string, schema?: string): Promise<ColumnInfo[]>
getIndexes(table: string, schema?: string): Promise<IndexInfo[]>
getPrimaryKey(table: string, schema?: string): Promise<string[]>
getDDL(table: string, schema?: string): Promise<string>
getRoutines(schema?: string): Promise<RoutineInfo[]>
getRoutineDefinition(name, type, schema?): Promise<string>
getUsers(schema?: string): Promise<UserInfo[]>

// 数据操作
executeQuery(sql: string, params?, signal?): Promise<SQLResult>
queryPage(table: string, options): Promise<PaginationResult>
}

共 14 个方法,分为三大类:

类别 方法数 职责
连接生命周期 3 createPool / closePool / isConnected
元数据查询 10 数据库、表、视图、列、索引、主键、DDL、存储过程/函数、用户
数据操作 2 executeQuery(原生 SQL)、queryPage(分页查询)

2.2 接口设计的考量

设计原则一:最少惊讶(Least Surprise)

每个驱动对外表现一致的行为。用户(调用方)不需要知道底层是 MySQL 还是 Oracle——同样的调用链,同样的返回值类型。

为什么不用更通用的 API(如 pool.query)?

  • 不同数据库的元数据查询方式差异巨大(MySQL 的 SHOW DATABASES vs PostgreSQL 的 pg_database vs Oracle 的 all_users
  • 接口封装了这些差异,让上层 IPC handler 和 UI 组件完全不受影响

设计原则二:Schema 参数可选

绝大多数元数据方法都接受可选的 schema 参数:

1
2
getTables(schema?: string): Promise<TableInfo[]>
getColumns(table: string, schema?: string): Promise<ColumnInfo[]>

这反映了不同数据库的层级模型差异:

  • 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
2
3
isConnected(): boolean {
return this.pool !== null
}

不过这个不一致也确实带来了一点调用方的麻烦:调用者需要记住 isConnected() 不需要 await。如果未来统一为 Promise<boolean>,可以平滑升级支持真实心跳检测。

缺少什么?

回头看,这个接口遗漏了两个重要的方法:

  1. testConnection() — 当前验证连接用 createPool 后立即 getConnection()release(),应该拆分为独立方法
  2. getSchemaNames() — PostgreSQL 需要额外查询 pg_namespace,目前放在 pg-driver.ts 中作为私有方法 getSchemas(),接口没有暴露它

2.3 DriverFactory 工厂模式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// src/main/db/driver-factory.ts
export class DriverFactory {
static createDriver(type: ConnectionConfig['type']): DatabaseDriver {
switch (type) {
case 'mysql':
return new MySQLDriver()
case 'postgresql':
return new PostgreSQLDriver()
case 'sqlite':
return new SQLiteDriver()
case 'oracle':
return new OracleDriver()
default:
throw new Error(`Unsupported database type: ${type}`)
}
}
}

简单但有效。每次创建驱动都是全新的实例,没有复用或缓存——因为 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
2
3
4
5
6
7
// 获取表信息 — 一次性拿到注释、引擎、行数、创建时间
const [rows] = await pool.query(
`SELECT TABLE_NAME, TABLE_COMMENT, ENGINE, TABLE_ROWS, CREATE_TIME, UPDATE_TIME
FROM INFORMATION_SCHEMA.TABLES
WHERE TABLE_SCHEMA = ? AND TABLE_TYPE IN ('BASE TABLE', 'SYSTEM VIEW')`,
[db]
)

关键设计:系统库排序

getDatabases() 将系统库排在末尾,而非过滤:

1
2
3
const SYSTEM_DBS = ['information_schema', 'performance_schema', 'sys', 'mysql']
// 排序:用户库在前,系统库在后
const aIsSys = SYSTEM_DBS.includes(a) ? 1 : 0

这是开发日志 03 中修复的内容——之前直接过滤系统库,导致用户无法浏览 information_schema

查询取消: 通过 AbortSignal 监听取消事件,在取消时 conn.destroy() 强制中断。由于获取了独立连接执行查询,destroy() 不会影响连接池中的其他连接。

3.3 PostgreSQL 驱动 (pg-driver.ts)

特点:类型解析最精细,DDL 重建最复杂。

1
2
3
4
// 类型解析器注册 — 将数字类型从字符串自动转为 number
pgTypes.setTypeParser(pgTypes.builtins.NUMERIC, (val: string) => parseFloat(val))
pgTypes.setTypeParser(pgTypes.builtins.INT8, (val: string) => Number(val))
pgTypes.setTypeParser(pgTypes.builtins.FLOAT8, (val: string) => parseFloat(val))

pg 库默认将所有类型返回为字符串,这导致数字列在前端显示为字符串。注册类型解析器后,前端不需要额外转换。

DDL 生成 vs MySQL 的 SHOW CREATE TABLE

PostgreSQL 没有 SHOW CREATE TABLE 命令。PG 驱动实现了 DDL 重建:先查询 information_schema.columns 获取列信息,再查询 pg_indexes 获取索引,然后拼接为 DDL 字符串:

1
2
3
4
5
6
7
8
9
10
11
12
let ddl = `CREATE TABLE "${schemaName}"."${table}" (\n`
ddl += columns.map(col => {
let line = ` "${col.name}" ${col.type}`
if (col.maxLength) line += `(${col.maxLength})`
if (!col.nullable) line += ' NOT NULL'
if (col.defaultValue !== null) line += ` DEFAULT ${col.defaultValue}`
return line
}).join(',\n')
if (pkCols.length > 0) {
ddl += `,\n PRIMARY KEY (${pkCols.map(c => `"${c}"`).join(', ')})`
}
ddl += '\n);\n'

这条路上踩过的坑:

  • 列的顺序必须与 ordinal_position 一致
  • 索引列名需要从 indexdef 中用正则 \(([^)]+)\) 解析
  • 注释需要通过 pg_description 表查询

查询取消: pg 库原生支持 Query.abort() 方法,是最优雅的实现。

参数占位符: $1, $2, ... — 比 MySQL 的 ? 更麻烦,分页查询中需要动态计算参数索引:

1
2
3
4
5
let paramIndex = 1
// ... filters push params with $${paramIndex++}
params.push(options.pageSize)
params.push(offset)
// LIMIT $${paramIndex++} OFFSET $${paramIndex++}

3.4 SQLite 驱动 (sqlite-driver.ts)

特点:同步执行,也是最大的挑战。

使用 better-sqlite3——这是唯一一个同步 API 的驱动。这带来了一系列设计挑战:

同步 vs 异步的张力:

DatabaseDriver 接口所有方法都是 async,但 SQLite 的底层操作是同步的。解决方法很简单——使用 async function 包装同步调用:

1
2
3
4
5
6
7
async getTables(_schema?: string): Promise<TableInfo[]> {
const db = this.getDb() // 同步
const rows = db.prepare('SELECT ...').all() // 同步
// 同步执行 COUNT(*)
const countResult = db.prepare('SELECT COUNT(*) as count FROM ...').get()
return tables // 返回 Promise -> 自动包装
}

查询取消的难题:

SQLite 的同步 API 意味着无法中断一个正在执行的查询。当 AbortSignal 触发时,同步执行的 db.prepare(sql).all() 不会被中断。

采用的折衷方案:监听到 abort 信号后,关闭数据库连接(db.close()),后续操作会因”数据库已关闭”而失败:

1
2
3
4
5
6
7
8
9
10
11
let interrupted = false
const onAbort = () => { interrupted = true; db.close() }
signal?.addEventListener('abort', onAbort, { once: true })

try {
const rows = stmt.all()
if (interrupted) throw new Error('查询已取消')
// ...
} finally {
signal?.removeEventListener('abort', onAbort)
}

这个方案的代价:取消查询后,用户需要重新连接才能再次使用。这是同步驱动不得已的折衷。

WAL 模式:

1
2
this.db.pragma('journal_mode = WAL')
this.db.pragma('foreign_keys = ON')

SQLite 连接时自动启用 WAL(Write-Ahead Logging)模式,提升并发读取性能,同时开启外键约束。

3.5 Oracle 驱动 (oracle-driver.ts)

特点:连接管理最复杂,元数据查询模式最不同。

Oracle 的 oracledb 库与其他驱动有显著差异:

连接模型:

每次查询获取独立连接,用完立即释放:

1
2
3
4
5
6
7
8
9
async getTables(schema?: string): Promise<TableInfo[]> {
const conn = await this.getConnection()
try {
const result = await conn.execute(...)
return result.rows ? result.rows.map(...) : []
} finally {
await conn.close() // 每次操作都要手动关闭连接
}
}

与其他三个驱动不同,Oracle 的 getTablesgetColumns 等每个元数据方法都 getConnection()execute()close()。这比 MySQL/PG 的共享连接池模式开销大,但更安全——Oracle 连接可能因为查询超时等原因被服务端断开。

连接字符串构造:

1
2
3
const connectString = config.oracleServiceName
? `${config.host}:${config.port}/${config.oracleServiceName}`
: `${config.host}:${config.port}/${config.database}`

Oracle 的连接字符串有两种格式:SID 格式(host:port/dbname)和服务名格式(host:port/servicename)。兼容两种是早期用户反馈的结果。

元数据:

Oracle 的元数据分散在多张系统视图(all_tablesall_tab_columnsall_indexesall_constraintsuser_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 是所有驱动中最复杂的方法之一,需要统一处理:

  1. 动态过滤条件 — 支持 = / != / > / < / >= / <= / LIKE / IS NULL / IS NOT NULL
  2. 排序 — 自定义排序列和方向
  3. 分页 — 页号 + 每页条数
  4. 总行数 — 额外 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
2
3
4
5
6
const conn = await pool.getConnection()
signal.addEventListener('abort', () => {
conn.destroy().catch(() => {}) // 强制销毁连接
}, { once: true })
;[result] = await conn.query(sql)
conn.release() // 如果没被 destroy

PostgreSQL — abort query:

1
2
3
4
5
6
7
const client = await pool.connect()
const query = client.query(sql)
signal.addEventListener('abort', () => {
query.abort().catch(() => {}) // 优雅取消查询
}, { once: true })
result = await query
client.release()

Oracle — break 连接:

1
2
3
4
5
const conn = await this.pool.getConnection()
const onAbort = () => { conn.break().catch(() => {}) }
signal?.addEventListener('abort', onAbort, { once: true })
const result = await conn.execute(sql)
conn.close()

四种实现中,PostgreSQL 的 query.abort() 最理想——它只取消当前查询而不影响连接本身,后续查询可以正常执行。

4.4 前端联动

在前端 SQL 编辑器中,取消按钮的状态与查询执行状态同步:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 伪代码 — 渲染进程
const [abortController, setAbortController] = useState<AbortController | null>(null)

const executeQuery = async (sql: string) => {
const controller = new AbortController()
setAbortController(controller) // 显示取消按钮

try {
const result = await window.api.executeQuery(connId, sql, controller.signal)
// 显示结果
} catch (err) {
if ((err as Error).message === '查询已取消') {
// 显示"查询已取消"通知
}
} finally {
setAbortController(null) // 隐藏取消按钮
}
}

const cancelQuery = () => {
abortController?.abort()
}

五、项目源文件统计

截至本期,项目驱动层的完整文件清单:

1
2
3
4
5
6
7
8
src/main/db/
├── db-driver.ts # 接口定义 (RoutineInfo + DatabaseDriver)
├── driver-factory.ts # 工厂类 (4 种驱动注册)
├── driver-logger.ts # 装饰器 (日志拦截层,详见日志 03)
├── mysql-driver.ts # MySQL 实现 (~270 行)
├── pg-driver.ts # PostgreSQL 实现 (~310 行)
├── sqlite-driver.ts # SQLite 实现 (~240 行)
└── oracle-driver.ts # Oracle 实现 (~350 行)
文件 行数 核心复杂度
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 当前测试覆盖情况

现状:项目目前没有专用的驱动测试文件。

测试缺失的原因:

  1. 项目开发节奏 — v1.0 以功能交付为主,测试被推迟到后续迭代
  2. 数据库依赖的测试难点 — 四种驱动都需要真实数据库才能跑完整测试,Mock 成本高
  3. Electron 环境 — 驱动运行在 Electron 主进程中,常规 Node.js 测试框架需要额外配置

6.2 推荐的测试策略

如果现在补充测试,建议采用三层测试金字塔

第一层:单元测试(最快,占 70%)

测试 DriverFactory 和接口契约:

1
2
3
4
5
6
7
8
9
10
11
12
13
// driver-factory.test.ts
describe('DriverFactory', () => {
it('should create MySQL driver', () => {
const driver = DriverFactory.createDriver('mysql')
expect(driver).toBeInstanceOf(MySQLDriver)
expect(driver).toImplement(DatabaseDriver)
})

it('should throw for unknown type', () => {
expect(() => DriverFactory.createDriver('mssql' as any))
.toThrow('Unsupported database type')
})
})

所有驱动共享的接口契约可以用一个测试矩阵覆盖:

1
2
3
4
5
6
7
8
9
10
11
12
13
const DRIVERS = ['mysql', 'postgresql', 'sqlite', 'oracle']
const METHODS = Object.getOwnPropertyNames(DatabaseDriver.prototype)

DRIVERS.forEach(type => {
describe(`${type} driver`, () => {
METHODS.forEach(method => {
it(`should implement ${method}()`, () => {
const driver = DriverFactory.createDriver(type as any)
expect(typeof driver[method]).toBe('function')
})
})
})
})

第二层:集成测试(需数据库,占 20%)

针对每种数据库启动真实实例(或用 Docker 容器),测试:

  • 连接与断开(createPool / closePool / isConnected
  • 元数据查询返回正确的结构(列名、类型匹配)
  • 基本 CRUD(执行 SQL → 验证结果)
  • 分页查询(queryPage 的正确性)
  • 查询取消(发出取消请求 → 验证查询中断)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// PostgreSQL 集成测试示例
describe('PostgreSQLDriver integration', () => {
let driver: PostgreSQLDriver

beforeAll(async () => {
driver = new PostgreSQLDriver()
await driver.createPool({
type: 'postgresql', host: 'localhost', port: 5432,
username: 'test', password: 'test', database: 'testdb'
})
})

afterAll(async () => {
await driver.closePool()
})

it('should list databases', async () => {
const dbs = await driver.getDatabases()
expect(dbs).toContain('testdb')
})

it('should cancel a long running query', async () => {
const controller = new AbortController()
setTimeout(() => controller.abort(), 50)

await expect(
driver.executeQuery('SELECT pg_sleep(10)', [], controller.signal)
).rejects.toThrow('canceling statement due to user request')
})
})

第三层:端到端测试(占 10%)

通过 Electron 的 IPC 通道完整测试:渲染进程 → preload → main → driver → database → 返回数据。

6.3 通用测试基础设施

三种数据库(MySQL、PostgreSQL、Oracle)启动成本高,推荐用 Docker Compose 管理测试环境:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# docker-compose.test.yml
services:
mysql-test:
image: mysql:8
environment:
MYSQL_ROOT_PASSWORD: test
MYSQL_DATABASE: testdb
pg-test:
image: postgres:16
environment:
POSTGRES_USER: test
POSTGRES_PASSWORD: test
POSTGRES_DB: testdb
oracle-test:
image: gvenzl/oracle-free:23-slim
environment:
ORACLE_PASSWORD: test

SQLite 不需要 Docker,直接用一个临时文件即可:

1
2
3
4
5
6
7
8
import { tmpdir } from 'os'
import { join } from 'path'

const testDbPath = join(tmpdir(), `test-${Date.now()}.db`)
const driver = new SQLiteDriver()
await driver.createPool({
type: 'sqlite', host: testDbPath
} as any)

6.4 测试优先级

建议按以下优先级补充测试:

  1. DriverFactory — 最简单,覆盖率提升立竿见影
  2. SQLite 驱动 — 不需要外部数据库,测试最容易写
  3. MySQL 驱动 — 最常用,最需要保障
  4. PostgreSQL 驱动 — DDL 重建逻辑复杂,需要重点测试
  5. Oracle 驱动 — 使用场景少,但连接管理逻辑复杂

七、经验与反思

做得好的

  1. 接口先行 — 先定义 DatabaseDriver 接口,再逐个实现驱动,确保了 API 一致性和新增驱动的可预测性
  2. 工厂模式简单可靠DriverFactory 虽然朴素,但零 bug,新增类型只需加一个 case
  3. Decorator 扩展性DriverLogger 装饰器证明接口可以零侵入地扩展功能(日志、未来可加缓存、监控等)
  4. 独立连接的取消模式 — MySQL 和 Oracle 使用独立连接执行查询 + destroy/break 取消,避免了连接池被污染

可以改进的

  1. 抽象基类缺失queryPage 在四个驱动中有大量重复逻辑(过滤条件构建、参数映射),可以提取一个 BaseDriver 抽象类
  2. 缺少 testConnection() 方法 — 当前用 createPool + 立即连接来验证,语义不清晰
  3. PostgreSQL Schema 扩展getSchemas() 是私有方法,接口未暴露,限制了 PG 用户切换 schema 的能力
  4. Oracle 连接开销 — 每次元数据操作都 getConnection/close,高频使用场景下性能不佳
  5. 错误处理粒度不够 — 有的驱动用 try/catch 优雅降级(如 MySQL 的 getIndexes 对系统视图返回空数组),有的直接抛出,行为不一致

未来展望

如果项目继续演进,驱动层可以考虑:

  • 连接池缓存 — 复用空闲连接,减少 createPool 开销
  • 事件驱动日志 — 通过事件发射器替代当前的日志 Service 引用,解耦更彻底
  • 查询超时 — 在 AbortSignal 基础上,支持 timeout 参数自动取消长时间查询
  • 批量操作 — 为大量 INSERT/UPDATE 场景提供批量执行接口
  • TTL 连接验证 — 定期心跳检测,避免用户操作时才发现连接已断开

DBView 开发日志系列 — 记录一个数据库可视化工具从 0 到 1 的完整历程