【前端監控】自動抓取接口請求數據

小東西快快學快快記,大知識按計劃學,不拖延


今天要寫的是前端監控SDK的自動抓取接口請求數據。內容不復雜,但是其中會涉及很多細節,不然會踩坑。廢話不多說

本文分爲2個部分

1、劫持原生方法

2、劫持導致直播內存泄露



劫持原生方法


1劫持說明

我們的目的是要做到自動抓取到頁面的所有接口請求上報,對代碼零入侵,所以最好的辦法就是對瀏覽器原生的 請求方法進行劫持


做法

具體就是重寫方法,對原方法包了一層新函數,讓我們可以在新函數裏面添加一些我們的自己的 抓取邏輯,保存我們需要的信息

簡單像這樣

const originFetch = window.fetch

window.fetch=()=>{
    // xxxx 我們自己的抓取信息邏輯
    originFetch()
}

當然了,這只是一個簡單的實例,實際怎麼可能這麼簡單,還需要做很多處理

抓取數據

那麼我們要在裏面抓取一些什麼信息呢

一般的有下面幾個

  1. url

  2. methods

  3. reqHeader

  4. reqBody

  5. statusCode

  6. responce

其中 reqHeader 只抓自定義傳入的部分,因爲全部的 reqHeader 抓不到...


另外有兩個需要額外說下

1、接口耗時 costTime

我們需要在裏面計算 接口耗時 costTime,以此來統計頁面平均的接口性能,好進行優化(甩鍋)

costTime 也很好獲取,簡單像這樣

const originFetch = window.fetch

window.fetch=()=>{
  const startTime = Date.now()
  let costTime = 0
  originFetch().then(()=>{
    costTime = Date.now() - startTime
  })
}


2、日誌跟蹤 trace_id

原先我們前端的日誌的 trace_id,會在用戶當前會話中生成一個 隨機的id 保存在 sessionStorage,之後當前會話每條日誌都會帶上這個 id,利用它來進行用戶單次訪問的日誌串聯

現在我們會優先抓取請求Header 中的 x-request-id 作爲 trace_id。

x-request-id 是 針對每個請求創建一個唯一的id

這樣服務器接收到這個請求產生的日誌都會帶上這個 id,從而在接口發生錯誤的時候,就可以根據id 查找出對應的日誌,而不用依賴時間戳,ip 等信息大海撈針



優先用 x-request-id 

好處是,前端的接口日誌可以和後臺的日誌串聯起來

壞處是,導致覆蓋我們前端自己的會話id,然後前端的日誌無法根據一個 trace_id 全部串聯。

這是當初設計的問題,後面新增了一個新字段 sessionId 代替原有 trace_id 的作用用於表示前端的會話id,trace_id 用於和後端日誌對接。這樣就互不影響了


劫持什麼原生方法

就是三個瀏覽器發起請求的方法

XMLHttpRequest  、fetch、websocket

我們只劫持前面兩個,ws之前我們會劫持,但是後面發現會影響第三方庫的邏輯,所以放棄了ws的劫持上報

下面會詳細說如何劫持這些原生方法


2劫持 XMLHttpRequest

以下簡稱xhr。

我們將會對 xhr的原型上4個方法進行劫持

1.xhr.prototype.open

2.xhr.prototype.setRequestHeader

3.xhr.prototype.send 

4.xhr.prototype.onreadystatechange

先看一個 xhr 使用的簡單例子來熟悉下

const xhr= new XMLHttpRequest()
const method = "GET"
const url = "https://www.test.com";

xhr.open(method, url, true);

xhr.setRequestHeader('a''aaa');
xhr.setRequestHeader('b''bbb');

xhr.onreadystatechange = function ({
  if(xhr.readyState === XMLHttpRequest.DONE && xhr.status === 200) {
    console.log(xhr.responseText)
  }
}

xhr.send();

我們可以看到使用xhr 發起一個請求,用到了上面說的四個原型方法

那麼我們就


1、重寫 open 方法

新建一個對象cgiInfo 去存儲我們需要的接口信息

這個 cgiInfo 是針對每個請求實例的,是獨有的

這裏主要保存 url 和 method ,以及接口請求開始時間點

const originOpen =XMLHttpRequest.prototype.open;

xhXMLHttpRequest.prototyperPro.open = (...args) => {
  this.cgiInfo = {
    url: args[1],
    method: args[0],
    reqHeaders: {},
    reqBody: {},
    statusCode: {},
    response: {},
    cost: {},
    start: Date.now(),
    traceId:""
  };
  return originOpen.apply(this, args);
};

2、重寫 setRequestHeader

這裏主要保存了自定義的 header

const originSetReqHeader =XMLHttpRequest.prototype.setRequestHeader;

const HEADERS_TRACE_ID = ['X-Request-Id''x-request-id'];

XMLHttpRequest.prototype;.setRequestHeader = (...args) => {
  const header = args[0];
  const value = args[1];
  if (typeof header === 'string' && this.cgiInfo) {
    // 優先使用 header 中的 x-request-id
    if (HEADERS_TRACE_ID.indexOf(header) >= 0) {
      this.cgiInfo.traceId = value;
    } else {
      this.cgiInfo.reqHeaders[header] = value;
    }
  }
  return originSetReqHeader.apply(this, args);
};

3、重寫 send 方法。

send 方法主要是發送請求,和 傳入 POST 時的 body 數據

而它更主要的,是在 send 中去重寫 onreadystatechange 方法

爲什麼呢?

從 上面使用 xhr 發起請求的例子中,我們可以看到,onreadystatechange  是要被 新建的xhr實例重寫的。

const xhr= new XMLHttpRequest()
xhr.onreadystatechange = function ({}

所以我們是不能直接像上面重寫原型方法的,會被覆蓋

所以我們需要重寫的是 實例的 onreadystatechange 方法,而不是原型上的 onreadystatechange

但是爲什麼放在 send 中,其實並不一定要放在 send 中,在 open,setRequestHeader 中都可以拿到 xhr 實例

但是在 send 中更合理,因爲調用了 send 纔會發送請求,這時候才需要監聽 state 變化

如果沒有 send,那麼監聽來幹嘛?

所以最終我們重寫 send 方法,並且裏面 重寫實例的 onreadystatechange.這裏獲取的信息就多了,stateCode,reponse,cost,reqBody 

先看下我們能從 xhr 實例上拿到的信息

開始重寫

const originSend =XMLHttpRequest.prototype.send;

XMLHttpRequest.prototype.send = () => {

  function onreadystatechangeHandler(...args) {
    if (this.cgiInfo && this.readyState === XMLHttpRequest.DONE) {

      const cgiInfo = this.cgiInfo;

      cgiInfo.reqBody = args[0];
      cgiInfo.status = this.status;
      cgiInfo.resHeader = 
this.getAllResponseHeaders()


      cgiInfo.cost = Date.now() - cgiInfo.start; // 耗時          const res = 'response' in this ? this.response : this.responseText;      // 把請求的響應轉成 字符串形式,方便存儲


      getXHRBodyTxt(res).then((resTxt) => {


        cgiInfo.response = resTxt;


        this.cgiInfo = null;


      });


    }


  }  const originStateChange = this.onreadystatechange;


  const ifExistOriginCall =


    'onreadystatechange' in this && typeof originStateChange === 'function';  if (ifExistOriginCall) {


    this.onreadystatechange = (...args) => {


      onreadystatechangeHandler.apply(this, args);


      return originStateChange.apply(this, args);


    };


  } else {


    // 如果原先沒有定義,加一個


    this.onreadystatechange = onreadystatechangeHandler;


  }  return originSend.apply(this, args);


};


其中,我們需要注意的是,我們需要把 響應內容轉成文本形式,這樣方便傳輸和存儲

所以我們自己通過一個方法 getXHRBodyTxt 轉換  

這個轉換,主要是爲了把響應是 Blob 的數據 也轉換文本,所以這裏需要有一層兼容(瀏覽器是否支持Blob)

function getXHRBodyTxt(body{
  return new Promise((resolve) => {
    if (!body) {
      resolve('');
    } else if (typeof body === 'string') {
      resolve(body);
    } else if (isSupportBlob && body instanceof Blob) {
      // 應該只兼容到Blob返回即可,application/json
      resolve(
        readBlobAsText(body).catch((e) => {
          // DOMException
          return e.message || e.name || '';
        })
      );
    } else {
      resolve('');
    }
  });
}

const isSupportBlob =
  'FileReader' in window &&
  'Blob' in window &&
  (function ({
    try {
      new Blob();
      return true;
    } catch (e) {
      return false;
    }
  })();

function readBlobAsText(blob{
  return new Promise(function (resolve, reject{
    const reader = new FileReader();
    reader.onload = function ({
      resolve(reader.result);
    };
    reader.onerror = function ({
      reject(reader.error);
    };
    reader.readAsText(blob);
  });
}


好的 ,XMLHttpRequest 我們就劫持完了



3劫持 fetch

劫持fetch就簡單的多了,他不像xhr那樣要重寫那麼多亂七芭蕉的方法

只需要重寫 window.fetch 就好了

但是要注意的是,如果你引入了 fetch polyfill,比如 whatwg-fetch 包,就不需要劫持 fetch 方法了,因爲它的底層是 XMLHttpRequest

有了上面的 xhr 的重寫例子,這裏也是差不多的處理邏輯

同樣是獲取 url、method 這些數據,然後再請求完成後把響應轉成文本的形式保存

const origFetch = window.fetch;


window.fetch = (...args) => {
  const cgiInfo = {
    url: args[0],
    method'GET',
    reqHeaders: {},
    reqBodynull,
    statusCode: {},
    response: {},
    cost: {},
    startDate.now(),
    traceIdnull,
  };

  let options = {};

  //   第一個參數可能是 Request 構造的實例
  if ('Request' in Window && args[0instanceof Window.Request) {
    options = args[0];
  } else {
    options = args[1] || options;
  }

  // 如果第一個參數爲string,優先取第一個參數
  cgiInfo.url = typeof url === 'string' ? cgiInfo.url : options.url;
  cgiInfo.method = options.method || cgiInfo.method;
  cgiInfo.reqHeaders = options.headers || cgiInfo.reqHeaders;
  cgiInfo.reqBody = options.body || cgiInfo.reqBody;

  HEADERS_TRACE_ID.some((k) => {
    cgiInfo.traceId = cgiInfo.reqHeaders[k] || null;
    // 匹配到一個就可以了
    return !!cgiInfo.traceId;
  });

  return origFetch.apply(this, args).then((response) => {
    cgiInfo.status = response.status;
    cgiInfo.cost = Date.now() - cgiInfo.start;

    // safari不支持clone API
    const cloned = response.clone();
    // 響應轉成文本
    cloned.text().then((text) => {
      cgiInfo.response = text;
    });

    return response;
  });
};

其中需要說明的兩點是

1、Request 方法

一般用 fetch 都是這樣

fetch("xxxxx", {
  method'POST', // or 'PUT'
  body: JSON.stringify(data), 
})})

其實 fetch 還支持傳入一個 Request 構造的實例,Request 和 fetch 接收同樣的參數

像這樣

const config= { method: 'GET'};

const req= new Request('xxxx.com/get_data',config);

fetch(req)


所以在 重寫 fetch 獲取參數的時候,需要對參數進行判斷

不能直接把第一個參數當做 url 處理


2、responce.clone

爲什麼不直接處理 responce,而需要clone 一個出來

因爲我們要保證 responce 的原始狀態,不污染源對象,

否則 對原 responce 的body 處理,會導致 原body 被標記爲已讀取,而 clone出來的則不會。

但是同時這裏也會存在一個坑,下面會說明


劫持導致直播內存泄露


在上面重寫 fetch 中,對 responce 進行 clone,有可能會導致 內存泄露,頁面崩潰

這種情況很特殊,沒有踩過坑是不會知道的,所以導致了我們現網的一個嚴重bug,已經算是現網事故了

最後經過我的大佬排查解決

具體發生是在 直播 的場景中

在 直播請求的 flv 流 中,responce.clone().then() 會導致對 flv 流 Blob 數據的引用計數

引用

垃圾回收算法主要依賴於引用的概念。在內存管理的環境中,一個對象如果有訪問另一個對象的權限(隱式或者顯式),叫做一個對象引用另一個對象。

例如,一個Javascript對象具有對它原型的引用(隱式引用)和對它屬性的引用(顯式引用)。

在這裏,“對象”的概念不僅特指 JavaScript 對象,還包括函數作用域(或者全局詞法作用域)。
引用計數垃圾收集

這是最初級的垃圾收集算法。此算法把“對象是否不再需要”簡化定義爲“對象有沒有其他對象引用到它”。如果沒有引用指向該對象(零引用),對象將被垃圾回收機制回收。

內容來自 https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Memory_Management



而直播的 flv 流一直不斷響應數據,導致 clone().then() 這個方法知道 直播結束後纔會觸發,所以內存中一直源源不斷地保存着 flv 流的響應數據不回收

到達一定程度後,內存爆炸,頁面就直接崩潰了

所以看來,我們不能對所有的請求 reponse 都clone() 了,flv 流的響應數據記錄價值也不大,我們可以直接判斷如果是 flv 流,那麼就不處理響應

對上面的 fetch 處理響應部分,進行一點小優化

如果請求的響應類型是 視頻的話,那麼就直接跳過

// 非視頻流,才處理響應
if (!/video/.match(resContentType)) {
    // safari不支持clone API
    const cloned = response.clone();
    // 響應轉成文本
    cloned.text().then((text) => {
      cgiInfo.response = text;
    });
}


具體文章可以參考我大佬寫的總結直播場景 http flv 流內存泄露排查小記


最後

鑑於本人能力有限,難免會有疏漏錯誤的地方,請大家多多包涵, 如果有任何描述不當的地方,歡迎後臺聯繫本人,領取紅包

本文分享自微信公衆號 - 神仙朱(skying-zhu)。
如有侵權,請聯繫 [email protected] 刪除。
本文參與“OSC源創計劃”,歡迎正在閱讀的你也加入,一起分享。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章