ETag 介面軟快取

momo707577045發表於2023-05-04

線上測試 Demo 效果對比

千條資料,657k 響應體,size 減少 99%,介面耗時縮短 94%。
  • get 引數攜帶在 url 中有長度限制,僅以post 請求作為測試示例
  • 由於作者當前環境上行頻寬大,而作者伺服器使用最低配置的網路,下行頻寬小。故可基本忽略上行耗時。
  • size 由最佳化前的 657000B 降低為 310B,減少 656690B,減少 99% 體積
  • Time 由最佳化前的 3460ms 降低為 193ms,減少 3267ms,減少 94% 耗時(注意,這個是因為作者的下行頻寬小,商用頻寬不會有這麼大的差異)

百條資料,65.9k 響應體,size 減少 99%,介面耗時縮短 70%。
  • size 由最佳化前的 65900B 降低為 310B,減少 65590B,減少 99% 體積
  • Time 由最佳化前的 68ms 降低為 20ms,減少 48ms,減少 70% 耗時

十條資料,7k 響應體,資料量較低,網速影響較大
  • size 由最佳化前的 7000B 降低為 310B,減少 6690B,減少 95% 體積
  • Time 由最佳化前的 86ms 降低為 41ms,減少 45ms,減少 52% 耗時

一條資料,1.2k 響應體,資料量更低存在一定誤差
  • 介面返回 size 由 1200B 降為 310B,減少 1200-310=890B,減少 890 / 1200 = 74% 體積
  • Time 由最佳化前的 32ms 降低為 23ms,減少 9ms,減少 28% 耗時

Demo 介紹

  • 執行邏輯

    • 先填寫請求引數(僅支援物件結構),服務端會把請求引數將會作為響應體返回給瀏覽器。在右側欄中顯示返回結果。
    • 以此來定製模擬特定體積響應體,測試資料量下的效果。
    • 測試前,強制重新整理當前頁面,以清除瀏覽器快取。並取消控制檯的「Disable cache」勾選
  • 功能介紹

    • sendGet,傳送 get 請求,當發生兩次相同的請求時,第二次會僅返回 304 狀態碼,size 及 time 對比上一次有明確變化
    • sendPost,傳送 post 請求,由於 If-None-Match 本身不會在 get 請求中新增,需要前端自行維護,具體實現邏輯下文將介紹到
    • 測試資料量,可動態生產請求引數,快速模擬大資料量的請求情況,最大值支援 10000 條資料。

原理

  • 協商快取 304

    客戶端向服務端發起 http 請求時,攜帶上次請求結果的特徵值,服務端對比執行結果與請求中的特徵值是否一致,如果特徵值一致,則直接返回 304 給客戶端,告知檔案未改變,客戶端使用本地快取.

  • 本次最佳化使用到 http1.1 新增的響應頭 ETag ,及新增的請求頭 f-None-Match實現協商快取。


實現思路

後端,以 node 為例

  • 新增一箇中介軟體,在 response 中做一個全域性處理,將響應體做一次 hash,並攜帶在 ETag 響應頭中
  • 若請求頭中存在if-none-match請求頭,則與 hash 值進行對比,一致則返回 304,無需返回響應體

    // Etag 處理的中間層
    function responseWithEtag(request, response, responseData) {
      const etag = crypto.createHash('md5').update(responseData).digest('hex') // 獲取返回引數的 hash 值
      response.setHeader("ETag", etag); // 設定 ETag,標識本次響應資訊的 hash 資訊,以對比資訊是否
      if (request.headers['if-none-match'] === etag) { // 若本次返回值的 hash 資訊,與請求頭的 if-none-match hash 值一致,則直接返回 304,複用瀏覽器快取的資料,無需要額外返回資料,節省頻寬
          console.log('ETag hash match')
          response.writeHead(304, "Not Modified");
          response.end();
          return
      } else { // 無 if-none-match 請求頭,或者返回值內容發生變化,hash 匹配不上
          console.log('ETag hash mismatching')
          response.writeHead(200, { "Content-Type": "text/plain; charset=utf-8" });
          response.end(responseData); // 返回業務資料
      }
    }

    前端

  • get 的 ETag 協商快取已由瀏覽器中實現,前端無需額外處理
  • post 請求本身不支援 ETag 快取(post 請求在設計時專為非冪等介面,但實際上已經很多用於複雜查詢介面),需要前端自行實現快取機制

    • 在 post 呼叫完成後,以請求路徑及請求體作為 hash 引數,唯一標識該請求。儲存對應的 ETag 響應頭,及對應的返回值。
    • 注意,js 獲取 ETag 請求頭,需要後端開放該請求頭的訪問權,response.setHeader("Access-Control-Expose-Headers", "ETag"); // 允許暴露ETag響應頭,否則前端無法用 JS 獲取 ETag 頭
    • 在發起 post 請求時,查詢該請求是否發起過(以請求路徑及請求體作為 hash 引數唯一標識),若發起過,則在請求頭中自行新增if-none-match請求頭
  // 由於 post 請求本身不支援 ETag 快取,需要前端自行實現快取機制
  const eTagPostMap = {}
  function ajax(url, type, reqData) {
    return new Promise((resolve, reject) => {
      let postReqHash = ''; // 以請求路徑及其引數的 hash 作為 key 進行快取
      if (type === 'POST') {
        postReqHash = md5.create().update(url + JSON.stringify(reqData)).digest('hex'); // 以請求路徑及其引數的 hash 作為 key 進行快取
      }
      const xhr = new XMLHttpRequest();
      xhr.onreadystatechange = function () {
        if (xhr.readyState === 4) {
          let { status, responseText, responseXML } = xhr;

          // 注意,GET 請求的 304 響應頭在 chrome 接受時,給到 JS 會變成 200 狀態碼,且有正常的 responseText
          if (status >= 200 && status < 300) {
            // POST 請求的響應頭有 ETag,則快取該結果
            if (type === 'POST' && xhr.getResponseHeader('ETag')) {
              eTagPostMap[postReqHash] = {
                content: responseText,
                etag: xhr.getResponseHeader('ETag'),
              }
            }
            return resolve(responseText);
          }

          if (status === 304) { // 注意,GET 請求的 304 響應頭在 chrome 接受時,給到 JS 會變成 200 狀態碼
            if (type === 'POST') {
              return resolve(eTagPostMap[postReqHash].content);
            } else if (type === 'GET') { // 在 chrome 瀏覽器 GET 請求不會有 304 狀態碼,在這裡只是兜底,以防其他瀏覽器表現不一致
              return resolve(responseText);
            }
          }
          if (!status) {
            alert('get 請求不支援這麼大的請求引數,請減少數量 或 使用 post 請求')
          }

          reject(status);
        }
      };

      if (type === 'GET') {
        xhr.open('GET', url + '?' + serializeParams(reqData), true);
        xhr.send(null);
      } else if (type === 'POST') {
        xhr.open('POST', url, true);
        // 如果之前傳送過這個請求,且有 ETag hash 記錄,則當再次發起時,攜帶上該 hash
        if (eTagPostMap[postReqHash]) {
          xhr.setRequestHeader("if-none-match", eTagPostMap[postReqHash].etag);
        }
        xhr.send(JSON.stringify(reqData));
      }
    })

hash 效能

  • 因引入了請求體 hash 運算邏輯,需評估其帶來的執行耗時
  • 以 1000 條資料 657k,執行 hash 100 次為例,共耗時 180ms,平均單次耗時 1.8ms
  • 對比減少的 3267ms 下行耗時,可忽略不計

風險管控

  • 適用場景

    • get,post 冪等的查詢類介面,不應用於非冪等介面
  • 快速切換與回滾

    • 後端下發 ETag 響應頭客戶端才會使用快取,整個快取開關的控制全均在後端,若功能出現問題,可實現快速切換恢復。
  • 瀏覽器相容性支援

    • 部分魔改的瀏覽器不確實是否支援 http1.1 協議
    • 後端可請求頭中的 User-Agent 判斷當前客戶端瀏覽器,先從 chrome 支援開始,再逐步支援其他瀏覽器,或全量支援。
  • 定製特定介面不快取

    • 特定介面不下發 ETag 即可關閉該介面的快取功能,實現介面快取的高度定製

相關文章