GYHttpMock:iOS HTTP请求模拟工具

GYHttpMock 是刚开源的 iOS 请求模拟工具,用于iOS App网络层开发,可以截获指定的 HTTP request,并根据规则,完全替换或部分修改真实的网络返回数据。

背景

iOS App开发过程中,前台开发过程通常都是并行进行的,因此难免会出现一些客户端需要等待后台开发联调的情景,等待的过程往往痛若而无奈(后台被催得痛苦,前端无奈等待)。通常解决办法是,客户端在某处 hardcode 网络返回数据,当然,一不小心,这种测试代码被提交到了线上也是常有的事情。还有更“高级”一点,通过设置代理,用抓包工具修改网络数据,但这种效率低得令人抓狂。

引入一个可以模拟网络请求的工具似乎就可以轻松满足需求,但实践证明,“模拟网络请求”这个需求并不简单。例如对于全新的业务,后台如果还没有数据,前端完全可以根据协议自己制造假数据返回。但是,很多情况下,可能是对已有业务的变更,也就是需要修改后台已有的业务数据。

业界解决方案

为了满足开发过程中模拟网络请求的需求,HttpMock 工具应运而生,目前业界已经有许多不同的实现方式,基本可以分为两类:

1.自建HTTP Server

可以在本地搭建 HTTP Server 模拟返回客户端所需要的数据。以 hibri/HttpMock 为例,它就是在本地搭建了一个HTTP Mock Server,然后根据需求返回指定数据。对于不需要模拟的请求,直接到达真实的Server,需要模拟的请求就转向MockServer。

这种方案的优势在于可以应用于多平台,也可以用各种语言来实现。但是局限性在于,要建立一个 HTTP Server,一方面得自己搭建并维护这个 Server,对于使用者的门槛较高,另一方面,使用时需要一边修改客户端代码,一边切换到Server环境修改返回数据,比较麻烦。此外这种方案只能选择替换或不替换,无法做到替换某个请求返回的数据。

2.客户端截获

客户端可以在网络层截获自己的网络请求,然后返回指定数据。这种方式实现的 HttpMock 更加灵活,但是不同的客户端实现方式会完全不一样。实现原理是 Hook 系统网络层的请求分发,对于符合规则的 http request 进行拦截,然后用之前定义的数据直接回调给上层,并不发出真实的请求。

iOS 上目前应用比较广泛的是OHHTTPStubsNocilla,这两种实现的功能都类似。Nocilla选择用领域专用语言(DSL)的形式创建模拟请求,更容易理解,但是mock的功能需要应用中主动开启和关闭,一旦开启或关闭会影响应用中所有的HTTP请求。OHHTTPStubs 安装后自动启动,根据 request 自动判断是否需要截获。但目前这些开源库都未能做到灵活修改网络返回的数据。

GYHttpMock 优势

GYHttpMock 采用客户端截获的方式,在 Nocilla DSL 特性基础上,同时学习OHHTTPStubs的自动开启和识别,实现了 http response 的部分替换功能。具体优势:

  • 支持部分替换 HTTP Response,也就是可以修改真实网络返回的数据,这是相对于其它 HttpMock 独有的核心功能。
  • 客户端引入 GYHttpMock 后,只需一行代码就可以截获指定请求,并返回所需要的数据。不需服务端支持,也不需要建立本地HTTP Server。
  • 支持 NSURLConnection, NSURLSession,AFNetworking 以及所有采用 iOS Cocoa URL 加载方式的网络框架。
  • 支持正则匹配 HTTP Request,这样一条 httpMock 可以同时支持多个请求。
  • mocked response 支持 json 内容的文件。一般情况下,mocked response 直接用 NSString 表达会比较清晰,但是返回内容比较多的情况下,因为转义符的原因,将内容以 json 格式写入文件会更容易些。

使用

安装

直接将 GYHttpMock 的源文件加入项目中即可。也可以通过 CocoaPods 的方式接入。

应用

在需要拦截的请求之前创建正确的mockRequest:

1.创建一个最简单的 mockRequest。截获应用中访问 www.weread.com 的 get 请求,并返回一个 response body为空的数据。

1
mockRequest(@"GET", @"http://www.weread.com");

2.创建一个拦截条件更复杂的 mockRequest。截获应用中 url 包含 weread.com,而且包含了 name=abc 的参数

1
2
mockRequest(@"GET", @"(.*?)weread.com(.*?)".regex).
withBody(@"{\"name\":\"abc\"}".regex);

3.创建一个指定返回数据的 mockRequest。withBody的值也可以是某个 xxx.json 文件,不过这个 json 文件需要加入到项目中。

1
2
3
4
mockRequest(@"POST", @"http://www.weread.com").
withBody(@"{\"name\":\"abc\"}".regex);
andReturn(200).
withBody(@"{\"key\":\"value\"}");

4.创建一个修改部分返回数据的 mockRequest。这里会根据 weread.json 的内容修改正常网络返回的数据

1
2
3
4
5
mockRequest(@"POST", @"http://www.weread.com").
isUpdatePartResponseBody(YES).
withBody(@"{\"name\":\"abc\"}".regex);
andReturn(200).
withBody(@“weread.json");

假设正常网络返回的原始数据是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
{"data": [ {
"bookId":"0000001",
"updated": [
{
"chapterIdx": 1,
"title": "序言",
},
{
"chapterIdx": 2,
"title": "第2章",
}
]
}]
}

weread.json的内容是这样:

1
2
3
4
5
6
7
{"data": [{
"updated": [
{
"hello":"world"
}

]
}]
}

修改后的数据就会就成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{"data": [ {
"bookId":"0000001",
"updated": [
{
"chapterIdx": 1,
"title": "序言",
"hello":"world"
},

{
"chapterIdx": 2,
"title": "第2章",
"hello":"world"
}

]
}]
}

GYHttpMock会根据 weread.json指定的层次结构来修改原始数据,前提是 wearied.json的数据结构需要和正常的返回数据一致,否则会导致修改失败或者不可预知的错误。

实现原理

GYHttpMock的工作流程如下:

其核心实现主要包括request匹配、request拦截、response替换三个部分。

request匹配

用于判断应用中的某个HTTP Request是否应该被mock。判断的条件包括method、URL、Headers、Body,其中URL和Body都支持正规匹配的方式,一个httpMock可以同时匹配多个HTTP Request。

request拦截

request拦截是通过继承NSURLProtocol的子类来实现。NSURLProtocol是iOS URL网络加载中功能非常强大的一个类,官方文档也有说明NSURLProtocol,通过重写它的方法,可以重新定义系统网络加载行为。在此之前,对于NSURLConnection的网络请求,需要这样注册NSURLProtocol的子类GYMockURLProtocol

1
[NSURLProtocol registerClass:[GYMockURLProtocol class]];

对于NSURLSession的网络请求,需要替换protocolClasses方法

1
2
Class cls = NSClassFromString(@"__NSCFURLSessionConfiguration") ?: NSClassFromString(@"NSURLSessionConfiguration");
[self swizzleSelector:@selector(protocolClasses) fromClass:cls toClass:[self class]];

最后,重点是重写NSURLProtocol类的canInitWithRequeststartLoading方法。canInitWithRequest是用于判断是否可以发起网络请求,可以通过这个过滤不在拦截范围内的request,不影响App的正常网络请求。startLoading是替换response数据的核心所在,成功截拦的request会进入该方法,在这个方法中替换或修改response数据,再回调给上层。

response替换

对于需要全部替换的response,实现方式是在startLoading方法中调中NSURLProtocolURLProtocol:didReceiveResponse:cacheStoragePolicy:方法,将替换好的response回调给上层。对于需要部分替换的response,GYHttpMock会用NSURLConnection的方式,发起一次真正的网络请求,待数据回来后,再与mockRequest中的response数据进行合并,最后将合并后的数据回调上层。部分替换过程中遇到两个问题:

  1. 部分替换时要发出一个真实网络请求拿到原始数据,这个请求按照之前的规则又会被NSURLProtocol截获,从而进入死循环。解决办法是,start request前将这个GYHttpRequest打上标记,表明是不需要再次截获的,等拿到reponse后再将GYHttpRequest上的标记去掉,避免死循环。

  2. 两个response内容合并的问题。因为json的数据结构非常灵活,可以任意层次嵌套,如何指定修改或添加某个节点下的数据是比较困难的,尤其是json中数组的嵌套,导致要指定修改数组中某个位置的元素变得非常困难。GYHttpMock采用的方式是,在mockRequest的response中指出需要修改的节点完整位置,然后用这个数据结构去匹配目标数据(具体算法请查看GYHttpMock源码,好处在于可以支持比较复杂的数据结构,但这就要求使用者对目标数据结构非常清楚。

GYHttpMock已经在GitHub开源,目前已用于微信读书项目中,使用过程如果有问题或者建议,欢迎提交 issue 和 pull request。