GYHttpMock 是刚开源的 iOS 请求模拟工具,用于iOS App网络层开发,可以截获指定的 HTTP request,并根据规则,完全替换或部分修改真实的网络返回数据。
iOS App开发过程中,前台开发过程通常都是并行进行的,因此难免会出现一些客户端需要等待后台开发联调的情景,等待的过程往往痛若而无奈(后台被催得痛苦,前端无奈等待)。通常解决办法是,客户端在某处 hardcode 网络返回数据,当然,一不小心,这种测试代码被提交到了线上也是常有的事情。还有更“高级”一点,通过设置代理,用抓包工具修改网络数据,但这种效率低得令人抓狂。
引入一个可以模拟网络请求的工具似乎就可以轻松满足需求,但实践证明,“模拟网络请求”这个需求并不简单。例如对于全新的业务,后台如果还没有数据,前端完全可以根据协议自己制造假数据返回。但是,很多情况下,可能是对已有业务的变更,也就是需要修改后台已有的业务数据。
为了满足开发过程中模拟网络请求的需求,HttpMock 工具应运而生,目前业界已经有许多不同的实现方式,基本可以分为两类:
可以在本地搭建 HTTP Server 模拟返回客户端所需要的数据。以 hibri/HttpMock 为例,它就是在本地搭建了一个HTTP Mock Server,然后根据需求返回指定数据。对于不需要模拟的请求,直接到达真实的Server,需要模拟的请求就转向MockServer。
这种方案的优势在于可以应用于多平台,也可以用各种语言来实现。但是局限性在于,要建立一个 HTTP Server,一方面得自己搭建并维护这个 Server,对于使用者的门槛较高,另一方面,使用时需要一边修改客户端代码,一边切换到Server环境修改返回数据,比较麻烦。此外这种方案只能选择替换或不替换,无法做到替换某个请求返回的数据。
客户端可以在网络层截获自己的网络请求,然后返回指定数据。这种方式实现的 HttpMock 更加灵活,但是不同的客户端实现方式会完全不一样。实现原理是 Hook 系统网络层的请求分发,对于符合规则的 http request 进行拦截,然后用之前定义的数据直接回调给上层,并不发出真实的请求。
iOS 上目前应用比较广泛的是 OHHTTPStubs 和 Nocilla ,这两种实现的功能都类似。Nocilla选择用领域专用语言(DSL)的形式创建模拟请求,更容易理解,但是mock的功能需要应用中主动开启和关闭,一旦开启或关闭会影响应用中所有的HTTP请求。OHHTTPStubs 安装后自动启动,根据 request 自动判断是否需要截获。但目前这些开源库都未能做到灵活修改网络返回的数据。
GYHttpMock 采用客户端截获的方式,在 Nocilla DSL 特性基础上,同时学习OHHTTPStubs的自动开启和识别,实现了 http response 的部分替换功能。具体优势:
直接将 GYHttpMock 的源文件加入项目中即可。也可以通过 CocoaPods 的方式接入。
在需要拦截的请求之前创建正确的mockRequest:
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"
}
]
}]}
GYHttpMock会根据 weread.json
指定的层次结构来修改原始数据,前提是 wearied.json
的数据结构需要和正常的返回数据一致,否则会导致修改失败或者不可预知的错误。
GYHttpMock的工作流程如下:
其核心实现主要包括request匹配、request拦截、response替换三个部分。
用于判断应用中的某个HTTP Request是否应该被mock。判断的条件包括method、URL、Headers、Body,其中URL和Body都支持正规匹配的方式,一个httpMock可以同时匹配多个HTTP Request。
request拦截是通过继承 NSURLProtocol
的子类来实现。 NSURLProtocol
是iOS URL网络加载中功能非常强大的一个类,官方文档也有说明 NSURLProtocol ,通过重写它的方法,可以重新定义系统网络加载行为。在此之前,对于 NSURLConnection
的网络请求,需要这样注册 NSURLProtocol
的子类 GYMockURLProtocol
[NSURLProtocol registerClass:[GYMockURLProtocol class]];
对于 NSURLSession
的网络请求,需要替换 protocolClasses
方法
Class cls = NSClassFromString(@"__NSCFURLSessionConfiguration") ?: NSClassFromString(@"NSURLSessionConfiguration");
[self swizzleSelector:@selector(protocolClasses) fromClass:cls toClass:[self class]];
最后,重点是重写 NSURLProtocol
类的 canInitWithRequest
和 startLoading
方法。 canInitWithRequest
是用于判断是否可以发起网络请求,可以通过这个过滤不在拦截范围内的request,不影响App的正常网络请求。 startLoading
是替换response数据的核心所在,成功截拦的request会进入该方法,在这个方法中替换或修改response数据,再回调给上层。
对于需要全部替换的response,实现方式是在 startLoading
方法中调中 NSURLProtocol
的 URLProtocol:didReceiveResponse:cacheStoragePolicy:
方法,将替换好的response回调给上层。对于需要部分替换的response,GYHttpMock会用NSURLConnection的方式,发起一次真正的网络请求,待数据回来后,再与mockRequest中的response数据进行合并,最后将合并后的数据回调上层。部分替换过程中遇到两个问题:
部分替换时要发出一个真实网络请求拿到原始数据,这个请求按照之前的规则又会被NSURLProtocol截获,从而进入死循环。解决办法是,start request前将这个GYHttpRequest打上标记,表明是不需要再次截获的,等拿到reponse后再将GYHttpRequest上的标记去掉,避免死循环。
两个response内容合并的问题。因为json的数据结构非常灵活,可以任意层次嵌套,如何指定修改或添加某个节点下的数据是比较困难的,尤其是json中数组的嵌套,导致要指定修改数组中某个位置的元素变得非常困难。GYHttpMock采用的方式是,在mockRequest的response中指出需要修改的节点完整位置,然后用这个数据结构去匹配目标数据(具体算法请查看 GYHttpMock源码 ,好处在于可以支持比较复杂的数据结构,但这就要求使用者对目标数据结构非常清楚。
GYHttpMock已经在 GitHub 开源,目前已用于 微信读书 项目中,使用过程如果有问题或者建议,欢迎提交 issue 和 pull request。