Skip to content
微信公众号

接口监控

接口请求的性能和稳定性是前端页面中至关重要的一个环节。用户的所有操作都是在页面上进行的,而页面上展示和提交的数据都依赖接口。

通过建立前端接口监控系统,可以监控并记录所有接口请求的返回状态和返回结果。当接口报错时,能够及时定位线上问题产生的原因。开发人员还能通过分析接口的平均耗时、成功率等信息对应用进行优化,提升系统的质量。

请求采集

请求信息

开发人员如果要进行接口监控,就必须先明确需要采集的信息。可以直接参考HTTP请求的发起方式,对接口请求的路径、方法、入参及响应结果等信息进行采集。为了方便追溯请求的发起页面地址,还可以使用window.location API来获取页面的URL信息。可以定义出如下数据结构来记录每个请求的信息。

js
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对象如下。
js
{
    "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。

js
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函数即可完成请求信息的采集。

js
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的条件移除。

js
//if(!global.fetch){
    global.fetch = fetch
    global.Headers = Headers
    global.Request = Request
    global.Response = Response
//}

让fetch polyfill的代码强制执行,从而确保Fetch中使用的是XhrInterceptor复写后的XMLHttpRequest。

js
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回调函数中添加判断条件,将不满足条件的请求直接剔除。

js
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回调函数中的请求进行过滤,丢弃不需要分析的请求。如果满足条件,则终止函数的后续处理逻辑。

Released under the MIT License.