本系列上一篇文章:【程式碼鑑賞】簡單優雅的JavaScript程式碼片段(一):非同步控制
流控(又稱限流,控制呼叫頻率)
後端為了保證系統穩定執行,往往會對呼叫頻率進行限制(比如每人每秒不得超過10次)。為了避免造成資源浪費或者遭受系統懲罰,前端也需要主動限制自己呼叫API的頻率。
前端需要大批量拉取列表時,或者需要對每一個列表項呼叫API查詢詳情時,尤其需要進行限流。
這裡提供一個流控工具函式wrapFlowControl
,它的好處是:
- 使用簡單、對呼叫者透明:只需要包裝一下你原本的非同步函式,即可得到擁有流控限制的函式,它與原本的非同步函式使用方式相同。
const apiWithFlowControl = wrapFlowControl(callAPI, 2);
- 不會丟棄任何一次呼叫(不像防抖或節流)。每一次呼叫都會被執行、得到相應的結果。只不過可能會為了控制頻率而被延遲執行。
使用示例:
// 建立了一個排程佇列
const apiWithFlowControl = wrapFlowControl(callAPI, 2);
// ......
<button
onClick={() => {
const count = ++countRef.current;
// 請求排程佇列安排一次函式呼叫
apiWithFlowControl(count).then((result) => {
// do something with api result
});
}}
>
Call apiWithFlowControl
</button>
這個方案的本質是,先通過wrapFlowControl
建立了一個排程佇列,然後在每次呼叫apiWithFlowControl
的時候,請求排程佇列安排一次函式呼叫。
程式碼實現
wrapFlowControl
的程式碼實現:
const ONE_SECOND_MS = 1000;
/**
* 控制函式呼叫頻率。在任何一個1秒的區間,呼叫fn的次數不會超過maxExecPerSec次。
* 如果函式觸發頻率超過限制,則會延緩一部分呼叫,使得實際呼叫頻率滿足上面的要求。
*/
export function wrapFlowControl<Args extends any[], Ret>(
fn: (...args: Args) => Promise<Ret>,
maxExecPerSec: number
) {
if (maxExecPerSec < 1) throw new Error(`invalid maxExecPerSec`);
// 排程佇列,記錄將要執行的任務
const queue: QueueItem[] = [];
// 最近一秒鐘的執行記錄,用於判斷執行頻率是否超出限制
const executed: ExecutedItem[] = [];
return function wrapped(...args: Args): Promise<Ret> {
return enqueue(args);
};
function enqueue(args: Args): Promise<Ret> {
return new Promise((resolve, reject) => {
queue.push({ args, resolve, reject });
scheduleCheckQueue();
});
}
function scheduleCheckQueue() {
const nextTask = queue[0];
// 僅在queue為空時,才會停止scheduleCheckQueue遞迴呼叫
if (!nextTask) return;
cleanExecuted();
if (executed.length < maxExecPerSec) {
// 最近一秒鐘執行的數量少於閾值,才可以執行下一個task
queue.shift();
execute(nextTask);
scheduleCheckQueue();
} else {
// 過一會再排程
const earliestExecuted = executed[0];
const now = new Date().valueOf();
const waitTime = earliestExecuted.timestamp + ONE_SECOND_MS - now;
setTimeout(() => {
// 此時earliestExecuted已經可以被清除,給下一個task的執行提供配額
scheduleCheckQueue();
}, waitTime);
}
}
function cleanExecuted() {
const now = new Date().valueOf();
const oneSecondAgo = now - ONE_SECOND_MS;
while (executed[0]?.timestamp <= oneSecondAgo) {
executed.shift();
}
}
function execute({ args, resolve, reject }: QueueItem) {
const timestamp = new Date().valueOf();
fn(...args).then(resolve, reject);
executed.push({ timestamp });
}
type QueueItem = {
args: Args;
resolve: (ret: Ret) => void;
reject: (error: any) => void;
};
type ExecutedItem = {
timestamp: number;
};
}
延遲確定函式邏輯
從上面的示例可以看出,在使用wrapFlowControl
的時候,你需要預先定義好非同步函式callAPI
的邏輯,才能得到流控函式。
但是在一些特殊場景中,我們要在發起呼叫的時候,才確定非同步函式應該執行什麼邏輯。即將“定義時確定”推遲到“呼叫時確定”。因此我們實現了另一個工具函式createFlowControlScheduler
。
在上面的使用示例中,DemoWrapFlowControl
就是一個例子:我們在使用者點選按鈕的時候,才決定要呼叫API1還是API2。
// 建立一個排程佇列
const scheduleCallWithFlowControl = createFlowControlScheduler(2);
// ......
<div style={{ marginTop: 24 }}>
<button
onClick={() => {
const count = ++countRef.current;
// 在呼叫時才決定要執行的非同步操作
// 將非同步操作加入排程佇列
scheduleCallWithFlowControl(async () => {
// 流控會保障這個非同步函式的執行頻率
if (count % 2 === 1) {
return callAPI1(count);
} else {
return callAPI2(count);
}
}).then((result) => {
// do something with api result
});
}}
>
Call scheduleCallWithFlowControl
</button>
</div>
這個方案的本質是,先通過createFlowControlScheduler
建立了一個排程佇列,然後每當scheduleCallWithFlowControl
接受到一個非同步任務,就會將它加入排程佇列。排程佇列會確保所有非同步任務都被呼叫(按照加入佇列的順序),並且任務執行頻率不超過指定的值。
createFlowControlScheduler
的實現其實非常簡單,基於前面的wrapFlowControl
實現:
/**
* 類似於wrapFlowControl,只不過將task的定義延遲到呼叫wrapper時才提供,
* 而不是在建立flowControl wrapper時就提供
*/
export function createFlowControlScheduler(maxExecPerSec: number) {
return wrapFlowControl(async <T>(task: () => Promise<T>) => {
return task();
}, maxExecPerSec);
}
擴充套件思考
如何改造我們的工具函式,讓它能夠支援“每分鐘不得超過n次”的頻率限制?
如何改造我們的工具函式,讓它能夠同時支援“每秒鐘不得超過n次”且“每分鐘不得超過m次”的頻率限制?如何實現更靈活的排程佇列?
舉個例子,頻率限制為“每秒鐘不得超過10次”且“每分鐘不得超過30次”。它的意義在於,允許短時間內的突發高頻呼叫(通過放鬆秒級限制),同時又阻止高頻呼叫持續太長之間(通過分鐘級限制)。
重試
前面我們已經得到了一個在前端限制呼叫頻率的方案。但是,即使我們已經在前端限制了呼叫頻率,依然可能遇到錯誤:
- 前端的流控無法完全滿足後端的流控限制。後端可能會對所有使用者的呼叫之和做一個整體限制。比如所有使用者的呼叫頻率不能超過每秒一萬次,前端流控無法對齊這種限制。
- 非流控錯誤。比如後端服務或網路不穩定,造成的短暫不可用。
因此,面對這些前端不可避免的錯誤,需要通過重試來得到結果。這裡提供一個重試工具函式wrapRetry
,它的好處是:
- 使用簡單、對呼叫者透明:與前面的流控工具函式一樣,只需要包裝一下你原本的非同步函式,即可得到自動重試的函式,它與原本的非同步函式使用方式相同。
- 支援自定義要重試的錯誤型別、重試次數、重試等待時間。
使用方式:
const apiWithRetry = wrapRetry(
callAPI,
(error, retryCount) => error.type === "throttle" && retryCount <= 5
);
它的使用方式與wrapFlowControl
類似。
程式碼實現
wrapRetry
程式碼實現:
/**
* 捕獲到特定的失敗以後會重試。適合無副作用的操作。
* 比如資料請求可能被流控攔截,就可以用它來做自動重試。
*/
export function wrapRetry<Args extends any[], Ret>(
fn: (...args: Args) => Promise<Ret>,
shouldRetry: (error: any, retryCount: number) => boolean,
startRetryWait: number = 1000
) {
return async function wrapped(...args: Args): Promise<Ret> {
return callFn(args, startRetryWait, 0);
};
async function callFn(
args: Args,
wait: number,
retryCount: number
): Promise<Ret> {
try {
return await fn(...args);
} catch (error) {
if (shouldRetry(error, retryCount)) {
if (wait > 0) await timeout(wait);
// nextWait是wait的 1 ~ 2 倍
// 如果startRetryWait是0,則wait總是0
const nextWait = wait * (Math.random() + 1);
return callFn(args, nextWait, retryCount + 1);
} else {
throw error;
}
}
}
}
function timeout(wait: number) {
return new Promise((res) => {
setTimeout(() => {
res(null);
}, wait);
});
}
其中,我們增加了一個優化點:讓重試等待時間逐步增加。比如,第2次重試的等待時間是第一次重試等待時間的1 ~ 2 倍。這是為了儘可能減少呼叫次數,避免給正處於不穩定的後端帶來更多壓力。
沒有選擇2倍增加,是為了避免重試等待時間太長,降低使用者體驗。
可組合性
值得一提的是,自動重試可以與前面的限流工具組合起來使用(得益於它們都對呼叫者透明,不改變函式使用方式):
const apiWithFlowControl = wrapFlowControl(callAPI, 2);
const apiWithRetry = wrapRetry(
apiWithFlowControl,
(error, retryCount) => error.type === "throttle" && retryCount <= 5
);
注意,限流包裝在內部,重試包裝在外部,這樣才能保證重試發起的請求也能受到限流的控制。