Skip to content

数据

计算机程序的本质是算法与数据结构,数据对于一个应用程序十分重要。
开发一个 OA 应用,必须明确用户发起了什么流程,数据应如何保存,保存在什么地方。
开发一个播放器应用,必须明确用户的播放记录应如何保存,保存在什么地方。这些与数据相关的问题都值得开发者深思熟虑。

使用本地文件持久化数据

用户数据目录

一般情况下我们不应该把用户的个性化数据,例如用户应用程序设置、用户基本信息、用户使用应用程序所产生的业务数据等保存在应用程序的安装目录下,
因为安装目录是不可靠的,用户升级应用程序或卸载应用程序再重新安装等操作都可能导致安装目录被清空,造成用户个性化数据丢失,影响用户体验。

所有应用程序都面临着这个问题,好在操作系统为应用程序提供了一个专有目录来存储应用程序的用户个性化数据:

1
2
3
Windows 操作系统: C:\Users\[your user name]\AppData\Roaming
Mac 操作系统: /Users/[your user name]/Library/Application Support/
Linux 操作系统: /home/[your user name]/.config/xiangxuema

应用程序的开发者应该把用户的个性化数据存放在上述这些目录中,然而每个系统的地址各不相同,为了解决这个问题,以前应用开发者要先判断自己的应用运行在什么系统上,再根据不同的系统设置不同的数据路径。
Electron 为我们提供了一个便捷的 API 来获取此路径:

1
app.getPath("userData")

此方法执行时会先判断当前应用正运行在什么操作系统上,然后根据操作系统返回具体的路径地址

扩展: 给 app.getPath 方法传入不同的参数,可以获取不同用途的路径。用户根目录对应的参数为 home。
desktop、documents、downloads、pictures、music、video 都可以当作参数传入,获取用户根目录下相应的文件夹。
另外还有一些特殊的路径

  • temp 对应系统临时文件夹路径
  • exe 对应当前执行程序的路径
  • appData 对应用用程序用户个性化数据的目录
  • userData 是 appData 路径后再加上应用名的路径,是 appData 的自路径。这里说的应用名是开发者在 package.json 中定义的 name 属性的值

所以,如果你开发的是一个音乐应用,那么保存音乐文件的时候,你可能并不会首选 userData 对应的路径,而是选择 music 对应的路径

除此之外,你还可以使用 Node.js 的能力获取系统默认路径,比如:

  • require('os').homedir(); // 返回当前用户的祝目录,如: "C:\Users\allen"
  • require('os').tmpdir(); // 返回默认临时文件目录,如: "C:\User\allen\AppData\Local\Temp"

Node.js 从设计之初就是为服务端的应用提供服务的,所以这方面提供的能力显然不如 Electron 强大。

有时候为了提升用户体验,应用需要允许用户设置自己的数据保存在什么目录,比如迅雷就允许用户指定下载目录。
开发者做一个类似的功能并不难,让用户选择一个路径然后把这个路径记下来以供使用即可
可喜的是我们并不用做这些额外的工作,Electron 为我们提供了相应的 API 来重置用户数据目录,代码如下:

1
2
3
4
5
6
let appDataPath = app.getPath('appData')
console.log(appDataPath)

app.setPath('appData', 'D:\\project\\electron_in_action\\chapter8\\public')
appDataPath = app.getPath('appData')
console.log(appDataPath)

app.setPath 方法接收两个参数,第一个是要重置的路径的名称,第二个是具体的路径。
设置完成后再获取该名称的路径,就会得到新的路径了,以上代码执行后输出结果如下:

1
2
> C:\Users\allen\AppData\Roaming
> D:\project\electron_in_action\chapter8\public

注意,这个重置只对本应用程序有效,其他应用程序不受影响

读写本地文件

Electron 保存用户数据到磁盘与 Node.js 并没有什么区别,代码如下:

1
2
3
4
5
let fs = require("fs-extra")
let path = require("path")
let dataPath = app.getPath("userData")
dataPaath = path.join(dataPath, "a.data")
fs.writeFileSync(dataPath, yourUserData, { encoding: 'utf8' })

上面代码中,我们把 yourUserData 变量里的数据保存到 [userData]/a.data 文件内了。

读取用户数据的代码如下:

1
2
3
4
5
let fs = require("fs-extra")
let path = require('path')
let dataPath = app.getPath('userData')
dataPath = path.join(dataPath, 'a.data')
let yourUserData = fs.readFileSync(dataPath, { encoding: 'utf8' })

上面代码中,我们又把 [userData]/a.data 文件内的数据读取到了 yourUserData 变量中了

此处我们并没有用 Node.js 原生提供的 fs 库,而是用了一个第三方库 fs-extra,因为原生 fs 库对一些常见的文件操作支持不足

以一个最简单的需求为例,删除一个目录,如果有子目录的话也删除其子目录。
如果使用原生 fs 库需要开发者写代码递归删除,但使用 fs-extra 库就只要使用 removeSync 方法即可。

另外,使用 fs-extra 库只要使用一个 ensureDirSync 方法即可实现判断一个目录是否存在,如果不存在则创建该目录的需求,即使路径中多个子目录不存在,它也会一并帮你创建出来。
而使用原生 fs 库就需要自己实现判断及创建目录的逻辑了

fs-extra 库是对原生 fs 库的一层包装,它除了原封不动地暴露出 fs 库的所有 API 外,还额外增加了很多非常实用的 API。
所以即使老项目中实用的是 fs 库,我们也可以将其顺利升级为 fs-extra 库,无需额外的开发支持

另外,读写文件时我们使用了同步方法 readFileSync 和 writeFileSync(大多数时候在 Node.js 的 API 中以 Sync 结尾的都是同步方法)。
因为 JavaScript 是单线程执行的,使用同步方法可能会阻塞程序执行,造成界面卡顿。
因此读写大文件时应考虑使用异步方法实现,或者将读写工作交由 Node.js 的 worker_threads 完成

值得推荐的第三方库

我们往往会把用户数据格式化成 JSON 形式以方便应用操作,然而即使把数据格式化成 JSON,在进行排序、查找时也还是非常麻烦。
这里推荐两个常用的库

首先是 lowdb(https://github.com/typicode/lowdb),它是一个基于 Lodash 开发的小巧的 JSON 数据库。
Lodash 是一个非常强大且业内知名的 JavaScript 工具库,使用它可以快速高效地操作数据、JSON 对象

lowdb 基于 Lodash 提供了更上层的封装,除了使开发者可以轻松地操作 JSON 数据外,它还内置了文件读写支持,非常简单易用。
示例代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
//创建数据访问对象
const low = require('lowdb')
const FileSync = require('lowdb/adapters/FileSync')
const adapter = new FileSync('db.json')
const db = low(adapter)
//查找数据
db.get('posts').find({ id: 1 }).value();
//更新数据
db.get('posts').find({ title: 'low!' }).assign({ title: 'hi!'}).write();
//删除数据
db.get('posts').remove({ title: 'low!' }).write();
//排序数据
db.get('posts').filter({published: true}).sortBy('views').take(5).value();

第二个是 electron-store(https://github.com/sindresorhus/electron-store),一个专门为 Electron 设计的,依赖的包很少的,很轻量的数据库,而且它还支持数据加密以防止数据被恶意用户窃取,甚至不需要你指定文件的路径和文件名,就直接把数据保存在用户的 userData 目录下.

代码示例如下:

1
2
3
4
5
6
7
8
const Store = require('electron-store');
const store = new Store();
store.set('key', 'value');
console.log(store.get('key'));            //输出value 
store.set('foo.bar', true);               //可以级联设置JSON对象的值
console.log(store.get('foo'));            //输出 {bar: true}
store.delete('key');
console.log(store.get('unicorn'));        //输出 undefined

使用浏览器技术持久化数据

浏览器数据存储技术对比

打开谷歌浏览器的开发者调试工具 Application 的标签页,你会发现左侧有一个 Storage 列表如图 7-1 所示

图7-1浏览器内的数据存储方式

图7-1 浏览器内的数据存储方式

这是浏览器为开发者提供的五种用来在浏览器客户端保存数据的技术。程序员经常会用到的是 Local Storage 和 Cookies

Electron 底层也是一个浏览器,所以开发 Electron 应用时,也可以自由地使用这些技术来存取数据,其控制能力甚至强于 Web 开发,比如读写被标记为 HttpOnly 的 Cookie 等。下面来了解一下这几种数据存储技术的用途和差异

Cookiie 用于存储少量的数据,最多不能超过 4KB,用来服务于客户端和服务端间的数据传输,一般情况下浏览器发起的每次请求都会携带同域下的 Cookie 数据,大多数时候服务端程序和客户端脚本都由访问 Cookie 的权力。
开发者可以设置数据保存的有效期,当 Cookie 数据超过有效期后将被浏览器自动删除

Local Storage 可以存储的数据量也不大,各浏览器限额不同,但都不会超过 10MB。
它只能被客户端脚本访问,不会自动随浏览器请求被发送给服务端,服务端也无权设置 Local Storage 的数据。
它存储的数据没有过期事件,除非手动删除,不然数据会一直保存在客户端

Session Storage 的特性大多与 Local Storage 相同,唯一不同的是浏览器关闭后 Session Storage 里的数据将被自动清空,因此 Electron 应用在需要保存程序运行期的临时数据时常常会用到它

Web SQL 是一种为浏览器提供的数据库技术,它最大的特点就是使用 SQL 指令来操作数据。目前此技术已经被 W3C 委员会否决了,在此不多做介绍,也不推荐使用

IndexedDB 是一个基于 JavaScript 的面向对象的数据库,开发者可以用它存储大量的数据,在 Electron 应用内它的存储容量限制与用户的磁盘容量有关。
IndexedDB 也只能被客户端脚本访问,不随浏览器请求被发送到服务端,服务端也无权利用 IndexedDB 内的数据,它存储的数据亦无过期时间

在开发 Electron 应用时,推荐使用 Cookie 和 IndexedDB 来存储数据。
虽然使用 Local Storage 相较于 IndexedDB 更简单,但它的容量限制是其最大的硬伤,而且使用第三方工具库也可以简化 IndexedDB 的使用

使用第三方库访问 IndexedDB

虽然使用原生 JavaScript 访问 IndexedDB 是完全每问题的,但由于其 API 设计比较传统,大部分数据读写操作都是异步的,因此需要使用大量的回调函数和事件注册函数,并没有 Promise 版本的 API 可用,所以开发效率并不高。
开发者如果不希望使用第三方库,那么可以参阅文档来完成 IndexedDB 的数据访问工作:
https://wangdoc.com/javascript/bom/indexeddb.html

开源社区也发现了这个问题,因此有很多人提供了 IndexedDB 的封装库,比如 Idb(https://github.com/jakearchibald/idb)和 Dexie.js(https://github.com/dfahlander/Dexie.js/)

这两个库都提供了 IndexedDB 数据访问的 Promise API。
从经验而言 Dexie.js 更胜一筹,推荐使用。下面介绍一下它的基本用法:

1
2
let db = new Dexie("testDb");
db.version(1).stores({articles: "id",settings: "id"});

第一行创建一个名为 testDb 的 IndexedDB 数据库。第二行中的 db.version(1) 需详细解释一下。
IndexedDB 有版本的概念,如果应用程序因为业务更新需要修改数据库的数据结构,那么此时就面临如何将用户原有的数据迁移到新数据库中的问题。

IndexedDB在这方面提供了支持。假设现有应用的数据库版本号为1(默认值也为1),新版本应用希望更新数据结构,可以把数据库版本号设置为2。
当用户打开应用访问数据时,会触发 IndexedDB 的 upgradeneeded 事件,我们可以在此事件中完成数据迁移的工作。

Dexie.js 对 IndexedDB 的版本 API 进行了封装,使用 db.version(1) 获得当前版本的实例,然后调用实例方法 stores,并传入数据结构对象。
数据结构对象相当于传统数据库的表,与传统数据库不同,你不必为数据结构对象指定每一个字段的字段名,此处我为 IndexedDB 添加了两个表articles 和 settings,它们都有一个必备字段为id,其他字段可以在写入数据时临时确定。
将来版本更新,数据库版本号变为2时,数据库增加了一张表users,代码如下:

1
db.version(2).stores({articles: "id",settings: "id",users: "id"});

此时 Dexie.js 会为我们进行相应的处理,在增加新的表的同时原有表及表里的数据不变。
这为我们从容地控制客户端数据库版本提供了强有力的支撑。

下面来看一下使用Dexie.js进行常用数据操作的代码:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
//增加数据
await db.articles.add({ id: 0, title: 'test'});
//查询数据
await db.articles.filter(article=> article.title === "test");
//修改数据
await db.articles.put({ id: 0, title:'testtest'});
//删除数据
await db.articles.delete(id);
//排序数据
await db.articles.orderBy('title');

注意,上面的代码中用到了await关键字,所以使用时,应放在async标记的函数下才能正常执行。

扩展:
除了 Idb 和 Dexie.js 之外,还有一个更强大的第三方库 pouchdb(https://pouchdb.com/)。它并没有直接封装IndexedDB的接口,而是默认使用 Idb 作为其与 IndexedDB 的适配器,除了此适配器外,你还可以选择内存适配器(把数据存储在内存中)或HTTP适配器(把数据存储在服务器端的 CouchDB 内)。

它的灵感来源于 CouchDB,为了和服务端 CouchDB 更好地对接,它也提供了一套类似 CouchDB 的API。此项目也是一个明星项目,但学习成本略高,感兴趣的读者可以花时间学习、使用。

另外还有一个非常有趣的数据库 rxdb 值得推荐。它是一个可以运行在各大浏览器和 Electron 内的实时数据库。
它最大的特点就是支持订阅数据变更事件,当你在一个窗口更改了某个数据后,你无需再发消息通知另一个窗口,另一个窗口就能通过数据变更事件获悉变更的内容。
对于需要向用户显示实时数据的客户端应用程序来说,这非常有用。它是一个开源项目,开源地址为:https://github.com/pubkey/rxdb

开发网页时,我们常用 document.cookie 来获取保存在 Cookie 中的数据,下面的代码是两个常见的读写指定 Cookie 的工具函数:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
//读取Cookie
let getCookie = function(name){
    let filter = new RegExp(name + "=([^;]*)(;|$)");
    let matches = document.cookie.match(filter);
    return matches ? matches[1] : null;
}
//设置Cookie
let setCookie = function (name,value,days) { 
    var exp = new Date(); 
    exp.setTime(exp.getTime() + days*24*60*60*1000); 
    document.cookie = name + "="+ escape (value) + ";expires=" + exp.toGMTString(); 
} 

设置 Cookie 时,我们并没有尝试去改变某个 Cookie 的值,而是直接创建了一个新 Cookie,因为浏览器发现存在同名 Cookie 时,会用新 Cookie 的值替换原有 Cookie 的值。

Electron 中也可以用这种方法读写 Cookie,但这种方法有其局限性,无法读写 HttpOnly 标记的 Cookie 和其他域下的 Cookie。

但好在 Electron 为开发者提供了专门用来读取 Cookie 的 API,可以读取受限访问的 Cookie,代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
const { remote } = require("electron");
//获取Cookie
let getCookie = async function(name) {
    let cookies = await remote.session.defaultSession.cookies.get({name});
    if(cookies.length>0) return cookies[0].value;
    else return '';
}
//设置Cookie
let setCookie = async function(cookie) {
    await remote.session.defaultSession.cookies.set(cookie);
}

以上代码运行在渲染进程中,我们通过 remote.session.defaultSession.cookies 对象完成 Cookie 的读写操作,session 是 Electron 用来管理浏览器会话、Cookie、缓存和代理的工具对象,defaultSession 是当前浏览器会话对象的实例。
你还可以通过如下代码获取当前页面的 session 实例:

1
let sess = win.webContents.session;

session 实例的 Cookie 属性用于管理浏览器 Cookie,它的 get 方法接收一个过滤器对象,该对象的关键配置包含 name、domain 等可选属性,为查找指定的 Cookie 提供支持。如果你传递一个空对象给 get 方法,将返回当前会话下的所有 Cookie。

set 方法接收一个 Cookie 对象,该对象除包含常见的 Cookie 属性外,还包含 HttpOnly 和 secure 属性,也就是说你可以在 Electron 客户端中用 JavaScript 代码为浏览器设置 HttpOnly 的 Cookie。
通常情况下这类 Cookie 是在服务端设置的,虽然它保存在浏览器客户端,但 JavaScript 不具有读写这类 Cookie 的能力。
现在这个限制被 Electron 打破了,这对于需要访问第三方网站执行一些极客工作的程序员来说帮助巨大。

扩展: 
在开发 Web 应用的过程中,我们经常会把 Cookie 标记上 secure 属性和 HttpOnly 属性,这两个属性都用于保护 Cookie 信息的安全。

为了防止用户的 Cookie 被恶意第三方嗅探获取,你可以给 Cookie 设置 secure 属性,这样标记的 Cookie 只允许经由 HTTPS 安全连接传输。

为了防止XSS跨站脚本攻击,你可以给 Cookie 设置 HttpOnly 属性,这样标记的 Cookie 不允许 JavaScript 访问。

清空浏览器缓存

有的时候你可能需要清空用户的数据,比如在用户希望重置所有与自己相关的数据时,这些数据可能保存在 Cookie 内(比如用户登录token),也可能保存在 IndexedDB中(比如用户的个性化配置等)。
开发者虽然可以编写代码手动删除这些数据,但更简单的方式是使用 Electron 为我们提供的 clearStorageData 方法,代码如下:

1
2
3
await remote.session.defaultSession.clearStorageData({ 
    storages: 'cookies,localstorage' 
})

这也是用户 session 实例下的一个方法,它接收一个 option 对象,该对象的 storages 属性可以设置为以下值的任意一个或多个(多个值用英文逗号分隔):appcache,cookies,filesystem,indexdb,localstorage,shadercache,websql,serviceworkers,cachestorage

如你所见,它能控制几乎所有浏览器相关的缓存。另外还可以为 option 对象设置配额和 origin 属性来更精细地控制清理的条件。

使用 SQLite 持久化数据

SQLite 是一个轻型的、嵌入式的SQL数据库引擎,其特点是自给自足、无服务器、零配置、支持事务。
它是在世界上部署最广泛的 SQL 数据库引擎。大部分桌面应用都使用 SQLite 在客户端保存数据。

一般情况下,我们为 Electron 工程安装一个第三方库,与为 Node.js 工程安装第三方库并没有太大区别,但这仅限于只有 JavaScript 语言开发的库。
如果第三方库是使用 C/C++ 开发的,那么在安装这个库的时候就需要本地编译安装。

SQLite3 是基于 C 语言开发的,node-sqlite3(SQLite3 提供给 Node.js 的绑定)也大量地使用了 C 语言,因此并不能使用简单的 yarn add 的方法给 Electron 工程安装 node-sqlite3 扩展,需要使用如下命令安装:

1
2
npm install sqlite3 --build-from-source --runtime=electron --target=8.1.1
 --dist-url=https://atom.io/download/electron

以上命令来自 node-sqlite3 官网,此处需要注意,--target=8.1.1 是写作此案例时使用的 Electron 版本号,需要把其更改成你使用的 Electron 版本号。你可以通过以下 JavaScript 代码查看当前正在使用的 Electron 版本号(可以直接在开发者工具栏内执行此代码):

1
process.versions.electron

node-sqlite3 库只对 SQLite3 做了简单封装,为了完成数据的 CRUD 操作还需要编写传统的 SQL 语句,开发效率低下。
这里推荐使用 knexjs 库作为对 node-sqlite3 的再次包装,完成业务数据访问读写工作。

knexjs 是一个 SQL 指令构建器,开发者可以使用它编写串行化的数据访问代码,它会把开发者编写的代码转换成 SQL 语句,再交由数据库执行处理。
数据库返回的数据,它也会格式化成JSON对象。它支持多种数据库,比如 Postgres、MSSQL、MySQL、MariaDB、SQLite3、Oracle 等,这里我们只用到了 SQLite3。knexjs 也是业内知名的数据库访问工具。

演示代码如下,创建数据访问对象:

1
2
3
4
let knex = require('knex')({
    client: 'sqlite3',
    connection: { filename: yourSqliteDbFilePath }
});

CRUD操作样例代码:

1
2
3
4
5
6
7
8
//查找
let result = await knex('admins').where({id:0});
//排序
let result = await knex('users').orderBy('name', 'desc')
//更新
await knex('admins').where("id", 0).update({ password: 'test' });
//删除
await knex('addresses').whereIn("id", [0,1,2]).del();

注意,上面代码因为用到了await关键字,所以使用时应放在async标记的函数下才能正常执行。

你可能需要一个客户端 GUI 工具来管理你的 SQLite3 数据库,比如完成创建表、修改表结构之类的工作。
SQLite3 的管理工具有很多,推荐 SQLite Experthttp://www.sqliteexpert.com/),它有着功能丰富、操作简捷的优点。
虽然是收费软件,但有免费的个人版本可以使用

重点:
开发一个Web应用,我们往往会选择 MSSQL、MySQL、Oracle 之类的数据库,但对于 Electron 应用访问这些数据库的技术我们一概没讲。
这些数据库都是网络服务数据库,一个客户端应用直接访问这些数据库是非常危险的,一旦一个客户端被破解或者被嗅探到了数据库用户名和密码,那么你所有用户的数据都将受到威胁。

一般情况下我们通过 Web API 来间接地访问这些数据库。所以如果你希望把用户数据保存在网络数据库内,那么你应该让后端开发人员给你提供 Web API 接口,并和后端开发人员协商好鉴权和身份验证的技术细节,再开始动手开发客户端。

如果安全问题不会给你造成影响,那么你可以考虑使用提到的 knexjs 工具来访问你的网络服务数据库。