Skip to content

Latest commit

 

History

History
397 lines (304 loc) · 24.3 KB

File metadata and controls

397 lines (304 loc) · 24.3 KB

架构设计文档(ARCHITECTURE.md)

本文档说明系统为什么这样分层、接口契约是什么、数据如何流动、关键取舍的理由。Claude Code 在动接口、写核心层、加新驱动前应先读本文。操作规约见 CLAUDE.md


1. 设计目标与约束

目标 设计回应
MySQL 先行,多库可扩展 统一 dbdriver 抽象接口 + 编译期注册插件
大结果集不卡死 后端分批 fetch + 前端虚拟滚动 + 流式导出
长查询可取消/可超时 全链路 context.Context
Wails v3 alpha 风险可控 防腐层隔离框架 API + 版本锁定
跨平台单二进制分发 纯 Go 依赖优先(modernc sqlite),避免 CGO
多窗口并发安全 会话管理器 + 窗口级事务隔离

2. 分层架构

┌─────────────────────────────────────────────────────────────┐
│  前端 (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✓] [MariaDB✓] [PostgreSQL✓] [SQLite✓] [DM✓] [...]  各实现 Driver 接口 │
└─────────────────────────────────────────────────────────────────┘

每层职责边界(重要,决定代码放哪):

  • 前端:UI + 交互,数据访问只走 api/。不感知 Wails 绑定细节。
  • api/ 防腐层(前端):把生成的 bindings/ 与事件封装成稳定的前端 API。框架升级只改这层。
  • Service 层:Wails 绑定入口。——只做入参校验、调用核心层、Emit 进度事件。不写 SQL、不写连接逻辑。
  • 核心层:真正的业务。连接生命周期、查询执行、结果扫描、元数据组装、DDL 生成、存储、隧道。不依赖任何具体数据库,只依赖 dbdriver 接口。
  • 驱动插件层:实现 dbdriver 接口,封装具体数据库的 SQL/协议/方言。

3. 驱动抽象契约(系统承重墙)

这是整个可扩展性的核心。改这里之前务必想清楚:任何改动都要让所有驱动同步实现,并通过契约测试套件。

3.1 关键原则

  1. 接口不绑定 database/sql 类型。 用自定义 ResultSet/ColumnMeta/ExecResult/TxOptions/ErrTxDone。这样 MySQL 插件内部可用 *sql.DB,未来 PG 插件可用 pgx 原生 *pgxpool.Pool,对核心层完全透明。抽象层定义"做什么",插件自选"怎么做"。
  2. 能力声明驱动 UI。 Capabilities() 让前端按库的能力显隐功能(如 ClickHouse 无事务则隐藏事务开关;Schemas 决定对象树是否插入 schema 层)。
  3. 连接参数自描述。 ConnectionSchema() 返回字段列表,前端据此动态渲染连接表单,新增数据库无需改前端表单代码。
  4. UI 方言自描述。 UIDialect() 是驱动的声明式 UI 契约:标识符引号、CodeMirror 方言 id、结构编辑器类型目录与参数规则、自增约束、补全函数/关键字/片段、系统库清单、默认字符集等。前端组件不写任何 per-database 知识,全部从描述符读取——新增数据库无需改前端组件
  5. 方言 SQL 只在插件里。 通用层(core/services)禁止出现方言 SQL 字面量(information_schema、USE、反引号等);internal/services/leakguard_test.go 用 AST 扫描强制这一点。默认库切换走 Dialect.DefaultNamespaceSQL,脚本分割规则走 Dialect.ScriptRules,类型归一化走 Dialect.NormalizeType,视图定义走 Metadata.ListViewDefinitions

3.2 接口定义

package dbdriver

import "context"

// Driver —— 一个数据库类型的插件入口
type Driver interface {
	Name() string                       // 唯一标识 "mysql"
	Version() string
	ConnectionSchema() []ConnParamField // 前端动态渲染连接表单
	Capabilities() Capabilities         // 前端据此显隐功能
	UIDialect() UIDialect               // 前端声明式 UI 契约(类型目录/引号/补全目录…)
	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 *TxOptions) (Tx, error) // 自定义 TxOptions,不绑 database/sql
}

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)
	// 结构同步用:一次取全某命名空间下所有视图定义(避免 N+1)
	ListViewDefinitions(ctx context.Context, db, schema string) (map[string]string, error)
	// ListColumns 必须返回完整精确的 NativeType(如 varchar(255)),
	// 数据浏览用它覆盖 scanner 的裸类型名
	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)
	GetCreateTable(ctx context.Context, db, schema, table string) (string, error)
}

// 可选扩展(类型断言探测):BulkMetadata(整库批量元数据,加速结构同步)、
// DatabaseEditor(建库/改库 UI:驱动自描述的选项字段目录 DatabaseOptionFields
// —— MySQL 是字符集/排序规则、PG 是所有者/模板/编码/locale/表空间 ——
// + map 选项渲染 CREATE/ALTER DATABASE,前端动态渲染表单,无需改前端)

type Dialect interface {
	QuoteIdentifier(name string) string                // MySQL `x` / PG "x"
	// 设定会话默认命名空间的语句:MySQL "USE `x`"、PG "SET search_path";
	// 空串 = 该库没有此类语句
	DefaultNamespaceSQL(name string) string
	// SQL 脚本分割的词法规则(反引号/反斜杠转义/#注释/DELIMITER/dollar-quoting)
	ScriptRules() ScriptRules
	// 第 i 个参数(1-based)的占位符:MySQL "?"、PG "$i"。
	// 通用层用它拼多值参数化 INSERT(如批量传输),值走绑定参数不走字面量
	Placeholder(i int) string
	Paginate(baseSQL string, limit, offset int) string // LIMIT/OFFSET vs OFFSET FETCH
	MapType(nativeType string) LogicalType
	// 类型串归一化(schemadiff 比较用;折叠 UNSIGNED 位置、ZEROFILL 等方言噪音)
	NormalizeType(nativeType string) string
	GenerateCreateTable(t TableSchema) (string, error)
	GenerateAlterTable(db, schema, table string, cs ChangeSet) ([]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   // 事务已结束时返回 dbdriver.ErrTxDone(驱动负责收敛映射)
	Rollback() error
}

type ExecResult struct{ RowsAffected, LastInsertID int64 }

3.3 编译期注册机制

// 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 依赖。

3.4 新增一个数据库驱动的标准步骤

  1. 新建 plugins/xxxdrv/ 包,实现 Driver/Connection/Querier/Metadata/Dialect/Editor,并编写 UIDialect() 描述符(类型目录、引号、补全函数目录等——参照 plugins/mysqldrv/uidialect.go)。可选实现 BulkMetadata(结构同步提速)、DatabaseEditor(建库/改库 UI)。
  2. init()registry.Register(...)
  3. plugins/plugins_all.go 加匿名导入(按需加 build tag)。
  4. 跑统一契约测试套件(见 §7),全绿才算接入完成。套件覆盖:元数据/编辑/DDL 往返、Dialect(精确列类型、NormalizeType 幂等、DefaultNamespaceSQL 可执行)、视图定义、事务生命周期(ErrTxDone 哨兵)、UIDialect 静态校验(contract.TestUIDialect 也可在纯单测里跑,无需真实库)。
  5. 前端无需改动——连接表单由 ConnectionSchema() 渲染,功能按 Capabilities() 显隐(含对象树 schema 层),编辑器/补全/结构编辑器按 UIDialect() 适配。

3.5 各库扩展要点

驱动 内部实现 关键差异
MySQL go-sql-driver/mysql *sql.DB 元数据走 information_schema;DDL 用 SHOW CREATE TABLE
MariaDB go-sql-driver/mysql 复用 mysqldrv(嵌入 mysqldrv.Driver 完全兼容 MySQL 协议;仅覆写驱动名与 editor dialect
PostgreSQL jackc/pgx/v5 pgx 原生 + pgxpool(性能更优) SSH 隧道需重写 LookupFunc(见 §6.2)
达梦 DM gitee.com/chunanyong/dm *sql.DB 模式折叠进数据库层级(同 MySQL 模型);元数据走 ALL_*/SYSOBJECTS;DDL 用 TABLEDEF();不支持 REPEATABLE READ
SQLite modernc.org/sqlite *sql.DB 纯 Go,无 CGO;元数据走 pragma_* 表值函数(可参数化);类型归一化折叠到 5 种亲和性;ALTER 仅 ADD/DROP/RENAME COLUMN(其余需重建表,生成器直接报错);契约测试免 Docker
SQL Server microsoft/go-mssqldb *sql.DB 分页 OFFSET..FETCH
Redis/Mongo go-redis / mongo-driver 独立 KVDriver/DocDriver 不套 SQL 接口,前端独立 UI

已知边界:结构同步 / 数据传输的建表路径按同构/异构分叉(createTableDDL):同构走源库 GetCreateTable 原生 DDL(保真度最高);异构走 TableSchema + 目标库 Dialect.GenerateCreateTable 重新渲染,但列类型暂原样传递(无跨方言类型映射),MySQL 特有类型在 PG 上仍会建表失败。行数据传输/数据同步已异构可用(多值参数化 INSERT / Editor 参数化语句)。完整异构方案与路线图见 docs/异构数据库同步与传输方案.md


4. Go↔前端通信

4.1 Service 绑定

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{})}})

4.2 取消与进度

  • 取消:前端 cancel 该方法的 promise(或 cancelOn 绑 AbortSignal)→ Go 侧 ctx 取消 → QueryContext 中断。
  • 进度:核心层 app.Event.Emit("export-progress", {done,total}),前端 On("export-progress", cb)

4.3 类型映射

Go→TS:map[K]VRecord<K,V>,slice→数组,[]byteUint8Array。生成命令带 -names 保留字段名与位置参数,降低重构成本。


5. 数据流:一次查询的完整路径

前端编辑器执行
  → 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。前端虚拟滚动到底/跳转未加载区时,经事件请求下一批。


6. 关键技术机制

6.1 动态结果集扫描(核心层 core/scanner)

动态 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 + 列数步长还原。

6.2 SSH 隧道(core/tunnel)

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

6.3 连接生命周期与多窗口并发隔离(core/session)

  • *sql.DB 连接池按连接 ID 封装在管理器,Service 启动钩子初始化,应用退出钩子优雅 Close()
  • 多窗口并发风险:多窗口在同一连接池上跑未提交事务,无序抢占会导致事务在同一物理连接上交叉/死锁。
  • 解决:开启事务/独占操作时,会话管理器 BeginTx 分离独立会话并绑定窗口 ID,事务结束前该物理连接不被其他窗口借调。普通自动提交查询仍走共享池。

6.4 表数据在线编辑(core + Editor)

  • UPDATE/DELETE 必须 WHERE pk=? 参数化,pk 来自 Editor.PrimaryKeys 探测。
  • 乐观锁可选 WHERE pk=? AND col=?old,受影响 0 行 → "数据已被他人修改"。
  • 无主键/唯一键的表 → 标记只读,不生成写语句(最稳妥)。

6.5 原生 UI 集成层(部分 UI 活在 Go 侧)

为达到 UI_SPEC.md 的"去 Web 感"要求,以下 UI 不在 Vue 里实现,而是用 Wails 原生能力,封装在 wailsbridge/

  • 窗口外壳:Frameless + 自绘标题栏(CSS --wails-draggable);平台分叉——macOS Mac.InvisibleTitleBarHeight + 交通灯 + Mac.Backdrop 毛玻璃,Windows WindowsWindow{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 双套色同步。

6.6 结构 diff 引擎(core/schemadiff + Dialect DDL 渲染)

结构编辑器与结构同步共用一套 diff 引擎,分两层:

  • diff(驱动无关)schemadiff.Diff(current dbdriver.TableSchema, desired schemadiff.Table, opts) 产出结构化 dbdriver.ChangeSet(列 add/drop/modify/rename + 位置、PK drop/add、索引与外键的 drop+add 对、表注释),不含任何 SQL 文本desired 里的 OrigName 承载改名语义:结构编辑器由 draft 提供;结构同步用 FromTableSchema(src, target) 按同名回填(同步场景无改名,源缺列=ADD、目标多列=DROP)。比较时对原生类型做归一化(NormalizeNativeType:大小写/括号内空白/UNSIGNED 位置/ZEROFILL),索引省略的 ASC/BTREE 与显式值视为相等,FK 的空 ON UPDATE/DELETE 视同 RESTRICT——都是为了消除"读回值 vs 书写值"的假差异。
  • 渲染(方言相关)Dialect.GenerateCreateTable(TableSchema)Dialect.GenerateAlterTable(db, schema, table, ChangeSet) 把结构/变更集渲染成目标数据库的 DDL。ChangeSet 类型定义在 dbdriver(Dialect 接口要引用它;schemadiff 又依赖 dbdriver,放 core 会成环)。

调用方:MetadataService.BuildAlterPlan/BuildCreateTable(结构编辑器,前端 draft 过 IPC 后端算 diff)与 SyncService.CompareSchemas(结构同步,双端元数据读回后 diff,用目标端 Dialect 渲染——语句在目标服务器执行)。

6.7 数据同步归并引擎(core/datasync)

按主键排序的双端流式归并比对,内存与表大小无关:

  • 两端各发一条 SELECT <公共列> FROM t ORDER BY <pk>,经 ResultSet.Next(batch) 流式拉取;双指针归并把每行分类为 insert(仅源有)/ update(键同值异,含变更列位置)/ delete(仅目标有),交给调用方注入的 Handlers。
  • Compare(dry-run):Handlers 只累计计数 + 保留有限采样(默认 100 条)给前端预览——差异明细不整批过 IPC。
  • Execute:Handlers 用目标端 Editor.BuildInsert/Update/Delete 生成参数化语句,在 session.Manager.OpenDedicated独立连接上按批(默认 500 条/事务)提交——共享连接不被长事务占用(规约 9)。AllowDelete=false 时 delete 只计数不执行。
  • 值比较前做归一化([]byte↔string、整型宽度、float32→64);NULL 排最前。已知限制:字符串主键在大小写不敏感 collation 下的服务端排序可能与 Go 侧比较不一致,可能错位归并——数值/二进制键(绝大多数场景)精确。
  • 前置校验在 SyncService.resolveTablePairs:双端 PK 探测(Editor.PrimaryKeys)、PK 列集一致、公共列含全部 PK,不满足的表标记 skipped slug(no-primary-key/pk-mismatch/…)而非静默跳过。

7. 测试架构

  • 单元测试:Dialect 类型映射、Editor 语句生成、分页 SQL、DSN 构建、scanner 类型转换——纯逻辑,无需数据库。
  • 契约测试套件(关键):对 dbdriver.Driver 写一套通用测试(连接/Ping/查询/元数据/编辑/取消),任何驱动都跑同一套,保证接口语义一致。新驱动接入的验收标准 = 契约测试全绿。
  • 集成测试testcontainers-go/modules/mysql 起真实 MySQL(mysql.Run(ctx,"mysql:8.0") + WithScripts + ConnectionString())。

8. 已知约束与缓解(影响设计的现实因素)

约束 缓解
Wails v3 仍 alpha,API 可能 break v3.0.0-alpha2.106;wailsbridge/api 防腐层隔离
Service 方法命名 v2→v3 已变更 统一用 ServiceStartup/ServiceShutdown
WebView2 IPC body ~2MB 上限 v3 已自动分块;但仍靠分页+虚拟滚动防卡死
Linux GTK3/GTK4 过渡期不稳 锁 GTK3;优先 Win/Mac
CGO 复杂化交叉编译 SQLite 用 modernc 纯 Go;避免 CGO 驱动

9. 设计决策速查(FAQ)

  • 为什么编译期注册而非动态加载? 零运行时开销、类型安全、易交叉编译、天然支持 cgo 驱动。go plugin 仅 Linux/macOS 且版本脆弱,Goja 有性能/调试成本——都不是本项目主线。
  • 为什么接口不直接用 database/sql 为了让 pgx 等能用原生高性能接口;抽象层只定义语义。
  • 为什么 CodeMirror 不用 Monaco? 体积小一个数量级、多实例独立、补全可组合(适合基于元数据的自定义补全)。
  • 为什么行数据用数组不用 map? 减少 hashing/内存碎片/IPC 载荷;列名单独传一次即可。