# NSURLProtocol 应用场景 > 在做 Hybrid 的时候就使用到 NSURLProtocol,对于网络监控依旧可以使用它,所以本文就总结下 NSURLProtocol 的应用场景和如何用 ## 一、 NSURLProtocol 是什么 > An NSURLProtocol object handles the loading of protocol-specific URL data. The NSURLProtocol class itself is an abstract class that provides the infrastructure for processing URLs with a specific URL scheme. You create subclasses for any custom protocols or URL schemes that your app supports. ![URL Loading System](https://raw.githubusercontent.com/FantasticLBP/knowledge-kit/master/assets//2020-04-18-URL-loading-system.png) NSURLProtocol 是 Foundation 框架中 URL Loading System 的一部分。它可以让开发者可以在不修改应用内原始请求代码的情况下,去改变 URL 加载的全部细节。换句话说,NSURLProtocol 是一个被 Apple 默许的中间人攻击。 虽然 NSURLProtocol 叫“Protocol”,却不是协议,而是一个抽象类。 既然 NSURLProtocol 是一个抽象类,说明它无法被实例化,那么它又是如何实现网络请求拦截的? 答案就是通过子类化来定义新的或是已经存在的 URL 加载行为。如果当前的网络请求是可以被拦截的,那么开发者只需要将一个自定义的 NSURLProtocol 子类注册到 App 中,在这个子类中就可以拦截到所有请求并进行修改。 ## 二、NSURLProtocol 使用场景 ### 1. 技术层面 NSURLProtocol 是 URL Loading System 的一部分,所以它可以拦截所有基于 URL Loading System 的网络请求: - NSURLSession - NSURLConnection - NSURLDownload - NSURLResponse - NSHTTPURLResponse - NSURLRequest - NSMutableURLRequest 所以,基础这些基础技术开发的网络框架比如 AFNetworking、Alamofire 也可以拦截。 想到了2种场景不能拦截: - 早期使用 CFNetwork 实现的 ASIHTTPRequest 框架就无法拦截 - UIWebView 也是可以被拦截的。但是 WKWebView 是基于 webkit,不走底层 c socket。 ### 2. 需求层面 #### 2.1 Hybrid - 对 webview 上运行的资源进行监控(大小、时间等) - Native 代理 WebView 上面的图片资源,使用和客户端一致的图片管理策略(比如 SDWebImage 管理) - 访问提速。App 在 cd 阶段打包内置了项目技术栈的框架库、样式库、一些业务频道的基础包。配合一定的资源更新策略就可以给 Hybrid 提速 - 代理 WebView 上的网络请求。Native 针对网络请求具备更高的安全性和灵活性、网络请求收口策略 #### 2.2 针对网络进行监控 - 对 App 内的网络请求进行重定向,解决 DNS 域名劫持问题 - 针对全局网络请求设置。比如缓存管理、请求地址修改、header - App 网络安全性。设置 App 网络白名单 - 自定义网络请求,过滤垃圾内容 - H5 加速,请求走本地离线包 ## 三、NSURLProtocol 的相关方法 创建协议对象 ```Objective-c // 创建一个 URL 协议实例来处理 request 请求 - (instancetype)initWithRequest:(NSURLRequest *)request cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id)client; // 创建一个 URL 协议实例来处理 session task 请求 - (instancetype)initWithTask:(NSURLSessionTask *)task cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id)client; ``` 注册和注销协议类 ```Objective-c // 尝试注册 NSURLProtocol 的子类,使之在 URL 加载系统中可见 + (BOOL)registerClass:(Class)protocolClass; // 注销 NSURLProtocol 的指定子类 + (void)unregisterClass:(Class)protocolClass; ``` 确定子类是否可以处理请求 子类化 NSProtocol 的首要任务就是告知它,需要控制什么类型的网络请求。 ```objective-c // 确定协议子类是否可以处理指定的 request 请求,如果返回 YES,请求会被其控制,返回 NO 则直接跳入下一个 protocol + (BOOL)canInitWithRequest:(NSURLRequest *)request; // 确定协议子类是否可以处理指定的 task 请求 + (BOOL)canInitWithTask:(NSURLSessionTask *)task; ``` 获取和设置请求属性 NSURLProtocol 允许开发者去获取、添加、删除 request 对象的任意元数据。这几个方法常用来处理请求无限循环的问题。 ```Objective-c // 在指定的请求中获取与指定键关联的属性 + (id)propertyForKey:(NSString *)key inRequest:(NSURLRequest *)request; // 设置与指定请求中的指定键关联的属性 + (void)setProperty:(id)value forKey:(NSString *)key inRequest:(NSMutableURLRequest *)request; // 删除与指定请求中的指定键关联的属性 + (void)removePropertyForKey:(NSString *)key inRequest:(NSMutableURLRequest *)request; ``` 提供请求的规范版本 如果你想要用特定的某个方式来修改请求,可以用下面这个方法。 ```Objective-c // 返回指定请求的规范版本 + (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request; ``` 确定请求是否相同 ```Objective-c // 判断两个请求是否相同,如果相同可以使用缓存数据,通常只需要调用父类的实现 + (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a toRequest:(NSURLRequest *)b; ``` 启动和停止加载 这是子类中最重要的两个方法,不同的自定义子类在调用这两个方法时会传入不同的内容,但共同点都是围绕 protocol 客户端进行操作。 ```Objective-c // 开始加载 - (void)startLoading; // 停止加载 - (void)stopLoading; ``` 获取协议属性 ```Objective-c // 获取协议接收者的缓存 - (NSCachedURLResponse *)cachedResponse; // 接受者用来与 URL 加载系统通信的对象,每个 NSProtocol 的子类实例都拥有它 - (id)client; // 接收方的请求 - (NSURLRequest *)request; // 接收方的任务 - (NSURLSessionTask *)task; ``` ## 四、 如何利用 NSProtocol 拦截网络请求 NSURLProtocol 在实际应用中,主要是完成两步:拦截 URL 和 URL 转发。先来看如何拦截网络请求。 **创建 NSURLProtocol 子类** 这里创建一个名为 HTCustomURLProtocol 的子类。 ```Objective-c @interface HTCustomURLProtocol : NSURLProtocol @end ``` **注册 NSURLProtocol 的子类** 在合适的位置注册这个子类。对基于 NSURLConnection 或者使用 [NSURLSession sharedSession] 初始化对象创建的网络请求,调用 registerClass 方法即可。 ```objective-c [NSURLProtocol registerClass:[NSClassFromString(@"CustomURLProtocol") class]]; // or // [NSURLProtocol registerClass:[HTCustomURLProtocol class]]; ``` 如果需要全局监听,可以设置在 `AppDelegate.m` 的 `didFinishLaunchingWithOptions` 方法中。如果只需要在单个 UIViewController 中使用,记得在合适的时机注销监听: ```objective-c [NSURLProtocol unregisterClass:[NSClassFromString(@"HTCustomURLProtocol") class]]; ``` 如果是基于 `NSURLSession` 的网络请求,且不是通过 `[NSURLSession sharedSession]` 方式创建的,就得配置 `NSURLSessionConfiguration` 对象的 `protocolClasses` 属性。 ```objective-c NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration]; config.protocolClasses = @[[NSClassFromString(@"CustomProtocol") class]]; NSURLSession *session = [NSURLSession sessionWithConfiguration:config]; ``` **实现 NSURLProtocol 子类** > 注册 → 拦截 → 转发 → 回调 → 结束 以拦截 UIWebView 为例,这里需要重写父类的这五个核心方法。 ```objective-c // 定义一个协议 key static NSString * const HTCustomURLProtocolHandledKey = @"HTCustomURLProtocolHandledKey"; // 在拓展中定义一个 NSURLConnection 属性。通过 NSURLSession 也可以拦截,这里只是以 NSURLConnection 为例。 @property (nonatomic, strong) NSURLConnection *connection; // 定义一个可变的请求返回值, @property (nonatomic, strong) NSMutableData *responseData; // 方法 1:在拦截到网络请求后会调用这一方法,可以再次处理拦截的逻辑,比如设置只针对 http 和 https 的请求进行处理。 + (BOOL)canInitWithRequest:(NSURLRequest *)request { // 只处理 http 和 https 请求 NSString *scheme = [[request URL] scheme]; if ( ([scheme caseInsensitiveCompare:@"http"] == NSOrderedSame || [scheme caseInsensitiveCompare:@"https"] == NSOrderedSame)) { // 看看是否已经处理过了,防止无限循环 if ([NSURLProtocol propertyForKey:HTCustomURLProtocolHandledKey inRequest:request]) { return NO; } // 如果还需要截取 DNS 解析请求中的链接,可以继续加判断,是否为拦截域名请求的链接,如果是返回 NO return YES; } return NO; } // 方法 2:【关键方法】可以在此对 request 进行处理,比如修改地址、提取请求信息、设置请求头等。 + (NSURLRequest *) canonicalRequestForRequest:(NSURLRequest *)request { // 可以打印出所有的请求链接包括 CSS 和 Ajax 请求等 NSLog(@"request.URL.absoluteString = %@",request.URL.absoluteString); NSMutableURLRequest *mutableRequest = [request mutableCopy]; return mutableRequest; } // 方法 3:【关键方法】在这里设置网络代理,重新创建一个对象将处理过的 request 转发出去。这里对应的回调方法对应 协议方法 - (void)startLoading { // 可以修改 request 请求 NSMutableURLRequest *mutableRequest = [[self request] mutableCopy]; // 打 tag,防止递归调用 [NSURLProtocol setProperty:@YES forKey:HTCustomURLProtocolHandledKey inRequest:mutableRequest]; // 也可以在这里检查缓存 // 将 request 转发,对于 NSURLConnection 来说,就是创建一个 NSURLConnection 对象;对于 NSURLSession 来说,就是发起一个 NSURLSessionTask。 self.connection = [NSURLConnection connectionWithRequest:mutableRequest delegate:self]; } // 方法 4:主要判断两个 request 是否相同,如果相同的话可以使用缓存数据,通常只需要调用父类的实现。 + (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a toRequest:(NSURLRequest *)b { return [super requestIsCacheEquivalent:a toRequest:b]; } // 方法 5:处理结束后停止相应请求,清空 connection 或 session - (void)stopLoading { if (self.connection != nil) { [self.connection cancel]; self.connection = nil; } } // 按照在上面的方法中做的自定义需求,看情况对转发出来的请求在恰当的时机进行回调处理。 #pragma mark- NSURLConnectionDelegate - (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error { [self.client URLProtocol:self didFailWithError:error]; } #pragma mark - NSURLConnectionDataDelegate // 当接收到服务器的响应(连通了服务器)时会调用 - (void)connection:(NSURLConnection *)connection didReceiveResponse:(NSURLResponse *)response { self.responseData = [[NSMutableData alloc] init]; self.internalResponse = response; // 可以处理不同的 statusCode 场景 // NSInteger statusCode = [(NSHTTPURLResponse *)response statusCode]; // 可以设置 Cookie [self.client URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageNotAllowed]; } // 接收到服务器的数据时会调用,可能会被调用多次,每次只传递部分数据 - (void)connection:(NSURLConnection *)connection didReceiveData:(NSData *)data { [self.responseData appendData:data]; [self.client URLProtocol:self didLoadData:data]; } // 服务器的数据加载完毕后调用 - (void)connectionDidFinishLoading:(NSURLConnection *)connection { [self.client URLProtocolDidFinishLoading:self]; } // 请求错误(失败)的时候调用,比如出现请求超时、断网,一般指客户端错误 - (void)connection:(NSURLConnection *)connection didFailWithError:(NSError *)error { [self.client URLProtocol:self didFailWithError:error]; } - (NSURLRequest *)connection:(NSURLConnection *)connection willSendRequest:(NSURLRequest *)request redirectResponse:(NSURLResponse *)response { if (response != nil) { [[self client] URLProtocol:self wasRedirectedToRequest:request redirectResponse:response]; } return request; } - (BOOL)connectionShouldUseCredentialStorage:(NSURLConnection *)connection { return YES; } - (BOOL)connection:(NSURLConnection *)connection canAuthenticateAgainstProtectionSpace:(NSURLProtectionSpace *)protectionSpace { return [protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]; } - (void)connection:(NSURLConnection *)connection didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge { if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) { [[challenge sender] useCredential:[NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust] forAuthenticationChallenge:challenge]; [[challenge sender] continueWithoutCredentialForAuthenticationChallenge:challenge]; } [self.client URLProtocol:self didReceiveAuthenticationChallenge:challenge]; } - (void) connection:(NSURLConnection *)connection didCancelAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge { [self.client URLProtocol:self didCancelAuthenticationChallenge:challenge]; } - (NSCachedURLResponse *)connection:(NSURLConnection *)connection willCacheResponse:(NSCachedURLResponse *)cachedResponse { return cachedResponse; } ``` 注意:NSURLConnection 已经被废弃,推荐使用 NSURLSession 进行网络请求,它好处多多,具体的自行查阅官方介绍。 ## 五、 读源码,学习 NSURLProtocol iOS 中网络测试框架 [ OHHTTPStubs](https://github.com/AliSoftware/OHHTTPStubs)的实现就是利用了 NSURLProtocol 实现的。 几个主要类及其功能:HTTPStubsProtocol 拦截网络请求;HTTPStubs 单例管理 HTTPStubsDescriptor 实例对象;HTTPStubsResponse 伪造 HTTP 请求。 HTTPStubsProtocol 继承自 NSURLProtocol,可以在 HTTP 请求发送之前对 request 进行过滤处理 ```objective-c + (BOOL)canInitWithRequest:(NSURLRequest *)request { BOOL found = ([HTTPStubs.sharedInstance firstStubPassingTestForRequest:request] != nil); if (!found && HTTPStubs.sharedInstance.onStubMissingBlock) { HTTPStubs.sharedInstance.onStubMissingBlock(request); } return found; } ``` `firstStubPassingTestForRequest` 方法内部会判断请求是否需要被当前对象处理 紧接着开始发送网络请求。实际上在 `- (void)startLoading` 方法中可以用任何网络能力去完成请求,比如 NSURLSession、NSURLConnection、AFNetworking 或其他网络框架。OHHTTPStubs 的做法是获取 request、client 对象。如果 HTTPStubs 单例中包含 `onStubActivationBlock` 对象,则执行该 block,然后利用 responseBlock 对象返回一个 HTTPStubsResponse 响应对象。 ## 六、 补充内容 ### 1. 使用 NSURLSession 时的注意事项 如果在 NSURLProtocol 中使用 NSURLSession,需要注意: • 拦截到的 request 请求的 HTTPBody 为 nil,但可以借助 HTTPBodyStream 来获取 body; • 如果要用 registerClass 注册,只能通过 ` [NSURLSession sharedSession] `的方式创建网络请求。 ### 2. 注册多个 NSURLProtocol 子类 当有多个自定义 NSURLProtocol 子类注册到系统中的话,会按照他们注册的反向顺序依次调用 URL 加载流程,也就是最后注册的 NSURLProtocol 会被优先判断。 对于通过配置 NSURLSessionConfiguration 对象的 protocolClasses 属性来注册的情况,protocolClasses 数组中只有第一个 NSURLProtocol 会起作用,后续的 NSURLProtocol 就无法拦截到了。 ### 3. 如何拦截 WKWebview WKWebView 在独立于 app 进程之外的进程中执行网络请求,请求数据不经过主进程,因此,在 WKWebView 上直接使用 NSURLProtocol 无法拦截请求。苹果开源的 webKit2 源码暴露了 私有API : ```objective-c + [WKBrowsingContextController registerSchemeForCustomProtocol:] ``` 通过注册 http(s) scheme 后 WKWebView 将可以使用 NSURLProtocol 拦截 http(s) 请求。涉及到的私有 api :`WKBrowsingContextControoler` 、`registerSchemeForCustomProtocol` ```objective-c Class cls = NSClassFromString(@"WKBrowsingContextController"); SEL sel = NSSelectorFromString(@"registerSchemeForCustomProtocol:"); if ([cls respondsToSelector:sel]) { // 通过 http 和 https 的请求,同理可通过其他的 Scheme 但是要满足 URL Loading System [cls performSelector:sel withObject:@"http"]; [cls performSelector:sel withObject:@"https"]; } ``` 因为使用了私有 api,所以会无法过审,我们可以对字符串进行处理,比如对方法名进行加密。 该方案还存在2个严重缺陷: 1. post 请求 body 数据被清空 由于 WKWebview 在独立的进程中执行网络请求,一旦注册 registerSchemeForCustomProtocol http(https) scheme 后,网络请求将从 Network Process 发送到 App Process,这样 NSURLProtocol 才能拦截网络请求。在 Webkit2 的设计里使用 **MessageQueue** 进行进程间通信, Network Process 会将请求 encode 成一个 message,然后通过 IPC 发送给 App Process,出于性能角度的考虑,encode 的时候 HTTPBody 和 HTTPBodyStream 这2个字段被丢弃掉了。可以查看 [webkit2 源码](https://github.com/WebKit/webkit/blob/fe39539b83d28751e86077b173abd5b7872ce3f9/Source/WebKit2/Shared/mac/WebCoreArgumentCodersMac.mm#L61-L88)。 2. 对 ATS 支持不足 info.plist 中打开 ATS 开关,设置 Allow Arbitrary Loads 选项为 NO,设置 registerSchemeForCustomProtocol 注册了 http(https) scheme,WKWebView 发起的所有 http 网络请求将被阻塞(即使 Allow Arbitrary Loads in Web Content 选项为 YES)。 WKWebView 可以注册 customScheme,比如自定义 scheme:`Hybrid://`,因此使用离线包但不使用 post 方式的请求可以通过 customScheme 发起。比如 `Hybrid://www.xxx.com/`,然后在 App 进程被 NSURLProtocol 拦截这个请求,然后加载离线包资源。 不足:使用 post 方式的请求需要修改 h5 侧代码(scheme)。 ### 4. WKWebView loadRequest 问题 在 WKWebView 上通过 loadRequest 发起的 post 请求,会丢失 body 数据。 ```objective-c //同样是由于进程间通信性能问题,HTTPBody字段被丢弃 [request setHTTPMethod:@"POST"]; [request setHTTPBody:[@"bodyData" dataUsingEncoding:NSUTF8StringEncoding]]; [wkwebview loadRequest: request]; ``` 通过上面的基础条件,其实可以麻烦一点解决 post 请求的 body 丢失问题。 假如 WKWebView loadRequest 要加载 post 请求 request1: http://h5.xxx.com/mobile/index。步骤如下: 1. 子类继承自抽象类 NSURLProtocol,并向 App 注册。 2. 将 request1 的 scheme 进行替换,生成新的请求 request2:`post://h5.xxx.com/mobile/index`。同时将 rquest1 的 body 字段添加到 header 信息中(webkit 不会丢弃 header 信息)。 3. WKWebView 加载新的 request2。 `[WKWebView loadRequest:request2];` 4. 通过 `[WKBrowsingContextController registerSchemeForCustomProtocol:]` 注册 scheme:**post://**。 5. NSURLProtocl 拦截请求 ``post://h5.xxx.com/mobile/index`。做 scheme 还原的操作,也就是替换 scheme。生成新的请求 request3 `http://h5.xxx.com/mobile/index`,同时将 request2 header 中的 body 字段添加到 request3 的 body 中,并使用 NSURLConnection 加载 request3 6. 网络请求完成后,通过 NetworkProtocolClient 将请求结果返回给 WKWebView。 ### 5. 拦截 WebView 内 Ajax 请求 其实上述的方法也是可行,不过使用私有 API 的方式不是很推荐,一般在穷途末路的时候才选择私有 API,所以另一种思路是 hook Web 端的 ajax 请求。在执行 hook 后的 ajax 请求的时候将 ajax 的请求相关信息(请求方式、header、body 等)以 messageHandler 的方式告诉 Native,然后起到监控的效果。 参考: https://www.jianshu.com/p/7337ac624b8e;https://github.com/wendux/Ajax-hook 1. 关于 WKWebview 的各种问题可以查看这篇[文章](https://www.tuicool.com/articles/QbE3Mb7)。