我用js手搓一个类似于消息队列系统的数据管理工具类……

在最近的一个前端项目中,我遇到了一个关于数据提交的小需求。我们需要从前端收集几百条数据并提交到后台。如果每次获取到数据后立即调用接口,势必会导致频繁的接口请求,不仅效率低下,还可能给服务器带来巨大压力。虽然理想情况下,应该等所有数据收集完毕后再统一提交,但由于数据收集是一个较长的过程,如果中途发生错误,可能会导致数据丢失。

开发思路

为了解决这个问题,我想到了一个折中的解决方案:每隔一段时间批量提交数据。这种方式不仅能够减少对服务器的频繁请求,还能降低数据丢失的风险。由于项目中类似的场景较多,我决定开发一个通用的数据管理工具类。

在开发这个工具类时,我设定了几个关键需求:

  1. 适配不同数据类型:不同类型的数据往往需要调用不同的接口,因此工具类必须支持灵活的数据处理策略。例如,对于包含图片的数据,图片需要先上传获取链接,再将链接提交到服务器,这就需要支持数据的预处理功能。
  2. 支持批量提交与定时处理:为了避免频繁的网络请求,该工具类应能够在一段时间内批量收集数据并一并提交。
  3. 支持立即提交:在某些场景下,允许用户立即提交数据,而不需要等待批量处理。

1、什么是 DataManager 类?

有点沿用后端开发的思想,这里我这里创建一个DataManager 工具类,能够收集数据、批量处理,并按照设定的时间间隔或数据量大小提交给服务器。它的核心理念类似于一个消息队列,用于管理不同类型的数据,并支持对数据的灵活处理。对于前端开发中的高频数据收集和发送场景,DataManager 提供了一种更加高效和可控的解决方案。

它的核心功能包括:

  1. 批量收集数据:在每次收集数据时,不是立即发起请求,而是等到数据量达到一定数量时才进行提交。
  2. 定时提交:可以设置一个时间间隔,保证即使数据没有达到一定数量,也会在设定时间之后自动提交数据。
  3. 数据预处理:支持在数据发送之前进行自定义的预处理操作,如格式化数据、过滤无用属性等。
  4. 支持多种数据类型:可以针对不同类型的数据进行各自的处理和提交逻辑。
  5. 立即提交:当需要立刻提交数据时,允许手动触发立即提交,跳过定时器和批量条件。

就我当前做的项目来说,有两块功能涉及到这个工具类使用场景:

  • 用户行为记录:比如用户点击按钮、浏览页面、登录登出等频繁的操作,如果每次都直接发送请求,会增加服务器负担。可以使用 DataManager 将这些行为批量收集,定期或达到一定数量后再一次性发送。
  • 高频数据监控:对于数据的动态变化等高频触发的事件,直接每次请求服务器不仅效率低,还可能导致延迟。使用 DataManager 可以更智能地管理这些数据。

2、DataManager 的工作原理

DataManager 类的核心思路是通过消息队列的方式,它会为每一种数据类型(我们叫它 key)创建一个集合,不断往集合里添加数据。等到某个条件满足时,比如积累了一定数量的数据或者经过了设定的时间,就会自动将数据提交到服务器。

通过设计思路可以看出来这里涉及到定时器,而且每一种数据对应的定时器都不一样,比如A数据可能要30秒提交一次,B数据要60S提交一次,我想定时器管理这部分单独抽出来,这里单独设计了一个定时器管理的类。

代码如下所示:

/**
 * TimerPool类用于管理多个定时器。
 */
class TimerPool {
    
    
  /**
   * 创建一个TimerPool实例。
   */
  constructor() {
    
    
    this.timerMap = new Map() // 存储每个key的定时器
  }

  /**
   * 启动定时器,如果定时器已存在则不重复启动。
   * @param {string} key - 定时器的唯一标识符。
   * @param {number} interval - 定时器的间隔时间(毫秒)。
   * @param {Function} callback - 定时器触发时的回调函数。
   */
  startTimer(key, interval, callback) {
    
    
    if (this.timerMap.has(key)) return // 如果已存在,不再启动

    const timer = setTimeout(() => {
    
    
      callback() // 定时器触发时调用回调
      this.clearTimer(key) // 清理定时器
    }, interval)

    this.timerMap.set(key, timer) // 记录定时器
  }

  /**
   * 清理定时器。
   * @param {string} key - 定时器的唯一标识符。
   */
  clearTimer(key) {
    
    
    if (this.timerMap.has(key)) {
    
    
      clearTimeout(this.timerMap.get(key)) // 清理定时器
      this.timerMap.delete(key) // 删除记录
    }
  }

  /**
   * 停止所有定时器,适用于整个系统关闭时的情况。
   */
  clearAllTimers() {
    
    
    for (const [key, timer] of this.timerMap.entries()) {
    
    
      clearTimeout(timer) // 清除每个定时器
    }
    this.timerMap.clear() // 清空map
  }
}

export default TimerPool

定时器已经实现完成,接下来就是数据管理类的实现,主要实现代码如下所示:

import TimerPool from './TimerPool'
/**
 * DataManager类用于管理数据提交和定时器功能。
 */
class DataManager {
    
    
  /**
   * 创建一个DataManager实例。
   * @param {number} [globalBatchSize=100] - 全局默认批量大小。
   * @param {number} [globalInterval=5000] - 全局默认提交时间间隔(毫秒)。
   */
  constructor(globalBatchSize = 100, globalInterval = 5000) {
    
    
    this.dataMap = new Map() // 存储每个key的数据
    this.submitMethodMap = new Map() // 每个key对应的提交方法
    this.preprocessMethodMap = new Map() // 每个key对应的预处理方法,初始化在构造函数中
    this.globalBatchSize = globalBatchSize // 全局默认批量大小
    this.globalInterval = globalInterval // 全局默认提交时间
    this.timerPool = new TimerPool() // 使用定时器池来管理定时器
  }
  /**
   * 注册提交方法,根据key设置不同的提交逻辑
   * @param {string} key - 数据的唯一标识符。
   * @param {Function} submitCallback - 提交数据的回调函数。
   */
  registerSubmitMethod(key, submitCallback) {
    
    
    this.submitMethodMap.set(key, submitCallback)
  }
  /**
   * 注册预处理方法,根据key设置不同的预处理逻辑
   * @param {string} key - 数据的唯一标识符。
   * @param {Function} preprocessCallback - 提交数据的预处理函数。
   */
  registerPreprocessMethod(key, preprocessCallback) {
    
    
    this.preprocessMethodMap.set(key, preprocessCallback)
  }

  /**
   * 添加数据,添加数据(无预处理)
   * @param {string} key - 数据的唯一标识符。
   * @param {*} data - 要添加的数据。
   * @param {number} [batchSize=null] - 自定义批量大小。
   * @param {number} [interval=null] - 自定义提交间隔时间(毫秒)。
   */
  addData(key, data, batchSize = null, interval = null) {
    
    
    this._storeData(key, data, batchSize, interval)
  }

  /**
   * 添加数据(带预处理),根据key分类存储,支持自定义批量大小和定时器时间。
   * @param {string} key - 数据的唯一标识符。
   * @param {*} data - 要添加的数据。
   * @param {number} [batchSize=null] - 自定义批量大小。
   * @param {number} [interval=null] - 自定义提交间隔时间(毫秒)。
   */
  async addDataWithPreprocess(key, data, batchSize = null, interval = null) {
    
    
    // 如果有预处理方法,先处理数据
    if (this.preprocessMethodMap.has(key)) {
    
    
      data = await this.preprocessMethodMap.get(key)(data)
    }
    this._storeData(key, data, batchSize, interval)
  }

  /**
   * 批量添加数据(无预处理,数组)
   * @param {string} key
   * @param {Array} dataArray
   * @param {number} batchSize
   * @param {number} interval
   */
  addDataArray(key, dataArray, batchSize = null, interval = null) {
    
    
    if (!Array.isArray(dataArray)) {
    
    
      throw new Error('dataArray must be an array')
    }

    // 遍历数组并逐条存储数据
    dataArray.forEach((data) => {
    
    
      this._storeData(key, data, batchSize, interval)
    })
  }

  /**
   * 批量添加数据(带预处理,数组)
   * @param {string} key
   * @param {Array} dataArray
   * @param {number} batchSize
   * @param {number} interval
   */
  async addDataArrayWithPreprocess(key, dataArray, batchSize = null, interval = null) {
    
    
    if (!Array.isArray(dataArray)) {
    
    
      throw new Error('dataArray must be an array')
    }

    // 遍历数组,逐条预处理和存储数据
    for (const data of dataArray) {
    
    
      if (this.preprocessMethodMap.has(key)) {
    
    
        const preprocessMethod = this.preprocessMethodMap.get(key)
        const processedData = await preprocessMethod(data) // 等待异步预处理完成
        this._storeData(key, processedData, batchSize, interval)
      } else {
    
    
        this._storeData(key, data, batchSize, interval)
      }
    }
  }
  /**
   * 核心的存储逻辑(内部方法)
   * @param {string} key - 数据的唯一标识符。
   * @param {*} data - 要添加的数据。
   * @param {number} [batchSize=null] - 自定义批量大小。
   * @param {number} [interval=null] - 自定义提交间隔时间(毫秒)。
   */
  _storeData(key, data, batchSize = null, interval = null) {
    
    
    // 数据存入数据Map
    if (!this.dataMap.has(key)) {
    
    
      this.dataMap.set(key, [])
      // 启动定时器池中的定时器,使用传入的时间或全局时间
      this.timerPool.startTimer(key, interval || this.globalInterval, () => {
    
    
        this.submitDataForKey(key) // 定时器到期时提交数据
      })
    }

    this.dataMap.get(key).push(data)
    const currentBatchSize = batchSize || this.globalBatchSize // 使用传入批量大小或全局批量大小
    this.checkSubmitConditionForKey(key, currentBatchSize)
  }

  /**
   * 检查是否需要触发提交。
   * @param {string} key - 数据的唯一标识符。
   * @param {number} batchSize - 触发提交的数据批量大小。
   */
  checkSubmitConditionForKey(key, batchSize) {
    
    
    if (this.dataMap.get(key).length >= batchSize) {
    
    
      this.submitDataForKey(key)
    }
  }

  /**
   * 提交数据,根据key进行处理。
   * @param {string} key - 数据的唯一标识符。
   * @returns {Promise<void>} 提交操作的Promise。
   */
  async submitDataForKey(key) {
    
    
    const dataList = this.dataMap.get(key)
    if (!dataList || dataList.length === 0) return

    // 提交数据后,清除定时器
    this.timerPool.clearTimer(key)

    try {
    
    
      // 从提交方法映射中找到对应的提交逻辑
      const submitMethod = this.submitMethodMap.get(key)
      if (submitMethod) {
    
    
        await submitMethod(key, dataList) // 执行对应的提交逻辑`)
      } else {
    
    
        console.error(`未找到 ${
      
      key} 对应的提交方法`)
      }
      this.dataMap.delete(key) // 提交成功后,清除该key的数据
    } catch (error) {
    
    
      console.error(`提交数据失败,尝试重试: ${
      
      error}`)
      // 重试时,重新启动定时器
      this.timerPool.startTimer(key, this.globalInterval, () => {
    
    
        this.submitDataForKey(key) // 失败后再次尝试提交
      })
    }
  }
  /**
   * 提交指定 key 的数据。如果存在数据,则立即提交,否则记录一条错误消息。
   *
   * @param key - 要提交的数据的键
   * @throws {Error} 如果 key 对应的 dataMap 中没有数据,则抛出错误
   * @returns {Promise<void>} 当数据提交完成时,返回一个 Promise
   */
  async submitImmediately(key) {
    
    
    if (this.dataMap.has(key) && this.dataMap.get(key).length > 0) {
    
    
      // 清除 key 的定时器
      this.timerPool.clearTimer(key)
      // 提交 key 对应的数据
      await this.submitDataForKey(key)
    } else {
    
    
      console.warn(`Key "${
      
      key}" 无可提交的数据`)
    }
  }
}

export default DataManager
  • 构造方法:在创建 DataManager 时,可以传入默认的批量大小和提交时间间隔,便于全局管理,方便不添加参数的情况。

  • addDataaddDataWithPreprocess:用于添加数据到 DataManager,前者不进行预处理,后者支持在提交前对数据进行处理(比如格式化或过滤)。

  • submitImmediately:在特殊情况下,允许开发者主动调用该方法立即提交数据,而不需要等待定时器或者批量触发。

  • registerSubmitMethodregisterPreprocessMethod:可以为每种数据类型分别注册对应的提交方法和预处理方法,实现灵活的多类型数据管理。

3、如何使用 DataManager?

以下是一个简单的使用示例:

// 我们首先创建 `DataManager` 的实例,并且设定每批次数据的最大数量(即达到这个数量就会提交)和提交的时间间隔。
const dataManager = new DataManager(50, 10000); // 每收集到 50 条数据,或者每隔 10 秒,就会触发一次数据提交。

// 每种数据类型(即 `key`)都需要定义如何提交。我们可以为不同的 `key` 注册不同的提交方法。
// 例如,我们有用户操作日志数据,我们希望当日志数据积累到一定数量后提交到服务器。
dataManager.registerSubmitMethod('userLogs', async (data) => {
    
    
  // 提交数据给服务器
  console.log("提交用户日志: ", data);
});

// 有些时候,我们需要在提交之前对数据做一些处理,比如去除无用字段、格式转换等。
// 这时我们可以先注册一个预处理方法,再使用带预处理的添加方法。
dataManager.registerPreprocessMethod('userLogs', (data) => {
    
    
  // 预处理:去掉无用字段
  delete data.unnecessaryField;
  return data;
});

// 当你需要向数据队列添加新数据时,如果你不需要对数据进行处理,可以调用 `addData` 方法。
dataManager.addData('userLogs', {
    
     userId: 1, action: 'login', unnecessaryField: 'removeMe' });
dataManager.addDataWithPreprocess('userLogs', {
    
     userId: 2, action: 'logout', unnecessaryField: 'removeMe' });

// 也支持一次性添加多个数据。你可以用数组形式传递多个数据,让它们一同被处理。
dataManager.addDataArray('userLogs', [
  {
    
     userId: 1, action: 'login' },
  {
    
     userId: 2, action: 'logout' }
]);

// 如果需要对每个数据进行预处理,也可以使用相应的批量方法。
dataManager.addDataArrayWithPreprocess('userLogs', [
  {
    
     userId: 1, action: 'login' },
  {
    
     userId: 2, action: 'logout' }
]);

// 立即提交数据
dataManager.submitImmediately('userLogs');

进阶优化

使用单例模式

因为在应用程序中可能只需要一个数据管理工具来统一管理不同的 key、数据存储、提交和定时器管理。如果创建多个实例,会导致不同实例之间的数据和逻辑不共享,增加复杂度和可能的错误。

保持单个 DataManager 实例,可以减少内存和资源的浪费,因为多个实例会同时存在多个 dataMaptimerMap,这些数据和定时器应全局共享。

有了单例模式,所有需要 DataManager 的地方都可以访问同一个实例,避免重复初始化,保持数据和提交逻辑的统一。

实现思路:在这里我们可以使用静态属性或静态方法来管理单个实例。在实例化 DataManager 时,检查是否已有实例,如果有则直接返回现有实例,如果没有则创建新的实例。

class DataManager {
    
    
  constructor(globalBatchSize = 100, globalInterval = 5000) {
    
    
    if (DataManager.instance) {
    
    
      return DataManager.instance; // 返回已存在的实例
    }

    this.dataMap = new Map(); // 存储每个key的数据
    this.submitMethodMap = new Map(); // 每个key对应的提交方法
    this.preprocessMethodMap = new Map(); // 每个key对应的预处理方法
    this.globalBatchSize = globalBatchSize; // 全局默认批量大小
    this.globalInterval = globalInterval; // 全局默认提交时间
    this.timerPool = new TimerPool(); // 使用定时器池来管理定时器

    DataManager.instance = this; // 保存实例
    return this; // 返回实例
  }
  
  // 省略其他方法……

  // 静态方法获取单例
  static getInstance(globalBatchSize = 100, globalInterval = 5000) {
    
    
    if (!DataManager.instance) {
    
    
      DataManager.instance = new DataManager(globalBatchSize, globalInterval);
    }
    return DataManager.instance;
  }
}

数据丢失问题

在提交数据时,如果清理了数据,但在清理之前或期间恰好有新的数据被添加,就会导致新添加的数据在提交后丢失。

submitDataForKey 方法开始执行时,它会提交数据并清理数据,但是这时如果有新数据通过 addData 或其他方法添加到同一个 key,新的数据可能还没来得及处理就被一起清空了。

处理这个问题需要确保数据操作的同步性,以防止在提交和清理数据的过程中发生冲突。

这里有两种解决方案:使用锁机制(通过对数据操作加锁,保证在提交过程中,其他操作无法对数据进行修改)和提交前暂存新数据(提交数据时暂时将新数据缓存在内存中,等提交完成后再将缓存的数据重新放入 dataMap

提交前暂存新数据
  • 这里需要标记当前key对应的数据是否正在提交,新增一个属性isSubmitting

  • 如果要提交数据,先把key加入标记中,如果某个 key 正在提交数据,新的数据将不会直接添加到 dataMap,而是暂时放入 pending_${key} 中的暂存队列,避免清理时丢失数据。

  • 当提交完成后,如果发现有暂存数据,将这些数据重新加入 dataMap,继续正常处理。

代码实现:

class DataManager {
    
    
  constructor(globalBatchSize = 100, globalInterval = 5000) {
    
    
    if (DataManager.instance) {
    
    
      return DataManager.instance;
    }

    this.dataMap = new Map(); // 存储每个key的数据
    this.submitMethodMap = new Map(); // 每个key对应的提交方法
    this.preprocessMethodMap = new Map(); // 每个key对应的预处理方法
    this.globalBatchSize = globalBatchSize; // 全局默认批量大小
    this.globalInterval = globalInterval; // 全局默认提交时间
    this.timerPool = new TimerPool(); // 使用定时器池来管理定时器
    this.isSubmitting = new Map(); // 标记每个key是否正在提交

    DataManager.instance = this;
    return this;
  }

  /** 核心的存储逻辑(内部方法)**/
  _storeData(key, data, batchSize = null, interval = null) {
    
    
    // 如果正在提交数据,暂存新数据,避免清理时丢失
    if (this.isSubmitting.get(key)) {
    
    
      if (!this.dataMap.has(`pending_${
      
      key}`)) {
    
    
        this.dataMap.set(`pending_${
      
      key}`, []); // 暂存新数据
      }
      this.dataMap.get(`pending_${
      
      key}`).push(data);
      return;
    }

    // 如果该key还未存储数据,初始化数据存储
    if (!this.dataMap.has(key)) {
    
    
      this.dataMap.set(key, []);
      this.timerPool.startTimer(key, interval || this.globalInterval, () => {
    
    
        this.submitDataForKey(key); // 定时器到期时提交数据
      });
    }
    
    // 添加数据
    this.dataMap.get(key).push(data);
    
    const currentBatchSize = batchSize || this.globalBatchSize;
    this.checkSubmitConditionForKey(key, currentBatchSize);
  }

  /** 提交数据 **/
  async submitDataForKey(key) {
    
    
    // 标记为正在提交数据,避免同时修改数据
    this.isSubmitting.set(key, true);

    const dataList = this.dataMap.get(key);
    if (!dataList || dataList.length === 0) {
    
    
      this.isSubmitting.set(key, false);
      return;
    }

    this.timerPool.clearTimer(key);

    try {
    
    
      const submitMethod = this.submitMethodMap.get(key);
      if (submitMethod) {
    
    
        await submitMethod(dataList); // 提交数据
      } else {
    
    
        console.error(`未找到 ${
      
      key} 对应的提交方法`);
      }
      this.dataMap.delete(key); // 清理该key的数据

      // 如果有暂存的数据,将暂存的数据重新加入到dataMap中
      if (this.dataMap.has(`pending_${
      
      key}`)) {
    
    
        const pendingData = this.dataMap.get(`pending_${
      
      key}`);
        this.dataMap.set(key, pendingData);
        this.dataMap.delete(`pending_${
      
      key}`);
      }

    } catch (error) {
    
    
      console.error(`提交数据失败,尝试重试: ${
      
      error}`);
      this.timerPool.startTimer(key, this.globalInterval, () => {
    
    
        this.submitDataForKey(key); // 失败后再次尝试提交
      });
    } finally {
    
    
      this.isSubmitting.set(key, false); // 提交完成后,解除提交状态
    }
  }
}
锁机制
  • 为每个 key 引入一个锁对象,锁定期间其他操作无法访问该 key 的数据,这时候需要一个属性存放是否有锁的标记。

  • submitDataForKey 中提交数据前会获取锁,提交完成或失败后再释放锁,保证整个提交操作是原子的。

  • 在提交过程中,任何对同一 key 的数据修改请求都会排队等待锁释放,防止在提交过程中同时修改数据,避免数据丢失。

代码实现:

class DataManager {
    
    
  constructor(globalBatchSize = 100, globalInterval = 5000) {
    
    
    if (DataManager.instance) {
    
    
      return DataManager.instance;
    }

    this.dataMap = new Map(); // 存储每个key的数据
    this.submitMethodMap = new Map(); // 每个key对应的提交方法
    this.preprocessMethodMap = new Map(); // 每个key对应的预处理方法
    this.globalBatchSize = globalBatchSize; // 全局默认批量大小
    this.globalInterval = globalInterval; // 全局默认提交时间
    this.timerPool = new TimerPool(); // 使用定时器池来管理定时器
    this.lockMap = new Map(); // 用于加锁的Map,表示每个key的锁状态

    DataManager.instance = this;
    return this;
  }

  // 获取锁
  // 这个方法是异步的,用来获取某个 key 的锁
  // 如果当前 key 的锁已被占用,则会通过轮询等待,直到锁可用为止
  // 一旦锁可用,标记该 key 的锁状态为 true,表示这个 key 正在执行一个关键操作(如提交数据)
  async acquireLock(key) {
    
    
    while (this.lockMap.get(key)) {
    
    
      // 如果锁已被占用,等待一段时间后重试
      await new Promise((resolve) => setTimeout(resolve, 50));
    }
    // 获得锁
    this.lockMap.set(key, true);
  }

  // 释放锁
  // 当某个关键操作完成后,调用该方法将锁标记为 false,表示此 key 已释放,可以被其他操作获取锁并执行操作
  releaseLock(key) {
    
    
    this.lockMap.set(key, false);
  }
  
  // 核心的存储逻辑(内部方法)
  async _storeData(key, data, batchSize = null, interval = null) {
    
    
    await this.acquireLock(key); // 确保数据的存储操作和提交操作是互斥的
    try {
    
    
      // 如果该key还未存储数据,初始化数据存储
      if (!this.dataMap.has(key)) {
    
    
        this.dataMap.set(key, []);
        this.timerPool.startTimer(key, interval || this.globalInterval, () => {
    
    
          this.submitDataForKey(key); // 定时器到期时提交数据
        });
      }

      // 添加数据
      this.dataMap.get(key).push(data);

      const currentBatchSize = batchSize || this.globalBatchSize;
      this.checkSubmitConditionForKey(key, currentBatchSize);
    } finally {
    
    
      this.releaseLock(key); // 添加数据后释放锁
    }
  }

  // 提交数据(带锁机制)
  async submitDataForKey(key) {
    
    
    // 加锁,确保提交期间其他操作不会干扰
    await this.acquireLock(key);

    const dataList = this.dataMap.get(key);
    if (!dataList || dataList.length === 0) {
    
    
      this.releaseLock(key);
      return;
    }

    this.timerPool.clearTimer(key);

    try {
    
    
      const submitMethod = this.submitMethodMap.get(key);
      if (submitMethod) {
    
    
        await submitMethod(dataList); // 提交数据
      } else {
    
    
        console.error(`未找到 ${
      
      key} 对应的提交方法`);
      }
      this.dataMap.delete(key); // 清理该key的数据

    } catch (error) {
    
    
      console.error(`提交数据失败,尝试重试: ${
      
      error}`);
      this.timerPool.startTimer(key, this.globalInterval, () => {
    
    
        this.submitDataForKey(key); // 失败后再次尝试提交
      });
    } finally {
    
    
      // 提交完成后,解锁
      this.releaseLock(key);
    }
  }
}

最后聊聊

为什么不是在后端使用消息队列,入库的时候批量操作

首先要明白一句话压力不会消失,但是压力可以转移,这个项目分两部分:前端在客户电脑上运行,后端在服务器上运行,毕竟没钱买高配服务器,为了缓解服务器的工作压力,能苦了用户,把一些操作放在前端执行了。

DataManager 类也只是提供了一个简单高效灵活的数据收集与提交方案,通过批量管理、定时发送、数据预处理、多类型支持,业务逻辑中不需要关注数据转化,数据如何提交的实现,大大简化了前端开发中数据处理的复杂性。不知道大家还有什么好的想法?

猜你喜欢

转载自blog.csdn.net/weixin_43242942/article/details/142026423