前端实现跨平台一致的缓存数据管理

50 阅读6分钟

前端实现跨平台一致的缓存数据管理

背景

业务在不断使用前端存储的过程中如果不做管理,可能会遇到以下问题及更多其他问题

  • 无超时设置无法做主动清理策略.
  • 同域数据无隔离,不同租户,不同用户访问同一存储等串数据场景
  • 本地存储内会存在大量数据,缩小可用空间,影响其他关联业务模块使用
  • 接口数据缓存时也会面临缓存数据无超时设置,需要业务各显神通来控制数据有效性.
  • 业务五花八门直接读取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;
  }
}

大致逻辑:

  1. 执行处理器的validate方法来验证getItem是否可用
  2. 执行处理器的get方法来获取全量配置数据
  3. 如果启用了读取刷新缓存配置则更新缓存的最新时间
  4. 如果数据超时则删除数据返回null
  5. 将实际值反序列化
  6. (可选)如果数据使用了加密存储,在这里需要进行解密,无法解密的数据也是删除并返回null
  7. (可选)如果设置了拦截器可以走一次hook
  8. 根据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自动化等请查阅项目仓库

项目地址

github.com/Aiden-FE/co…