Skip to content

Latest commit

 

History

History
418 lines (301 loc) · 20 KB

1.83.md

File metadata and controls

418 lines (301 loc) · 20 KB

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

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 的相关方法

创建协议对象

// 创建一个 URL 协议实例来处理 request 请求
- (instancetype)initWithRequest:(NSURLRequest *)request cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id<NSURLProtocolClient>)client;
// 创建一个 URL 协议实例来处理 session task 请求
- (instancetype)initWithTask:(NSURLSessionTask *)task cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id<NSURLProtocolClient>)client;

注册和注销协议类

// 尝试注册 NSURLProtocol 的子类,使之在 URL 加载系统中可见
+ (BOOL)registerClass:(Class)protocolClass;
// 注销 NSURLProtocol 的指定子类
+ (void)unregisterClass:(Class)protocolClass;

确定子类是否可以处理请求

子类化 NSProtocol 的首要任务就是告知它,需要控制什么类型的网络请求。

// 确定协议子类是否可以处理指定的 request 请求,如果返回 YES,请求会被其控制,返回 NO 则直接跳入下一个 protocol
+ (BOOL)canInitWithRequest:(NSURLRequest *)request;
// 确定协议子类是否可以处理指定的 task 请求
+ (BOOL)canInitWithTask:(NSURLSessionTask *)task;

获取和设置请求属性

NSURLProtocol 允许开发者去获取、添加、删除 request 对象的任意元数据。这几个方法常用来处理请求无限循环的问题。

// 在指定的请求中获取与指定键关联的属性
+ (id)propertyForKey:(NSString *)key inRequest:(NSURLRequest *)request;
// 设置与指定请求中的指定键关联的属性
+ (void)setProperty:(id)value forKey:(NSString *)key inRequest:(NSMutableURLRequest *)request;
// 删除与指定请求中的指定键关联的属性
+ (void)removePropertyForKey:(NSString *)key inRequest:(NSMutableURLRequest *)request;

提供请求的规范版本

如果你想要用特定的某个方式来修改请求,可以用下面这个方法。

// 返回指定请求的规范版本
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request;

确定请求是否相同

// 判断两个请求是否相同,如果相同可以使用缓存数据,通常只需要调用父类的实现
+ (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a toRequest:(NSURLRequest *)b;

启动和停止加载

这是子类中最重要的两个方法,不同的自定义子类在调用这两个方法时会传入不同的内容,但共同点都是围绕 protocol 客户端进行操作。

// 开始加载
- (void)startLoading;
// 停止加载
- (void)stopLoading;

获取协议属性

// 获取协议接收者的缓存
- (NSCachedURLResponse *)cachedResponse;
// 接受者用来与 URL 加载系统通信的对象,每个 NSProtocol 的子类实例都拥有它
- (id<NSURLProtocolClient>)client;
// 接收方的请求
- (NSURLRequest *)request;
// 接收方的任务
- (NSURLSessionTask *)task;

四、 如何利用 NSProtocol 拦截网络请求

NSURLProtocol 在实际应用中,主要是完成两步:拦截 URL 和 URL 转发。先来看如何拦截网络请求。

创建 NSURLProtocol 子类

这里创建一个名为 HTCustomURLProtocol 的子类。

@interface HTCustomURLProtocol : NSURLProtocol
@end

注册 NSURLProtocol 的子类

在合适的位置注册这个子类。对基于 NSURLConnection 或者使用 [NSURLSession sharedSession] 初始化对象创建的网络请求,调用 registerClass 方法即可。

[NSURLProtocol registerClass:[NSClassFromString(@"CustomURLProtocol") class]];
// or
// [NSURLProtocol registerClass:[HTCustomURLProtocol class]];

如果需要全局监听,可以设置在 AppDelegate.mdidFinishLaunchingWithOptions 方法中。如果只需要在单个 UIViewController 中使用,记得在合适的时机注销监听:

[NSURLProtocol unregisterClass:[NSClassFromString(@"HTCustomURLProtocol") class]];

如果是基于 NSURLSession 的网络请求,且不是通过 [NSURLSession sharedSession] 方式创建的,就得配置 NSURLSessionConfiguration 对象的 protocolClasses 属性。

 NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration];
 config.protocolClasses = @[[NSClassFromString(@"CustomProtocol") class]];
 NSURLSession *session = [NSURLSession sessionWithConfiguration:config];

实现 NSURLProtocol 子类

注册 → 拦截 → 转发 → 回调 → 结束

以拦截 UIWebView 为例,这里需要重写父类的这五个核心方法。

// 定义一个协议 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 转发出去。这里对应的回调方法对应 <NSURLProtocolClient> 协议方法
- (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的实现就是利用了 NSURLProtocol 实现的。

几个主要类及其功能:HTTPStubsProtocol 拦截网络请求;HTTPStubs 单例管理 HTTPStubsDescriptor 实例对象;HTTPStubsResponse 伪造 HTTP 请求。

HTTPStubsProtocol 继承自 NSURLProtocol,可以在 HTTP 请求发送之前对 request 进行过滤处理

+ (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 :

+ [WKBrowsingContextController registerSchemeForCustomProtocol:]

通过注册 http(s) scheme 后 WKWebView 将可以使用 NSURLProtocol 拦截 http(s) 请求。涉及到的私有 api :WKBrowsingContextControolerregisterSchemeForCustomProtocol

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 源码

  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 数据。

//同样是由于进程间通信性能问题,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 的各种问题可以查看这篇文章