0%

弃用Electron-store,手写一个键值数据持久化保存的类

  1. 前言
  2. 思路
  3. 实现
    1. v1.0 实现写入、读取
    2. v2.0 级联索引
    3. v3.0 文件加密

前言

electron-store为Electron提供了一个持久化保持数据的方法,通过设置键值对的值,将json数据保存到文件中。可以使用级联键(默认开启,如key1.key2.key3),可以对文件进行加密(默认不开启),用法如下:

1
2
3
4
5
6
7
8
9
10
11
const Store = require('electron-store');

const s = new Store({
cwd: __dirname,
name: 'store',
fileExtension: 'json'
});

const t = Date.now();
s.set('a', 1);
console.log(`${Date.now() - t}ms`); // 56ms/45ms/74ms/...

方便是挺方便的,用了也有一段时间,直到遇到了一个高频写入的场景,原本只需要0.7s的工作时间,加入了过程记录后差不多增加了800次写入操作,耗费的时间来到了7.79s,严重影响了使用体验。过程的记录又不能省略,无法,只好考虑采用更高效的写法。

思路

考虑到记录到文件中的内容为json字符串,因此直接在内存中构建json、再将json通过fs写入到文件中,或许是一个好办法。

最简单的实现:

1
2
3
4
5
6
7
import * as fs from 'fs-extra';
import * as path from 'path';

const data = {};
data['a'] = 1;

fs.writeFileSync(path.join(__dirname, 'store.json'), JSON.stringify(data));

花费时间不到1ms!

实现

前后实现了三个版本,不断更新,功能逐渐丰富

v1.0 实现写入、读取

实例化时设置文件夹和文件名,若文件存在,则读取数据进来。

可以设置某个键为某个值,每次数据更新都同步更新文件内容。

4000次写入耗时约为550ms。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86

import * as fs from 'fs-extra';
import * as path from 'path';

class Store<T extends Record<string, any> = Record<string, unknown>> {
private _dirName: string;
private _fileName: string;
private _json: T = {} as T;

constructor(opts: {
dirName: string;
fileName: string;
}) {
this._dirName = opts.dirName;
this._fileName = opts.fileName;
const filepath = path.join(this._dirName, this._fileName);
if (fs.existsSync(filepath)) {
try {
this._json = JSON.parse(fs.readFileSync(filepath, {
encoding: 'utf8'
}));
} catch (_) { }
} else {
if (!fs.existsSync(this._dirName)) {
fs.ensureDirSync(this._dirName);
}
}
}
data(format: 'string' | 'format-string'): string;
data(format?: 'object'): T;
data(format?: 'string' | 'format-string' | 'object') {
switch (format) {
case 'string':
return JSON.stringify(this._json);
case 'format-string':
return JSON.stringify(this._json, undefined, 4);
case 'object':
default:
return this._json;
}
}

get<K extends keyof T>(key: K) {
return this._json[key] || {} as T[K];
}

set<K extends keyof T>(key: K, value: T[K]) {
this._json[key] = value;
fs.writeFileSync(path.join(this._dirName, this._fileName), JSON.stringify(this._json, undefined, 4));
}

clear(delFile = false) {
this._json = {} as T;
if (delFile) {
const filepath = path.join(this._dirName, this._fileName);
if (fs.existsSync(filepath)) {
fs.unlinkSync(filepath);
}
}
}
}

const s = new Store({
dirName: __dirname,
fileName: 'store.dat'
});

// 耗时统计
const count = 20;
let sum = 0;
for (let i = 0; i < count; i++) {
const t = Date.now();
for (let i = 0; i < 1000; i++) {
s.set('a', 1);
s.set('b', 2);
s.set('c', {
c1: 11,
c2: 22,
c3: 33
});
s.set('d', [1, 2, 3, 4, 5]);
}
sum += Date.now() - t;
}

console.log(`${sum / count}ms`); // 568.8ms

v2.0 级联索引

实现了级联索引,通过点.设置层级。

1000次写入耗时约为140ms

v1.0的缺陷

1
2
s.set('a.b.c', 'ccc')
// { 'a.b.c': 'ccc' }

希望达到的效果:

1
2
s.set('a.b.c', 'ccc')
// { a: { b: { c: 'ccc' } } }

核心实现:在json中索引层级并设置参数

1
2
3
4
5
6
7
8
9
10
11
12
13
const ObjSet = (obj: Record<string, any>, key: string, value: any) => {
const sp = key.split('.');

for (let i = 0; i < sp.length - 1; i++) {
if (!(obj[sp[i]] && typeof obj[sp[i]] === 'object')) {
obj[sp[i]] = {};
}

obj = obj[sp[i]];
}

obj[sp[sp.length - 1]] = value;
}

改进后的Store类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107

import * as fs from 'fs-extra';
import * as path from 'path';

class Store<T extends Record<string, any> = Record<string, unknown>> {
private _dirName: string;
private _fileName: string;
private _useDot: boolean = true; // 使用点分割层级

private _json: T = {} as T;

constructor(opts: {
dirName: string;
fileName: string;
useDot?: boolean;
}) {
this._dirName = opts.dirName;
this._fileName = opts.fileName;
if (typeof opts.useDot === 'boolean') {
this._useDot = opts.useDot;
}

const filepath = path.join(this._dirName, this._fileName);
if (fs.existsSync(filepath)) {
try {
this._json = JSON.parse(fs.readFileSync(filepath, {
encoding: 'utf8'
}));
} catch (_) { }
} else {
if (!fs.existsSync(this._dirName)) {
fs.ensureDirSync(this._dirName);
}
}
}
data(format: 'string' | 'format-string'): string;
data(format?: 'object'): T;
data(format?: 'string' | 'format-string' | 'object') {
switch (format) {
case 'string':
return JSON.stringify(this._json);
case 'format-string':
return JSON.stringify(this._json, undefined, 4);
case 'object':
default:
return this._json;
}
}

get<K extends keyof T>(key: K) {
return this._json[key] || {} as T[K];
}

set<K extends keyof T>(key: K, value: T[K]) {
if (this._useDot) {
const sp = key.toString().split('.');
if (sp.length === 1) {
this._json[key] = value;
} else if (sp.length > 1) {
let obj: any = this._json;

for (let k of sp.slice(0, -1)) {
if (!(obj[k] && typeof obj[k] === 'object')) {
obj[k] = {};
}

obj = obj[k];
}

obj[sp[sp.length - 1]] = value;
}
} else {
this._json[key] = value;
}

fs.writeFileSync(path.join(this._dirName, this._fileName), JSON.stringify(this._json, undefined, 4));
}

clear(delFile = false) {
this._json = {} as T;
if (delFile) {
const filepath = path.join(this._dirName, this._fileName);
if (fs.existsSync(filepath)) {
fs.unlinkSync(filepath);
}
}
}
}

const s = new Store({
dirName: __dirname,
fileName: 'store.dat',
useDot: true
});

// 耗时统计
const count = 20;
let sum = 0;
for (let i = 0; i < count; i++) {
const t = Date.now();
for (let i = 0; i < 1000; i++) {
s.set('a.b.c', 'ccc');
}
sum += Date.now() - t;
}

console.log(`${sum / count}ms`); // 143.3ms

v3.0 文件加密

使用nodejs的crypto模块对文件进行加密

1000次写入耗时约为150ms

核心代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import * as crypto from 'crypto';

const obj = { a: 1 };

const iv = crypto.randomBytes(16);
const key = crypto.pbkdf2Sync('<EncKey>', iv, 10000, 32, 'sha512');
const cipher = crypto.createCipheriv('aes-256-gcm', key, iv);
const encBuf = Buffer.concat([cipher.update(Buffer.from(JSON.stringify(obj))), cipher.final()]);

const authTag = cipher.getAuthTag();
const decipher = crypto.createDecipheriv('aes-256-gcm', key, iv);
decipher.setAuthTag(authTag);

// 解密后的结果
const obj2 = JSON.parse(Buffer.concat([decipher.update(encBuf), decipher.final()]).toString('utf-8'));

改进后的Store类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
import * as fs from 'fs-extra';
import * as path from 'path';
import * as crypto from 'crypto';

class Store<T extends Record<string, any> = Record<string, unknown>> {
private _dirName: string;
private _fileName: string;
private _useDot: boolean = true; // 使用点分割层级

private _fileEnc?: {
iv: Buffer;
key: Buffer;
algo: crypto.CipherGCMTypes;
};

private _json: T = {} as T;

constructor(opts: {
dirName: string;
fileName: string;
useDot?: boolean;
fileEncKey?: string;
}) {
this._dirName = opts.dirName;
this._fileName = opts.fileName;
if (typeof opts.useDot === 'boolean') {
this._useDot = opts.useDot;
}

// 如果要求加密,则初始化加密参数
// 每次实例化创建一个key和iv即可,无需每次保存都创建
if (opts.fileEncKey) {
const iv = crypto.randomBytes(16);
const key = crypto.pbkdf2Sync(opts.fileEncKey, iv, 10000, 32, 'sha512');
this._fileEnc = {
algo: 'aes-256-gcm',
key, iv
};
}

const filepath = path.join(this._dirName, this._fileName);
if (fs.existsSync(filepath)) {
try {
if (opts.fileEncKey) {
const _buf = fs.readFileSync(filepath);
const _iv = _buf.slice(0, 16);
const _key = crypto.pbkdf2Sync(opts.fileEncKey, _iv, 10000, 32, 'sha512');
const _authTag = _buf.slice(-16);
const decipher = crypto.createDecipheriv(this._fileEnc!.algo, _key, _iv);
decipher.setAuthTag(_authTag);

this._json = JSON.parse(Buffer.concat([decipher.update(_buf.slice(16, -16)), decipher.final()]).toString('utf-8'));
} else {
this._json = JSON.parse(fs.readFileSync(filepath, {
encoding: 'utf8'
}));
}
} catch (_) { }
} else {
if (!fs.existsSync(this._dirName)) {
fs.ensureDirSync(this._dirName);
}
}
}
data(format: 'string' | 'format-string'): string;
data(format?: 'object'): T;
data(format?: 'string' | 'format-string' | 'object') {
switch (format) {
case 'string':
return JSON.stringify(this._json);
case 'format-string':
return JSON.stringify(this._json, undefined, 4);
case 'object':
default:
return this._json;
}
}

get<K extends keyof T>(key: K) {
return this._json[key] || {} as T[K];
}

set<K extends keyof T>(key: K, value: T[K]) {
if (this._useDot) {
const sp = key.toString().split('.');
if (sp.length === 1) {
this._json[key] = value;
} else if (sp.length > 1) {
let obj: any = this._json;

for (let k of sp.slice(0, -1)) {
if (!(obj[k] && typeof obj[k] === 'object')) {
obj[k] = {};
}

obj = obj[k];
}

obj[sp[sp.length - 1]] = value;
}
} else {
this._json[key] = value;
}

if (this._fileEnc) {
const cipher = crypto.createCipheriv(this._fileEnc.algo, this._fileEnc.key, this._fileEnc.iv);
const writeBuffer = Buffer.concat([this._fileEnc.iv, cipher.update(Buffer.from(this.data('string'))), cipher.final(), cipher.getAuthTag()]);
fs.writeFileSync(path.join(this._dirName, this._fileName), writeBuffer);
} else {
fs.writeFileSync(path.join(this._dirName, this._fileName), JSON.stringify(this._json, undefined, 4));
}
}

clear(delFile = false) {
this._json = {} as T;
if (delFile) {
const filepath = path.join(this._dirName, this._fileName);
if (fs.existsSync(filepath)) {
fs.unlinkSync(filepath);
}
}
}
}

const s = new Store({
dirName: __dirname,
fileName: 'store.dat',
fileEncKey: '123456'
});

// 耗时统计
const count = 20;
let sum = 0;
for (let i = 0; i < count; i++) {
const t = Date.now();
for (let i = 0; i < 1000; i++) {
s.set('a.b.c', 'ccc');
}
sum += Date.now() - t;
}

console.log(`${sum / count}ms`); // 151.6ms