前端实现跨平台一致的缓存数据管理
背景
业务在不断使用前端存储的过程中如果不做管理,可能会遇到以下问题及更多其他问题
- 无超时设置无法做主动清理策略.
- 同域数据无隔离,不同租户,不同用户访问同一存储等串数据场景
- 本地存储内会存在大量数据,缩小可用空间,影响其他关联业务模块使用
- 接口数据缓存时也会面临缓存数据无超时设置,需要业务各显神通来控制数据有效性.
- 业务五花八门直接读取storage,切换存储介质有极大的代码侵入风险
- 数据的写入与读取没有全局的hooks劫持
所以需要一个标准化的类Redis的数据缓存管理,能够具备或扩展下列特性
- 多平台可扩展,localStorage,sessionStorage,IndexDB,Memory,Redis等等
- 多平台API一致性
- 主动及被动垃圾回收
- 可设置超时时间或不主动超时的持久缓存
- 读取能刷新缓存时间
- 可加密存储
- hooks可扩展
思考🤔
既然是要做一个横跨多平台的存储管理工具,那么实现逻辑注定是要与通用的核心逻辑做解耦.
这里可以延伸出 1个核心包对多平台的处理器插件的基本架构
所以项目的结构理应采用Monorepo来管理.
接下来就是职责的明确
- 核心包: 主要维护与平台无关的管理逻辑,如: 配置管理,处理器调度,数据设置前及读取前的处理,垃圾回收管理,插件架构约束等
- localStorage平台的处理器
- sessionStorage平台的处理器
- Memory基于内存的处理器
- 以及其他平台的处理
项目创建
这里采用脚手架安装Monorepo项目,也可以选择直接下载模板或自行创建
pnpm add @compass-aiden/commander -g
安装命令行脚手架 默认采用pnpm命令,也可以自主换成yarn,npm
compass create <project_name>
创建项目,模板选择Monorepo模板
cd <project_name>
进入项目
mkdir libraries
创建包的一级目录
cd libraries && compass create <core_name>
创建核心包项目,选择utils模板
在根目录的rush.json的projects内加入该项目
{
"packageName": "<project_name的package.name>",
"projectFolder": "libraries/<core_name>",
"versionPolicyName": "<策略名>"
}
在common/config/rush/version-policies.json
文件内加入版本策略
{
"definitionName": "lockStepVersion",
"policyName": "<策略名>",
"version": "1.0.0",
"nextBump": "patch"
},
rush update
恢复项目依赖,没有rush命令的执行pnpm add -g @microsoft/rush
命令安装
到这里一个基础的项目仓库就建好了
核心包实现
现在我们要定义插件的基类,对插件做出约束,抽象类本身没有特别复杂的逻辑,主要是做一个插件实现的引导和约束.
libraries/locker-core/src/processor-abstract.ts
实现如下:
export default class LockerProcessorAbstract {
/** 插件自身的一些选项 */
protected option!: LockerProcessorInitOption;
/** 最大的缓冲区限制 */
protected maxBufferSize = 0;
/** 当前缓冲区大小 */
protected bufferSize = 0;
/** 当前实例,以便控制权反转 */
protected instance!: Locker;
/**
* @description API 可用性检查
* @param key
* @abstract
*/
validate(key: 'setItem' | 'getItem' | 'clear' | 'removeItem'): boolean {
this.option.logger.error('处理器未实现 validate 方法', item);
return false;
}
/**
* @description 设置存储
*
* 1. 检查插入新的数据项后是否会溢出最大存储限制
* 2. 如果会溢出限制,调用基类 clearDataBySize 方法,尝试清理指定空间
* 3. 如果清理的空间大小仍旧小于插入项大小停止写入并抛出异常
* 4. 不会溢出限制则执行写入
* 5. 写入失败抛出异常
*
* @param item
* @abstract
*/
async set(item: OriginLockerItem): Promise<void> {
this.option.logger.error('处理器未实现 set 方法', item);
}
/**
* @description 获取存储
* @param key
* @abstract
*/
async get(key: string): Promise<OriginLockerItem | null> {
this.option.logger.error('处理器未实现 get 方法', key);
return null;
}
/**
* @description 需要实现移除存储功能
* @param key
* @abstract
*/
async remove(key: string): Promise<void> {
this.option.logger.error('处理器未实现 remove 方法', key);
}
/**
* @description 获取所有数据,垃圾回收需要扫描数据是否过期, 如果扫描开销较大建议根据实际情况返回计算属性或缓存结果集
* @abstract
*/
async getAllData(): Promise<LockerItem[]> {
this.option.logger.error('处理器未实现 getAllData 方法');
return [];
}
/**
* @description 初始化
*/
// eslint-disable-next-line class-methods-use-this,@typescript-eslint/no-unused-vars
async initialize(option: LockerProcessorInitOption) {
this.option = option;
this.maxBufferSize = (option.maximum === undefined || option.maximum) < 0
? 0
: (option.maximum || 0);
this.instance = option.instance;
await this.refreshBufferSize();
this.option.logger.debug('初始化内存大小', this.bufferSize);
this.option.logger.debug('许可的最大内存大小', this.maxBufferSize);
}
/**
* @description 卸载阶段释放资源
*/
destroy() {
this.maxBufferSize = 0;
this.bufferSize = 0;
}
/**
* @description 刷新占用缓冲区大小
*/
refreshBufferSize = debounce(async () => {
this.bufferSize = (await this.getAllData()).reduce((totalSize, item) => {
// eslint-disable-next-line no-param-reassign
totalSize += item.size;
return totalSize;
}, 0);
this.option.logger.debug('当前缓冲区大小: ', this.bufferSize);
});
/**
* @description 按需释放对应空间,不影响超时时间为0的数据
* @param size 需要腾出的空间大小
*/
async clearDataBySize(size: number) {
if (size <= 0) {
return 0;
}
const promiseArr: Promise<unknown>[] = [];
const removedSize = (await this.getAllData())
.filter((data) => data.expires > 0)
.sort((data1, data2) => (
(data1.updatedAt + data1.expires) > (data2.updatedAt + data2.expires) ? 1 : -1))
.reduce((removeSize: number, item) => {
if (removeSize <= size) {
promiseArr.push(this.remove(item.key));
// eslint-disable-next-line no-param-reassign
removeSize += item.size;
}
return removeSize;
}, 0);
await Promise.all(promiseArr);
return removedSize;
}
}
其中refreshBufferSize,clearDataBySize是抽象出来的逻辑处理函数,减少插件必需实现内容.
我们先定义核心包的一个基础实现
libraries/locker-core/src/core.ts
假设核心包的项目文件夹为locker-core,基础的结构定义如下:
export default class Locker<Processor extends LockerProcessorAbstract = LockerProcessorAbstract> {
/** 对应平台的处理器插件 */
private readonly processor: Processor;
/** 配置项 */
private readonly settings: Required<Omit<LockerSettings<Processor>, 'processor'>>;
private readonly logger: Logger;
/** key唯一前缀,多实例隔离 */
private readonly prefixKey: string;
/** 垃圾回收的定时器 */
private garbageTimer?: number;
constructor(opts: LockerSettings<Processor>) {
this.processor = opts.processor;
this.settings = {
lockerKey: opts.lockerKey || 'default',
clearGarbageInterval: opts.clearGarbageInterval === undefined
? 1000 * 15
: opts.clearGarbageInterval,
maximum: opts.maximum === undefined ? 0 : parseInt(String(opts.maximum * 1024 * 1024), 10),
debug: opts.debug || false,
autoReadRefresh: opts.autoReadRefresh || false,
defaultExpires: typeof opts.defaultExpires === 'undefined' ? 1000 * 10 : opts.defaultExpires,
created: () => {},
};
this.logger = new Logger();
this.logger.updateConfig({
subject: 'CPLocker',
logLevel: this.settings.debug ? 'debug' : 'log',
});
this.prefixKey = `${INTERNAL_PREFIX}${this.settings.lockerKey}`;
this.processor.initialize({
logger: this.logger,
maximum: this.settings.maximum,
prefixKey: this.prefixKey,
instance: this,
}).then(() => {
// 定时垃圾回收
if (this.settings.clearGarbageInterval > 0 && this.garbageTimer === undefined) {
this.garbageTimer = setInterval(
() => this.clearGarbage(),
this.settings.clearGarbageInterval,
);
}
opts.created?.();
this.logger.success('Locker 准备就绪');
});
}
/** 让业务可以主动摧毁内部闭包 */
destroy() {
this.logger.debug('开始执行卸载');
this.processor.destroy();
if (this.garbageTimer) {
clearInterval(this.garbageTimer);
this.garbageTimer = undefined;
}
}
/** 设置数据 */
async setItem() {}
/** 内部垃圾回收处理 */
async getItem() {}
/** 内部垃圾回收处理 */
async removeItem() {}
/** 内部垃圾回收处理 */
async clear() {}
/** 内部垃圾回收处理 */
private async clearGarbage() {}
}
对外的数据操作API采用async是为了兼容更多的平台,并防止阻塞业务主流程
我们先来实现getItem逻辑
class Locker {
/**
* @description 获取存储数据
* @param key
* @param option
*/
async getItem<T extends LockerItemValue = unknown>(
key: string, option: { full: true }
): Promise<LockerItem<T> | null>;
/**
* @description 获取存储数据
* @param key 存储key
* @param [option] 配置项
*/
async getItem<T extends LockerItemValue = unknown>(
key: string, option?: { full?: false }
): Promise<T | null>;
/**
* @description 获取存储数据
* @param key 存储key
* @param [option] 配置项
*/
async getItem<T extends LockerItemValue = unknown, IsFull extends boolean = false>(
key: string,
option?: { full?: IsFull },
): Promise<LockerItem<T> | T | null> {
const { full } = {
full: false,
...option,
};
if (!this.processor.validate('getItem')) {
this.logger.error('当前运行环境不支持 getItem API.');
return null;
}
const originItem = await this.processor.get(`${this.prefixKey}${STRING_DELIMITER}${key}`);
// 刷新缓存时间
if (originItem?.autoReadRefresh) {
originItem.updatedAt = Date.now();
await this.processor.set(originItem);
}
// 数据是否过期
if (originItem && isExpired(originItem)) {
this.logger.debug(`Get item ${key} 已经过期`);
// 主动垃圾清理
this.removeItem(key);
return null;
}
const item = valueStringToValue(originItem);
this.logger.debug(`Get item ${key}`, full ? item : item?.value);
if (full) {
return item as LockerItem<T>;
} if (item) {
return item.value as T;
}
return null;
}
}
大致逻辑:
- 执行处理器的validate方法来验证getItem是否可用
- 执行处理器的get方法来获取全量配置数据
- 如果启用了读取刷新缓存配置则更新缓存的最新时间
- 如果数据超时则删除数据返回null
- 将实际值反序列化
- (可选)如果数据使用了加密存储,在这里需要进行解密,无法解密的数据也是删除并返回null
- (可选)如果设置了拦截器可以走一次hook
- 根据full来决定是否返回全量数据
实现 removeItem逻辑:
export default class Locker {
/**
* @description 删除存储数据
* @param key
*/
async removeItem(key: string) {
// 验证api是否可用
if (!this.processor.validate('removeItem')) {
this.logger.error('当前运行环境不支持 removeItem API.');
return;
}
await this.processor.remove(`${this.prefixKey}${STRING_DELIMITER}${key}`);
this.logger.debug(`Remove item ${key}`);
// 删除完成后更新缓冲区大小
this.processor.refreshBufferSize();
}
}
实现clear逻辑:
export default class Locker {
/**
* @description 卸载所有存储数据
*/
async clear() {
const promiseArr = [] as Promise<unknown>[];
(await this.processor.getAllData())
.forEach((item) => promiseArr.push(this.processor.remove(item.key)));
await Promise.all(promiseArr);
this.logger.debug('Clear all items');
// 清空后更新缓冲区大小
this.processor.refreshBufferSize();
}
}
实现clearGarbage逻辑:
export default class Locker {
/**
* @description 定期执行的垃圾回收
*/
private async clearGarbage() {
const promiseArr = [] as Promise<unknown>[];
// 找出所有过期数据及将要移除的总数据大小
const removedSize = (await this.processor.getAllData()).reduce<number>((rmSize, item) => {
if (isExpired(item)) {
promiseArr.push(this.processor.remove(item.key));
return item.size + rmSize;
}
return rmSize;
}, 0);
// 等待清理完成
await Promise.all(promiseArr);
this.logger.debug('主动垃圾回收机制执行,本次回收大小: ', removedSize);
if (removedSize > 0) {
// 主动清理后应该更新缓冲区大小
this.processor.refreshBufferSize();
}
}
}
最后是setItem实现:
export default class Locker {
/**
* @description 设置存储数据
* @param key 数据key
* @param value 数据值
* @param [opts] 配置项
* @param opts.expires 超时时间
* @param opts.autoReadRefresh 读取后是否刷新超时时间
*/
async setItem(
key: string,
value: LockerItemValue | LockerItemValue[],
opts?: Pick<Partial<LockerItem>, 'expires' | 'autoReadRefresh'>,
) {
// 避免过长的key降低跨平台性
if (key.length > 64) {
this.logger.error('key的长度不应超过64位');
return;
}
// 验证api可用性
if (!this.processor.validate('setItem')) {
this.logger.error('当前运行环境不支持 setItem API.');
return;
}
// 获取值类型,如果不是许可的类型,throw阻塞后续流程
const valueType = getValueType(value);
// 将存储值序列化
const valueStr = valueToString(value);
// 正常返回的只能是string了,不是则需要阻塞后续流程
if (valueStr === null) {
this.logger.error('无法将值进行序列化,请检查数据是否可被 JSON.stringify 处理');
return;
}
const cache = await this.getItem(key, { full: true });
// 如果数据存在则将全局配置与缓存数据的配置合并
let { autoReadRefresh, defaultExpires } = this.settings;
if (opts?.expires === undefined && cache) {
defaultExpires = cache.expires;
} else if (opts?.expires !== undefined) {
defaultExpires = opts.expires;
}
if (opts?.autoReadRefresh === undefined && cache) {
autoReadRefresh = cache.autoReadRefresh;
} else if (opts?.autoReadRefresh !== undefined) {
autoReadRefresh = opts.autoReadRefresh;
}
const item = {
key: cache?.key || `${this.prefixKey}${STRING_DELIMITER}${key}`,
// (可选)如果后续要支持加密存储,在这里可根据配置对item.value进行加密处理
value: valueStr,
type: valueType,
expires: defaultExpires,
autoReadRefresh,
size: 0,
createdAt: cache?.createdAt || Date.now(),
updatedAt: Date.now(),
};
// 获取存储数据的大小
item.size = getValueSize(JSON.stringify(item));
// (可选)如果设置了拦截器可以走一次hook
// 设置数据
await this.processor.set(item);
this.logger.debug('Set item', item);
// 更新缓冲区大小
this.processor.refreshBufferSize();
}
}
到这里我们的核心包就基本收尾了,如果需要查看文末的项目链接,接下来我们从插件开发人员的视角来实现平台处理器插件
各平台处理器实现
插件的实现就相对比较简单了,继承前文的插件基类 LockerProcessorAbstract,实现关键的五个函数就成功了validate,set,get,remove,getAllData就完成了,深度扩展就可以进一步实现其他方法
下面是localStorage平台的扩展示例:
import { LockerProcessorAbstract, OriginLockerItem } from '@compass-aiden/locker';
export default class LockerLocalStorageProcessor extends LockerProcessorAbstract {
// 检查API的可用性
validate(key: 'setItem' | 'getItem' | 'clear' | 'removeItem'): boolean {
switch (key) {
case 'setItem':
return localStorage?.setItem && typeof localStorage.setItem === 'function';
case 'getItem':
return localStorage?.getItem && typeof localStorage.getItem === 'function';
case 'removeItem':
return localStorage?.removeItem && typeof localStorage.removeItem === 'function';
case 'clear':
return localStorage?.clear && typeof localStorage.clear === 'function';
default:
return false;
}
}
// 存入数据
async set(item: OriginLockerItem) {
// 当存储数据的大小会溢出限制需要进一步处理
if (this.maxBufferSize > 0 && (item.size + this.bufferSize) > this.maxBufferSize) {
// 主动释放所需空间大小
const removeSize = await this.clearDataBySize(item.size);
// 如果主动释放的空间大小依然不足则应该停止写入
if (removeSize < item.size) {
this.option.logger.error('存储失败,超出最大容量限制!请移除部分永久数据或提高最大存储容量.');
throw new Error('存储失败,超出最大容量限制!请移除部分永久数据或提高最大存储容量.');
}
}
// 写入存储
localStorage.setItem(item.key, JSON.stringify(item));
}
// 读取存储数据
async get(key: string) {
const data = localStorage.getItem(key);
try {
return data ? (JSON.parse(data) as OriginLockerItem) : null;
} catch (e) {
return null;
}
}
// 删除存储数据
async remove(key: string) {
localStorage.removeItem(key);
}
// 获取所有数据
async getAllData() {
const keys: string[] = [];
// 不确定数据量大小的情况下,采用for会稍微快一点点,聊胜于无
for (let i = 0; i < localStorage.length; i += 1) {
const key = localStorage.key(i);
// 只提取当前实例的数据
if (key && key.startsWith(this.option.prefixKey)) {
keys.push(key);
}
}
const getDataPromise = [] as Promise<OriginLockerItem | null>[];
keys.forEach((key) => getDataPromise.push(this.get(key)));
return (await Promise.all(getDataPromise))
.filter((data) => !!data) as OriginLockerItem[];
}
}
插件的扩展在核心包的规范下,仅实现五个函数就可以运行起来啦.后面扩展其他平台的逻辑也是一样.
在灵活点可以提供一些写入和读取的hooks给业务使用
更多平台的扩展与更具体的实现,CICD自动化等请查阅项目仓库