背景
之前写了一篇 《ChatGPT 给我的「万能的」控制 Promise 并发的方法》,里面控制了网络请求并发时的数量,也就是同一时刻,最多只能有 n 个请求发送。
值得一提的是 ChatGPT 给了我非常大的启发,它提供的这种信号量(Semaphore)的方式是我第一次了解(也可能是我孤陋寡闻),这种思路绝对是可以复用在很多其它地方的。
比如这次,在项目中虽然前端请求用了频控,但是后端接口规定 QPS < 15 才可以 。也就是说如果每个接口返回的都很快,即使用了频控,也很有可能在 1s 内发出 15 个以上的请求。所以在实际使用过程中,还是会出现接口报错。
参考信号量频控的思路,我们来修改一下逻辑就可以了。
分析
我们先来看下频控的使用方法:
ts
// 自定义装饰器函数,用于限制并发请求数量
function limitConcurrency(fn, limit) {
const semaphore = new Semaphore(limit);
return async function(...args) {
await semaphore.acquire(); // !!!!注意这里!!!!!
try {
return await fn.apply(this, args);
} finally {
semaphore.release();
}
};
}
// 使用装饰器包裹 fetch 请求
const limitedFetch = limitConcurrency(fetch, 5);
// 并发发起 20 个请求
const urls = ['url1', 'url2', 'url3', ...]; // 假设有 20 个请求
const promises = urls.map(url => limitedFetch(url));
Promise.all(promises)
.then(responses => {
// 处理所有请求的响应
})
.catch(error => {
// 处理错误
});
看过 《ChatGPT 给我的「万能的」控制 Promise 并发的方法》 文章的朋友可能注意到了,整个方法的关键就是在 await semaphore.acquire();
这一句。没看过也不要紧,简单粗暴一点的解释,就是这个地方一直在阻塞着,等待着,不满足条件这里就进行不下去。好了,我们借着这个思路,来改造一下这里的逻辑就可以了。
首先得有计数和计时,它们的消费逻辑大概是:
- 如果 1s 以内的请求数小于 n,那就正常通过,不阻塞;
- 如果超过了 n,并且还没超过 1s,就无限循环阻塞,每次判断是否可以通过;
- 如果时间超过了 1s,就可以将计数清零,通过正在阻塞的请求;
- 重复 1~3 的逻辑。
我们直接上代码。
代码实现
ts
class Semaphore {
private limit: number;
private count: number; // 计数
private lastRunTime: number; // 计时
private duration = 1000;
constructor(limit: number) {
this.limit = limit;
this.count = 0;
this.lastRunTime = Date.now();
}
public acquire() {
// 每次发起都判断下是否超过了 1s,如果超过就更新下计数和计时
if (Date.now() - this.lastRunTime > this.duration) {
this.lastRunTime = Date.now();
this.count = 0;
}
// 靠 Promise 控制 resolve 的触发来实现阻塞
return new Promise<void>((resolve) => {
// 计数未超过限制时正常执行
if (this.count < this.limit) {
this.count++;
resolve();
} else {
// 计数超过限制后开始无限循环
const timer = setInterval(() => {
if (Date.now() - this.lastRunTime > this.duration) {
// 直到超过 1s 后,更新计数和计时
this.lastRunTime = Date.now();
this.count = 0;
}
if (this.count < this.limit) {
// 满足条件时,正常执行,跳出循环
this.count++;
resolve();
clearInterval(timer);
}
}, 100);
}
});
}
}
我们看到,这回不需要 release 方法来解除 acquire 的阻塞了。直接在 acquire 内部实现解除阻塞的逻辑就可以了。所以 Decorator 的代码稍微改动一下,去掉 release 就可以了,如下:
ts
type AsyncFunction = (...args: any[]) => Promise<any>;
export const limitConcurrency = (
fn: AsyncFunction,
limit: number,
): AsyncFunction => {
const semaphore = new Semaphore(limit);
return async (...args): Promise<any> => {
await semaphore.acquire();
try {
return await fn(...args);
} catch (e) {
console.error('limitConcurrency', e);
}
// 去掉了 semaphore.release();
};
};
// 使用装饰器包裹 fetch 请求
const limitedFetch = limitConcurrency(fetch, 15);
到此代码就都完成了,使用的话把所有的 fetch 替换成 limitedFetch 就可以了。
总结
这种信号量的思路的确很精彩,但是其本质又非常的简单。就是在你原本的方法前面加了一个 Semaphore 阻塞的逻辑。
这个阻塞的逻辑又是内聚在 Semaphore 内部的,非常的解耦和易于维护,侵入性极小。强烈推荐掌握这种思路。
大胆一点,如果既想要频控,又想要控 QPS,把 2 个 Decorator 同时使用就可以了。
ts
const limitedFetch = limitCount(limitQps(fetch, 15), 5)
是不是很简单~