海神平台网络监控SDK(Android)开发经验总结

海神平台是我们自主研发的一个移动端质量监控平台,从去年7月份开始至今,已陆续上线了Crash监控、ANR监控、网络监控、自定义错误等功能,目前已接入了公司内10余款APP。针对Crash我们之前在《海神平台Crash监控SDK(Android)开发经验总结》一文中有专门介绍,本文会着重讲一下Android端在开发网络监控SDK过程中的一些实践和经验。希望大家能有所收获。

一 完整网络请求的流程

一次网络请求通常会经过以下步骤:

  1. DNS过程:将目标域名转成IP;
  2. Connect过程:主要包括TCP的三次握手、HTTPS的SSL握手;
  3. 业务数据传输过程: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的诸多回调方法中,你可以:

  1. 直接得到一次请求的开始(通过callStart方法)和结束时间(通过callEnd或者callFailed方法);
  2. 容易计算出DNS时间、TCP时间以及SSL时间;
  3. 直接得到Request Body的大小(通过方法requestBodyEnd(Call call, long byteCount)的入参byteCount)和Response body的大小(通过responseBodyEnd(Call call, long byteCount)的入参byteCount);其中body的大小是压缩后的数据大小(如果Response的Header中是”Content-Encoding: gzip
    “的话),确切点说是传输过程中流量大小;
  4. 至于Request Header和Response Header的数据大小,则可以通过回调方法返回的对象Request、Response自行获取Header成员变量,然后进行字符数统计;
2.2 首包时间的单独处理

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毫秒以内。

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 两种方式的对比
OkHttpSocket
数据处理简单复杂
版本兼容OKHttp版本Android系统版本
开发难度
监控范围基于OkHttp库的API原生API、所有图片库
四 移动端网络监控的现状

从APP端发出的网络请求,往往会被分散到服务器端各个业务系统中,虽然服务端也有监控系统,但是APP端的监控仍必不可少,二者可以相辅相成,共同协助线上问题的快速定位及解决。

移动端网络监控的优势:

  1. 更容易做到全服务监控;从APP端监控,出口唯一,监控时操作简单,而且不容易遗漏API;
  2. 现场数据更丰富;
  3. 能覆盖到未到达服务器端的场景,如域名劫持,请求超时等;
  4. 能够为APP端性能优化提供数据支撑

移动端网络监控的劣势:

  1. 监控数据带来的额外的内存、流量等性能损耗;
  2. 监控数据的时效性

海神平台的网络监控,目前已经具备了线上问题预警、APP异常现场的网络日志提供、以及APP性能优化衡量指标等能力。欢迎大家使用和反馈。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值