作移動測試的同窗常常會在app和server中間架設一個代理(例如charles或者fiddler等),由經代理,app和server之間的交互及交互內容變得可視化,使得咱們再也不摸黑測試。事實上,可以很好的掌握app和server端的交互不只對於測試,對於開發,對於產品的整個質量提升都是有很是大益處的。可是,有些場景下,架設代理變得不易,或者難於知足要求,舉幾個例子:json
這時候需求就變成了:最好在app內部可以截取全部的HTTP/HTTPS流量,以某種方式保存下來,而且可以以某種方式傳遞給須要用這些數據的人。這實際上是一種APM(Application Performance Monitoring)的概念,國外最先已經有人實現了這種功能,如 newrelic https://newrelic.com/ 國內也有一些相似的廠商了。數組
先想一下咱們天天都在使用的代理工具是如何實現的呢?代理工具會攔截全部的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兼容,重定向,超時處理,返回值內容解析,各類異常處理(不能由於你崩了讓程序跟着崩了),開關,截獲的信息本地存儲策略,回傳服務端策略等。真正寫一個可用的工具不是那麼簡單。因此,若是金錢容許,仍是讓公司去採購吧。。。
1.本人OC菜鳥,確定有理解不當的地方,有高手請多加指正。 2.有小夥伴想一塊兒作的話能夠一同起個開源啊,一塊兒利用一下碎片化的時間(除非專職的開發測試,不然幾乎沒有大把時間和機會寫產品形態的測試工具的)。