Hyoban

Hyoban

Don’t do what you should do, do you want.
x
github
telegram
follow
email

Folo 中的狀態管理 - 資料庫篇

最近將 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 函數(來源)

閱讀更多#

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。