{T}

本地数据库和多端数据同步

## 前言 数据持久化存储是每个桌面端应用基本上都会面对的问题,作为前端开发者,我们通常会使用 localstorage 或者 indexedDB 作为我们的存储方式。

但是在桌面端应用开发中,我们通常会使用 SQLite 或者 PouchDB 作为我们的存储方式,它们分别是关系型数据库管理系统(RDBMS)和 NoSQL 数据库中的代表工具。至于对于 Rubick 而言,选择什么类型的数据库我们来简单分析一下。

特性SQLitePouchDB
类型关系型数据库管理系统(RDBMS),以单个文件形式存储数据库,可以通过 SQL 进行数据操作。NoSQL 数据库,特别适用于在浏览器和 Node.js 环境中工作,并支持实时数据同步。
适用场景用于需要在单个设备上进行数据存储和处理的应用程序,例如移动应用程序或桌面应用程序。通常用于需要严格的数据结构和复杂查询的场景。更适合需要在不同设备之间同步数据的应用程序,尤其是需要离线数据操作和实时同步的情况。它可在离线状态下操作数据,并在重新联网时与远程服务器同步。
架构传统的关系型数据库,使用SQL进行数据操作,支持复杂的查询语言和事务管理。基于文档的数据库,使用JavaScript API来操作数据,而不是SQL。支持JSON文档,并且与CouchDB等兼容,能够在离线状态下工作。
数据同步通常需要手动实现数据同步和复制来实现设备之间的数据一致性。专注于实时数据同步,并内置了处理多设备间数据同步的功能。
跨平台支持支持各种平台,但需要特定的驱动程序来与不同编程语言和环境交互。能够无缝地在浏览器和Node.js环境中运行,并且可以直接在这些环境中使用JavaScript API。

再来谈谈 Rubick 数据库使用的场景:首先我们的用于数据持久化存储的场景大多是插件产生的数据,我们并不需要插件开发者特别关系数据表的数据结构。其次插件可能需要存储各种类型的离线文件,比如 OCR 插件需要存储图片文件。最后我们的用户可能有多台设备,需要进行跨设备间的数据同步。

基于此前提下,我们选择的是 PouchDB 作为 Rubick 的本地存储数据库。

引入 PouchDB

在 Electron 中引入 PouchDB 也是非常方便,首先需要安装 PouchDB 的依赖包:

bash
npm install pouchdb --save
# or
yarn add pouchdb

然后在项目中引入它:

js
import PouchDB from 'pouchdb';

如果不出意外在运行的时候就要报错了:

bash
App threw an error during load
Error: No native build was found for platform=xxx ...

这个是因为 pouchdb 本身依赖了 leveldown 模块,该模块是一个基于 node-gyp 构建的 .node 二进制模块。在 Electron 中,对于这类二进制模块的应用需要进行单独打包,因为我们使用的是 electron-builder 构建,那么就可以直接添加如下配置:

js
externals: [
  'pouchdb',
]

PouchDB 初始化

初始化一个 PouchDB 也非常简单:

js
new PouchDB([name], [options])

其中,name 是数据库名称,是一个必填值。Options 包含数据库创建的一些参数设置,比如在 Rubick 中我们实例化一个 db 实例:

js
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 调用方式大致为:

js
// 创建数据
db.put({
  // 键
  _id: "demo",
  // 值
  data: "demo"
})
// 返回 {id: "demo", ok: true, rev: "1-05c9b92e6f24287dc1f4ec79d9a34fa8"}

那么实现方式如下:

js
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 使用方式如下:

js
db.put({
  _id: "demo",
  data: "demo",
  _rev: "1-05c9b92e6f24287dc1f4ec79d9a34fa8"
})

对应的实现函数依然是之前的 db.put

3. 获取数据

获取数据需要向 pouchDB 传入一个 id 作为键值,使用示例:

js
db.get("demo")
// 返回 {_id: "demo", _rev: "3-9836c5c68af5aef618e17d615882942a", data: "demo"}

实现方式:

js
export default class DB {
  // ...
  async get(id) {
    try {
      return await this.pouchDB.get(id);
    } catch (e) {
      return null;
    }
  }
}

4. 删除数据

删除数据需要向 pouchDB 传入一个文档对象或文档 id 进行操作,使用示例:

js
db.remove("demo")
// 返回 {id: "demo", ok: true, rev: "2-effe5dbc23dffc180d8411b23f3108fb"}

实现方式:

js
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.bulkDocsdb.allDocs

具体实现细节和整个完整案例可以见 Rubick 源码关于数据实现部分:https://github.com/rubickCenter/rubick/blob/master/src/core/db/index.ts

多端数据同步

Rubick 数据存储是基于 pouchdb 的,所以数据同步的方案可以有:

  1. 直接把 pouchdb 里面的数据以文件的形式导出,其他设备再以文件的形式导入。
  2. 基于 webdav,把数据先导入到支持 webdav 的云盘,其他设备再从云盘导入 rubick。
  3. 自建中央数据存储服务器,然后将 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 插件:

js
const replicationStream = require('pouchdb-replication-stream/dist/pouchdb.replication-stream.min.js');
 
PouchDB.plugin(replicationStream.plugin);

然后,初始化 webdav 客户端:

js
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-streampouchdb 中的数据导出到云端指定目录:

js
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

js
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 实现了一个多端数据同步的功能。其实多端数据同步在很多桌面端软件中都有应用,如果你希望你的软件也支持多端数据同步,不妨也来试试吧!