WonderPen 存储格式迁移历程
最近几年我一直在开发写作软件 WonderPen,去年我对 WonderPen 的底层数据存储格式做了一个重大重构,将原本使用 JSON 存储的数据迁移到了 SQLite 数据库。这儿回顾记录一下相关要点。
原本的方案
WonderPen 是一款写作应用,用户可以使用它写文章或笔记,以下统称为文档,一个非常底层的基础需求是如何存储这些文档。
在最早的设计中 WonderPen 功能不多,为了让系统尽可能简单以便尽早发布,我采用了 Markdown 文件来存储文档,每个文档就对应硬盘上的一个 Markdown 文件。
这个方案非常直观,也非常有效,不过,一段时间之后,我发现单纯的 Markdown 只适合记录文档内容,但真实写作场景中,每个文档还有很多额外信息需要记下来,比如基本的添加、修改时间、版本号,以及一些 WonderPen 特色功能(比如每个文档都有对应的备注、写作目标等等)。
虽然这些信息也可以使用 Markdown 头信息或自定义扩展格式来记录,但扩展和处理起来终究有一些不便。再加上 WonderPen 的设计目标并不是做一个 Markdown 编辑器,只是“刚好”选择了 Markdown 格式,且这些文档的原始文件都存储在被称为“文档库”的文件夹中,并不向用户直接暴露。于是,我索性激进一些,将文档的存储格式改为了 JSON 文件格式,一个文档对应一个 JSON 文件,同时有一些专门的 JSON 文件用于记录各种关系数据和额外的属性信息。
之后的几年里,这套系统运行良好,为了管理好这些 JSON 文件,我甚至还专门开发了一个简单的基于 JSON 文件的本地文本数据库系统。
这个方案还有一个额外的好处:用户可以直接把文档库建在 OneDrive、Dropbox 等云盘内,使用第三方云盘来同步数据。虽然偶尔也会遇到云同步时的文件冲突,但总体来说问题不大。
为什么要迁移?
这个方案在初期确实很好,不过,随着软件的不断迭代,使用 JSON 存储数据的缺点也逐渐暴露了出来。
WonderPen 的主要使用场景之一是写长篇著作,比如一部有数百个章节的书籍,或者网络小说。在这个场景中,章节之间并不是独立的,首先章节天然有先后顺序,且这个顺序不一定和文件名的顺序一样,因此除了存储文档的 JSON 之外,还需要一些 JSON 文件来存储章节目录顺序等元信息。
同时,一些新的需求和场景也在不断出现,比如对全库进行搜索、替换等操作,以及快速读取各个章节的备注内容等操作。如果每个文档对应硬盘上的一个 JSON 文件,当文档数量很多时,搜索、批量替换等操作意味着要在短时间内对大量的小文件进行读写,这个操作是复杂且低效的。
一个方案是在每次启动时把整个文档库的所有数据读入内存,之后所有的操作都在内存中处理,并定期写回硬盘。这确实是一个可行的方案,我曾经认真考虑过,且对写作应用来说,用户作品的主要内容是纯文本,即使数百万字的文档库,加上各种额外数据,理论上也不过占用十几兆至几十兆内存,对现代设备来说不会有太大的压力。
不过我最后还是没有采用这个方案,因为深入评估之后,我发现要让一些操作尽可能高效,我需要引入不少复杂的算法,如果沿着这条路走下去,我实际上是在实现一个简化但基本功能都有的内存数据库系统。
既然这样,为什么不直接使用已有的成熟数据库呢?
文档库中的数据是高度结构化的,且用户并不会直接访问这些原始数据,要进一步高效地处理这些数据,使用数据库就成了自然的选择。
使用 SQLite
定下方向之后,接下来就简单多了。
现在可供客户端(包括桌面端和移动端)使用的数据库有不少方案,有一些还自带了云同步的功能。不过综合比较之后,我还是选择了最成熟的 SQLite。
接下来就是客户端的改造了。WonderPen 桌面端基于 Electron,下面重点介绍一下桌面端的处理。
在 Node.js 22.5 之前(对应 Electron 35 之前),Node.js 没有内置 SQLite 支持,因此在 Electron 中使用 SQLite 需要安装第三方库,比较知名的有 sqlite3、better-sqlite3、libsql 等,其中比较推荐的是 better-sqlite3 和 libsql。
不过 Node.js 22.5(对应 Electron 35)开始内置了 SQLite 支持,如果你在使用 Electron 35 或更新的版本,可以考虑直接使用 Node.js 内置的 SQLite 支持。
WonderPen 刚开始向 SQLite 迁移时,Electron 35 还没有发布,因此只能使用第三方库。由于 better-sqlite3 在性能等测试上表现优异,我便也选择了它。
不过,由于 better-sqlite3 包含原生 Node addon(.node 文件),随之而来的缺点是在 Electron 打包时需要编译一些原生模块,可能会遇到一些环境或配置上的麻烦。好在这些麻烦是一次性的,加上现在 AI 已经足够聪明,遇到问题问一下 AI 基本就能搞定,只需搞定一次配置,后续升级、打包时都可以复用。
libsql 原本是 Turso 这家公司为自己的 SQLite 服务写的库,可以访问由 Turso 提供的远程 SQLite 服务,但它并没有和 Turso 强绑定,也可以用作一个通用的访问本地 SQLite 的库。
有评测表示 libsql 的性能比 better-sqlite3 差一些,约 20% 这样的数量级,但对大多数桌面或移动端应用来说这一点性能损失影响很小,因为多数应用的性能瓶颈本并不在数据库上。
和 better-sqlite3 相比,libsql 内置了所需的二进制文件,不需要额外编译,因此在 Electron 中打包时非常方便,就和安装普通 npm 包一样。
你可以根据自己的实际情况,决定选择 better-sqlite3 还是 libsql。
在 WonderPen 项目中,我曾经同时使用了 better-sqlite3 和 libsql,正式项目中使用的是 better-sqlite3,以获得更好的性能,但在测试用例中使用了 libsql,以简化配置。因为测试环境是纯 Node.js 环境,和 Electron 环境的 ABI 不同,如果两边都使用 better-sqlite3,就需要在同一个库中安装和编译两个不同的版本,比较麻烦。
另外,如果性能要求不是很高,也可以直接在正式项目中也使用 libsql,配置会简单很多。不过在写作本文时,如果你在使用 Electron 35 或更高的版本,最简单的方案是直接使用 Node.js 内置的 SQLite 库。
目前,WonderPen 也已经从 better-sqlite3 和 libsql 迁移到了 Node.js 内置的 SQLite 库。虽然内置的 SQLite 库现在还被标记为“Release candidate”,但实际使用下来已经足够稳定,基本没有遇到问题。
移动端
WonderPen 的移动端一开始是基于 Flutter 的,后来迁移到了 Capacitor.js。和桌面端一样,移动端也使用了 SQLite。
我在桌面端和移动端之间共享了数据库迁移文件,确保两端的数据库结构完全一样。这样的好处是只需要维护一套数据结构,开发时心智负担会小很多,且一些代码和流程可以直接复用。理论上,两端的数据库文件也可以直接复用。
ORM
WonderPen 没有使用传统的 ORM 库,但也没有直接拼 SQL 语句,桌面端最早使用了 Knex.js,后来改用了 Drizzle.js。
改用 Drizzle.js 的原因是它能很容易地同时运行在 Electron 和 Capacitor.js 环境,而且它支持 Proxy,意味着你可以自己抽象一层代理,无论底层运行的是 better-sqlite3、Node.js SQLite 库还是 Capacitor.js 的 SQLite 库,都可以使用 Drizzle.js 包装一层,对外使用一样的接口。这样,Electron 端和 Capacitor.js 端涉及数据库操作的业务代码可以完全共享,保证关键逻辑一致。
其他
当然,从文件存储迁移到 SQLite 也不是完全没有代价的,最大的问题是如果数据库文件损坏,可能所有文档都无法再正常读取。
SQLite 本身足够强健,正常使用一般不会出现文件损坏。但一些用户可能会将数据库放到 OneDrive 等云盘中,使用第三方云盘在不同设备之间同步数据。WonderPen 在使用时对数据库的读写很频繁,每次文档修改都可能会触发云盘同步,如果 SQLite 文件比较大或网络比较慢,经常会发生上次同步还没完成文件又被修改了的情况,有时候,云盘在处理同步时可能会出错,导致 SQLite 文件损坏。
以前使用 JSON 文件存储数据时这个问题不明显,因为即使同步出错也最多影响那一个文件,但 SQLite 数据库文件本身是一个大的二进制文件,一旦出错,有一定概率损坏数据库的格式,导致这个数据库文件后续无法正常读写。
简单来说,OneDrive 等云盘不适合存储比较大且会频繁变化的二进制文件。
好在这种情况一般只在将 SQLite 数据库文件放在云盘中同步时才会发生,如果是放在普通盘,基本不会遇到什么问题。
小结
如果应用的数据量比较小,且没有复杂的查询需求,使用文本文件或 JSON 文件存储数据是简单可行的方案。不过,如果数据比较多,或者开始有复杂的数据关系或查询需求时,应该考虑使用数据库方案。
对本地应用来说,SQLite 数据库是不错的选择。如果有桌面端、移动端等不同的版本,可尽量保证底层数据结构一样,以便共享数据结构以及操作相关的代码,减小工作量。