接口监控
接口请求的性能和稳定性是前端页面中至关重要的一个环节。用户的所有操作都是在页面上进行的,而页面上展示和提交的数据都依赖接口。
通过建立前端接口监控系统,可以监控并记录所有接口请求的返回状态和返回结果。当接口报错时,能够及时定位线上问题产生的原因。开发人员还能通过分析接口的平均耗时、成功率等信息对应用进行优化,提升系统的质量。
请求采集
请求信息
开发人员如果要进行接口监控,就必须先明确需要采集的信息。可以直接参考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回调函数中的请求进行过滤,丢弃不需要分析的请求。如果满足条件,则终止函数的后续处理逻辑。