DBeaver 鸿蒙 PC 版:用 ArkTS + NAPI 重建一个通用数据库客户端

欢迎加入开源鸿蒙 PC 社区:https://harmonypc.csdn.net/

欢迎在 PC 社区平台申请新建项目:https://atomgit.com/OpenHarmonyPCDeveloper

适配开源地址(AtomGit 仓库):https://atomgit.com/OpenHarmonyPCDeveloper/ohos_dbeaver


DBeaver 是使用最广的开源通用数据库客户端之一:连接会话、库表树、数据网格、SQL 编辑、结构管理、导入导出,一个工具覆盖了开发者与 DBA 的大部分日常。它上游是 Java / Eclipse RCP 技术栈,依赖 JVM、OSGi、SWT 和 JDBC——这四样在鸿蒙 PC 上一样都不存在。

本文讲的是这个项目在 HarmonyOS PC 上的一次「原生重建」:界面与领域逻辑用 ArkTS + ArkUI 重写,数据库协议层把 libmariadb / libpq / sqlite3 交叉编译成 OHOS ARM64 动态库随 HAP 分发,中间用 NAPI 做一层统一的 provider 桥。文章侧重技术实现——架构怎么分层、句柄生命周期怎么管、C 库在 musl 工具链下怎么编、ArkTS 领域层几个关键模块(数据网格、行编辑缓冲、SQL 词法高亮、三方言元数据)是怎么设计的,以及踩过的那些坑。

请添加图片描述

一、项目定位:重写交互,复用协议

上游 DBeaver 的核心价值不在某个具体控件的实现,而在「数据库工作台」这套交互模型和十几年打磨出来的数据库访问层。前者是平台无关的,完全可以按规格用 ArkUI 重建;后者是纯协议实现,没必要、也没能力用 ArkTS 重写。所以路线选择很直接:

  • 界面与领域逻辑:ArkTS + ArkUI 声明式范式,全部原创实现,不复制上游源码、图标与素材;
  • 数据库协议:libmariadb、libpq、sqlite3 三套成熟 C 客户端库交叉编译进 HAP;
  • 桥接层:NAPI(C++17)统一 provider 接口,产物 libdbclient.so;
  • 交付形态:单一 HAP,deviceTypes 含 2in1,装到鸿蒙 PC 上就是原生桌面 GUI 应用——没有独立进程、没有 HNP、没有终端命令。
层次上游实现HarmonyOS PC 侧处理
UI 框架Eclipse RCP / SWTArkTS + ArkUI 声明式范式(State Management V1)
领域逻辑Java 插件(OSGi Bundle)ArkTS 分层:viewmodel / catalog / editor / model / dbclient
数据库驱动JDBC Driver交叉编译 C 客户端库:libmariadb 3.3.3、libpq 15.13、sqlite3 3.46.0
数据库桥接JDBC APINAPI(C++17)统一 provider 接口,产物 libdbclient.so
TLSJSSEOpenSSL 3.3.1 交叉编译,libmariadb / libpq 共用
字符编码JVM 内置libiconv 1.17 + libintl(gettext 0.22.5,libpq 依赖)
密码存储Eclipse Secure Storage系统密钥管理(HUKS)AES-256-GCM
交付形态桌面安装包arm64-v8a 签名 HAP,随通用设备安装

需要说明的边界:上游依赖 Java 生态的部分(上百个 JDBC 驱动、OSGi 扩展体系、ER 图与 AI 能力)不在本次范围内,后文能力分级一节如实列出。


二、架构分层与工程结构

整个系统的分层很干净:ArkTS 管一切看得见的,C 库只管协议,NAPI 做桥。

┌──────────────────────────────────────────────────────┐
│ components/    ArkUI 视图(数据网格/树/SQL 编辑器…)    │
│ pages/         单 Entry 主壳 + 状态机导航              │
├──────────────────────────────────────────────────────┤
│ viewmodel/     SQL 构建、事务编排、导入导出、结构加载    │
│ catalog/       数据库树目录服务、字段类型注册表          │
│ editor/        SQL 词法高亮、格式化、补全               │
│ model/         会话、连接参数、HUKS 加密、查询历史       │
├──────────────────────────────────────────────────────┤
│ dbclient/      ConnRegistry + DbConnection(ArkTS 侧)│
├──────────────────────────────────────────────────────┤
│ cpp/           NAPI 桥:DbProvider 统一接口            │
│                ├ mysql_provider    → libmariadb       │
│                ├ postgres_provider → libpq            │
│                └ sqlite_provider   → sqlite3          │
├──────────────────────────────────────────────────────┤
│ native/        交叉编译 ARM64 .so(musl 兼容)         │
└──────────────────────────────────────────────────────┘

对应工程目录(App 工程与交叉编译体系完全解耦):

HarmonDb/
├── AppScope/                      # 应用级配置
├── build-profile.json5            # 工程级构建配置
├── entry/                         # 唯一业务模块
│   └── src/main/
│       ├── ets/
│       │   ├── pages/             # 主壳(单 @Entry + 条件渲染导航)
│       │   ├── components/        # 19 个视图组件(DataGrid / SqlEditor / …)
│       │   ├── viewmodel/         # SQL 构建、行编辑缓冲、导入导出、结构加载
│       │   ├── catalog/           # 树目录服务、字段类型注册表
│       │   ├── editor/            # 高亮 / 格式化 / 补全 / 关键字表
│       │   ├── model/             # 会话、连接参数、HUKS、查询历史、错误映射
│       │   ├── dbclient/          # 连接注册表与 NAPI 的 OO 封装
│       │   └── entryability/      # EntryAbility(PC 窗口配置)
│       ├── cpp/                   # NAPI 桥接层 → libdbclient.so
│       │   ├── db_provider.h/.cpp # 统一接口与工厂
│       │   ├── mysql_provider.cpp
│       │   ├── postgres_provider.cpp
│       │   ├── sqlite_provider.cpp
│       │   ├── napi_init.cpp
│       │   ├── CMakeLists.txt
│       │   └── types/libdbclient/ # Index.d.ts(ArkTS 侧类型声明)
│       └── module.json5           # deviceTypes 含 2in1、INTERNET 权限
└── native/                        # 数据库客户端库交叉编译
    ├── build/
    │   ├── env.sh                 # OHOS 工具链(clang / sysroot / lld)
    │   └── build_libs.sh          # 按依赖顺序编 7 步
    ├── compat/musl_compat.h       # glibc 扩展符号的 musl 兼容桩
    └── prebuilt/arm64-v8a/        # 产物:lib/*.so + include/

连接链路从前台一路贯通到 native 层:

EntryAbility(主窗口 1280×800)
    └── pages/Index(单 Entry 主壳,条件渲染切换)
          ├── 会话管理视图            # 新建/编辑/测试连接
          └── 工作台视图
                ├── DbTreeView       # 数据库树(catalog 懒加载)
                ├── DataGrid         # 数据网格(分页/排序/过滤/编辑)
                ├── SqlEditor        # SQL 编辑器 + 结果面板
                └── ConnRegistry     # ArkTS 连接注册表(整数 id)
                      └── NAPI: libdbclient.so
                            ├── mysql_provider    → libmariadb.so
                            ├── postgres_provider → libpq.so
                            └── sqlite_provider   → libsqlite3.so

这条路线有三个工程上的收益:依赖库以预编译 .so 随 HAP 分发,设备上零预装;领域逻辑全在 ArkTS 层,换数据库只动 provider 一层;ArkTS 侧的演进也完全不触碰 C 库。


三、跨语言桥接:NAPI 怎么把 C 库变成 ArkTS 可用的对象

3.1 统一 Provider 接口

C++ 侧的核心抽象是一个纯虚接口,把三种数据库收敛成同一套方法面:

class DbProvider {
public:
  virtual bool Connect(const std::map<std::string, std::string>& params,
                       std::string& err) = 0;
  virtual void Disconnect() = 0;
  virtual bool Ping(std::string& err) = 0;
  virtual std::string GetServerVersion() = 0;
  virtual std::string GetServerInfo() = 0;
  virtual std::string EscapeString(const std::string& s) = 0;
  virtual ResultSet Query(const std::string& sql) = 0;
  virtual bool IsConnected() = 0;
};

工厂按 kind 分发(mysql / mariadb / postgres / postgresql / sqlite / sqlite3),ArkTS 侧完全不关心底下是哪套库:

std::shared_ptr<DbProvider> CreateProvider(const std::string& kind) {
  if (kind == "sqlite"   || kind == "sqlite3")        return CreateSqliteProvider();
  if (kind == "mysql"    || kind == "mariadb")        return CreateMysqlProvider();
  if (kind == "postgres" || kind == "postgresql")     return CreatePostgresProvider();
  return nullptr;
}

接口的注释里写得很清楚:这是对标 org.jkiss.dbeaver.model.jdbc 的 C++ 等价物。换句话说,移植的是架构意图,不是代码。

3.2 句柄路由:跨语言生命周期怎么管

NAPI 层用「全局连接表 + 自增整型句柄」管对象生命周期。C++ 侧每个连接持有一个 std::shared_ptr<DbProvider>,只把一个 int32_t 句柄返回给 ArkTS,后续所有调用都通过句柄路由:

std::mutex g_mutex;
std::map<int32_t, std::shared_ptr<harmondb::DbProvider>> g_conns;
std::atomic<int32_t> g_nextHandle{1};

有个值得单独说的细节:移除连接时先把 shared_ptr 移出表,在锁外析构:

void RemoveProvider(int32_t h) {
  std::shared_ptr<harmondb::DbProvider> p;
  {
    std::lock_guard<std::mutex> lk(g_mutex);
    auto it = g_conns.find(h);
    if (it != g_conns.end()) {
      p = std::move(it->second);
      g_conns.erase(it);
    }
  }
  // 析构在锁外执行(避免 Provider 析构回调上层再抢同一把锁)
}

Provider 析构里会调 mysql_close / PQfinish,这些清理动作有可能触发回调路径;如果在持锁时析构,轻则卡顿、重则死锁。这种「锁外析构」是跨语言句柄管理里最容易漏掉的一环。

ArkTS 侧再用 DbConnection 包成面向对象的形式,业务代码不感知任何 NAPI 细节:

const conn = new DbConnection('mysql');
conn.connect({ host: '10.0.2.2', port: '3306', user: 'root', password: '***', database: 'test' });
const rs = conn.query('SELECT id, name FROM test.game_news;');
conn.close();

ConnRegistry 则在页面之间按整数 id 分发连接(registerConn / getConn / unregisterConn),只把 id 放进 @State。

3.3 ResultSet 归一化

ResultSet 的结构设计是「一次写、三库通用」的关键:所有列值统一以字符串承载,同时带一份与行同形状的 NULL 标记数组:

struct ResultSet {
  std::vector<Column>                       columns;
  std::vector<std::vector<std::string>>     rows;
  std::vector<std::vector<bool>>            nulls;   // true 表示 NULL
  int64_t      affectedRows = 0;
  int64_t      insertId     = 0;
  bool         ok           = true;
  std::string  error;
  std::string  sqlState;
  int32_t      errorCode    = 0;
};

这个设计把类型转换后置到 ArkTS 侧按列类型自行解析,native 层不必为每个库维护不同的返回值结构;NAPI 出口时再把 nulls 为真的位置转成 JS 的 null。列元信息(dbTypeName / type / nullable / isPrimaryKey / maxLength)也一并带出,直接支撑上层的编辑与结构管理。

一个容易忽略的点:Query 不抛异常,失败时返回 ok=false 并填充 sqlState / errorCode。这不是偷懒,而是为了让多语句批量执行能逐条校验——一旦抛异常,循环就断了,还得靠 try/catch 判断是第几条失败。

NAPI 模块注册就是一张 9 个入口的方法表:

static napi_value Init(napi_env env, napi_value exports) {
  napi_property_descriptor desc[] = {
    {"create", nullptr, M_Create, ...}, {"connect", ...}, {"disconnect", ...},
    {"destroy", ...}, {"ping", ...}, {"getServerVersion", ...},
    {"getServerInfo", ...}, {"escapeString", ...}, {"query", ...},
  };
  napi_define_properties(env, exports, sizeof(desc) / sizeof(desc[0]), desc);
  return exports;
}

3.4 三个 provider 的差异都收敛在实现内部

MySQL / MariaDB:走 mysql_real_connect,mysqlDriver 参数区分两套认证策略——MariaDB 旧驱动设 MYSQL_DEFAULT_AUTH=mysql_native_password 并开启明文插件;MySQL 8 分支则交给静态链接进 libmariadb.so 的 caching_sha2_password 插件。列类型映射把 MYSQL_TYPE_* 收敛成 Integer / Real / Text / Binary / Date / Time / DateTime / Json / Enum / Set,PRI_KEY_FLAG 直接标出主键列,maxLength 取 fields[i].length,给上层的长文本截断提供依据。

PostgreSQL:用 PQconnectdb 拼 keyword 连接串,sslmode / sslrootcert / sslcert / sslkey 原样透传,另外硬加了一条 connect_timeout=10——没有超时的连接最伤 UI,网络不通时用户会以为应用卡死。类型映射维护了一张 PG 内置 OID 表(含 int2/int4/int8、bytea、jsonb、uuid 以及常见的数组类型 OID),PGRES_TUPLES_OK / PGRES_COMMAND_OK / 错误三种状态分别处理。

SQLite:sqlite3_open_v2 用 READWRITE | CREATE | NOMUTEX(文件不存在即创建,与 DBeaver 行为一致),打开后立刻 PRAGMA foreign_keys=ON。列类型先按 decltype 映射,表达式列没有 decltype 时保持 Unknown,在逐行 step 时按运行期值类型再细化一次——这是 SQLite 这种动态类型库必须做的兜底。BLOB 按原始字节读入 std::string(可能含 \0,长度由 sqlite3_column_bytes 决定),中途失败时清空已取数据,保证失败语义干净。


四、协议层:7 步交叉编译与 musl 兼容

界面可以重写,数据库协议不必重造轮子。libmariadb / libpq / sqlite3 都是成熟 C 库,只要能交叉编译到 OHOS ARM64,协议层就交给它们。

4.1 工具链封装(native/build/env.sh)

所有构建脚本只从一个文件取环境:SDK 自带 LLVM/Clang、--sysroot 指向 musl sysroot、链接器 ld.lld。其中两个三元组变量必须并存,这是最容易踩的坑:

# autoconf 系工程的 --host 只认标准三元组
export TARGET_HOST="aarch64-linux-gnu"
# clang 的 --target 用 ohos 三元组
export TARGET_TRIPLE="aarch64-linux-ohos"

export TARGET_FLAGS="--target=$TARGET_TRIPLE --sysroot=$SYSROOT \
                     -D__linux__ -D__MUSL__ -fPIC"
export LDFLAGS="--target=$TARGET_TRIPLE --sysroot=$SYSROOT -fuse-ld=$LLVM_BIN/ld.lld"

两个自定义宏很关键:-D__linux__(musl 上部分头文件靠它才暴露 API)、-D__MUSL__(自己的兼容桩与补丁全靠这个宏切分支)。

还有一个非常隐蔽的环境问题,值得单独提一句:DevEco command-line-tools 的 toolchains 目录自带 diff/sed 且排在 PATH 前面,其中 diff 对任何输入都返回 0,导致 autoconf 的 config.status 误判「配置未变化」,pg_config.h / config.h 根本没落盘。解法是构建脚本开头把系统工具链提到最前:

export PATH="/usr/bin:/bin:/usr/sbin:/sbin:/usr/local/bin:$PATH"

这不是编译错误,而是「configure 看起来成功、make 却找不到头文件」的玄学问题,第一次遇到很难往工具链污染上想。

4.2 版本锁定的 6 个库 + 1 步收尾

脚本按「先依赖后主库」的顺序编译,版本全部写死,保证可复现:

顺序库版本说明
1SQLite3.46.0纯 C amalgamation,一条 clang 命令编成 .so(开 FTS5 / JSON1 / RTREE)
2libiconv1.17libpq 的字符集依赖,手工 lld 链接(libcharset + libiconv 两个 .so)
3libintlgettext 0.22.5只编 gettext-runtime 子集,libpq 依赖
4OpenSSL3.3.1libmariadb / libpq 的 TLS 依赖
5libpqPostgreSQL 15.13只编 src/interfaces/libpq,不编 server 端
6libmariadb3.3.3MySQL / MariaDB 客户端,认证插件全部静态链接
7finalize—strip --strip-unneeded + 生成 SONAME 软链 + 产物汇总

脚本是幂等的:源码包已存在则跳过下载、已解压则跳过解压、产物已存在则跳过编译。

cd native/build
./build_libs.sh all            # 按依赖顺序编译全部
./build_libs.sh libmariadb     # 也可单独编某一个
OHOS_SDK_PATH=/path/to/sdk ./build_libs.sh all   # 指定 SDK 路径

4.3 每个库都有自己的「特殊处理」

OpenSSL:不走 autoconf,用自己的 Configure——平台选 linux-aarch64,-D__MUSL__ 让它走 musl 路径,shared no-tests no-asm 减少坑面;make 时显式 CROSS_COMPILE="",安装阶段还要 unset CROSS_COMPILE,否则 make install 会拿交叉前缀去找本机不存在的工具。

libpq:--without-readline --without-zlib --without-icu --disable-nls 砍掉不必要依赖、--with-openssl 接 TLS,只编客户端接口层。这里有个 autoconf 旧式探测的经典误判——用无原型调用来检测 pthread_barrier_*,现代 clang 直接拒绝,于是 configure 误判 OHOS musl 缺这些函数,编出一套 fallback 实现并与 musl 头冲突。脚本用 cache 变量强制纠正:

export ac_cv_search_pthread_barrier_wait="none required"
export ac_cv_func_pthread_barrier_wait=yes

另外它采用 in-tree 构建:之前试过 out-of-tree,在 macOS 上 pg_config.h 会被后续 make 步骤异常清除(config.status 报 unchanged 却不落盘),排查成本很高,索性放弃。

libmariadb(坑最多的一个),三处处理:

  • CMAKE_SYSTEM_NAME=Linux 必须显式指定,否则 CMake 在 macOS 上把它检测成 Darwin,交叉编译直接翻车;
  • 交叉环境下 FIND_PACKAGE(OpenSSL) 内部的 TRY_RUN 无法执行,OPENSSL_FOUND 拿不到值、TLS 会被静默裁掉——脚本用 sed 往它的 CMakeLists.txt 注入一段 OHOS_CROSS_COMPILE_HACK,跳过探测、直接注入头文件与库路径;
  • 认证插件全部静态链接(CLIENT_PLUGIN_caching_sha2_password=STATIC 等)。这是真机上最容易撞上的坑:MySQL 8+ 默认认证就是 caching_sha2_password,插件若靠动态加载,运行时在应用沙箱里找不到插件文件,连接直接报错;GSSAPI、ed25519 等用不到的直接 OFF。

4.4 musl 兼容桩(native/compat/musl_compat.h)

这些库默认按 glibc 假设编译,交叉链接时会报一串 undefined symbol。逐个改库源码既脏又不可持续——每个库每个版本都要重新打补丁。最终解法是一个兼容桩头,通过 -include 强制注入到所有编译单元:

export CFLAGS="$CFLAGS -include $NATIVE_ROOT/compat/musl_compat.h"
符号处理
backtrace / backtrace_symbolsmusl 无 <execinfo.h>,libmariadb 崩溃诊断路径会引用,提供空桩
malloc_usable_sizeglibc 扩展,用于内存统计,返回 0
program_invocation_name / program_invocation_short_nameglibc 全局变量,声明为外部符号
__libc_current_sigrtmin / sigrtmaxlibpq 代码路径引用,返回常量
secure_getenvmusl 无,转发到 getenv
PTHREAD_MUTEX_ADAPTIVE_NP / ROBUST_NP缺失宏,补 0

库源码零改动,升级库版本时不需要重新维护补丁集。

4.5 组包:rpath、SONAME 与插件目录

NAPI 模块的 CMake 指向 native/prebuilt/arm64-v8a/,链接 sqlite3 / mariadb / pq / ssl / crypto 加 SDK 的 ace_napi.z / hilog_ndk.z,并用 POST_BUILD 把运行期依赖的 .so 自动拷进 libdbclient.so 同目录:

target_link_options(dbclient PRIVATE "-Wl,-rpath,\$ORIGIN")

add_custom_command(TARGET dbclient POST_BUILD
  COMMAND ${CMAKE_COMMAND} -E copy_directory
          "${PREBUILT_DIR}/lib" "$<TARGET_FILE_DIR:dbclient>"
  COMMAND ${CMAKE_COMMAND} -E copy_if_different
          "${PREBUILT_DIR}/lib/libpq.so.5.15"
          "$<TARGET_FILE_DIR:dbclient>/libpq.so.5")

最后那条 copy_if_different 是个很具体的坑:动态链接器按 SONAME 加载,而 libpq 实体文件名是 libpq.so.5.15、SONAME 是 libpq.so.5,只拷实体文件会在运行时找不到库。所以必须额外补一份按 SONAME 命名的副本。libmariadb / libssl / libcrypto 恰好 SONAME 与实体名一致,才没暴露这个问题。

CMake 里还有一段条件编译:只有 libpq.so 与 libpq-fe.h 都在,才定义 HARMONDB_WITH_LIBPQ 并链接 pq,否则 PostgreSQL provider 退化为桩实现。这样即使某个库还没编好,UI 与其余功能也不会被阻塞构建。

4.6 连接失败要给人话,不要给 mysql_error 原文

真机连库失败时,把原始错误原样抛给用户体验很差。MySQL provider 会识别错误特征并给出可操作提示:

if (originalErr.find("caching_sha2_password") != std::string::npos ||
    originalErr.find("Authentication plugin")  != std::string::npos) {
  err = "MySQL 8.0+ 默认使用 caching_sha2_password 认证...请在连接配置中"
        "将驱动类型切换为「MySQL 8.0+ (新版)」,或在服务器端执行: "
        "ALTER USER '用户名'@'主机' IDENTIFIED WITH mysql_native_password BY '密码';";
}

PostgreSQL 侧同理,对 password authentication failed / does not exist / Connection refused 三类高频错误分别给出中文诊断。这类「错误文案工程」在桌面工具里价值很高——它把排查成本从「搜错误码」降到「照着提示改配置」。


五、ArkTS 领域层的几个关键设计

5.1 数据网格:不是一句 SELECT *

数据网格是数据库工具的体验核心,它的数据流由 DataGridLoader 管理。一次刷新分三步:

  1. 探针查询拿列结构:首次进入用 LIMIT 0, 1 探一次,拿到列名、类型、是否主键、最大长度;
  2. 分页取数据:按 pageSize(默认 100)构造 SELECT ... LIMIT n OFFSET m;
  3. 单独统计总数:另发一条 SELECT COUNT(*)(同样带过滤条件)驱动分页控件。

SQL 全部在 ArkTS 侧拼接,排序、过滤、列裁剪都在这一层:

SELECT <列清单> FROM `db`.`table` WHERE <过滤条件> ORDER BY `col` DESC LIMIT 100 OFFSET 0

三个值得注意的设计:

  • 列裁剪的触发条件很克制:只有表存在主键时才逐列写清单;无主键或全短列直接 SELECT *。原因很简单——逐列写清单是为了给超长文本列套 LEFT() / SUBSTR() 截断,没有主键时这套改写对用户没有收益,反而增加 SQL 复杂度。
  • 长文本截断:对 TEXT / JSON / ENUM / SET / ARRAY 这类文本列,若 maxLength > 1000 或长度未知(0),只取前 1000 个字符,避免把超大字段整个拉回内存拖垮网格。SQLite 用 SUBSTR,MySQL / PG 用 LEFT。
  • 过滤操作符结构化:eq / ne / like / notlike / gt / lt / gte / lte / isnull / isnotnull / raw。数值列走数字比较、文本列走转义后的引号比较,还支持直接写原生 WHERE 子句。标识符引用按方言区分:MySQL 用反引号,PG / SQLite 用双引号。

渲染侧用了 LazyForEach + 自定义 IDataSource:

class DisplayRowSource {
  private rows: DisplayRow[] = [];
  totalCount(): number { return this.rows.length; }
  getData(index: number): Object { return this.rows[index]; }
  // onDataReloaded / onDataAdded / onDataChanged …
}

DisplayRow 是「服务端行 + 编辑态」的合并快照。Loader 与 RowEditBuffer 都是长生命周期业务对象、刻意不进 @State,组件只把合并后的结果拷贝进 @State displayRows,再通知数据源刷新。这样 1000 行数据也不会一次性生成 1000 个组件节点,可视窗口之外的行按需调度。

请添加图片描述

5.2 行编辑缓冲区:桌面级编辑的「脏数据」模型

数据网格支持行内编辑 + 批量事务提交 / 回滚,核心是 RowEditBuffer。它把用户在网格上的改动先暂存,点「提交」前不写库,点「回滚」丢弃全部待提交编辑:

export interface PendingEdit {
  rowKey: string;                 // 行的唯一 key
  op: 'update' | 'insert' | 'delete';
  original: (string | null)[];    // 原始值(UPDATE/DELETE 的 WHERE 定位用)
  current: (string | null)[];     // 编辑后的值
  changedColumns: Set<string>;    // 被改过的列
}

几个关键决策:

  • rowKey 生成:优先用主键列值拼接(|| 分隔,NULL 用 \N 占位);无主键时退化为全部列值拼接——这也是「无主键表只能受限编辑」的由来;
  • 新增行的临时 key:__new_<时间戳>_<随机数>,避免与真实主键冲突;删除一个尚未提交的新增行时直接把它从缓冲区移除,不生成 DELETE;
  • 值语义约定清晰:空串 '' 和 NULL 是两件事,NULL 只能通过显式入口写入;数值列的空串在生成 SQL 时兜底按 NULL 处理;
  • 提交顺序固定:INSERT → UPDATE → DELETE,并用单个事务包起来(BEGIN → 逐条 → 失败 ROLLBACK → COMMIT)。

SqlBuilder 负责把缓冲区变成 SQL。这里有两个在真机上被验证过的「小修复」,非常值得写出来:

// 1) WHERE 里 NULL 必须用 IS NULL("col" = NULL 恒为假 → 永远命中 0 行)
if (v === null) {
  clauses.push(this.quoteIdent(c.name) + ' IS NULL');
} else {
  clauses.push(this.quoteIdent(c.name) + ' = ' + this.formatValue(v, c));
}

// 2) DELETE 影响 0 行不是成功,而是并发冲突信号
if (s.op === 'delete' && rs.affectedRows === 0) {
  this.conn.query('ROLLBACK');
  return { ok: false, executedCount: executed,
           error: 'DELETE 未匹配到任何行(该行可能已被其他会话修改或删除)' };
}

第一条是 SQL 语义层面的经典错误,第二条是产品语义层面的判断:桌面数据库工具经常多窗口、多人协作,把 0 行删除当成功会静默丢操作。这里选择回滚并明确报错,本质是一套轻量的乐观并发检测。

5.3 SQL 编辑器:自研词法高亮、格式化与补全降级

编辑器没有依赖现成高亮库,而是写了一个词法扫描器 SqlHighlighter.ets:把 SQL 切成 whitespace / comment / string / quotedIdent / number / keyword / function / datatype / operator / punctuation / identifier 十一种 token,再按 token 类型生成带颜色的 HTML 交给 RichText 渲染,明暗双主题各有一套色板。

扫描器只做词法、不做语法树,但该尊重的上下文都尊重了:-- 与 # 单行注释、/* */ 多行注释、单引号字符串(含反斜杠转义)、双引号、MySQL 反引号标识符、十六进制与科学计数法数字、以及「后跟 ( 才算函数名」的判定。

formatSql 也基于同一套 token 流,规则化地处理主关键字换行、括号层级缩进、列清单逗号换行、关键字大小写(upper / lower / preserve 三档可配):

export interface FormatOptions {
  keywordCase: 'upper' | 'lower' | 'preserve';
  indentUnit: 'spaces' | 'tabs';
  indentSize: number;
}

配套的 SqlExecutor 负责按 ; 拆多语句并逐条执行。拆分是词法级的:字符串、三种引号、单行/多行注释里的分号都不拆,反斜杠转义后的字符跳过。执行时统计每条语句的耗时、影响行数与错误,支持「首条失败即中止后续」:

const { results, summary } = executor.execute(sql, /* abortOnError */ true);
// summary: { totalStatements, successCount, failedCount, totalElapsedMs, aborted }

自动补全则是一个诚实的降级实现,注释里写得很明白:ArkTS TextArea 当时不暴露光标位置 API,也没有语法解析树,所以只能做「文本尾端倒推前缀 + 候选集 startsWith 匹配」:

  1. 从 SQL 文本尾部倒推最后一个 word 作为待补全前缀;
  2. 候选来源:关键字 / 类型 / 函数 Set + 传入的表名列表 +(表名 → 列名)字典;
  3. 轻量扫描 SQL 中 FROM / JOIN / UPDATE / INTO / TABLE 之后出现的表名,优先补这些表的列;
  4. 单次最多返回 60 条,按类别着色,选中后替换原前缀。

它不完美,但「能补关键字、能补当前表列、不会卡住输入」已经覆盖了编辑器 80% 的补全需求。这类「先降级再演进」的取舍在平台能力不完整时比硬做更划算。

请添加图片描述

5.4 结构元数据:三套方言,一个领域模型

结构管理不是把 SHOW CREATE TABLE 原样展示,而是把字段、索引、外键读成统一的 FieldDefinition / IndexDefinition / ForeignKeyDefinition,编辑后做 diff,再生成 DDL。StructureLoader 为此维护了三套查询:

元数据MySQL / MariaDBPostgreSQLSQLite
字段information_schema.columns(含 column_key / extra / column_comment)information_schema.columns + 主键列集合查询PRAGMA table_info
索引information_schema.statistics(按 seq_in_index 归并多列索引)pg_index + pg_class + pg_get_indexdefPRAGMA index_list + PRAGMA index_info
外键key_column_usage + referential_constraintspg_constraint + pg_get_constraintdefPRAGMA foreign_key_list

几处细节很能体现移植的复杂度:

  • PG 的 pg_get_constraintdef 返回一整段 FOREIGN KEY (...) REFERENCES ... ON DELETE ...,只能写解析器把它拆成列、引用表、引用列与两个动作;
  • PG 的 data_type 是 character varying 这种全称,需要归一化成 VARCHAR 才和编辑器里的类型下拉对得上;
  • SQLite 的 origin='pk' 表示主键自动索引,不可编辑,DDL 生成时必须跳过;
  • MySQL 的 column_key='PRI' 与 PG 的主键集合查询走的是完全不同的路径,但最终都落到同一个 isPrimaryKey 字段。

方言差异集中在加载与 DDL 构建层,UI 完全不感知——这也是这套设计最值得称赞的地方:同一个表结构编辑器,底下换数据库只需要换 loader 与 builder。

请添加图片描述
请添加图片描述

请添加图片描述

5.5 密码安全存储(HUKS)

SecureSecretStore 基于 @ohos.security.huks 实现,替代 Windows 上的 DPAPI / Eclipse Secure Storage 思路:

  • 密钥由 HUKS 生成并持久化存储,密钥永不出 HUKS,加解密都在 HUKS 内部完成;
  • 算法 AES-256-GCM,HUKS_PADDING_NONE,12 字节随机 IV + 16 字节认证标签;
  • 走 HUKS 标准三阶段会话:initSession → updateSession → finishSession;
  • 密文按 Base64(IV + ciphertext + tag) 拼接存储,每条密文独立;
  • 解密时把 tag 拆出来放进 HUKS_TAG_AE_TAG、密文单独喂入——GCM 的 tag 位置在 HUKS 接口里是有约定的,搞错就是「加密能过、解密必失败」;
  • 解密失败(密钥变更或数据被篡改)直接抛异常,不静默返回脏数据。
const initRes = await huks.initSession(KEY_ALIAS, initOpts);
await huks.updateSession(initRes.handle, { data: updateData });
const finishRes = await huks.finishSession(initRes.handle, { data: new Uint8Array(0) });
// 拼装:IV(12) + ciphertext(含 tag),整体 Base64

另外还有一个 testHuksAvailable() 自检:加密一段固定字符串再解密回环。密钥服务不可用时,这个自检能把「密码存不进去」与「连接参数写错了」这两类问题快速分开。


六、PC 形态适配与状态管理经验

「让它像桌面应用、而不是大号手机应用」是重点投入的部分。

窗口与主题:module.json5 的 deviceTypes 显式声明 2in1,随通用设备一起安装;EntryAbility.onWindowStageCreate 里主窗口拉起即 win.resize(1280, 800),符合桌面工作区习惯。这里有个必须知道的单位问题——resize 的参数是 px 不是 vp,在鸿蒙 PC 的高密度屏上若严格按 vp 布局设计,需要先用 display.getDefaultDisplaySync() 取 densityPixels 做换算,否则窗口实际只有设计尺寸的一半,模态对话框会超出窗口被裁剪。主题色全部引用集中色板,品牌常量单独收敛,保证明暗主题切换时颜色不散落。

右键菜单与键鼠交互:数据库树与表格统一走右键上下文菜单(刷新、新建对象、编辑、导出等),数据网格的行与列区分鼠标按键行为,列宽拖拽是独立组件并带悬停高亮,同时保持触控可用。

状态管理 V1 的两个真实坑:

  • 长生命周期对象不能进 @State:DbConnection、DataGridLoader、DbTreeLoader、RowEditBuffer 全部用 private 字段持有。原因很直接——@State 会用 Proxy 包裹对象,破坏 private 字段访问,还会让 NAPI 句柄这种原生资源变得不可控。页面之间只传整数 connId,需要时从 ConnRegistry 取连接。
  • 嵌套字段写入不具备可观察性:this.vm.editing.kind = 'sqlite' 这种写法不会触发依赖 editing 的表达式重新求值,界面会停在旧状态。正确做法是把结构性联动收敛成方法,重建对象并整体重新赋值:
changeKind(kind: DbKind): void {
  const next: SessionEditing = { /* 逐字段显式拷贝 */ };
  next.kind = kind;
  this.editing = next;   // 整体赋值才会触发依赖 editing 的表达式重新求值
}

配套还有「结果拷贝进 @State + refreshTick++ 手动重建」的模式:Loader 算完数据后同步到 @State 快照,再递增一个 tick 触发重渲染。这不是优雅方案,但在 V1 装饰器体系下是可靠方案。


七、功能走查

以下按用户使用路径整理,截图均来自鸿蒙 PC 实机。

7.1 连接管理

应用启动先进入会话管理:左侧会话列表(新建 / 保存 / 删除),右侧「连接属性」面板覆盖名称、类型(MySQL / MariaDB、PostgreSQL、SQLite)、主机、端口、用户、密码、数据库、字符集,以及驱动类型、SSL 模式、查询超时、Keep-Alive 等高级项。连接前可先「测试连接」,成功后回显服务器版本与 SSL 状态。密码经 HUKS 加密存储,不以明文落盘。

请添加图片描述

7.2 工作台与数据库树

连接成功后进入工作台:左侧数据库树按「实例 → 数据库 → 表 / 视图」分层展开,支持按名称过滤;树节点绑定统一的右键菜单。树是懒加载的——展开时才发查询,flattenVisible 把展开节点拍平成渲染行,过滤时保留匹配节点及其祖先链。SQLite 这类单库数据源会收敛成虚拟库节点,交互与 MySQL / PostgreSQL 保持一致。

7.3 SQL 编辑器与查询历史

SQL 脚本标签支持关键字高亮(多主题可切换)、多语句一次提交、结果按语句分区展示;左侧竖排工具条提供执行、执行模式(全部 / 选中 / 当前语句)、清空、格式化、查询历史与数据源标识。查询历史独立持久化,可回看历史 SQL 与执行时间。

请添加图片描述

7.4 建表与新建对象

新建对象走统一菜单(表 / 视图 / 存储过程 / 触发器 / 事件等),表设计器按字段、索引、外键分区编辑,保存时由 AlterTableBuilder / IndexFkBuilder 生成 DDL。

请添加图片描述

请添加图片描述

7.5 数据导入导出

数据可导出为 SQL 文件或 CSV,CSV 亦可导入。导入是逐条 INSERT 并逐条检查 rs.ok 的:批量拼接多句 INSERT 时,只要中间某句失败,后续语句和整体结果都会被吞掉,所以这里宁可慢一点也要拿到准确的行号错误,失败时回滚并给出提示。
请添加图片描述

请添加图片描述

7.6 对象与运维

对象编辑器延伸到视图 / 存储过程 / 触发器 / 事件,另有用户管理与偏好设置(字体、字号、每页行数、NULL 显示、驱动、超时、高亮主题等)。偏好链路前端到后端统一用 @Prop + @Watch,保存后重新构造子组件参数触发热更新。

请添加图片描述
请添加图片描述

请添加图片描述

请添加图片描述

八、环境要求与构建步骤

项目要求备注
IDE / SDKDevEco Studio(含 HarmonyOS SDK)提供 OHOS LLVM/Clang、musl sysroot、hvigor、CMake
交叉编译工具cmake、make、curl、tar、xz、unzipcmake 优先用 SDK 自带
目标设备2in1(鸿蒙 PC)及通用设备deviceTypes = ["default", "2in1"]
权限ohos.permission.INTERNET连接远程数据库必需,已在 module.json5 带用途说明声明

整个流程是「交叉编译 C 库 → 构建签名 HAP → 装到鸿蒙 PC」:

# 1. 交叉编译数据库客户端 C 库(仓库已附带预编译产物,常规开发可跳过)
cd native/build && ./build_libs.sh all

# 2. 构建签名 HAP(DevEco 里 Signing Configs 点 Fix 生成调试签名后 Run,或命令行)
export JAVA_HOME=/Applications/DevEco-Studio.app/Contents/jbr/Contents/Home
export DEVECO_SDK_HOME=/Applications/DevEco-Studio.app/Contents/sdk
export PATH="$JAVA_HOME/bin:/Applications/DevEco-Studio.app/Contents/tools/hvigor/bin:$PATH"
hvigorw assembleHap

# 3. 安装并启动
hdc install -r entry/build/default/outputs/default/entry-default-signed.hap

产物落在 native/prebuilt/arm64-v8a/(.so + 头文件),native 依赖库全部随 HAP 打包,单文件即可分发安装。native/prebuilt 随仓库分发,日常开发无需每次重跑交叉编译。

首次使用建议先用 SQLite:只需填一个本地文件路径,最容易跑通「连接 → 浏览 → 查询 → 编辑」全链路。


九、能力分级与当前边界

仓库内的 README.OpenHarmony_CN.md 按社区规范给出了完整的 T0 / T1 / T2 能力分级,这里给出结论性摘要。

T0(基础能力,已达成):HAP 构建 / 签名 / 安装 / 启动;2in1 桌面窗口与主题适配;NAPI 桥接闭环;三套 C 客户端库 musl 交叉编译;MySQL / MariaDB(TCP + TLS)、PostgreSQL(TCP + TLS)、SQLite(本地文件)三类连接;密码加密存储。

T1(主要能力,已达成):会话管理(新建 / 编辑 / 复制 / 删除 / 测试连接);数据库树分层导航、过滤与右键菜单;数据网格浏览 / 编辑双模式、列宽拖拽、快速与条件过滤、行内编辑 + 批量事务提交 / 回滚、脏数据守卫、无主键表风险告警、BLOB / TEXT 专用编辑器;SQL 编辑器高亮、多语句执行、执行模式切换、查询历史、格式化;SQL / CSV 导出与 CSV 导入。

T2(增强能力,部分达成):数据库 / 表对象编辑器、视图 / 存储过程 / 触发器 / 事件管理、用户管理、偏好设置、TLS 连接配置已交付;SQL 自动补全为降级实现;SSH 隧道、ER 图 / 数据建模、任务调度 / 仪表盘监控未排期;Oracle / SQL Server / MongoDB / Redis 等其余数据源按 provider 模式逐步扩展。

需要如实说明两点边界:数据编辑的批量提交依赖目标表具备主键或唯一键,无主键表提供的是带风险告警的受限编辑;上游的 JDBC 驱动框架与 OSGi 插件体系与原生架构不兼容,本项目以「源码级 provider 扩展」替代「装 JDBC 驱动包」这条路。


十、总结分析

把 DBeaver 搬到鸿蒙 PC,真正的工作量并不在「画界面」,而在三处结构性差异上。

第一,复用边界要划在协议层,而不是 UI 层。 数据库客户端最容易低估的是协议实现:认证插件、TLS、字符集、类型映射、错误码,每一项都是十几年打磨的结果。这里的取舍是——UI 与工作流 100% 重写,协议 100% 复用 C 库,桥接层只做统一形状,不做业务。结果是加一个新数据库只需要写一个 provider 和一套元数据查询,UI 一行不改。

第二,跨语言桥接的关键是「形状收敛」,不是「功能映射」。 ResultSet 把列值统一成字符串 + 独立 NULL 标记,看起来放弃了类型信息,实际上换来的是三个 provider 完全一致的出口结构,类型解析集中在一层做。句柄表 + shared_ptr + 锁外析构这套组合,解决了原生资源跨 GC 边界的管理问题。这两点比多写几个 API 重要得多。

第三,平台差异要当设计输入,而不是事后补丁。 musl 兼容桩、三元组双写、CMake 的 TRY_RUN、SONAME 命名、resize 的 px/vp 单位、状态管理 V1 的嵌套刷新——每一个都不是「代码写错了」,而是「平台假设不同」。把它们集中到脚本与工具层(env.sh、musl_compat.h、build_libs.sh),而不是散落到业务代码里,是这类移植能持续维护的前提。

回头看,这个项目最值得同类项目借鉴的可能是那条**「先降级、再演进」**的节奏:补全先做前缀匹配,虚拟化先做 LazyForEach,PostgreSQL 先做条件编译桩——每一步都保证「能编译、能安装、能启动、核心链路可用」,再把能力一格一格补上。桌面工具的用户预期很高,但只要「连库、查数、改数据」这条主线是通的,它就已经是一个可日常使用的工具。

开源鸿蒙 PC 需要的正是这类基础设施软件。项目代码已托管在 AtomGit,无论是 provider 扩展、数据网格性能打磨,还是新场景的适配反馈,都欢迎参与共建。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐