在最近的一个前端项目中,我遇到了一个关于数据提交的小需求。我们需要从前端收集几百条数据并提交到后台。如果每次获取到数据后立即调用接口,势必会导致频繁的接口请求,不仅效率低下,还可能给服务器带来巨大压力。虽然理想情况下,应该等所有数据收集完毕后再统一提交,但由于数据收集是一个较长的过程,如果中途发生错误,可能会导致数据丢失。
开发思路
为了解决这个问题,我想到了一个折中的解决方案:每隔一段时间批量提交数据。这种方式不仅能够减少对服务器的频繁请求,还能降低数据丢失的风险。由于项目中类似的场景较多,我决定开发一个通用的数据管理工具类。
在开发这个工具类时,我设定了几个关键需求:
- 适配不同数据类型:不同类型的数据往往需要调用不同的接口,因此工具类必须支持灵活的数据处理策略。例如,对于包含图片的数据,图片需要先上传获取链接,再将链接提交到服务器,这就需要支持数据的预处理功能。
- 支持批量提交与定时处理:为了避免频繁的网络请求,该工具类应能够在一段时间内批量收集数据并一并提交。
- 支持立即提交:在某些场景下,允许用户立即提交数据,而不需要等待批量处理。
1、什么是 DataManager 类?
有点沿用后端开发的思想,这里我这里创建一个DataManager
工具类,能够收集数据、批量处理,并按照设定的时间间隔或数据量大小提交给服务器。它的核心理念类似于一个消息队列,用于管理不同类型的数据,并支持对数据的灵活处理。对于前端开发中的高频数据收集和发送场景,DataManager
提供了一种更加高效和可控的解决方案。
它的核心功能包括:
- 批量收集数据:在每次收集数据时,不是立即发起请求,而是等到数据量达到一定数量时才进行提交。
- 定时提交:可以设置一个时间间隔,保证即使数据没有达到一定数量,也会在设定时间之后自动提交数据。
- 数据预处理:支持在数据发送之前进行自定义的预处理操作,如格式化数据、过滤无用属性等。
- 支持多种数据类型:可以针对不同类型的数据进行各自的处理和提交逻辑。
- 立即提交:当需要立刻提交数据时,允许手动触发立即提交,跳过定时器和批量条件。
就我当前做的项目来说,有两块功能涉及到这个工具类使用场景:
- 用户行为记录:比如用户点击按钮、浏览页面、登录登出等频繁的操作,如果每次都直接发送请求,会增加服务器负担。可以使用
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
时,可以传入默认的批量大小和提交时间间隔,便于全局管理,方便不添加参数的情况。 -
addData
和addDataWithPreprocess
:用于添加数据到DataManager
,前者不进行预处理,后者支持在提交前对数据进行处理(比如格式化或过滤)。 -
submitImmediately
:在特殊情况下,允许开发者主动调用该方法立即提交数据,而不需要等待定时器或者批量触发。 -
registerSubmitMethod
和registerPreprocessMethod
:可以为每种数据类型分别注册对应的提交方法和预处理方法,实现灵活的多类型数据管理。
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
实例,可以减少内存和资源的浪费,因为多个实例会同时存在多个 dataMap
和 timerMap
,这些数据和定时器应全局共享。
有了单例模式,所有需要 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
类也只是提供了一个简单高效灵活的数据收集与提交方案,通过批量管理、定时发送、数据预处理、多类型支持,业务逻辑中不需要关注数据转化,数据如何提交的实现,大大简化了前端开发中数据处理的复杂性。不知道大家还有什么好的想法?