GYHttpMock:使用及原始碼解析

我是繁星發表於2019-03-03

背景

GYHttpMock是騰訊團隊開源的用於模擬網路請求的工具。截獲指定的http Request,返回我們自定義的response。本文意在解析其細節和原理。

作用

客戶端開發過程中,經常會遇到等服務端聯調的情景,往往這個時候我們什麼都做不了,這個工具可以輕鬆解決這個問題。只需要引入工程新增request限制條件,並制定返回json即可。

用法

api用的DSL的形式,不懂得可以看這《objective-c DSL的實現思路》
關於用法官方都有寫《GYHttpMock:iOS HTTP請求模擬工具》粘過來的 ̄□ ̄||
1.建立一個最簡單的 mockRequest。截獲應用中訪問 www.weread.com 的 get 請求,並返回一個 response body為空的資料。

mockRequest(@"GET", @"http://www.weread.com");
複製程式碼

2.建立一個攔截條件更復雜的 mockRequest。截獲應用中 url 包含 weread.com,而且包含了 name=abc 的引數

mockRequest(@"GET", @"(.*?)weread.com(.*?)".regex).
    withBody(@"{"name":"abc"}".regex);
複製程式碼

3.建立一個指定返回資料的 mockRequest。withBody的值也可以是某個 xxx.json 檔案,不過這個 json 檔案需要加入到專案中。

mockRequest(@"POST", @"http://www.weread.com").
    withBody(@"{"name":"abc"}".regex);
    andReturn(200).
    withBody(@"{"key":"value"}");
複製程式碼

4.建立一個修改部分返回資料的 mockRequest。這裡會根據 weread.json 的內容修改正常網路返回的資料

mockRequest(@"POST", @"http://www.weread.com").
    isUpdatePartResponseBody(YES).
    withBody(@"{"name":"abc"}".regex);
    andReturn(200).
    withBody(@“weread.json");
複製程式碼

假設正常網路返回的原始資料是這樣:

{"data": [ {
      "bookId":"0000001",
      "updated": [
        {
          "chapterIdx": 1,
          "title": "序言",
        },
        {
          "chapterIdx": 2,
          "title": "第2章",
        }
      ]
}]}
複製程式碼

weread.json的內容是這樣

{"data": [{
      "updated": [
        {
           "hello":"world"
        }
      ]
}]}
複製程式碼

修改後的資料就會就成這樣:

{"data": [ {
      "bookId":"0000001",
      "updated": [
        {
          "chapterIdx": 1,
          "title": "序言",
           "hello":"world"
        },
        {
          "chapterIdx": 2,
          "title": "第2章",
          "hello":"world"
        }
      ]
}]}
複製程式碼

實現原理

流程

HttpMock.png

這是官方的一張流程圖,其中每次的請求都會經過NSURLProtocol,通過NSURLProtocol 對request的篩選有三種情況:
1. request不符合攔截條件,不做任何處理,直接發請求並返回response。
2.request符合攔截條件,不做請求,直接由本地資料生成response返回。
3.request符合攔截條件,但是為部分替換response,發出網路請求,並由本地資料修改response資料。

NSURLProtocol

一個不恰當的比喻,NSURLProtocol就好比一個城門守衛,請求就相當於想進城買東西的平民,平民從老家來想進城,這時城門守衛自己做起了生意,看到有漂亮姑娘就直接把東西賣給她省的她進城了,小夥子就讓他進城自己去買。等這些人買到東西回村兒,村裡人看見他們買到了東西很高興,但是並不知道這個東西的來源。

城門守衛.jpeg

首先NSURLProtocol是一個類,並不是一個協議。我們要使用它的時候必須要建立其子類。它在IOS系統中處於這樣一個位置:

872807-fcdfa47bfd980abf.png

在IOS 的URL Loading System中的網路請求它都可以攔截到,IOS在整個系統設計上的強大之處。
貼一張URL Loading System的圖

872807-b7f17b6fbaf25831.png

也就是說常用的NSURLSession、NSURLConnection及UIWebView都可以攔截到,但是WKWebView走的不是IOS系統的網路庫,並不能攔截到。

GYHttpMock也正是基於NSURLProtocol實現的,NSURLProtocol主要分為5個步驟:
註冊——>攔截——>轉發——>回撥——>結束

註冊:

呼叫NSURLProtocol的工廠方法,但是GYHttpMock用了更巧妙的方式,待會再說。註冊之後URL Loading System的request都會經過myURLProtocol了。

[NSURLProtocol registerClass:[myURLProtocol class]];
複製程式碼

攔截:

判斷是否對該請求進行攔截。

+ (BOOL)canInitWithRequest:(NSURLRequest *)request
複製程式碼

在該方法中,我們可以對request進行處理。例如修改頭部資訊等。最後返回一個處理後的request例項。

+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request
複製程式碼

轉發:

核心方法將處理後的request重新傳送出去。這裡完全由自己定義,可以直接返回本地的資料,可以對請求進行重定位等等。

- (void)startLoading {
複製程式碼

回撥:

因為是面向切面程式設計,所以不能影響到原來的網路邏輯。需要將處理後返回的資料傳送給原來網路請求的地方。

[self.client URLProtocol:self didFailWithError:error];
[self.client URLProtocolDidFinishLoading:self];
[self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed];
[self.client URLProtocol:self didLoadData:data];
複製程式碼

這裡self.client 是URLProtocol的一個屬性,是客戶端的一個抽象類。通過呼叫其方法可以把資料回撥給網路請求的地方。

結束:

//請求完全結束的時候,會呼叫

- (void)stopLoading
複製程式碼

應用

URLProtocol功能非常強大,可以用作請求快取、網路請求mock正如GYHttpMock、網路相關資料統計、URL重定向。等等。。。

##原始碼解析
####檔案結構

GYHttpMock.png

原始碼並不多,檔案結構也很簡單。

  • GYMockURLProtocol:請求攔截核心程式碼。
  • GYMatcher:工具類,比較資料等同性。
    *GYHttpMock:單例,起調配作用,儲存待攔截request、註冊網路相關類的hook、等。
  • Response:對httpResponse的描述和抽象,還有DSL相關類。
  • Request:對httpRequest的描述和抽象,還有DSL相關類。
  • Hooks:對網路配置相關類的hook(就是鉤子的意思,通常是用Swizzle替換系統方法或在系統方法中插入程式碼來實現某種功能),通過hook相關方法註冊GYMockURLProtocol類,使Request攔截生效。
  • Categories:對NSString和request的擴充套件,方便使用

看下原始碼的呼叫過程。

    mockRequest(@"GET", @"(.*?)feed/setting(.*?)".regex).
    andReturn(200).
    withBody(@"test.json");
複製程式碼

mockRequestGYMockRequest並不是繼承自NSURLRequest,它是對request的描述,GYMockRequestDSL起到了鏈式程式設計中傳值的作用,用且block代替方法(block和方法返回的都是GYMockRequestDSL物件)。在方法中建立的request被分別儲存在GYMockRequestDSL(用作GYMockRequest賦值)中和GYHttpMock(用作攔截請求)中。

GYMockRequestDSL *mockRequest(NSString *method, id url) {
    GYMockRequest *request = [[GYMockRequest alloc] initWithMethod:method urlMatcher:[GYMatcher GYMatcherWithObject:url]];
    GYMockRequestDSL *dsl = [[GYMockRequestDSL alloc] initWithRequest:request];
    [[GYHttpMock sharedInstance] addMockRequest:request];
    [[GYHttpMock sharedInstance] startMock];
    return dsl;
}
複製程式碼

GYHttpMock維護著如下兩個陣列,addMockRequest方法會將request儲存在stubbedRequests中儲存,

//儲存的request
@property (nonatomic, strong) NSMutableArray *stubbedRequests;
//需要hock的類,儲存類物件
@property (nonatomic, strong) NSMutableArray *hooks;
複製程式碼

並且在初始化時候判斷需要hook的類,此處因為低版本中無NSURLSession型別,所以新增次判斷

- (id)init
{
    self = [super init];
    if (self) {
        //初始化資料
        _stubbedRequests = [NSMutableArray array];
        _hooks = [NSMutableArray array];
        //註冊URLConnectionHook,
        [self registerHook:[[GYNSURLConnectionHook alloc] init]];
        if (NSClassFromString(@"NSURLSession") != nil) {
            //判斷是否有NSURLSession,如果有的則一樣註冊
            [self registerHook:[[GYNSURLSessionHook alloc] init]];
        }
    }
    return self;
}
複製程式碼

startMock方法開啟網路相關類的hook,這裡我們以GYNSURLSessionHook為例

//GYHttpMock.m
- (void)startMock
{
    if (!self.isStarted){
        [self loadHooks];
        self.started = YES;
    }
}
- (void)loadHooks {
    @synchronized(_hooks) {
        for (GYHttpClientHook *hook in _hooks) {
            //load hock
            [hook load];
        }
    }
}
複製程式碼

GYNSURLSessionHook 中是hook的NSURLSessionConfigurationprotocolClasses的get方法,它的作用是返回URL會話支援的公共網路協議,作用跟[NSURLProtocol registerClass:[myURLProtocol class]]一樣,目的是使我們自定義的NSURLProtocol生效,且不用使用者新增註冊子類的程式碼。

//GYNSURLSessionHook.m
@implementation GYNSURLSessionHook
- (void)load {
    Class cls = NSClassFromString(@"__NSCFURLSessionConfiguration") ?: NSClassFromString(@"NSURLSessionConfiguration");
    //修改NSURLSessionConfiguration中protocolClasses的返回,
    [self swizzleSelector:@selector(protocolClasses) fromClass:cls toClass:[self class]];
}

- (void)unload {
    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:@"Couldn`t load NSURLSession hook."];
    }
    method_exchangeImplementations(originalMethod, stubMethod);
}
//更改URL會話支援的公共網路協議
- (NSArray *)protocolClasses {
    return @[[GYMockURLProtocol class]];
}
@end
複製程式碼

mockRequest(@"GET", @"(.*?)feed/setting(.*?)".regex)方法完成了GYMockURLProtocol的註冊,並且把篩選條件(@”get”,@”(.?)feed/setting(.?)”.regex)都儲存到GYMockRequest中了。
接下來的andReturn(200).withBody(@"test.json");一樣是通過中間類傳入響應資料,生成GYMockResponse的物件,並儲存在了對應的GYMockResponse物件中,不再贅述,接下來看下攔截請求的程式碼。

//GYMockURLProtocol.m
+ (BOOL)canInitWithRequest:(NSURLRequest *)request {
    [[GYHttpMock sharedInstance] log:@"mock request: %@", request];
    //根據request判斷是否可以傳送網路請求
    GYMockResponse* stubbedResponse = [[GYHttpMock sharedInstance] responseForRequest:(id<GYHTTPRequest>)request];
    if (stubbedResponse && !stubbedResponse.shouldNotMockAgain) {
        return YES;
    }
    return NO;
}

//GYHttpMock.m
//獲取request對應的respond
- (GYMockResponse *)responseForRequest:(id<GYHTTPRequest>)request
{
    @synchronized(_stubbedRequests) {
        
        for(GYMockRequest *someStubbedRequest in _stubbedRequests) {
            if ([someStubbedRequest matchesRequest:request]) {
                someStubbedRequest.response.isUpdatePartResponseBody = someStubbedRequest.isUpdatePartResponseBody;
                return someStubbedRequest.response;
            }
        }
        
        return nil;
    }
    
}
複製程式碼

判斷髮送的request是否需要攔截,如果在_stubbedRequests中可以匹配到,則返回我們自定義的response,並攔截,否則不攔截。
接下來是重中之重,開始請求

- (void)startLoading {
    NSURLRequest* request = [self request];
    id<NSURLProtocolClient> client = [self client];
    
    GYMockResponse* stubbedResponse = [[GYHttpMock sharedInstance] responseForRequest:(id<GYHTTPRequest>)request];
    
    if (stubbedResponse.shouldFail) {
        [client URLProtocol:self didFailWithError:stubbedResponse.error];
    }
    else if (stubbedResponse.isUpdatePartResponseBody) {
        stubbedResponse.shouldNotMockAgain = YES;
        NSOperationQueue *queue = [[NSOperationQueue alloc]init];
        [NSURLConnection sendAsynchronousRequest:request
                                           queue:queue
                               completionHandler:^(NSURLResponse *response, NSData *data, NSError *error){
                                   if (error) {
                                       NSLog(@"Httperror:%@%@", error.localizedDescription,@(error.code));
                                       [client URLProtocol:self didFailWithError:[NSError errorWithDomain:NSCocoaErrorDomain code:NSUserCancelledError userInfo:nil]];
                                   }else{
                                       
                                       id json = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingMutableContainers error:&error];
                                       NSMutableDictionary *result = [NSMutableDictionary dictionaryWithDictionary:json];
                                       if (!error && json) {
                                           NSDictionary *dict = [NSJSONSerialization JSONObjectWithData:stubbedResponse.body options:NSJSONReadingMutableContainers error:nil];
                                           
                                           [self addEntriesFromDictionary:dict to:result];
                                       }
                                       
                                       NSData *combinedData = [NSJSONSerialization dataWithJSONObject:result options:NSJSONWritingPrettyPrinted error:nil];
                                       
                                       
                                       [client URLProtocol:self didReceiveResponse:response
                                        cacheStoragePolicy:NSURLCacheStorageNotAllowed];
                                       [client URLProtocol:self didLoadData:combinedData];
                                       [client URLProtocolDidFinishLoading:self];
                                   }
                                   stubbedResponse.shouldNotMockAgain = NO;
                               }];
        
    }
    else {
        NSHTTPURLResponse* urlResponse = [[NSHTTPURLResponse alloc] initWithURL:request.URL statusCode:stubbedResponse.statusCode HTTPVersion:@"1.1" headerFields:stubbedResponse.headers];
        
        if (stubbedResponse.statusCode < 300 || stubbedResponse.statusCode > 399
            || stubbedResponse.statusCode == 304 || stubbedResponse.statusCode == 305 ) {
            NSData *body = stubbedResponse.body;
            
            [client URLProtocol:self didReceiveResponse:urlResponse
             cacheStoragePolicy:NSURLCacheStorageNotAllowed];
            [client URLProtocol:self didLoadData:body];
            [client URLProtocolDidFinishLoading:self];
        } else {
            NSHTTPCookieStorage *cookieStorage = [NSHTTPCookieStorage sharedHTTPCookieStorage];
            [cookieStorage setCookies:[NSHTTPCookie cookiesWithResponseHeaderFields:stubbedResponse.headers forURL:request.URL]
                               forURL:request.URL mainDocumentURL:request.URL];
            
            NSURL *newURL = [NSURL URLWithString:[stubbedResponse.headers objectForKey:@"Location"] relativeToURL:request.URL];
            NSMutableURLRequest *redirectRequest = [NSMutableURLRequest requestWithURL:newURL];
            
            [redirectRequest setAllHTTPHeaderFields:[NSHTTPCookie requestHeaderFieldsWithCookies:[cookieStorage cookiesForURL:newURL]]];
            
            [client URLProtocol:self
         wasRedirectedToRequest:redirectRequest
               redirectResponse:urlResponse];
            // According to: https://developer.apple.com/library/ios/samplecode/CustomHTTPProtocol/Listings/CustomHTTPProtocol_Core_Code_CustomHTTPProtocol_m.html
            // needs to abort the original request
            [client URLProtocol:self didFailWithError:[NSError errorWithDomain:NSCocoaErrorDomain code:NSUserCancelledError userInfo:nil]];
            
        }
    }
}
複製程式碼

這裡的邏輯是,判斷我們對response是部分修改還是全部修改,

  • 部分修改:發出網路請求,並根據預設的response要求修改返回值,通過client返回給原來請求的位置。
  • 全部修改:直接根據預設的條件建立response,並通過client返回給原來請求的位置。

最後

總體的流程是這樣的,當然還有細節值得推敲,大神們如果發現問題,還請及時指出哦!

相關文章