本文档说明系统为什么这样分层、接口契约是什么、数据如何流动、关键取舍的理由。Claude Code 在动接口、写核心层、加新驱动前应先读本文。操作规约见
CLAUDE.md,任务范围见MVP.md。
| 目标 | 设计回应 |
|---|---|
| MySQL 先行,多库可扩展 | 统一 dbdriver 抽象接口 + 编译期注册插件 |
| 大结果集不卡死 | 后端分批 fetch + 前端虚拟滚动 + 流式导出 |
| 长查询可取消/可超时 | 全链路 context.Context |
| Wails v3 alpha 风险可控 | 防腐层隔离框架 API + 版本锁定 |
| 跨平台单二进制分发 | 纯 Go 依赖优先(modernc sqlite),避免 CGO |
| 多窗口并发安全 | 会话管理器 + 窗口级事务隔离 |
┌─────────────────────────────────────────────────────────────┐
│ 前端 (WebView, embed.FS) Vue 3 + TS + Vite + Naive UI + Pinia │
│ 连接管理 / SQL编辑器(CodeMirror6) / 结果表格(TanStack)/对象树 │
│ frontend/src/api ← 防腐层:封装绑定 + 事件,组件只调这层 │
└───────────────────────────┬───────────────────────────────────┘
Wails v3 IPC(内存内,无 HTTP;自动分块绕过 2MB body 上限)
方法调用 Promise(+cancel) ┃ 事件 Emit/On
┌───────────────────────────┴───────────────────────────────────┐
│ Wails 绑定层 internal/services/*Service │
│ Connection / Query / Metadata / Edit / Transfer / Settings │
│ 仅做参数校验 + 调核心层 + Emit 事件,不放业务逻辑 │
└───────────────────────────┬───────────────────────────────────┘
┌───────────────────────────┴───────────────────────────────────┐
│ Go 核心层 internal/core, storage, tunnel │
│ 会话/连接管理器 · 查询引擎(ctx) · 动态扫描器 · 元数据 · DDL │
│ 配置存储(SQLite) · 凭据(keyring) · SSH隧道 │
└───────────────────────────┬───────────────────────────────────┘
┌───────────────────────────┴───────────────────────────────────┐
│ 驱动插件层 plugins/* + internal/dbdriver(接口) + registry │
│ [MySQL✓] [PostgreSQL] [SQLite] [...] 各实现 Driver 接口 │
└─────────────────────────────────────────────────────────────────┘
每层职责边界(重要,决定代码放哪):
- 前端:UI + 交互,数据访问只走
api/。不感知 Wails 绑定细节。 - api/ 防腐层(前端):把生成的
bindings/与事件封装成稳定的前端 API。框架升级只改这层。 - Service 层:Wails 绑定入口。薄——只做入参校验、调用核心层、Emit 进度事件。不写 SQL、不写连接逻辑。
- 核心层:真正的业务。连接生命周期、查询执行、结果扫描、元数据组装、DDL 生成、存储、隧道。不依赖任何具体数据库,只依赖
dbdriver接口。 - 驱动插件层:实现
dbdriver接口,封装具体数据库的 SQL/协议/方言。
这是整个可扩展性的核心。改这里之前务必想清楚:任何改动都要让所有驱动同步实现,并通过契约测试套件。
- 接口不绑定
database/sql类型。 用自定义ResultSet/ColumnMeta/ExecResult。这样 MySQL 插件内部可用*sql.DB,未来 PG 插件可用 pgx 原生*pgxpool.Pool,对核心层完全透明。抽象层定义"做什么",插件自选"怎么做"。 - 能力声明驱动 UI。
Capabilities()让前端按库的能力显隐功能(如 ClickHouse 无事务则隐藏事务开关)。 - 连接参数自描述。
ConnectionSchema()返回字段列表,前端据此动态渲染连接表单,新增数据库无需改前端表单代码。
package dbdriver
import (
"context"
"database/sql"
)
// Driver —— 一个数据库类型的插件入口
type Driver interface {
Name() string // 唯一标识 "mysql"
Version() string
ConnectionSchema() []ConnParamField // 前端动态渲染连接表单
Capabilities() Capabilities // 前端据此显隐功能
Dialect() Dialect
Open(ctx context.Context, cfg ConnConfig) (Connection, error)
}
type ConnParamField struct {
Key, Label, Type, Default string // Type: text|number|password|select|bool
Required bool
Options []string
Group string // "常规"|"SSL"|"SSH"
}
type Capabilities struct {
Schemas, StoredProcedures, Triggers, Views bool
Transactions, ExplainPlan bool
}
type ConnConfig struct {
Host, Port, User, Password, Database string
Params map[string]string // charset/loc/tls 等驱动特定参数
SSL *SSLConfig
SSHTunnel *SSHConfig
}
// Connection —— 已建立的连接(封装连接池)
type Connection interface {
Ping(ctx context.Context) error
Close() error
Querier() Querier
Metadata() Metadata
Editor() Editor
Begin(ctx context.Context, opts *sql.TxOptions) (Tx, error)
}
type Querier interface {
Exec(ctx context.Context, sql string, args ...any) (ExecResult, error)
Query(ctx context.Context, sql string, args ...any) (ResultSet, error)
Explain(ctx context.Context, sql string) (ResultSet, error)
}
// ResultSet —— 流式分批读取,绝不一次性载入全部
type ResultSet interface {
Columns() []ColumnMeta
Next(batch int) (rows [][]any, done bool, err error)
Close() error
}
type Metadata interface {
ListDatabases(ctx context.Context) ([]string, error)
ListSchemas(ctx context.Context, db string) ([]string, error)
ListTables(ctx context.Context, db, schema string) ([]TableInfo, error)
ListViews(ctx context.Context, db, schema string) ([]ViewInfo, error)
ListColumns(ctx context.Context, db, schema, table string) ([]ColumnMeta, error)
ListIndexes(ctx context.Context, db, schema, table string) ([]IndexInfo, error)
ListForeignKeys(ctx context.Context, db, schema, table string) ([]ForeignKeyInfo, error)
ListRoutines(ctx context.Context, db, schema string) ([]RoutineInfo, error)
}
type Dialect interface {
QuoteIdentifier(name string) string // MySQL `x` / PG "x"
Paginate(baseSQL string, limit, offset int) string // LIMIT/OFFSET vs OFFSET FETCH
MapType(nativeType string) LogicalType
GenerateCreateTable(t TableSchema) (string, error)
}
// Editor —— 基于主键生成安全的增删改
type Editor interface {
PrimaryKeys(ctx context.Context, db, schema, table string) ([]string, error)
BuildInsert(table string, row map[string]any) (string, []any, error)
BuildUpdate(table string, pk, changes map[string]any) (string, []any, error)
BuildDelete(table string, pk map[string]any) (string, []any, error)
}
type Tx interface {
Querier
Commit() error
Rollback() error
}
type ExecResult struct{ RowsAffected, LastInsertID int64 }// internal/registry
var (
mu sync.RWMutex
drivers = make(map[string]dbdriver.Driver)
)
func Register(d dbdriver.Driver) { /* 加锁写 map,重复名 panic */ }
func Get(name string) (dbdriver.Driver, error) { /* 加锁读 */ }
func List() []dbdriver.Driver { /* 供前端"新建连接"下拉 */ }驱动侧:
// plugins/mysqldrv
func init() { registry.Register(mysqlDriver{}) }聚合导入(build tag 可裁剪):
// plugins/plugins_all.go
//go:build !no_mysql
import _ "yourapp/plugins/mysqldrv"go build -tags "no_oracle" 即可不编进 Oracle/CGO 依赖。
- 新建
plugins/xxxdrv/包,实现Driver/Connection/Querier/Metadata/Dialect/Editor。 init()里registry.Register(...)。plugins/plugins_all.go加匿名导入(按需加 build tag)。- 跑统一契约测试套件(见 §7),全绿才算接入完成。
- 前端无需改动——连接表单由
ConnectionSchema()自动渲染,功能按Capabilities()自动显隐。
| 库 | 驱动 | 内部实现 | 关键差异 |
|---|---|---|---|
| MySQL | go-sql-driver/mysql | *sql.DB |
元数据走 information_schema;DDL 用 SHOW CREATE TABLE |
| PostgreSQL | jackc/pgx/v5 | pgx 原生 + pgxpool(性能更优) | SSH 隧道需重写 LookupFunc(见 §6.2) |
| SQLite | modernc.org/sqlite | *sql.DB |
纯 Go,无 CGO |
| SQL Server | microsoft/go-mssqldb | *sql.DB |
分页 OFFSET..FETCH |
| Redis/Mongo | go-redis / mongo-driver | 独立 KVDriver/DocDriver |
不套 SQL 接口,前端独立 UI |
type QueryService struct {
app *application.App
mgr *core.SessionManager
}
// v3 生命周期方法(注意不是 v2 的 OnStartup/OnShutdown)
func (s *QueryService) ServiceStartup(ctx context.Context, _ application.ServiceOptions) error {
s.app = application.Get()
return nil
}
// 公共方法 → 自动生成 TS 绑定;首参 ctx 由运行时注入,支持前端取消
func (s *QueryService) RunQuery(ctx context.Context, connID, sql string) (*QueryResult, error) { ... }注册:application.New(Options{Services: []application.Service{application.NewService(&QueryService{})}})。
- 取消:前端 cancel 该方法的 promise(或
cancelOn绑 AbortSignal)→ Go 侧ctx取消 →QueryContext中断。 - 进度:核心层
app.Event.Emit("export-progress", {done,total}),前端On("export-progress", cb)。
Go→TS:map[K]V→Record<K,V>,slice→数组,[]byte→Uint8Array。生成命令带 -names 保留字段名与位置参数,降低重构成本。
前端编辑器执行
→ api/query.run(connID, sql, signal) // 防腐层,绑定 AbortSignal
→ QueryService.RunQuery(ctx, connID, sql) // Service 层,薄
→ core.QueryEngine.Run(ctx, conn, sql) // 取连接、QueryContext
→ driver.Querier.Query(ctx, sql) // 插件,返回 ResultSet
→ core.Scanner 分批 Next(batch=500) // 动态扫描,[][]any
→ 首批 + 列元数据经 IPC 返回;后续批次按需拉取/Emit
→ 前端 TanStack Virtual 仅渲染视口行 + LRU 预读缓存
列元数据只传一次(列名 + 类型),行数据用 [][]any。前端虚拟滚动到底/跳转未加载区时,经事件请求下一批。
动态 SQL 的列名/列数/类型编译期未知,必须运行时反射扫描:
func scanBatch(rows *sql.Rows, colTypes []*sql.ColumnType, batch int) (data [][]any, done bool, err error) {
n := len(colTypes)
for i := 0; i < batch; i++ {
if !rows.Next() { done = true; break }
holders := make([]any, n)
raw := make([]sql.RawBytes, n)
for j := range holders { holders[j] = &raw[j] }
if err = rows.Scan(holders...); err != nil { return }
row := make([]any, n)
for j := range raw {
row[j] = convert(raw[j], colTypes[j]) // 按 DatabaseTypeName 做 Type Switch
}
data = append(data, row)
}
return data, done, rows.Err()
}- 用
rows.ColumnTypes()的DatabaseTypeName/Nullable/DecimalSize做精确转换(BIGINT→int64、VARCHAR→string、DATETIME→格式化时间、NULL→nil)。 - 行数据用
[]any不用map[string]any(避免 hashing + 内存碎片,减小 IPC 载荷)。 - 高吞吐优化:
sync.Pool复用扫描缓冲;极致场景改一维平铺[]any+ 列数步长还原。
MySQL:建立 *ssh.Client 后用 mysql.RegisterDialContext("mysql+ssh", dialer),dialer 内 sshClient.Dial("tcp", addr)。DSN:user:pass@mysql+ssh(dbhost:3306)/db。
PostgreSQL 的 DNS 陷阱(务必注意):pgx 默认在客户端本地做 DNS 解析,内网私有域名会超时失败。必须在 pgxpool.Config 同时设置 DialFunc(透传 SSH)和 LookupFunc(把域名解析也透传给跳板机)。只设 DialFunc 会"隧道通但连接挂"。
主机密钥用 ssh.FixedHostKey 校验,禁止 InsecureIgnoreHostKey。
*sql.DB连接池按连接 ID 封装在管理器,Service 启动钩子初始化,应用退出钩子优雅Close()。- 多窗口并发风险:多窗口在同一连接池上跑未提交事务,无序抢占会导致事务在同一物理连接上交叉/死锁。
- 解决:开启事务/独占操作时,会话管理器
BeginTx分离独立会话并绑定窗口 ID,事务结束前该物理连接不被其他窗口借调。普通自动提交查询仍走共享池。
- UPDATE/DELETE 必须
WHERE pk=?参数化,pk 来自Editor.PrimaryKeys探测。 - 乐观锁可选
WHERE pk=? AND col=?old,受影响 0 行 → "数据已被他人修改"。 - 无主键/唯一键的表 → 标记只读,不生成写语句(最稳妥)。
为达到 UI_SPEC.md 的"去 Web 感"要求,以下 UI 不在 Vue 里实现,而是用 Wails 原生能力,封装在 wailsbridge/:
- 窗口外壳:Frameless + 自绘标题栏(CSS
--wails-draggable);平台分叉——macOSMac.InvisibleTitleBarHeight+ 交通灯 +Mac.Backdrop毛玻璃,WindowsWindowsWindow{BackdropType: Mica}+ caption 按钮;标题栏配色用CustomTheme双套色。 - 应用菜单:
app.NewMenu()建 File/Edit/View/Query/Window/Help,SetAccelerator注册快捷键;macOS 进系统菜单栏,Win/Linux 用UseApplicationMenu。 - 上下文菜单:Wails 原生(前端 CSS
--custom-contextmenu: <id>+--custom-contextmenu-data,Go 侧OnClick)。对象树/结果集/标签页的右键都走这条,不用 HTML 浮层。注意:context data 来自前端 CSS,属不可信输入,Go 侧须校验。 - 系统对话框:文件打开/保存/选目录、确认/警告 → Wails 原生 Dialog,不用 HTML 模拟。
架构含义:这些 Go 侧 UI 通过 wailsbridge 暴露给前端调用/联动(如菜单项触发前端动作用事件;右键菜单点击在 Go 侧处理后 Emit 给前端)。前端只负责应用内的密集内容区(树/编辑器/表格/表单),系统级外壳交给 Go。
已知缺口:Wails v3 暂无统一主题(明暗)读取/设置/订阅 API(官方 issue #4665 未实现)。
wailsbridge需自封装:前端matchMedia('(prefers-color-scheme: dark)')驱动 Naive UI 暗色主题,Go 侧标题栏用CustomTheme双套色同步。
- 单元测试:Dialect 类型映射、Editor 语句生成、分页 SQL、DSN 构建、scanner 类型转换——纯逻辑,无需数据库。
- 契约测试套件(关键):对
dbdriver.Driver写一套通用测试(连接/Ping/查询/元数据/编辑/取消),任何驱动都跑同一套,保证接口语义一致。新驱动接入的验收标准 = 契约测试全绿。 - 集成测试:
testcontainers-go/modules/mysql起真实 MySQL(mysql.Run(ctx,"mysql:8.0")+WithScripts+ConnectionString())。
| 约束 | 缓解 |
|---|---|
| Wails v3 仍 alpha,API 可能 break | 锁 v3.0.0-alpha.96;wailsbridge/api 防腐层隔离 |
| Service 方法命名 v2→v3 已变更 | 统一用 ServiceStartup/ServiceShutdown |
| WebView2 IPC body ~2MB 上限 | v3 已自动分块;但仍靠分页+虚拟滚动防卡死 |
| Linux GTK3/GTK4 过渡期不稳 | MVP 锁 GTK3;优先 Win/Mac |
| CGO 复杂化交叉编译 | SQLite 用 modernc 纯 Go;避免 CGO 驱动 |
- 为什么编译期注册而非动态加载? 零运行时开销、类型安全、易交叉编译、天然支持 cgo 驱动。go plugin 仅 Linux/macOS 且版本脆弱,Goja 有性能/调试成本——都不是本项目主线。
- 为什么接口不直接用
database/sql? 为了让 pgx 等能用原生高性能接口;抽象层只定义语义。 - 为什么 CodeMirror 不用 Monaco? 体积小一个数量级、多实例独立、补全可组合(适合基于元数据的自定义补全)。
- 为什么行数据用数组不用 map? 减少 hashing/内存碎片/IPC 载荷;列名单独传一次即可。