【程式碼鑑賞】簡單優雅的JavaScript程式碼片段(二):流控和重試

csRyan發表於2021-11-07
本系列上一篇文章:【程式碼鑑賞】簡單優雅的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>

codesandbox線上示例

這個方案的本質是,先通過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>

codesandbox線上示例

這個方案的本質是,先通過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次”。它的意義在於,允許短時間內的突發高頻呼叫(通過放鬆秒級限制),同時又阻止高頻呼叫持續太長之間(通過分鐘級限制)。

重試

前面我們已經得到了一個在前端限制呼叫頻率的方案。但是,即使我們已經在前端限制了呼叫頻率,依然可能遇到錯誤:

  1. 前端的流控無法完全滿足後端的流控限制。後端可能會對所有使用者的呼叫之和做一個整體限制。比如所有使用者的呼叫頻率不能超過每秒一萬次,前端流控無法對齊這種限制。
  2. 非流控錯誤。比如後端服務或網路不穩定,造成的短暫不可用。

因此,面對這些前端不可避免的錯誤,需要通過重試來得到結果。這裡提供一個重試工具函式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
);

注意,限流包裝在內部,重試包裝在外部,這樣才能保證重試發起的請求也能受到限流的控制。

相關文章