海神平台是我们自主研发的一个移动端质量监控平台,从去年7月份开始至今,已陆续上线了Crash监控、ANR监控、网络监控、自定义错误等功能,目前已接入了公司内10余款APP。针对Crash我们之前在《海神平台Crash监控SDK(Android)开发经验总结》一文中有专门介绍,本文会着重讲一下Android端在开发网络监控SDK过程中的一些实践和经验。希望大家能有所收获。
一次网络请求通常会经过以下步骤:
下图以OKHttp在github上提供的流程图(https://square.github.io/okhttp/events/)为蓝本,将一些监控指标标示出来。
借用听云官方文档中的介绍,一次网络请求的响应时间可以分解为如下几部分:
- DNS时间:将域名转换为数字IP的时间。
- TCP时间:建立TCP/IP连接的时间。
- SSL时间:建立安全套接层(SSL)连接的消耗时间。
- 客户端耗时:客户端等待处理请求响应的耗时。
- 首包时间:发送HTTP请求开始,到收到服务器返回的第一个数据包所消耗时间。此指标反映服务器的响应速度。
- 剩余包时间:客户端接受服务器返回的非第一个数据包的消耗时间。
目前的海神版本只提供了DNS时间、TCP时间、SSL时间以及首包时间等四大分解时间。DNS时间主要取决于DNS服务商,TCP时间和SSL时间能够反映当时的网络链路状况,首包时间主要受网络链路状况和服务端响应速度影响。此外数据包大小也会影响到响应时间。
OKHttp从3.11.0版本开始,就提供了类EventListener,详见:https://square.github.io/okhttp/events/。
EventListener的调用方式如下:
OkHttpClient client = new OkHttpClient.Builder()
.eventListener(new CustomerXXXEventListener())
.build();
其中CustomerXXXEventListener继承自EventListener。在EventListener的诸多回调方法中,你可以:
EventListener中虽有回调方法requestBodyEnd和responseHeadersStart,但首包时间并不是二者时间之差。我们从源码来看一下,在Okhttp中,CallServerInterceptor位于拦截器链条中最末端,负责发出请求和接收响应。相关代码如下:
// okhttp3.internal.http.CallServerInterceptor#intercept
// 此处省略若干行代码......
realChain.eventListener().requestHeadersStart(realChain.call());
httpCodec.writeRequestHeaders(request);
realChain.eventListener().requestHeadersEnd(realChain.call(), request);
Response.Builder responseBuilder = null;
if (HttpMethod.permitsRequestBody(request.method()) && request.body() != null) {
// 此处省略若干行代码......
if (responseBuilder == null) {
// Write the request body if the "Expect: 100-continue" expectation was met.
realChain.eventListener().requestBodyStart(realChain.call());
long contentLength = request.body().contentLength();
CountingSink requestBodyOut =
new CountingSink(httpCodec.createRequestBody(request, contentLength));
BufferedSink bufferedRequestBody = Okio.buffer(requestBodyOut);
request.body().writeTo(bufferedRequestBody);
bufferedRequestBody.close();
realChain.eventListener()
.requestBodyEnd(realChain.call(), requestBodyOut.successfulCount);
}
// 此处省略若干行代码......
}
httpCodec.finishRequest();
if (responseBuilder == null) {
realChain.eventListener().responseHeadersStart(realChain.call());
responseBuilder = httpCodec.readResponseHeaders(false);
}
// 此处省略若干行代码......
从上述代码可以看到,Eventer是在"httpCodec.readResponseHeaders"语句之前就调用了回调方法responseHeadersStart,至于此时服务器是否返回了数据,都还是未知;此时若要以responseHeadersStart与requestBodyEnd(或者requestHeadersEnd)时间戳之差作为首包时间,得到的数值往往是0或者1毫秒,这明显是不正确的。
以目前占绝大多数的Http1.1协议版本为例跟进代码,可以从方法readResponseHeaders跟进到方法readHeaderLine:
// okhttp3.internal.http1.Http1Codec
private String readHeaderLine() throws IOException {
String line = this.source.readUtf8LineStrict(this.headerLimit);
this.headerLimit -= (long) line.length();
return line;
}
从source.readUtf8LineStrict往下继续追,可以最终追到Socket层的InputStream类。借用听云的代码改造出一个Demo,来看一下:
public class HttpResponseParsingInputStream extends InputStream {
// 此处省略若干行代码......
@Override
public int read(byte[] buffer, int byteOffset, int byteCount) throws IOException {
LJTSLog.i("HttpResponseParsingInputStream read start: time:" + System.currentTimeMillis()+";byteOffset:"+byteOffset+";byteCount:"+byteCount);
int read = this.mInputStream.read(buffer, byteOffset, byteCount);
this.mLJTSJ.c(System.currentTimeMillis());// 听云的首包时间计算
LJTSLog.i("HttpResponseParsingInputStream read end: time:" + System.currentTimeMillis()+";read:"+read);
return read;
}
// 此处省略若干行代码......
}
大家知道,Socket的InputStream的read方法是阻塞式的,当读不到数据时会一直等待;一旦读到数据,说明接收到了服务端的响应。
下面是Demo中打印出的系统日志:
05-15 19:37:30.412 24252-24292/com.ke.demo.crashly I/HaiShen/LJTrafficStats: requestBodyStart url:http://gateway.lj-web-21.lianjia.com/netstat/neteye/sample/api
05-15 19:37:30.413 24252-24292/com.ke.demo.crashly I/HaiShen/LJTrafficStats: requestBodyEnd url:http://gateway.lj-web-21.lianjia.com/netstat/neteye/sample/api
05-15 19:37:30.414 24252-24292/com.ke.demo.crashly I/System.out: HaiShen/Socket:i:HttpRequestParsingOutputStream write time:1557920250414
HaiShen/Socket:i:HttpRequestParsingOutputStream flush>>
05-15 19:37:30.415 24252-24292/com.ke.demo.crashly I/HaiShen/LJTrafficStats: responseHeadersStart url:http://gateway.lj-web-21.lianjia.com/netstat/neteye/sample/api
05-15 19:37:30.415 24252-24292/com.ke.demo.crashly I/System.out: HaiShen/Socket:i:HttpResponseParsingInputStream read start: time:1557920250415;byteOffset:0;byteCount:8192
05-15 19:37:30.553 24252-24292/com.ke.demo.crashly I/System.out: HaiShen/Socket:i:tingyun firstReadTime:138,lastReadStamp:1557920250552, lastWriteStamp:1557920250414
HaiShen/Socket:i:HttpResponseParsingInputStream read end: time:1557920250553;read:247
从日志中可以明显看到,从responseHeadersStart被调用(时间19:37:30.415)到从InputStream首次读到数据(时间19:37:30.553),相距了138毫秒的时间,而这个时间才更符合实际的首包时间。
鉴于开发成本和项目需求,海神目前没有改为Socket层HOOK的方式,而是继续在Eventer的基础上HOOK了Http1Codec类的readHeaderLine方法,对首包时间进行单独的计算,其结果与听云相比,误差在5毫秒以内。
海神平台上网络监控除了提供请求耗时、数据传输量、错误数/率等指标的统计功能外,还提供一些现场数据等的日志查询类功能。EventListener提供的回调和数据能够很好地满足统计类的需求,但是无法提供Response的Body数据。从其回调方法里获取到的Response对象,Body数据是空的,早已通过数据复制的方式被转移到了新的Response对象中。为此,海神目前的方式是自定义了一个拦截器,在拦截器中对Response的Body进行存储和解析。
由于统计类数据在EventListener类中,Header、Body等现场数据在Interceptor中,对于同一次请求来说,如何将两部分数据进行关联就为一个问题。海神SDK目前的关联key由url(包括Query参数)+ sentRequestAtMillis(请求发出时间戳) + receivedResponseAtMillis(收到响应时间戳)三部分组成。
SocketFactory、Socket、InputStream、OutputStream是Socket层的基本类;我们需要自定义相应的包装类,以代理的方式调用原始对象,这样就能保证原有逻辑正常执行的同时还能进行全流程的侦听。
总入口有两个:
// java.net.Socket
public static synchronized void setSocketImplFactory(SocketImplFactory fac)
// javax.net.ssl.HttpsURLConnection
public static void setDefaultSSLSocketFactory(SSLSocketFactory sf)
OkHttp | Socket | |
---|---|---|
数据处理 | 简单 | 复杂 |
版本兼容 | OKHttp版本 | Android系统版本 |
开发难度 | 低 | 高 |
监控范围 | 基于OkHttp库的API | 原生API、所有图片库 |
从APP端发出的网络请求,往往会被分散到服务器端各个业务系统中,虽然服务端也有监控系统,但是APP端的监控仍必不可少,二者可以相辅相成,共同协助线上问题的快速定位及解决。
移动端网络监控的优势:
移动端网络监控的劣势:
海神平台的网络监控,目前已经具备了线上问题预警、APP异常现场的网络日志提供、以及APP性能优化衡量指标等能力。欢迎大家使用和反馈。