Orion's Studio.

自动批量请求(3)—— 同时发送多个 batch 请求

2021/03/05

我们已经实现了自动把同一时刻的请求合并,并统一渲染。

存在问题

老样子,先来看图回顾一下(假设一次 EventLoop 占用 5 个时间单位,请求回复也需要 5 个时间单位):

问题图

由图可见,我们遇到了一个新的问题:

同一时刻,最多只能合并一个 batch 请求,导致多次重复的同类型请求,无法合并成多个 batch 请求(如上图的 2 个 A、2 个 B,最后只合成了 1 个 batch、1 个 A 和 1 个 B,而非 2 个 batch)。

我们的期望是尽可能合并 batch 请求,如上图第 3、第 4 个请求可以合并成一个新的 batch 请求,并在 5 个时间单位的时候发送。

所以本文对原有的代码又做了一次优化。

具体实现

因为原先是用一个对象来保存多个请求的信息,键名容易重,这才导致无法实现。

所以我们的方案就是,把原有的对象形式转为数组形式,以此来存储信息,而数组的长度就代表了 batch 请求的次数。

注:之所以没法只用一个 batch 请求的原因是,batch 请求的参数无法支持同时处理多个同名请求。

衔接上一篇的 IRequestMap 对象,我们定义如下数组来代表 batch 队列:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 单个请求的元数据信息
*/
interface IRequestItem {
name: string; // 请求 url
data: IObject; // 深入 stringify 之后的请求参数
success: (obj: IObject) => void; // 成功回调
error: (err: Error) => void; // 错误回调
};

/**
* 同一个队列多个请求组成的 Map,键名是 api
*/
interface IRequestMap {
[url: string]: IRequestItem; // 每个请求对应的元数据信息
};

let batchList: IRequestMap[] = []; // 多个batch请求组成的数组

在我们编写 batch 队列处理的逻辑前,不妨先考虑一下,当一个新的请求,即 IRequestItem 对象进来时,我们该如何处理。

流程图

所以 addRequest 可调整为如下写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/**
* 增加请求到队列中
* @param requestItem 单个请求信息
*/
const addRequest: (requestItem: IRequestItem) => void = (requestItem) => {
const { name } = requestItem;

// 查找最后一个含有对应 name 的请求对象,查找不到时默认赋值为 -1,这样当队列为空即数组长度为0时,lastIndex + 1 也为 0
const lastIndex = Math.max(
-1,
...batchList.map((item: IRequestMap, index: number) => (item[name] ? index : -1))
);

// 在 batch 队列为空,或batch 队列的所有元素都包含该 name 时,都是创建一个新 Map
if (lastIndex + 1 === batchList.length) {
batchList.push({
name: requestItem
});
} else if (lastIndex) {
// 否则在第一个未出现该请求的 Map 增加该请求
batchList[lastIndex + 1][name] = requestItem;
}
};

相应地,clearRequest 也可以简单调整成这样:

1
2
3
4
5
6
/**
* 清空请求队列
*/
const clearRequest: () => void = () => {
batchList = [];
};

但是,clearRequest 的调用时机却发生了变化。

我们再来梳理一下,之前 clearRequest 是在 Promise.resolve().then() 的回调中,发完请求之后执行。

但是现在同一个 EventLoop 会包含多个 batch 队列,而每个 batch 队列都对应一个 Promise.resolve(),也就是说,我们必然不能在每个 Promise.resolve().then() 的回调中都执行清空队列的操作,而是只能在最后一个中执行。

而这样,又暴露了一个新的问题,那就是创建 Promise.resolve() 的时机:之前是每个 EventLoop 的第一个请求添加到队列的时候执行,而现在变成了每当 batch 队列添加新元素时都应该执行。

我们期盼的场景是这样(假设一次 EventLoop 占用 5 个时间单位,请求回复也需要 5 个时间单位):

期望图

于是,我们将发送请求的逻辑迁移到 addRequest 创建 batch 队列的逻辑中:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
if (lastIndex + 1 === batchList.length) {
batchList.push({
[name]: requestItem
});
// 同时也要创建一个新的 Promise
Promise.resolve().then(() => {
// 缓存的 lastIndex 其实是当前 Map 前一个,为空是 lastIndex 也为 -1,lastIndex + 1 即为当前值
const requestMap = batchList[lastIndex + 1];
// 当数量大于 1 才启用 batch 请求
if (Object.keys(requestMap).length > 1) {
sendRequestBatch(requestMap);
} else {
// 等于 1 时,则降级到正常请求发送
fetch(name, options).then(success, error);
}

// 清空变量,lastIndex + 1 为当前值,lastIndex + 2 即为数组当前长度,相等即为最后一个 batch 队列
if (lastIndex + 2 === batchList.length) {
clearRequest();
}
});
}

同时,我们也可以把累赘的 batchFlag 给去掉。最后,生成的 fetchBatch 简直简得不能再简:

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
/**
* 自动批处理及批量更新的 fetch,通过闭包保存一些变量
*/
export const fetchBatch = (function anonymousFetch() {
let batchList: IRequestMap[] = []; // 多个batch请求组成的数组
const sendRequestBatch = () => {}; /* 见上述代码 */
const addRequest = () => {}; /* 见上述代码 */
const clearRequest = () => {}; /* 见上述代码 */

// 实际返回匿名函数
return function anonymous(
name: string = '', // 透传 api 地址
options: IFetchOptions = { data: {} }, // 透传 options 参数对象,格式为 { data: {} }
success: (obj: IObject) => void = noop, // 成功回调
error: (err: Error) => void = noop // 错误回调
) {
// 增加请求到队列中
addRequest({
name,
options,
success,
error
});
};
}());

试验了一下,就可以在同一个 EventLoop 中发送多次 batch 请求啦~

CATALOG
  1. 1. 存在问题
  2. 2. 具体实现