• 0

  • 472

  • 收藏

APM 监控系统:网络篇(下)

3个月前

2.5 iOS 流量监控

2.5.1 HTTP 请求、响应数据结构

HTTP 请求报文结构 请求报文结构

响应报文的结构

响应报文结构

  1. HTTP 报文是格式化的数据块,每条报文由三部分组成:对报文进行描述的起始行、包含属性的首部块、以及可选的包含数据的主体部分。
  2. 起始行和手部就是由行分隔符的 ASCII 文本,每行都以一个由2个字符组成的行终止序列作为结束(包括一个回车符、一个换行符)
  3. 实体的主体或者报文的主体是一个可选的数据块。与起始行和首部不同的是,主体中可以包含文本或者二进制数据,也可以为空。
  4. HTTP 首部(也就是 Headers)总是应该以一个空行结束,即使没有实体部分。浏览器发送了一个空白行来通知服务器,它已经结束了该头信息的发送。

请求报文的格式

<method> <request-URI> <version>
<headers>

<entity-body>
复制代码

响应报文的格式

<version> <status> <reason-phrase>
<headers>

<entity-body>
复制代码

下图是打开 Chrome 查看极课时间网页的请求信息。包括响应行、响应头、响应体等信息。

请求数据结构

下图是在终端使用 curl 查看一个完整的请求和响应数据

curl查看HTTP响应

我们都知道在 HTTP 通信中,响应数据会使用 gzip 或其他压缩方式压缩,用 NSURLProtocol 等方案监听,用 NSData 类型去计算分析流量等会造成数据的不精确,因为正常一个 HTTP 响应体的内容是使用 gzip 或其他压缩方式压缩的,所以使用 NSData 会偏大。

2.5.2 问题
  1. Request 和 Response 不一定成对存在

    比如网络断开、App 突然 Crash 等,所以 Request 和 Response 监控后不应该记录在一条记录里

  2. 请求流量计算方式不精确

    主要原因有:

    • 监控技术方案忽略了请求头和请求行部分的数据大小
    • 监控技术方案忽略了 Cookie 部分的数据大小
    • 监控技术方案在对请求体大小计算的时候直接使用 HTTPBody.length,导致不够精确
  3. 响应流量计算方式不精确

    主要原因有:

    • 监控技术方案忽略了响应头和响应行部分的数据大小
    • 监控技术方案在对 body 部分的字节大小计算,因采用 exceptedContentLength 导致不够准确
    • 监控技术方案忽略了响应体使用 gzip 压缩。真正的网络通信过程中,客户端在发起请求的请求头中 Accept-Encoding 字段代表客户端支持的数据压缩方式(表明客户端可以正常使用数据时支持的压缩方法),同样服务端根据客户端想要的压缩方式、服务端当前支持的压缩方式,最后处理数据,在响应头中Content-Encoding 字段表示当前服务器采用了什么压缩方式。
2.5.3 技术实现

第五部分讲了网络拦截的各种原理和技术方案,这里拿 NSURLProtocol 来说实现流量监控(Hook 的方式)。从上述知道了我们需要什么样的,那么就逐步实现吧。

2.5.3.1 Request 部分
  1. 先利用网络监控方案将 NSURLProtocol 管理 App 的各种网络请求

  2. 在各个方法内部记录各项所需参数(NSURLProtocol 不能分析请求握手、挥手等数据大小和时间消耗,不过对于正常情况的接口流量分析足够了,最底层需要 Socket 层)

    @property(nonatomic, strong) NSURLConnection *internalConnection;
    @property(nonatomic, strong) NSURLResponse *internalResponse;
    @property(nonatomic, strong) NSMutableData *responseData;
    @property (nonatomic, strong) NSURLRequest *internalRequest;
    复制代码
    - (void)startLoading
    {
        NSMutableURLRequest *mutableRequest = [[self request] mutableCopy];
        self.internalConnection = [[NSURLConnection alloc] initWithRequest:mutableRequest delegate:self];
        self.internalRequest = self.request;
    }
       
    - (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response
    {
        [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
        self.internalResponse = response;
    }
       
    - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data 
    {
        [self.responseData appendData:data];
        [self.client URLProtocol:self didLoadData:data];
    }
    复制代码
  3. Status Line 部分

NSURLResponse 没有 Status Line 等属性或者接口,HTTP Version 信息也没有,所以要想获取 Status Line 想办法转换到 CFNetwork 层试试看。发现有私有 API 可以实现。

思路:将 NSURLResponse 通过 _CFURLResponse 转换为 CFTypeRef,然后再将 CFTypeRef 转换为 CFHTTPMessageRef,再通过 CFHTTPMessageCopyResponseStatusLine 获取 CFHTTPMessageRef 的 Status Line 信息。

将读取 Status Line 的功能添加一个 NSURLResponse 的分类。

// NSURLResponse+apm_FetchStatusLineFromCFNetwork.h
#import <Foundation/Foundation.h>

NS_ASSUME_NONNULL_BEGIN

@interface NSURLResponse (apm_FetchStatusLineFromCFNetwork)

- (NSString *)apm_fetchStatusLineFromCFNetwork;

@end

NS_ASSUME_NONNULL_END

// NSURLResponse+apm_FetchStatusLineFromCFNetwork.m
#import "NSURLResponse+apm_FetchStatusLineFromCFNetwork.h"
#import <dlfcn.h>


#define SuppressPerformSelectorLeakWarning(Stuff) \
do { \
    _Pragma("clang diagnostic push") \
    _Pragma("clang diagnostic ignored \"-Warc-performSelector-leaks\"") \
    Stuff; \
    _Pragma("clang diagnostic pop") \
} while (0)

typedef CFHTTPMessageRef (*APMURLResponseFetchHTTPResponse)(CFURLRef response);

@implementation NSURLResponse (apm_FetchStatusLineFromCFNetwork)

- (NSString *)apm_fetchStatusLineFromCFNetwork
{
    NSString *statusLine = @"";
    NSString *funcName = @"CFURLResponseGetHTTPResponse";
    APMURLResponseFetchHTTPResponse originalURLResponseFetchHTTPResponse = dlsym(RTLD_DEFAULT, [funcName UTF8String]);
    
    SEL getSelector = NSSelectorFromString(@"_CFURLResponse");
    if ([self respondsToSelector:getSelector] && NULL != originalURLResponseFetchHTTPResponse) {
        CFTypeRef cfResponse;
        SuppressPerformSelectorLeakWarning(
            cfResponse = CFBridgingRetain([self performSelector:getSelector]);
        );
        if (NULL != cfResponse) {
            CFHTTPMessageRef messageRef = originalURLResponseFetchHTTPResponse(cfResponse);
            statusLine = (__bridge_transfer NSString *)CFHTTPMessageCopyResponseStatusLine(messageRef);
            CFRelease(cfResponse);
        }
    }
    return statusLine;
}

@end
复制代码
  1. 将获取到的 Status Line 转换为 NSData,再计算大小

    - (NSUInteger)apm_getLineLength {
        NSString *statusLineString = @"";
        if ([self isKindOfClass:[NSHTTPURLResponse class]]) {
            NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)self;
            statusLineString = [self apm_fetchStatusLineFromCFNetwork];
        }
        NSData *lineData = [statusLineString dataUsingEncoding:NSUTF8StringEncoding];
        return lineData.length;
    }
    复制代码
  2. Header 部分

    allHeaderFields 获取到 NSDictionary,然后按照 key: value 拼接成字符串,然后转换成 NSData 计算大小

    注意:key: value key 后是有空格的,curl 或者 chrome Network 面板可以查看印证下。

    - (NSUInteger)apm_getHeadersLength
    {
        NSUInteger headersLength = 0;
        if ([self isKindOfClass:[NSHTTPURLResponse class]]) {
            NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)self;
            NSDictionary *headerFields = httpResponse.allHeaderFields;
            NSString *headerString = @"";
            for (NSString *key in headerFields.allKeys) {
                headerString = [headerStr stringByAppendingString:key];
                headheaderStringerStr = [headerString stringByAppendingString:@": "];
                if ([headerFields objectForKey:key]) {
                    headerString = [headerString stringByAppendingString:headerFields[key]];
                }
                headerString = [headerString stringByAppendingString:@"\n"];
            }
            NSData *headerData = [headerString dataUsingEncoding:NSUTF8StringEncoding];
            headersLength = headerData.length;
        }
        return headersLength;
    }
    复制代码
  3. Body 部分

    Body 大小的计算不能直接使用 excepectedContentLength,官方文档说明了其不准确性,只可以作为参考。或者 allHeaderFields 中的 Content-Length 值也是不够准确的。

    /*!

    @abstract Returns the expected content length of the receiver.

    @discussion Some protocol implementations report a content length

    as part of delivering load metadata, but not all protocols

    guarantee the amount of data that will be delivered in actuality.

    Hence, this method returns an expected amount. Clients should use

    this value as an advisory, and should be prepared to deal with

    either more or less data.

    @result The expected content length of the receiver, or -1 if

    there is no expectation that can be arrived at regarding expected

    content length.

    */

    @property (readonly) long long expectedContentLength;

    • HTTP 1.1 版本规定,如果存在 Transfer-Encoding: chunked,则在 header 中不能有 Content-Length,有也会被忽视。
    • 在 HTTP 1.0及之前版本中,content-length 字段可有可无
    • 在 HTTP 1.1及之后版本。如果是 keep alive,则 Content-Lengthchunked 必然是二选一。若是非keep alive,则和 HTTP 1.0一样。Content-Length 可有可无。

    什么是 Transfer-Encoding: chunked

    数据以一系列分块的形式进行发送 Content-Length 首部在这种情况下不被发送. 在每一个分块的开头需要添加当前分块的长度, 以十六进制的形式表示,后面紧跟着 \r\n , 之后是分块本身, 后面也是 \r\n ,终止块是一个常规的分块, 不同之处在于其长度为0.

    我们之前拿 NSMutableData 记录了数据,所以我们可以在 stopLoading 方法中计算出 Body 大小。步骤如下:

    • didReceiveData 中不断添加 data

      - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data
      {
          [self.responseData appendData:data];
          [self.client URLProtocol:self didLoadData:data];
      }
      复制代码
    • stopLoading 方法中拿到 allHeaderFields 字典,获取 Content-Encoding key 的值,如果是 gzip,则在 stopLoading 中将 NSData 处理为 gzip 压缩后的数据,再计算大小。(gzip 相关功能可以使用这个工具

      需要额外计算一个空白行的长度

      - (void)stopLoadi
      {
          [self.internalConnection cancel];
           
          HCTNetworkTrafficModel *model = [[HCTNetworkTrafficModel alloc] init];
          model.path = self.request.URL.path;
          model.host = self.request.URL.host;
          model.type = DMNetworkTrafficDataTypeResponse;
          model.lineLength = [self.internalResponse apm_getStatusLineLength];
          model.headerLength = [self.internalResponse apm_getHeadersLength];
          model.emptyLineLength = [self.internalResponse apm_getEmptyLineLength];
          if ([self.dm_response isKindOfClass:[NSHTTPURLResponse class]]) {
              NSHTTPURLResponse *httpResponse = (NSHTTPURLResponse *)self.dm_response;
              NSData *data = self.dm_data;
              if ([[httpResponse.allHeaderFields objectForKey:@"Content-Encoding"] isEqualToString:@"gzip"]) {
                  data = [self.dm_data gzippedData];
              }
              model.bodyLength = data.length;
          }
          model.length = model.lineLength + model.headerLength + model.bodyLength + model.emptyLineLength;
          NSDictionary *networkTrafficDictionary = [model convertToDictionary];
          [[HermesClient sharedInstance] sendWithType:APMMonitorNetworkTrafficType meta:networkTrafficDictionary payload:nil];
      }
      复制代码
2.5.3.2 Resquest 部分
  1. 先利用网络监控方案将 NSURLProtocol 管理 App 的各种网络请求

  2. 在各个方法内部记录各项所需参数(NSURLProtocol 不能分析请求握手、挥手等数据大小和时间消耗,不过对于正常情况的接口流量分析足够了,最底层需要 Socket 层)

    @property(nonatomic, strong) NSURLConnection *internalConnection;
    @property(nonatomic, strong) NSURLResponse *internalResponse;
    @property(nonatomic, strong) NSMutableData *responseData;
    @property (nonatomic, strong) NSURLRequest *internalRequest;
    复制代码
    - (void)startLoading
    {
        NSMutableURLRequest *mutableRequest = [[self request] mutableCopy];
        self.internalConnection = [[NSURLConnection alloc] initWithRequest:mutableRequest delegate:self];
        self.internalRequest = self.request;
    }
       
    - (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response
    {
        [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
        self.internalResponse = response;
    }
       
    - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data 
    {
        [self.responseData appendData:data];
        [self.client URLProtocol:self didLoadData:data];
    }
    复制代码
  3. Status Line 部分

    对于 NSURLRequest 没有像 NSURLResponse 一样的方法找到 StatusLine。所以兜底方案是自己根据 Status Line 的结构,自己手动构造一个。结构为:协议版本号+空格+状态码+空格+状态文本+换行

    为 NSURLRequest 添加一个专门获取 Status Line 的分类。

    // NSURLResquest+apm_FetchStatusLineFromCFNetwork.m
    - (NSUInteger)apm_fetchStatusLineLength
    {
      NSString *statusLineString = [NSString stringWithFormat:@"%@ %@ %@\n", self.HTTPMethod, self.URL.path, @"HTTP/1.1"];
      NSData *statusLineData = [statusLineString dataUsingEncoding:NSUTF8StringEncoding];
      return statusLineData.length;
    }
    复制代码
  4. Header 部分

    一个 HTTP 请求会先构建判断是否存在缓存,然后进行 DNS 域名解析以获取请求域名的服务器 IP 地址。如果请求协议是 HTTPS,那么还需要建立 TLS 连接。接下来就是利用 IP 地址和服务器建立 TCP 连接。连接建立之后,浏览器端会构建请求行、请求头等信息,并把和该域名相关的 Cookie 等数据附加到请求头中,然后向服务器发送构建的请求信息。

    所以一个网络监控不考虑 cookie 😂,借用王多鱼的一句话「那不完犊子了吗」。

    看过一些文章说 NSURLRequest 不能完整获取到请求头信息。其实问题不大, 几个信息获取不完全也没办法。衡量监控方案本身就是看接口在不同版本或者某些情况下数据消耗是否异常,WebView 资源请求是否过大,类似于控制变量法的思想。

    所以获取到 NSURLRequest 的 allHeaderFields 后,加上 cookie 信息,计算完整的 Header 大小

    // NSURLResquest+apm_FetchHeaderWithCookies.m
    - (NSUInteger)apm_fetchHeaderLengthWithCookie
    {
        NSDictionary *headerFields = self.allHTTPHeaderFields;
        NSDictionary *cookiesHeader = [self apm_fetchCookies];
       
        if (cookiesHeader.count) {
            NSMutableDictionary *headerDictionaryWithCookies = [NSMutableDictionary dictionaryWithDictionary:headerFields];
            [headerDictionaryWithCookies addEntriesFromDictionary:cookiesHeader];
            headerFields = [headerDictionaryWithCookies copy];
        }
           
        NSString *headerString = @"";
       
        for (NSString *key in headerFields.allKeys) {
            headerString = [headerString stringByAppendingString:key];
            headerString = [headerString stringByAppendingString:@": "];
            if ([headerFields objectForKey:key]) {
                headerString = [headerString stringByAppendingString:headerFields[key]];
            }
            headerString = [headerString stringByAppendingString:@"\n"];
        }
        NSData *headerData = [headerString dataUsingEncoding:NSUTF8StringEncoding];
        headersLength = headerData.length;
        return headerString;
    }
       
    - (NSDictionary *)apm_fetchCookies
    {
        NSDictionary *cookiesHeaderDictionary;
        NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
        NSArray<NSHTTPCookie *> *cookies = [cookieStorage cookiesForURL:self.URL];
        if (cookies.count) {
            cookiesHeaderDictionary = [NSHTTPCookie requestHeaderFieldsWithCookies:cookies];
        }
        return cookiesHeaderDictionary;
    }
    复制代码
  5. Body 部分

    NSURLConnection 的 HTTPBody 有可能获取不到,问题类似于 WebView 上 ajax 等情况。所以可以通过 HTTPBodyStream 读取 stream 来计算 body 大小.

    - (NSUInteger)apm_fetchRequestBody
    {
        NSDictionary *headerFields = self.allHTTPHeaderFields;
        NSUInteger bodyLength = [self.HTTPBody length];
       
        if ([headerFields objectForKey:@"Content-Encoding"]) {
            NSData *bodyData;
            if (self.HTTPBody == nil) {
                uint8_t d[1024] = {0};
                NSInputStream *stream = self.HTTPBodyStream;
                NSMutableData *data = [[NSMutableData alloc] init];
                [stream open];
                while ([stream hasBytesAvailable]) {
                    NSInteger len = [stream read:d maxLength:1024];
                    if (len > 0 && stream.streamError == nil) {
                        [data appendBytes:(void *)d length:len];
                    }
                }
                bodyData = [data copy];
                [stream close];
            } else {
                bodyData = self.HTTPBody;
            }
            bodyLength = [[bodyData gzippedData] length];
        }
        return bodyLength;
    }
    复制代码
  6. - (NSURLRequest *)connection:(NSURLConnection *)connection willSendRequest:(NSURLRequest *)request redirectResponse:(NSURLResponse *)response 方法中将数据上报会在 打造功能强大、灵活可配置的数据上报组件

    -(NSURLRequest *)connection:(NSURLConnection *)connection willSendRequest:(NSURLRequest *)request redirectResponse:(NSURLResponse *)response
    {
        if (response != nil) {
            self.internalResponse = response;
            [self.client URLProtocol:self wasRedirectedToRequest:request redirectResponse:response];
        }
       
        HCTNetworkTrafficModel *model = [[HCTNetworkTrafficModel alloc] init];
        model.path = request.URL.path;
        model.host = request.URL.host;
        model.type = DMNetworkTrafficDataTypeRequest;
        model.lineLength = [connection.currentRequest dgm_getLineLength];
        model.headerLength = [connection.currentRequest dgm_getHeadersLengthWithCookie];
        model.bodyLength = [connection.currentRequest dgm_getBodyLength];
        model.emptyLineLength = [self.internalResponse apm_getEmptyLineLength];
        model.length = model.lineLength + model.headerLength + model.bodyLength + model.emptyLineLength;
           
        NSDictionary *networkTrafficDictionary = [model convertToDictionary];
        [[HermesClient sharedInstance] sendWithType:APMMonitorNetworkTrafficType meta:networkTrafficDictionary payload:nil];
        return request;
    }
    复制代码
免责声明:文章版权归原作者所有,其内容与观点不代表Unitimes立场,亦不构成任何投资意见或建议。

ios

472

相关文章推荐

未登录头像

暂无评论