xChar
·11 days ago

最近将 Folo 桌面端和移动端中的状态管理合并到了同一的模块中,就想着记录一下相关的设计和踩坑经验。(很多是从 Innei 的实践中总结出来的,学习到了很多)

文章大概会有两到三篇,本文中主要介绍数据库的选型和整合。

为什么需要数据库?

如果应用较为简单,一般可以直接使用 TanStack Query / SWR 的 Cache 来持久化请求到的数据,以改善应用首屏加载的加载体验。但是这样的话,一般对于缓存数据的操作会比较麻烦,也可能缺少类型安全。因此手动控制数据的持久化和预加载,将缓存的管理变得和 TanStack Query/SWR 无关,可能长期看来更好维护。

数据库的选型

因为在移动端使用了 Expo SQLite, 为了保持数据库 schema 一致,避免写两套数据库操作的代码,在桌面端就使用了 SQLite WASM 的方案。或许也可以看看 PGlite

在浏览器中运行 SQLite 一般可以使用以下几个库:

  • sql.js 已知的第一个在 Web 浏览器中直接使用 sqlite3 的程序
    • 只支持内存数据库,除了一次性导入导出整个数据库文件外,不支持持久化。
  • wa-sqlite 已知的第一个的 OPFS 存储实现 sqlite3 数据库,支持很多类型的 VFS(来源)
  • SQLite Wasm sqlite3 WebAssembly 的 javascript 包装
    • SQLocal,在 SQLite Wasm 上构建,添加了更高级别的抽象,以便与 SQLite Wasm 交互(来源)。包括与 Kysely 和 Drizzle ORM 的集成。

关于这三者的比较相关信息,可以查看 how is this different from the @rhashimoto/wa-sqlite and sql.js?。从暴露出来的 API 访问级别来看是,SQLite Wasm < wa-sqlite < sql.js,SQLite Wasm 最底层。

最后,SQLocal 是 Folo 桌面端的数据库方案,因为它基于官方的 SQLite Wasm,由 SQLite 核心团队构建,在维护方面的表现应该会更好(来源)

SQLite 在浏览器中的运行模式

SQLite 在浏览器中的运行模式主要有三种,在 sqlite3 WebAssembly & JavaScript Documentation 中有详细的介绍。

  • Key-Value VFS (kvvfs):在主 UI 线程中运行,使用如 localStorage 或 IndexedDB 来持久化数据。问题是存储空间有限,性能相对较差。
  • The Origin-Private FileSystem (OPFS):在 Worker 中运行,OPFS 对于浏览器的要求相对较高,需要 23 年 3 月之后的浏览器版本。
    • OPFS via sqlite3_vfs:需要 COOPCOEP HTTP 标头以使用 SharedArrayBuffer,这个要求较高,比较难以满足。对于图片的加载和外站资源的引入都需要额外的配置。
    • OPFS SyncAccessHandle Pool VFS:不需要 COOP 和 COEP HTTP 标头,性能相对更好,但不支持并发连接,文件系统不透明(即并非将数据库保存为一个 sqlite 文件)

这些运行模式各有优劣,第一种性能较差,存储空间有限,但对浏览器的要求最低,因此仍有很多应用使用它来存储数据库到 indexedDB。第二种对于 COOP 和 COEP HTTP 标头的要求较高,难以满足,但第三种的并发支持又比较麻烦有限。因此,可以在条件允许的情况下,使用第二种,否则回退到第三种。值得一提的是,PGlite 的文件系统也很相似,在浏览器中同样是 In-memory FS、IndexedDB FS、OPFS AHP FS 三种 (来源)

前面提到 OPFS SAH 不支持并发,默认情况下,用户打开两个窗口时就会出错。要如何解决呢?需要从多个客户端中协商出一个可以执行查询的,然后暂停其他客户端的使用。PGlite 也有类似的 Multi-tab Worker 实现。目前 SQLocal 还没有对 OPFS SAH 的支持,相关的 issue 可以查看 Allow using sqlite's OPFS_SAH backend。我基于作者的实现分支进行了一些探索,实现了基础的支持,但目前测试还未完全通过 (PR)

所以 Folo 中会使用哪种运行模式呢?在本地使用网页代理来开发时,由于跨源运行 worker 的限制,会使用 Key-Value VFS;网页端和桌面端的生产环境中,因为 COOP 和 COEP HTTP 标头的条件无法满足,使用 OPFS SAH VFS;

不过桌面端 Electron 中,也可以直接开启 SharedArrayBuffer 的支持,来使用 OPFS via sqlite3_vfs。

app.commandLine.appendSwitch("enable-features", "SharedArrayBuffer")

值得一提的是,由于 Electron 中使用的协议不同,一般是 file:// 或是自定义的 app://,因此为了访问安全环境的中才有的 API,需要注册协议。

// https://github.com/getsentry/sentry-electron/issues/661
protocol.registerSchemesAsPrivileged([
  {
    scheme: "sentry-ipc",
    privileges: { bypassCSP: true, corsEnabled: true, supportFetchAPI: true, secure: true },
  },
  {
    scheme: "app",
    privileges: {
      standard: true,
      bypassCSP: true,
      supportFetchAPI: true,
      secure: true,
    },
  },
])

由于 registerSchemesAsPrivileged 这个 API 最好只被调用一次,所以如果使用了 sentry 的话,推荐将它的的 registerSchemesAsPrivileged 调用给 patch 掉,然后在自己的代码中调用。

如何为多端复用代码?

显然桌面端和移动端的 SQLite Client 是不同的,所以在打包的时候需要为不同的平台导入不同的文件。Folo 的代码使用后缀来区分,比如 db.desktop.ts 用于桌面端,db.rn.ts 用于移动端。Vite 可以通过插件来实现(代码),Metro 可以通过自定义 resolver.resolveRequest 来实现(代码)

这样就可以给每个平台提供不同的数据库实现了。db.ts 中定义类型,db.desktop.tsdb.rn.ts 中实现具体逻辑。这里由于使用了 Drizzle ORM,所以自然用上了 Drizzle 的数据表类型定义,来给数据库的操作提供一定的类型安全。至于实际的数据库操作,则和平常写 Drizzle 的代码没有区别。

// db.ts
import type { BaseSQLiteDatabase } from "drizzle-orm/sqlite-core/db"

import type * as schema from "./schemas"

type DB =
  | BaseSQLiteDatabase<"async", any, typeof schema>
  | BaseSQLiteDatabase<"sync", any, typeof schema>

export declare const sqlite: unknown
export declare const db: DB
export declare function initializeDB(): void
export declare function migrateDB(): Promise<void>
export declare function exportDB(): Promise<Blob>

数据库迁移

  • Drizzle Kit 有非常好用的 migrate 工具,可以通过 drizzle-kit generate 命令来生成迁移文件。它和 Expo SQLite 的整合使用已经有完善的文档来说明,这里不多赘述。桌面端的迁移可以基于这套方案。
  • 因为 migrate 的运行时代码并不依赖 Node,所以也可以在 Web 端来运行(代码)
  • 由于生成的 SQL 文件引入语句是直接 import 的,所以为了照顾移动端,这里不使用 Vite 的 ?raw,而是自定义一个插件,将 SQL 文件文本转成正常的 js 模块导出(代码)

最后

这一套下来就能在 Folo 中使用单独的包来维护数据库增删改查相关的逻辑,并且多端的代码实现了复用,减少维护的成本和潜在的实现不一致导致的问题。

最后留一个小 Tip,Drizzle ORM 的更新操作处理更新值的时候有些麻烦,需要手写每一列名,且没有类型安全,可以创建一个简单的 helper 函数(来源)

阅读更多

Loading comments...