iOS 测试 在 iOS 设备内截取 HTTP/HTTPS 信息

刘晓光 · 2017年03月29日 · 最后由 026 回复于 2017年04月27日 · 54 次阅读
本帖已被设为精华帖!

0x01.Why?

做移动测试的同学经常会在 app 和 server 中间架设一个代理(例如 charles 或者 fiddler 等),由经代理,app 和 server 之间的交互及交互内容变得可视化,使得我们不再摸黑测试。事实上,能够很好的掌握 app 和 server 端的交互不仅对于测试,对于开发,对于产品的整个质量提高都是有非常大益处的。但是,有些场景下,架设代理变得不易,或者难于满足要求,举几个例子:

  • 想要找出正常用户使用时候,哪些场景最耗流量(你不能让用户挂代理,如果有针对网络流量的优化,挂代理也看不出问题来)。
  • 想要找出请求的各种接口中,哪些服务不稳定,如间歇出现 4xx 或者 5xx 错误,这需要统计大量的数据,单一客户端挂代理是做不到的(当然服务端监控如果做得好也能实现)。
  • 想要找出某些特定条件下(如弱网,网络切换等)客户端自己产生的请求错误或者超时等等。
  • 想要查看一些特殊场景下接口是否会发生重复调用,错误调用序列。这些 issue 往往藏的很深,不易出现。这时候往往需要分析日志的 pattern 来把问题揪出来,这时候你就会发现,代理软件做日志分析很麻烦,也要导出来专门分析,而且总挂着代理极为不方便(至少不能切换网络,日志也要根据 app 做筛查,因为一般都是全流量截取)。

这时候需求就变成了:最好在 app 内部能够截取所有的 HTTP/HTTPS 流量,以某种方式保存下来,并且能够以某种方式传递给需要用这些数据的人。这其实是一种 APM(Application Performance Monitoring)的概念,国外最早已经有人实现了这种功能,如 newrelic https://newrelic.com/ 国内也有一些类似的厂商了。

0x02. How?

先想一下我们每天都在使用的代理工具是如何实现的呢?代理工具会拦截所有的 http 的请求,记录下我们需要的信息后替代客户端重新发送相同的请求给服务端;拦截返回,记录下想要的东西后返回给客户端。如果 JAVA 写的多,你可能看到过各种 interceptor 来截取流量。OKHttp 的作者介绍这款被广泛应用的 http client 的时候曾经说过:OKHttp 只不过是请求和响应之间做了一堆 interceptor 而已。

具体落到 iOS 上。iOS 的 Foundation 框架提供了 URL Loading System 这个库 (后面简写为 ULS),所有基于 URL(例如 http://,https:// ,ftp://这些应用层的传输协议) 的协议都可以通过 ULS 提供的基础类和协议来实现,你甚至可以自定义自己的私有应用层通讯协议。

而 ULS 库里提供了一个强有力的武器 NSURLProtocol。 继承 NSURLProtocol 的子类都可以实现截取行为,具体的方式就是:如果注册了某个 NSURLProtocol 子类,ULS 管理的流量都会先交由这个子类处理,这相当于实现了一个拦截器。由于现在处于统治地位的的 http client 库 AFNetworking 和 Alamofire 都是基于 URL Loading System 实现的,所以他们俩和使用基础 URL Loading System API 产生的流量理论上都可以被截取到。

注意一点,NSURLProtocol 是一个抽象类,而不是一个协议(protocol)。

为了达到监控流量的目的,我们就先设计一个类来实现 NSURLProtocol 吧:


// MyHttpProtocol.h 
#import <Foundation/Foundation.h>
@interface MyHttpProtocol : NSURLProtocol
@end

//MyHttpProtocol.m

#import <Foundation/Foundation.h>
#import "MyHttpProtocol.h"

@implementation MyHttpProtocol

+(BOOL)canInitWithRequest:(NSURLRequest *)request{    
   NSString *scheme =[[request URL] scheme];
    if([[scheme lowercaseString] isEqualToString:@"http"]||
       [[scheme lowercaseString] isEqualToString:@"https"])
    {
        if([NSURLProtocol propertyForKey:@"processed" inRequest:request]){
            return NO;
        }
        return YES;
    }
    return NO;
}


+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request
{
    NSMutableURLRequest * duplicatedRequest;
    duplicatedRequest =  [request mutableCopy];
    [NSURLProtocol setProperty:@YES forKey:@"processed" inRequest:duplicatedRequest];
    NSLog(@"%@",request.HTTPBody);
    return (NSURLRequest *) duplicatedRequest;
}

上边的 MyHttpProtocol 类继承了 NSURLProtocol,并实现了 NSURLProtocol 的两个方法。

+ (BOOL)canInitWithRequest:(NSURLRequest *)request

这个方法返回 YES,MyHttpProtocol 类就会处理一个 request,否则就按照原有方式处理。在上边的代码里,我先判断了协议的类型是不是 http/https,如果不是,则返回 NO,如果是,则会做一个判断:这个 request 是否带有一个叫做 "processed"的标签,如果是,则返回 NO,不交给 MyHttpProtocol 处理;如果不是,则交给 MyHttpProtocol 处理。

重点说一下标签 “processed”:每当需要加载一个 URL 资源时,URL Loading System 会询问 MyURLProtocol 是否处理,如果返回 YES,URL Loading System 会创建一个 MyURLProtocol 实例,实例做完拦截工作后,会重新调用原有的方法,如 session GET,URL Loading System 会再一次被调用,如果在 +canInitWithRequest:中总是返回 YES,这样 URL Loading System 又会创建一个 MyURLProtocol 实例。。。。这样就导致了无限循环。为了避免这种问题,我们可以利用 +setProperty:forKey:inRequest:来给被处理过的请求打标签,然后在 +canInitWithRequest:中查询该 request 是否已经处理过了,如果是则返回 NO。 上文中的 “processed” 就是打的一个标签,标签是一个字符串,可以任意取名。而这个打标签的方法,通常会在

+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request 

中实现。

实现这个子类以后,在程序加载的地方,注册这个类,这样,理论上,请注意 “理论上” 这三个字,就可以截获所有的 http/https 流量了。注册的代码如下

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
    [NSURLProtocol registerClass:[MyHttpProtocol class]];
    return YES;
}

做完了上述工作,我们仍然无法实现我们所想:记录下所有的请求和响应。这是因为:如果你拦截了请求,你就需要对你的拦截负责:比如重新发送拦截的请求,处理请求对应的返回等。这里就需要完成非常多的 dirty work 了。下面的玩具代码只会处理最简单的情况,如果真实使用,得处理很多细节问题。

为了便于理解,先介绍 NSURLProtocol 的几个内置的属性,包括:client,request,cachedResponse,类型如下

@property(readonly, retain) id<NSURLProtocolClient> client;
@property(readonly, copy) NSURLRequest *request;
@property(readonly, copy) NSCachedURLResponse *cachedResponse;

这三个概念稍微有点儿绕,先简要说一下:request 被用作接收 ULS 转给 NSURLProtocol 的请求;client 的实现了 NSURLProtocolClient 这个协议,这里边有一堆 callback 函数,我们一会儿会用到 didLoadData;cachesResponse,顾名思义,请求对应的相应会被缓存在这里。

我们还要实现 NSURLProtocol 的两个方法。startLoading 和 stopLoading


- (void)startLoading{
    NSLog(@"Start loading -------");
    NSLog(@"request url is: %@",self.request.URL); //这里的self.request就是ULS传过来的请求体,这里我们记录下一些请求体的信息。
    NSLog(@"http method is:%@",self.request.HTTPMethod); //
    for (NSString *key in[self.request.allHTTPHeaderFields allKeys]){    //打印http请求的header
        NSLog(@"key:%@,value:%@",key,[self.request.allHTTPHeaderFields objectForKey:key]);
    }

    //重新转发请求
    NSMutableURLRequest *newRequest = [self.request mutableCopy];
    NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
    NSURLSession * session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil];
    self.task = [session dataTaskWithRequest:newRequest];
    [self.task resume];
}

-(void) stopLoading{
    NSLog(@"Stop loading -------");   
    [self.task cancel];
}

通过上述代码,我们成功的记录下来了请求体的一些信息,但是如何记录返回信息呢?由于 ULS 是异步框架,所以,响应会推给回调函数,我们必须在回调函数里进行截取。为了实现这一功能,我们需要实现 NSURLSessionDataDelegate 这个委托协议(NSURLSessionDataDelegate 也有局限性,这里不展开说了)。

@interface MyHttpProtocol ()<NSURLSessionDataDelegate>
@property (nonatomic, strong) NSMutableData *data;
@property (nonatomic, strong) NSURLSessionDataTask *task;
@end


//当服务端返回信息时,这个回调函数会被ULS调用,在这里实现http返回信息的截取
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {

    [self.client URLProtocol:self didLoadData:data]; //返回给URL Loading System接收到的数据,这个很重要,不然光截取不返回,就瞎了。
    NSLog(@"--data received");

   //下面的代码只打印json类型的http返回。
    NSError *error = nil;
    NSString *jsonObject = [NSJSONSerialization JSONObjectWithData:data options:0 error:&error];
    if(error){
        NSLog(@"error occured!");
        return;
    }
    NSData *jsonData = [NSJSONSerialization dataWithJSONObject:jsonObject options:NSJSONWritingPrettyPrinted error:nil];
    NSString *jsonString = [[NSString alloc]initWithData:jsonData encoding:NSUTF8StringEncoding];
    NSLog(@"nsdata is %@",jsonString);   
}

好了,上边这一坨代码,理论上实现了我们想要的功能的最小集:拦截 http/https 请求和响应,并打印出来。为什么说理论上呢。如果你使用 AFNETworking,你会发现,你的代码根本没有被调用。这是因为它根本不屌上边的注册,也就是下边这句代码:

[NSURLProtocol registerClass:[MyHttpProtocol class]];

实际上 ULS 允许加载多个 NSURLProtocol,它们被存在一个数组里,默认情况下,AFNETWorking 只会使用数组里的第一个 protocol。这看起来是个悲剧,如果不改源码,我想做的事儿不就止步于此了么?多亏 Objective C 是动态语言。我们可以用一项 “尖端科技”,也就是 object-c 的动态方法替换来实现动态的修改源码来达到目的。
实现一个类:MySessionConfiguration.m (这部分代码基本照抄的一个叫做 Netfox 的开源项目,大家有兴趣可以搜索)。

#import <Foundation/Foundation.h>
#import "MySessionConfiguration.h"
#import "MyHttpProtocol.h"
#import <objc/runtime.h>

@implementation MySessionConfiguration

//返回一个默认配置的单体
+ (MySessionConfiguration *) defaultConfiguration{
    static MySessionConfiguration *staticConfiguration;
    static dispatch_once_t onceToken;
    dispatch_once(&onceToken, ^{
        staticConfiguration =[[MySessionConfiguration alloc] init];
    });
    return staticConfiguration;
}


- (instancetype) init{
    self = [super init];
    if(self){
        self.isSwizzle=NO;
    }
    return self;
}

//load被调用的时候,其实吧session.configuration.protocolClasses 这个数组从原有配置换成了只有MyHttpProtocol
- (void)load{
    NSLog(@"----configuration load --");
    self.isSwizzle=YES;
    Class cls = NSClassFromString(@"__NSCFURLSessionConfiguration") ?:NSClassFromString(@"NSURLSessionConfiguration");
    [self swizzleSelector:@selector(protocolClasses) fromClass:cls toClass:[self class]];

}

- (void)unload {
    self.isSwizzle=NO;
     Class cls = NSClassFromString(@"__NSCFURLSessionConfiguration") ?:NSClassFromString(@"NSURLSessionConfiguration");
     [self swizzleSelector:@selector(protocolClasses) fromClass:cls toClass:[self class]];
}

- (void)swizzleSelector:(SEL)selector fromClass:(Class)original toClass:(Class)stub{
    Method originalMethod = class_getInstanceMethod(original, selector);
    Method stubMethod = class_getInstanceMethod(stub, selector);
    if(!originalMethod || !stubMethod){
        [NSException raise:NSInternalInconsistencyException format:@"Could't load NSURLSessionConfiguration "];
    }

   //真正的替换在这里
    method_exchangeImplementations(originalMethod, stubMethod);
}

 //返回MyHttpProtocol
- (NSArray *)protocolClasses{
    return @[[MyHttpProtocol class]];
}

@end

最后,简单粗暴的,在程序启动的时候加入这么一句:

- (BOOL)application:(UIApplication *)application didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {

   //就是这一句
   [[[MySessionConfiguration alloc] init] load];

    return YES;
}

这样,一个简单的监控功能就实现了。实际上,想让它能够变得实用起来还有无数的坑要填,代码量大概再增加 20 倍吧,这些坑包括:https 的证书校验,NSURLConnection 和 NSURLSession 兼容,重定向,超时处理,返回值内容解析,各种异常处理(不能因为你崩了让程序跟着崩了),开关,截获的信息本地存储策略,回传服务端策略等。真正写一个可用的工具不是那么简单。所以,如果金钱允许,还是让公司去采购吧。。。

0x03 BTW:

1.本人 OC 菜鸟,肯定有理解不当的地方,有高手请多加指正。
2.有小伙伴想一起做的话可以一同起个开源啊,一起利用一下碎片化的时间(除非专职的开发测试,否则几乎没有大把时间和机会写产品形态的测试工具的)。

共收到 12 条回复 时间 点赞

objc 代码块已经可以用了。

恒温 回复

迅速

好帖 表示正在学 oc

之前做过相同的功能,如果是 POST 请求,拿到的 body 会为空的。😅
这是基于 URLSession 的,如果是老版本 URLConnection 或者 CFNetwork,可能抓不到

1717p 回复

POST 可以拿到,得做点儿处理。 URLConnection 和 URLSession 兼容是个问题。现在想到的方法是动态识别适配,要有一大坨代码写。

刘晓光 回复

恩,处理一下可以拿到 body
其实还好,代码量不是特别大-。-

刘晓光 回复

connection 要更简单一点

思寒_seveniruby 将本帖设为了精华贴 03月29日 12:44

顶 晓光!!

不知道 swift 里面 oc 的方法灌醉还能不能用

写成 sdk,能用。

刘晓光 回复

如果 swift 里面 api 都是静态连接的花, oc 以后还是硬通货, qa 得掌握. 这个技术还是挺实用的.

请更新个人资料中的微信和支付宝的打赏二维码,用于精华帖打赏

需要 登录 后方可回复, 如果你还没有账号请点击这里 注册