iOS截取http/https流量

0x01.Why?

作移動測試的同窗常常會在app和server中間架設一個代理(例如charles或者fiddler等),由經代理,app和server之間的交互及交互內容變得可視化,使得咱們再也不摸黑測試。事實上,可以很好的掌握app和server端的交互不只對於測試,對於開發,對於產品的整個質量提升都是有很是大益處的。可是,有些場景下,架設代理變得不易,或者難於知足要求,舉幾個例子:json

  • 想要找出正經常使用戶使用時候,哪些場景最耗流量(你不能讓用戶掛代理,若是有針對網絡流量的優化,掛代理也看不出問題來)。
  • 想要找出請求的各類接口中,哪些服務不穩定,如間歇出現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產生的流量理論上均可以被截取到。session

注意一點,NSURLProtocol是一個抽象類,而不是一個協議(protocol)。app

爲了達到監控流量的目的,咱們就先設計一個類來實現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.有小夥伴想一塊兒作的話能夠一同起個開源啊,一塊兒利用一下碎片化的時間(除非專職的開發測試,不然幾乎沒有大把時間和機會寫產品形態的測試工具的)。

相關文章
相關標籤/搜索