前言:最近在了解HttpDns的实现方案,经过调研,发现了NSURLProtocol这个在Apple URL Loading System中的特殊角色,特此记录一下。
1. NSURLProtocol简介
NSURLProtocol
是一个抽象类,作为URL Loading System
系统的一部分,能够帮助我们拦截所有的URL Loading System
的请求,在此进行各种自定义的操作,是网络层实现AOP(面向切面变成)的利器。。
URL Loading System
是Apple提供的一系列的类和协议,主要用来通过URL请求获取资源,像我们非常了解的NSURLSession等相关类就包含其中,如图所示:
需要注意的是上面特殊标注:能够帮助我们拦截所有的URL Loading System
的请求。结合上图,我们可以知道NSURLProtocol可以拦截使用NSURLSession
、NSURLConnection
发起的请求,以及使用UIWebView发起的请求。
现在主流的iOS网络库,例如AFNetworking,Alamofire等网络库都是基于NSURLSession或NSURLConnection的,所以这些网络库的网络请求都可以被NSURLProtocol所拦截。
2. 使用场景
因为NSURLProtocol的强大特性,所以在实际应用中,它的使用场景也很广泛,比如:
- 重定向网络请求,解决DNS域名劫持的问题
- 进行全局或局部的网络请求设置,比如修改请求地址、header等
- 协助实现HttpDns
- 忽略网络请求,使用H5离线包或是缓存数据等
- 自定义网络请求的返回结果,比如过滤敏感信息
2. NSURLProcotol的属性和方法
/// 注册该类,使之对URL加载系统可见
+ (BOOL)registerClass:(Class)protocolClass;
/// 取消注册该类
+ (void)unregisterClass:(Class)protocolClass;
/// 过滤方法。返回YES,则由该类处理请求,否则URL Loading System使用系统默认的行为处理
+ (BOOL)canInitWithRequest:(NSURLRequest *)request;
/// 在该方法中自定义网络请求, 对请求进行修改,如URL重定向、添加Header
/// 无需额外处理可直接返回request
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request;
/// 创建一个实例
- (instancetype)initWithRequest:(NSURLRequest *)request cachedResponse:(nullable NSCachedURLResponse *)cachedResponse client:(nullable id <NSURLProtocolClient>)client;
/// 判断两个请求是否相同,相同的话可以使用缓存数据,一般直接返回父类实现,或者不用重写。
+ (BOOL)requestIsCacheEquivalent:(NSURLRequest *)a toRequest:(NSURLRequest *)b;
/// 开始加载请求,需要在该方法中发起一个新的请求
- (void)startLoading;
/// 取消加载请求
- (void)stopLoading;
/// 给指定的请求设置与指定键相关联的属性
+ (void)setProperty:(id)value forKey:(NSString *)key inRequest:(NSMutableURLRequest *)request;
/// 返回与指定的请求中指定的关键字关联的属性。如果没有该key,返回nil
+ (nullable id)propertyForKey:(NSString *)key inRequest:(NSURLRequest *)request;
/// 移除给指定的请求的指定key相关联的属性
+ (void)removePropertyForKey:(NSString *)key inRequest:(NSMutableURLRequest *)request;
/// 注册该类
+ (BOOL)registerClass:(Class)protocolClass;
/// 取消注册
+ (void)unregisterClass:(Class)protocolClass;
3. NSURLProtocol的使用
上面有提到NSURLProtocol是一个抽象类,所以使用的时候需要创建一个子类:
@interface CustomHTTPProtocol : NSURLProtocol
@end
使用NSURLProtocol主要可以分为5个步骤:注册—>拦截—>转发—>回调—>结束。
3.1 注册
- 对于基于NSURLConnection或者使用[NSURLSession sharedSession]创建的网络请求,调用registerClass方法即可:
[NSURLProtocol registerClass:[CustomHTTPProtocol class]];
- 对于基于NSURLSession的网络请求,需要通过配置NSURLSessionConfiguration对象的protocolClasses属性:
NSURLSessionConfiguration *sessionConfiguration = [NSURLSessionConfiguration defaultSessionConfiguration];
sessionConfiguration.protocolClasses = @[[NSClassFromString(@"CustomHTTPProtocol") class]];
3.2 拦截
拦截到网络请求后,NSURLProtocol会依次执行下面👇🏻几个方法。
3.2.1 控制是否需要被拦截
// 过滤方法。返回YES,则由该类拦截处理请求
+ (BOOL)canInitWithRequest:(NSURLRequest *)request;
示例如下:
+ (BOOL)canInitWithRequest:(NSURLRequest *)request{
NSString *scheme = [[request.URL scheme] lowercaseString];
if ([scheme isEqual:@"http"]) {
return YES;
}
return NO;
}
3.2.2 对request请求处理
/// 在该方法中自定义网络请求, 对请求进行修改,如URL重定向、添加Header
/// 无需额外处理可直接返回request
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request;
我们在这个方法里可以对原有的request请求进行处理,比如添加公共的请求头等,最后返回一个NSURLRequest实例即可:
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {
/// 如果是http请求改成https
if ([request.URL.scheme isEqualToString:@"http"]) {
NSMutableURLRequest *mutableRequest = [request mutableCopy];
NSString *urlString = mutableRequest.URL.absoluteString;
urlString = [urlString stringByReplacingOccurrencesOfString:@"http" withString:@"https"];
mutableRequest.URL = [NSURL URLWithString:urlString];
return mutableRequest;
}
return request;
}
3.3 转发
在拦截到网络请求,并且对网络请求进行定制处理以后。我们需要将网络请求重新发送出去。
3.3.1 创建一个NSURLProcotol实例
/// 创建一个实例
- (instancetype)initWithRequest:(NSURLRequest *)request cachedResponse:(nullable NSCachedURLResponse *)cachedResponse client:(nullable id <NSURLProtocolClient>)client;
该方法会创建一个NSURLProtocol实例,这里每一个网络请求都会创建一个新的实例。
3.3.2 发起请求
/// 开始加载请求,需要在该方法中发起一个新的请求
- (void)startLoading;
接下来就是转发的核心方法startLoading
。在该方法中,我们把处理过的request重新发送出去。至于发送的形式,可以是基于NSURLConnection,NSURLSession甚至CFNetwork:
/// 开始网络请求
- (void)startLoading {
NSMutableURLRequest *recursiveRequest = [[self request] mutableCopy];
NSURLSessionConfiguration *configuration = [NSURLSessionConfiguration defaultSessionConfiguration];
NSMutableArray *protocolClasses = [configuration.protocolClasses mutableCopy];
[protocolClasses addObject:self];
configuration.protocolClasses = @[self.class];
self.session = [NSURLSession sessionWithConfiguration:configuration delegate:self delegateQueue:nil];
NSURLSessionDataTask *task = [self.session dataTaskWithRequest:recursiveRequest];
[task resume];
}
3.4 回调
既是面向切面的编程,就不能影响到原来网络请求的逻辑。所以上一步将网络请求转发出去以后,当收到网络请求的返回,还需要再将返回值返回给URL Loading System
。
3.4.1 数据回调
这就需要使用到实现NSURLProtocolClient
协议的client属性,在NSURLSessionDelegate的回调方法中,将数据返回给URL Loading System
:
#pragma mark -- NSURLSessionDataDelegate
/// 接收到服务响应时调用的方法
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveResponse:(NSURLResponse *)response completionHandler:(void (^)(NSURLSessionResponseDisposition))completionHandler {
[[self client] URLProtocol:self didReceiveResponse:response cacheStoragePolicy:NSURLCacheStorageAllowed];
completionHandler(NSURLSessionResponseAllow);
}
///接收到服务器返回数据的时候会调用该方法,如果数据较大那么该方法可能会调用多次
- (void)URLSession:(NSURLSession *)session dataTask:(NSURLSessionDataTask *)dataTask didReceiveData:(NSData *)data {
[[self client] URLProtocol:self didLoadData:data];
}
/// 当请求完成(成功|失败)的时候会调用该方法,如果请求失败,则error有值
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didCompleteWithError:(NSError *)error {
if (error) {
[[self client] URLProtocol:self didFailWithError:error];
} else {
[[self client] URLProtocolDidFinishLoading:self];
}
}
3.4.2 NSURLProtocolClient
记录下上面用到的NSURLProtocolClient的方法:
@protocol NSURLProtocolClient <NSObject>
// 请求重定向
- (void)URLProtocol:(NSURLProtocol *)protocol wasRedirectedToRequest:(NSURLRequest *)request redirectResponse:(NSURLResponse *)redirectResponse;
// 响应缓存是否合法
- (void)URLProtocol:(NSURLProtocol *)protocol cachedResponseIsValid:(NSCachedURLResponse *)cachedResponse;
// 刚接收到 response 信息
- (void)URLProtocol:(NSURLProtocol *)protocol didReceiveResponse:(NSURLResponse *)response cacheStoragePolicy:(NSURLCacheStoragePolicy)policy;
// 数据加载成功
- (void)URLProtocol:(NSURLProtocol *)protocol didLoadData:(NSData *)data;
// 数据完成加载
- (void)URLProtocolDidFinishLoading:(NSURLProtocol *)protocol;
// 数据加载失败
- (void)URLProtocol:(NSURLProtocol *)protocol didFailWithError:(NSError *)error;
// 为指定的请求启动验证
- (void)URLProtocol:(NSURLProtocol *)protocol didReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge;
// 为指定的请求取消验证
- (void)URLProtocol:(NSURLProtocol *)protocol didCancelAuthenticationChallenge:(NSURLAuthenticationChallenge *)challenge;
@end
3.5 结束
在一个网络请求完全结束以后,NSURLProtocol回调用到stopLoading。
在该方法里,我们完成在结束网络请求的操作。以NSURLSession为例:
- (void)stopLoading {
[self.session invalidateAndCancel];
self.session = nil;
}
4. 注意事项
虽然NSURLProtocol功能很强大,但是坑也不少。
4.1 拦截到的 request 请求的 HTTPBody 为 nil
可以借助 HTTPBodyStream 来获取 body,代码如下:
NSMutableURLRequest * req = [self mutableCopy];
if ([self.HTTPMethod isEqualToString:@"POST"]) {
if (!self.HTTPBody) {
NSInteger maxLength = 1024;
uint8_t d[maxLength];
NSInputStream *stream = self.HTTPBodyStream;
NSMutableData *data = [[NSMutableData alloc] init];
[stream open];
BOOL endOfStreamReached = NO;
//不能用 [stream hasBytesAvailable]) 判断,处理图片文件的时候这里的[stream hasBytesAvailable]会始终返回YES,导致在while里面死循环。
while (!endOfStreamReached) {
NSInteger bytesRead = [stream read:d maxLength:maxLength];
if (bytesRead == 0) { //文件读取到最后
endOfStreamReached = YES;
} else if (bytesRead == -1) { //文件读取错误
endOfStreamReached = YES;
} else if (stream.streamError == nil) {
[data appendBytes:(void *)d length:bytesRead];
}
}
req.HTTPBody = [data copy];
[stream close];
}
}
4.2 多个NSURLProtocol嵌套使用
对于通过配置 NSURLSessionConfiguration 对象的 protocolClasses 属性来注册的情况,protocolClasses 数组中只有第一个 NSURLProtocol 会起作用,后续的 NSURLProtocol 就无法拦截到了,同时如果要拦截AFN中的请求,也需要特殊处理,可以使用runtime交换NSURLSessionConfiguration
的protocolClasses
方法来解决:
///使用的是AFN 所以重新给session的protocolclasses赋值
+ (void)exchangeNSURLSessionConfiguration{
Class cls = NSClassFromString(@"__NSCFURLSessionConfiguration") ?: NSClassFromString(@"NSURLSessionConfiguration");
Method originalMethod = class_getInstanceMethod(cls, @selector(protocolClasses));
Method stubMethod = class_getInstanceMethod([DNSURLProtocol class], @selector(protocolClasses));
if (!originalMethod || !stubMethod) {
[NSException raise:NSInternalInconsistencyException format:@"Couldn't load NSURLSessionConfiguration."];
}
method_exchangeImplementations(originalMethod, stubMethod);
}
- (NSArray *)protocolClasses {
return @[[DNSURLProtocol class]];
}
4.3 canInitWithRequest方法多次调用
偶尔会出现canInitWithRequest方法多次调用的情况,这个问题出现非常的奇怪,目前还不清楚原因。但是因为我们在canInitWithRequest方法中会判断是否拦截过的标记。所以这个问题不会影响到正常使用。