接口监控
接口请求的性能和稳定性是前端页面中至关重要的一个环节。用户的所有操作都是在页面上进行的,而页面上展示和提交的数据都依赖接口。
通过建立前端接口监控系统,可以监控并记录所有接口请求的返回状态和返回结果。当接口报错时,能够及时定位线上问题产生的原因。开发人员还能通过分析接口的平均耗时、成功率等信息对应用进行优化,提升系统的质量。
请求采集
请求信息
开发人员如果要进行接口监控,就必须先明确需要采集的信息。可以直接参考HTTP请求的发起方式,对接口请求的路径、方法、入参及响应结果等信息进行采集。为了方便追溯请求的发起页面地址,还可以使用window.location API来获取页面的URL信息。可以定义出如下数据结构来记录每个请求的信息。
type TypeMap<T> = {
[i:string]:T;
}
type HttpRecord = {
method: string; //请求方法
url: string; //请求路径
query?:TypeMap<any>; //请求参数
body?:TypeMap<any>; //请求主体
status: number: //HTTP状态码
caceled: boolean; //请求是否被取消
requestHeaders: TypeMap<string>; //请求报头
responseHeaders: TypeMap<string>; //响应报头
requestStamp:number; //请求发起时间
responseStamp: number; //请求响应时间
costTime:number; //请求耗费时间
responseData:any; //请求响应结果
pageUrl: string; //发起请求时的页面地址
}
- method代表请求方法,常见的有GET、POST、PUT、DELETE 4种。
- url代表请求路径,例如,/api/v1/person/list代表用于查找人物列表的接口。
- query代表请求路径中的请求参数,当url中不存在请求参数时,query为undefined;当url中存在请求参数时,可以从url中提取出对应的query对象。例如,/api/v1/person/info?career=worker&gender=male,代表查询男性并且职业为工人的人物类别,从url中提取出来的query对象如下。
{
"career": "worker",
"gender": "male"
}
- body代表请求体,它的数据格式和HTTP请求头报文中的Content-Type有关。当Content-Type的值为application/json时,代表请求体中的数据是JSON格式的,它是开发中使用最多的数据类型。
- status代表请求响应的HTTP状态码,用于标记请求的状态。
- canceled代表请求是否被取消,用于判断请求被取消的异常情况。
- requestHeaders代表请求报头的信息,可以排查接口请求的自定义报头等信息。
- responseHeaders代表响应报头的信息,可以排查接口响应的信息。
- requestStamp代表请求发起的时间戳,可以统计同一时间段内请求发起的数量,用于分析高频请求、重复请求等。
- responseStamp代表请求响应的时间戳,可以和requestStamp结合使用,计算出costTime。
- costTime代表请求从发起到结束耗费的时间,单位是毫秒,可以统计请求平均耗时、慢请求等信息。
- responseData代表请求响应的结果,可以判断该请求是否属于正常的业务逻辑返回,从而监控业务异常。
- pageUrl代表请求发起时的页面地址,可以判断当前请求是从哪个页面发起的,帮助开发人员快速定位异常接口。
按照以上定义的数据结构,开发人员可以有效地采集接口中的数据,并基于采集到的数据制定监控措施。
XMLHttpRequest拦截器
现代浏览器都提供了XMLHttpRequest API,用于与服务器端进行数据交互。通过XMLHttpRequest,浏览器可以在不刷新页面的情况下,对页面的数据进行更新和提交。只需要对XMLHttpRequest方法进行复写,就能够有效捕获所有接口的请求信息。
首先定义一个类,取名为XhrInterceptor,用于实现复写XMLHttpRequest的逻辑,其构造函数会接受一个apiCallback函数作为回调参数,用于回调捕获到的请求信息。
在复写XMLHttpRequest函数之前,开发人员需要先把原来的XMLHttpRequest保存在类上,例如,命名为XHR。同时,用新实现的函数覆盖window上的XMLHttpRequest函数。当页面调用XMLHttpRequest发起请求时,所有的信息都能被XhrInterceptor捕获。
在拦截XMLHttpRequest后,开发人员需要通过overwrite对XMLHttpRequest实例_xhr上的方法的属性进行复写,以便收集相关信息。
当实例_xhr上的键值类型为函数时,调用overwriteMethod方法对函数进行复写。当实例_xhr上的键值类型为非函数时,调用overwriteAttributes方法对属性进行复写。
当开发人员完成对_xhr实例上的函数和属性的复写后,还需要实现请求信息中的采集函数setRecord。setRecord中会使用query-string获取url中的参数,将url和query隔离开。HTTP报文中的响应结果response只解析了响应体为字符串或者JSON格式的情况,其余情况需要开发人员根据项目情况进行一定程度的改造。HTTP报文中的请求报头和响应报头都是以字符串的形式描述的,setRecord将其转换成了JSON格式,以requestHeaders和responseHeaders字段存储。
在所有功能实现完毕后,还需要提供一个unset函数用于取消对XMLHttpRequest的拦截。此时,只需要将之前备份的XMLHttpRequest重新覆盖window上的XMLHttpRequest。
import queryString from 'query-string';
class XhrInterceptor{
options;
XHR;
constructor(p){
this.options = p;
this.init();
}
//初始化,重写XMLHttpRequest对象
init(){
this.XHR = window.XMLHttpRequest;
const _this = this;
window.XMLHttpRequest = function(){
this._xhr=new _this.XHR();
//用于记录请求的整个链路路径
this._xhr._record = {
canceled: false
};
this._xhr.__ = false;
//对XMLHttpRequest实例上的属性进行复写
_this.overwrite(this);
}
}
overwrite(proxyXHR){
for(let key in proxyXHR._xhr){
if(typeof proxyXHR._xhr[key]=== 'function'){
this.overwriteMethod(key,proxyXHR);
continue;
}
this.overwriteAttributes(key,proxyXHR);
}
}
//重写方法
overwriteMethod(key,proxyXHR){
proxyXHR[key] = (...args)=>{
//abort需要优先上报,因为内部会触发xhrState
if(key === 'abort'){
this.setRecord(proxyXHR,key,args);
}
//执行方法本体
const res = proxyXHR._xhr[key].apply(proxyXHR._xhr,args);
this.setRecord(proxyXHR,key,args);
return res;
}
}
//对请求进行记录
setRecord(proxyXHR,key,args){
let record = proxyXHR._xhr.__record;
const hasCallback = proxyXHR._xhr.__hasCallback;
if(hasCallback){
return;
}
if(key === 'open'){
const result = queryString.parseUrl(args[1]);
Object.assign(record,{
method:args[0],
url:result.url,
params:result.query,
pageUrl:window.location.href
});
}else if(key === 'send'){
let body = args[0];
try{
body=JSON.parse(body);
}catch{}
Object.assign(record,{
body,
requestStamp: Date.now()
});
}else if(key === 'abort'){
Object.assign(record,{
canceled:true
});
this.options.apiCallback(record);
}else if(key === 'onreadystatechange'){
//记录返回参数
//readyState === 4 响应已完成
if(proxyXHR.readyState === 4){
const responseHeadersString = proxyXHR.getAllResponseHeaders() || '';
const responseHeaders = {};
responseHeadersString.split('\r\n').filter(Boolean).forEach((_)=>{
const [k,v] = _.split(': ');
responseHeaders[k] = v;
});
let responseData;
try{
//在http code 204时会暴多
//只解析了响应体为字符串或者JSON格式的情况,其余情况需根据项目情况进行适配
responseData = proxyXHR.response || proxyXHR.responseText || '{}';
}catch(error){
responseData = '{}';
}
try{
if(typeof responseData === 'string'){
responseData = JSON.parse(responseData);
}
}catch(){}
const responseStamp = Date.now();
Object.assign(record,{
responseData,
responseHeaders,
responseStamp,
costTime: responseStamp - record.requestStamp,
status:proxyXHR.status
});
this.options.apiCallback(record);
}
}else if(key === 'setRequestHeader'){
if(!record.requestHeaders){
record.requestHeaders={};
}
record.requestHeaders[args[0]]=args[1];
}
}
//重写属性
overwriteAttributes(key,proxyXHR){
Object.defineProperty(proxyXHR,key,this.setPropertyDescriptor(key,proxyXHR));
}
//设置属性的属性描述
setPropertyDescriptor(key,proxyXHR){
const obj = Object.create(null);
const _this = this;
obj.set = function(val){
if(!key.startsWith('on')){
proxyXHR['__'+key]=val;
this._xhr[key] =val;
return;
}
const fn = function(...args){
_this.setRecord(proxyXHR,key,args);
val.apply(proxyXHR,args);
}
this._xhr[key] = fn;
};
obj.get=function(){
return proxyXHR['__'+key] || this._xhr[key];
};
return obj;
}
//复原XMLHttpRequest
unset(){
window.XMLHttpRequest = this.XHR;
}
}
将XhrInterceptor实例化,并传入自定义的apiCallback函数即可完成请求信息的采集。
const instance = new XhrInterceptor({
apiCallback: console.log
});
XhrInterceptor虽然可以实现对XMLHttpRequest请求的信息采集,但是它无法对Fetch请求进行采集
Fetch拦截器
相比XMLHttpRequest方法,Fetch方法在使用性和阅读性上更加友好,它是基于XMLHttpRequest实现的。既然Fetch是基于XMLHttpRequest实现的,那么为什么XhrInterceptor不能采集Fetch请求呢?
因为Fetch的初始化是由浏览器内核进行的,它比XhrInterceptor更早执行,所以提前访问了XMLHttpRequest。即Fetch使用的是浏览器原生的XMLHttpRequest,并不是XhrInterceptor复写了以后的XMLHttpRequest。如果要采集Fetch请求,那么有两种方法。一种是以Fetch为对象实现FetchInterceptor,另一种是使用fetch polyfill函数对Fetch函数重新初始化。
FetchInterceptor方案的实现成本与XhrInterceptor相当,并且会导致项目中存在两个拦截器,对以后的扩展维护极不友好,因此并不推荐。
fetch polyfill的成本接近于零,GitHub官方提供了该方案,访问相关网站即可查看。同时,fetch polyfill能够确保项目中仅有一套拦截器方案,维护成本更低。
综合来说,建议使用fetch polyfill完成对Fetch请求的采集。在使用该方案时,需要注意对github/fetch的代码进行修改,将尾部判断是否进行polyfill的条件移除。
//if(!global.fetch){
global.fetch = fetch
global.Headers = Headers
global.Request = Request
global.Response = Response
//}
让fetch polyfill的代码强制执行,从而确保Fetch中使用的是XhrInterceptor复写后的XMLHttpRequest。
const instance = new XhrInterceptor({
apiCallback:console.log
});
//强制覆盖Fetch
fetchPolyfill();
fetch('/foo').then(function(response){
return response.text();
}).then(function(body){
document.body.innerHTML = body;
})
请求过滤
通过XhrInterceptor复写XMLHttpRequest方法,可以采集到页面中发起的所有请求。实际上,并不是所有被采集到的请求都需要被分析。因此,在对请求进行分析前应该先过滤无意义的需求,例如,数据埋点上报、心跳检测等。请求过滤的方式非常简单。在apiCallback回调函数中添加判断条件,将不满足条件的请求直接剔除。
type BlackApiItem = {
method: RegExp;
url: RegExp;
}
const blackApiList: BlackApiItem[] = [];
const shouldFilter = (method:string,url:string)=>{
return blackApiList.find((_)=>{
method.match(_.method)&&url.match(_.url)
});
}
const instance = new XhrInterceptor({
apiCallback:(r)=>{
if(shouldFilter(r.method,r.url)){
return;
}
}
});
开发人员只需要使用blackApiList数组维护一套与请求相关的method、url的对象数组,使用shouldFilter函数对apiCallback回调函数中的请求进行过滤,丢弃不需要分析的请求。如果满足条件,则终止函数的后续处理逻辑。