GYHttpMock:使用及源码解析

背景

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)request];
    if (stubbedResponse && !stubbedResponse.shouldNotMockAgain) {
        return YES;
    }
    return NO;
}

//GYHttpMock.m
//获取request对应的respond
- (GYMockResponse *)responseForRequest:(id)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 client = [self client];
    
    GYMockResponse* stubbedResponse = [[GYHttpMock sharedInstance] responseForRequest:(id)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返回给原来请求的位置。

最后

总体的流程是这样的,当然还有细节值得推敲,大神们如果发现问题,还请及时指出哦!

你可能感兴趣的:(GYHttpMock:使用及源码解析)