本地数据库和多端数据同步
## 前言
数据持久化存储是每个桌面端应用基本上都会面对的问题,作为前端开发者,我们通常会使用 localstorage 或者 indexedDB 作为我们的存储方式。
但是在桌面端应用开发中,我们通常会使用 SQLite 或者 PouchDB 作为我们的存储方式,它们分别是关系型数据库管理系统(RDBMS)和 NoSQL 数据库中的代表工具。至于对于 Rubick 而言,选择什么类型的数据库我们来简单分析一下。
| 特性 | SQLite | PouchDB |
|---|---|---|
| 类型 | 关系型数据库管理系统(RDBMS),以单个文件形式存储数据库,可以通过 SQL 进行数据操作。 | NoSQL 数据库,特别适用于在浏览器和 Node.js 环境中工作,并支持实时数据同步。 |
| 适用场景 | 用于需要在单个设备上进行数据存储和处理的应用程序,例如移动应用程序或桌面应用程序。通常用于需要严格的数据结构和复杂查询的场景。 | 更适合需要在不同设备之间同步数据的应用程序,尤其是需要离线数据操作和实时同步的情况。它可在离线状态下操作数据,并在重新联网时与远程服务器同步。 |
| 架构 | 传统的关系型数据库,使用SQL进行数据操作,支持复杂的查询语言和事务管理。 | 基于文档的数据库,使用JavaScript API来操作数据,而不是SQL。支持JSON文档,并且与CouchDB等兼容,能够在离线状态下工作。 |
| 数据同步 | 通常需要手动实现数据同步和复制来实现设备之间的数据一致性。 | 专注于实时数据同步,并内置了处理多设备间数据同步的功能。 |
| 跨平台支持 | 支持各种平台,但需要特定的驱动程序来与不同编程语言和环境交互。 | 能够无缝地在浏览器和Node.js环境中运行,并且可以直接在这些环境中使用JavaScript API。 |
再来谈谈 Rubick 数据库使用的场景:首先我们的用于数据持久化存储的场景大多是插件产生的数据,我们并不需要插件开发者特别关系数据表的数据结构。其次插件可能需要存储各种类型的离线文件,比如 OCR 插件需要存储图片文件。最后我们的用户可能有多台设备,需要进行跨设备间的数据同步。
基于此前提下,我们选择的是 PouchDB 作为 Rubick 的本地存储数据库。
引入 PouchDB
在 Electron 中引入 PouchDB 也是非常方便,首先需要安装 PouchDB 的依赖包:
npm install pouchdb --save
# or
yarn add pouchdb然后在项目中引入它:
import PouchDB from 'pouchdb';如果不出意外在运行的时候就要报错了:
App threw an error during load
Error: No native build was found for platform=xxx ...这个是因为 pouchdb 本身依赖了 leveldown 模块,该模块是一个基于 node-gyp 构建的 .node 二进制模块。在 Electron 中,对于这类二进制模块的应用需要进行单独打包,因为我们使用的是 electron-builder 构建,那么就可以直接添加如下配置:
externals: [
'pouchdb',
]PouchDB 初始化
初始化一个 PouchDB 也非常简单:
new PouchDB([name], [options])其中,name 是数据库名称,是一个必填值。Options 包含数据库创建的一些参数设置,比如在 Rubick 中我们实例化一个 db 实例:
export default class DB {
public dbpath;
public defaultDbName;
public pouchDB;
constructor(dbPath) {
this.dbpath = dbPath;
this.defaultDbName = path.join(dbPath, 'default');
}
init() {
this.pouchDB = new PouchDB(this.defaultDbName, { auto_compaction: true });
}
// ...
}auto_compaction 是 PouchDB 中一个配置属性,用于指定数据库是否开启自动压缩。
数据库的增删改查
1. 增加数据
我们要实现向数据库中增加数据的 API,其 API 调用方式大致为:
// 创建数据
db.put({
// 键
_id: "demo",
// 值
data: "demo"
})
// 返回 {id: "demo", ok: true, rev: "1-05c9b92e6f24287dc1f4ec79d9a34fa8"}那么实现方式如下:
export default class DB {
// ...
async put(doc) {
try {
// 调用 pouchDB.put 函数
const result = await this.pouchDB.put(doc);
doc._id = result.id;
return result;
} catch (e: any) {
return { id: doc._id, name: e.name, error: true, message: e.message };
}
}
}2. 修改数据
修改数据需要向 pouchDB 中提供额外的索引参数 _rev,这是代表此文档的版本,每次对文档进行更新时,都要带上最新的版本号,否则更新将失败,版本化的意义在于解决同步时数据冲突。更新 API 使用方式如下:
db.put({
_id: "demo",
data: "demo",
_rev: "1-05c9b92e6f24287dc1f4ec79d9a34fa8"
})对应的实现函数依然是之前的 db.put。
3. 获取数据
获取数据需要向 pouchDB 传入一个 id 作为键值,使用示例:
db.get("demo")
// 返回 {_id: "demo", _rev: "3-9836c5c68af5aef618e17d615882942a", data: "demo"}实现方式:
export default class DB {
// ...
async get(id) {
try {
return await this.pouchDB.get(id);
} catch (e) {
return null;
}
}
}4. 删除数据
删除数据需要向 pouchDB 传入一个文档对象或文档 id 进行操作,使用示例:
db.remove("demo")
// 返回 {id: "demo", ok: true, rev: "2-effe5dbc23dffc180d8411b23f3108fb"}实现方式:
export default class DB {
// ...
async remove(doc) {
try {
let target;
// 如果是对象
if (typeof doc === 'object') {
target = doc;
// 判断是否存在 _id
if (!target._id || typeof target._id !== 'string') {
return this.errorInfo('exception', 'doc _id error');
}
} else {
// 如果也不是 string 则报错
if (typeof doc !== 'string') {
return this.errorInfo('exception', 'param error');
}
// 先获取对象内容
target = await this.pouchDB.get(this.getDocId(doc));
}
// 删除对象
return await this.pouchDB.remove(target);
} catch (e) {
return this.errorInfo(e.name, e.message);
}
}除了增删改查单条数据外,PouchDB 还有批量增加和删除数据的功能:db.bulkDocs、db.allDocs。
具体实现细节和整个完整案例可以见 Rubick 源码关于数据实现部分:https://github.com/rubickCenter/rubick/blob/master/src/core/db/index.ts
多端数据同步
Rubick 数据存储是基于 pouchdb 的,所以数据同步的方案可以有:
- 直接把
pouchdb里面的数据以文件的形式导出,其他设备再以文件的形式导入。 - 基于
webdav,把数据先导入到支持webdav的云盘,其他设备再从云盘导入 rubick。 - 自建中央数据存储服务器,然后将
pouchdb数据同步到云服务器。
方案一是可行的,但是不咋方便,因为虽然导出了文件,但是文件还是需要手动同步到另一台设备。方案三也是可行的,但是开源项目搞一台存储服务一方面是没钱,另一方面是把用户数据同步到自己的服务器对用户来说太不安全了。所以 rubick 多端同步选择的是方案二。
WebDAV (Web-based Distributed Authoring and Versioning),一种基于 HTTP 1.1 协议的通信协议。它扩展了 HTTP 1.1,在 GET、POST、HEAD 等几个 HTTP 标准方法以外添加了一些新的方法,使应用程序可对 Web Server 直接读写,并支持写文件锁定及解锁,还可以支持文件的版本控制。
目前,国内支持 webdav 服务的厂商不是很多,比较有名的就是坚果云。你可以打开坚果云官网快速注册一个坚果云账号,跟着 # 坚果云第三方应用授权WebDAV开启方法 这个文档一步步开启 webdav 授权,当然你也可以自建 webdav 服务器。
1. 数据导出
以把数据导入到坚果云为例,首先需要把 pouchdb 里面的数据导出到坚果云。这里就需要用到一个 pouchdb 的插件:pouchdb-replication-stream,以及一个支持 webdav 的 npm 包 webdav。
首先,安装引入 pouchdb-replication-stream 插件:
const replicationStream = require('pouchdb-replication-stream/dist/pouchdb.replication-stream.min.js');
PouchDB.plugin(replicationStream.plugin);然后,初始化 webdav 客户端:
import { createClient } from 'webdav';
export default class WebDav {
// 定义 client 实例
public client;
// 定义云盘存储的目录
private cloudPath = '/rubick/db.txt';
constructor({ username, password, url }) {
// 创建客户端连接
this.client = createClient(url, {
username,
password,
});
// 检测连接状态
this.client
.exists('/')
.then((result) => {
!result &&
new Notification({
title: '导出失败',
body: 'webdav 连接失败',
}).show();
})
.catch((r) => {
new Notification({
title: '导出失败',
body: 'WebDav连接出错' + r,
}).show();
});
}
}创建好 webdav 客户端后,就可以尝试通过 pouchdb-replication-stream 将 pouchdb 中的数据导出到云端指定目录:
import MemoryStream from 'memorystream';
async createWriteStream(dbInstance) {
try {
// 判断是否存在 /rubick 目录,不存在就新建
const result = await this.client.exists('/rubick');
if (!result) {
await this.client.createDirectory('/rubick');
}
} catch (e) {
new Notification({
title: '导出失败',
body: 'WebDav目录创建出错:' + e,
}).show();
}
// 创建一个内存流
const ws = new MemoryStream();
// 调用 pouchdb-replication-stream 插件提供的 dump 番薯导出
dbInstance.dump(ws);
// 写入坚果云
ws.pipe(
this.client.createWriteStream(this.cloudPath, {}, () => {
new Notification({
title: '已导出到坚果云',
body: `文件目录为:${this.cloudPath}`,
}).show();
})
);
}2. 数据导入
导出完成了,接下来就是从坚果云中下载数据了,这里也用到了一个加载数据的插件:pouchdb-load。
async createReadStream(dbInstance) {
try {
// 先检测是否存在 webdav 数据
const result = await this.client.exists(this.cloudPath);
if (!result) {
return new Notification({
title: '导入失败',
body: '请确认坚果云上已存在数据',
}).show();
}
// 下载数据
const str = await this.client.getFileContents(this.cloudPath, {
format: 'text',
});
// 使用 pouchdb-load 提供的 loadIt 函数来导入数据
await dbInstance.loadIt(str);
new Notification({
title: '导入成功',
body: '数据已导入到 rubick,主应用数据需重启后生效',
}).show();
} catch (e) {
new Notification({
title: '导入失败',
body: 'WebDav目录导入出错:' + e,
}).show();
}
}多端数据同步完整代码:https://github.com/rubickCenter/rubick/blob/master/src/core/db/index.ts
总结
本小节我们详细介绍了桌面端使用 pouchdb 进行数据存储的过程和方法,并基于 webdav 实现了一个多端数据同步的功能。其实多端数据同步在很多桌面端软件中都有应用,如果你希望你的软件也支持多端数据同步,不妨也来试试吧!