一, 完整网络请求的流程
一次网络请求通常会经过以下步骤:
DNS过程:将目标域名转成IP;
Connect过程:主要包括TCP的三次握手、HTTPS的SSL握手;
业务数据传输过程:request与response数据传输
下图以OKHttp在github上提供的流程图(https://square.github.io/okhttp/events/)为蓝本,将一些监控指标标示出来。
借用听云官方文档中的介绍,一次网络请求的响应时间可以分解为如下几部分:
DNS时间:将域名转换为数字IP的时间。
TCP时间:建立TCP/IP连接的时间。
SSL时间:建立安全套接层(SSL)连接的消耗时间。
客户端耗时:客户端等待处理请求响应的耗时。
首包时间:发送HTTP请求开始,到收到服务器返回的第一个数据包所消耗时间。此指标反映服务器的响应速度。
剩余包时间:客户端接受服务器返回的非第一个数据包的消耗时间。
目前的海神版本只提供了DNS时间、TCP时间、SSL时间以及首包时间等四大分解时间。DNS时间主要取决于DNS服务商,TCP时间和SSL时间能够反映当时的网络链路状况,首包时间主要受网络链路状况和服务端响应速度影响。此外数据包大小也会影响到响应时间。
二 ,基于OKHttp SDK进行Hook的实践
2.1 EventListener
OKHttp从3.11.0版本开始,就提供了类EventListener,详见:https://square.github.io/okhttp/events/。
EventListener的调用方式如下:
OkHttpClient client = new OkHttpClient.Builder()
.eventListener(new CustomerXXXEventListener())
.build();
其中CustomerXXXEventListener继承自EventListener。
在EventListener的诸多回调方法中,你可以:通过callStart方法直接得到一次请求的开始;通过callEnd或者callFailed方法得到结束时间;容易计算出DNS时间、TCP时间以及SSL时间;
通过方法requestBodyEnd(Call call, long byteCount)的入参byteCount得到Request Body的大小;
通过responseBodyEnd(Call call, long byteCount)的入参byteCount得到Response body的大小;如果Response的Header中是”Content-Encoding: gzip“的话 ,body的大小是压缩后的数据大小,确切点说是传输过程中流量大小;
至于Request Header和Response Header的数据大小,则可以通过回调方法返回的对象Request、Response自行获取Header成员变量,然后进行字符数统计;
2.2 首包时间的单独处理
EventListener中虽有回调方法requestBodyEnd和responseHeadersStart,但首包时间并不是二者时间之差。我们从源码来看一下,在Okhttp中,CallServerInterceptor位于拦截器链条中最末端,负责发出请求和接收响应。相关代码如下:
// okhttp3.internal.http.CallServerInterceptor#intercept
// 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毫秒以内。
2.3 Response Body的获取时机
海神平台上网络监控除了提供请求耗时、数据传输量、错误数/率等指标的统计功能外,还提供一些现场数据等的日志查询类功能。
EventListener提供的回调和数据能够很好地满足统计类的需求,但是无法提供Response的Body数据。从其回调方法里获取到的Response对象,Body数据是空的,早已通过数据复制的方式被转移到了新的Response对象中。为此,海神目前的方式是自定义了一个拦截器,在拦截器中对Response的Body进行存储和解析。
由于统计类数据在EventListener类中,Header、Body等现场数据在Interceptor中,对于同一次请求来说,如何将两部分数据进行关联就为一个问题。海神SDK目前的关联key由url(包括Query参数)+ sentRequestAtMillis(请求发出时间戳) + receivedResponseAtMillis(收到响应时间戳)三部分组成。
三, 针对Socket层进行Hook的探索
SocketFactory、Socket、InputStream、OutputStream是Socket层的基本类;我们需要自定义相应的包装类,以代理的方式调用原始对象,这样就能保证原有逻辑正常执行的同时还能进行全流程的侦听。
3.1 Hook点总入口有两个:
// java.net.Socket
public static synchronized void setSocketImplFactory(SocketImplFactory fac)
// javax.net.ssl.HttpsURLConnection
public static void setDefaultSSLSocketFactory(SSLSocketFactory sf)
3.2 两种方式的对比
OkHttp | Socket | |
---|---|---|
数据处理 | 简单 | 复杂 |
版本兼容 | OKHttp版本 | Android系统版本 |
开发难度 | 低 | 高 |
监控范围 | 基于OkHttp库的API | 原生API、所有图片库 |
四, 移动端网络监控的现状
从APP端发出的网络请求,往往会被分散到服务器端各个业务系统中,虽然服务端也有监控系统,但是APP端的监控仍必不可少,二者可以相辅相成,共同协助线上问题的快速定位及解决。
移动端网络监控的优势:
更容易做到全服务监控;从APP端监控,出口唯一,监控时操作简单,而且不容易遗漏API;
现场数据更丰富;
能覆盖到未到达服务器端的场景,如域名劫持,请求超时等;
能够为APP端性能优化提供数据支撑
移动端网络监控的劣势:
监控数据带来的额外的内存、流量等性能损耗;
监控数据的时效性
海神平台的网络监控,目前已经具备了线上问题预警、APP异常现场的网络日志提供、以及APP性能优化衡量指标等能力。欢迎大家使用和反馈。