小东西快快学快快记,大知识按计划学,不拖延
今天要写的是前端监控SDK的自动抓取接口请求数据。内容不复杂,但是其中会涉及很多细节,不然会踩坑。废话不多说
本文分为2个部分
1、劫持原生方法
2、劫持导致直播内存泄露
劫持原生方法
1劫持说明
我们的目的是要做到自动抓取到页面的所有接口请求上报,对代码零入侵,所以最好的办法就是对浏览器原生的 请求方法进行劫持
做法
具体就是重写方法,对原方法包了一层新函数,让我们可以在新函数里面添加一些我们的自己的 抓取逻辑,保存我们需要的信息
简单像这样
const originFetch = window.fetch
window.fetch=()=>{
// xxxx 我们自己的抓取信息逻辑
originFetch()
}
当然了,这只是一个简单的实例,实际怎么可能这么简单,还需要做很多处理
抓取数据
那么我们要在里面抓取一些什么信息呢
一般的有下面几个
其中 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: {},
reqBody: null,
statusCode: {},
response: {},
cost: {},
start: Date.now(),
traceId: null,
};
let options = {};
// 第一个参数可能是 Request 构造的实例
if ('Request' in Window && args[0] instanceof 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 流内存泄露排查小记
最后
鉴于本人能力有限,难免会有疏漏错误的地方,请大家多多包涵, 如果有任何描述不当的地方,欢迎后台联系本人,领取红包