iOS中NSURLProtocol 的简单研究
之前在做完HTTPDNS服务以后, 为了使用IP代替域名, 使用的方式是改造网络库, 也就是直接在网络库层改造NSURLRequest的相关内容, 用IP直连发送HTTP请求.
具体需要解决的问题如下:
- URL中的domain换IP
- 在NSURLRequest的
http header
的Host字段, 需要绑定原始的域名信息 - 在NSURLRequest的
http header
的增加Cookie字段, 在URL中host字段改造成IP以后, 系统底层不会帮你在http request header
中添加Cookie信息 - 在NSURLSession的
AuthManager Challenge
回调中, 针对challenge.protectionSpace.serverTrust
是HTTPS证书验证时,challenge.protectionSpace.host
需要获取原始域名去与HTTPS证书中进行security验证.
这种直接在网络层进行改造会有如下问题:
- 使用Apple的NSURLSession中使用域名请求时的优化, 具体的各种优化方法可以参考
libcurl
和happy eyeball
算法. - 对现有网络库相关代码入侵较为明显.
注意:
1: 通过
swift-core-foundation
的源码能看到, NSURLSession的底层底层网络请求使用libcurl的包装2: android中使用OkHttp网络库中可以直接在底层替换LocalDNS服务, 而且该服务可以支持一些列的IP列表, 并且OkHttp用于策略对多个IP进行轮询重试!!!
自定义NSURLProtocol的IP直连方案
iOS在URL Loading System
中NSURLProtocol
占有很重要的位置,是一个中间人的角色, 对业务层网络库或者NSURLSession来说, 这个NSURLProtocol就是一个server, 在NSURLProtocol通过client
属性将网络请求中关键流程的信息回调给业务层的NSURLSession.
本人在研究目前网站上的一些开源库对NSURLProtocol的常规用法, 大概分成三大类:
- 给UIWebView或者WKWebview做Cache的
- 网络监控和网络Mock的, 例如DebugTool, netfox, CocoaDebug, OHHTTPStubs, DoraemonKit等等
- 底层进行IP直连服务的比如腾讯云和阿里云关于HTTPDNS的最佳实践, 以及开源库 KIDDNS
以上内容中, 基本都是参考Apple官方Demo - CustomHTTPProtocol
进行实现的, 其中实现中最关键的几个重点如下:
-
NSURLProtocol
的self.client
的相关API必须在client thread
中调用, 因此Apple Demo中会在startLoading
中缓存client thread
和runloopMode
, 在底层进行真正的网络请求以后, 通过以下方法与业务层进行数据交换, 这其中缺少与progress
相关的API以及NSURLSessionDownloadDelegate
的回调, 因此使用NSURLProtocol拦截网络请求, 然后自行转发时, 通过官方的API是无法完成上下行数据进度更新的. 另外对于以下的client回调方法, Apple 官方将他们分成3类:pre-response
,response
,post-response
, 并对每个方法调用时机进行了解释, 可以参考项目的ReadMe- -URLProtocol:wasRedirectedToRequest:redirectResponse:
- -URLProtocol:didReceiveResponse:cacheStoragePolicy:
- -URLProtocol:didLoadData:
- -URLProtocolDidFinishLoading:
- -URLProtocol:didFailWithError:
- -URLProtocol:didReceiveAuthenticationChallenge:
- -URLProtocol:didCancelAuthenticationChallenge:
- Demo里面构建了一个单例模式的
QNSURLSessionDemux
来作为转发请求的构造发起点, 然后统一处理转发Request的关键的NSURLSessionDelegate和NSURLSessionDataDelegate. 但是我们能看到实际里面的部分方法是没有去实现的. 这里建议使用Apple的这种实现方法. - Demo里面构建了一个NSURLProtocol的Delegate, 让Delegate来实现对AuthManger Challenge的实现逻辑, 也值得借鉴.
部分开源网络监控模块的实现中, 每个NSURLProtocol都创建一个关联的NSURLSession, 然后使用这个NSURLSession去转发请求, 具体原因可以参考
swift-foundation
中NSURLSession的实现, 底层是curl中的multiHandle, 并且NSURLSession会与delegate强引用, 如果多个请求被同时拦截, 导致内存占用居高不下.
参考这个Demo, 比较容易的实现在startLoading
中拦截Request请求, 更换成IP, 然后进行IP直连服务, 但是会有如下一些问题:
1. 拦截的NSURLRequest的HTTPBody数据过大, 丢失的问题
可以进行如下处理, 将HTTPBody转化成HTTPStream方式 :
+ (NSURLRequest *)canonicalRequestForRequest:(NSURLRequest *)request {
NSURLRequest *newRequest = [self handleMutablePostRequestIncludeBody:[request mutableCopy]];
return newRequest;
}
+ (NSMutableURLRequest *)handleMutablePostRequestIncludeBody:(NSMutableURLRequest *)req {
if ([req.HTTPMethod isEqualToString:@"POST"]) {
if (!req.HTTPBody) {
NSInteger maxLength = 1024;
uint8_t d[maxLength];
NSInputStream *stream = req.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];
}
}
return req;
}
2. 拦截的请求需要在TLS握手时Pin证书
目前这边有两种解决方案, 方案一可以参考Apple Demo的实现, 让外部配置一个Delegate, 在Delegate去完成证书校验, 但是这里注意, 证书校验相关的逻辑需要实现到Delegate方法中, 而不是上层的AFNetworking的回调方法中!!
目前有另外一种方案可以参考KIDDNS
, 它将具体的证书校验的方法交给类似于proxy方式交给原始的originalSession的delegate来完成, 具体来说步骤如下:
-
HOOK NSURLSession的创建SessionTask的方法, 为了能在获取创建的task以及创建task的session
-
维护一个全局的URLSessionMap来缓存Hook中创建的task与session
-
在创建NSURLProtocol时, 缓存一个originalTask, 这样通过WBURLSessionMap就能获取到对应的session
-
在demux收到auth challenge 时, 直接通过WBURLSessionMap获取originalTask对应的originalSession, 然后直接在
originalSession.delegateQueue
中调用originalSession.delegate的回调方法
@interface WBHTTPURLProtocol()<NSURLSessionTaskDelegate, NSURLSessionDataDelegate, NSURLSessionDownloadDelegate> ... @property (nonatomic, strong) NSURLSessionTask *originalTask; // 缓存一份业务层使用的Task @end // 在上层初始化 task时, 先缓存一份originalTask - (instancetype)initWithTask:(NSURLSessionTask *)task cachedResponse:(NSCachedURLResponse *)cachedResponse client:(id<NSURLProtocolClient>)client { self.originalTask = task; if (self = [super initWithTask:task cachedResponse:cachedResponse client:client]) { } return self; } // QNSURLSessionDemux的回调 -(void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential * _Nullable))completionHandler{ if (task == self.originalTask) { return; } NSURLSession *originalSession = [WBURLSessionMap fetchSessionOfTask:self.originalTask]; if (originalSession.delegate && [originalSession.delegate respondsToSelector:@selector(URLSession:task:didReceiveChallenge:completionHandler:)]) { [originalSession.delegateQueue addOperationWithBlock:^{ [(id<NSURLSessionTaskDelegate>)originalSession.delegate URLSession:originalSession task:task didReceiveChallenge:challenge completionHandler:completionHandler]; }]; } }
@implementation NSURLSession (WBURLProtocol) + (void)load { [self swizzleMethods]; } + (void)swizzleMethods { NSArray<NSString *> *selectors = @[@"dataTaskWithRequest:", @"dataTaskWithURL:",@"uploadTaskWithRequest:fromFile:",@"uploadTaskWithRequest:fromData:",@"uploadTaskWithStreamedRequest:",@"downloadTaskWithRequest:",@"downloadTaskWithURL:",@"downloadTaskWithResumeData:"]; [selectors enumerateObjectsUsingBlock:^(NSString * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) { Method originalMethod = class_getInstanceMethod([NSURLSession class],NSSelectorFromString(obj)); NSString *fakeSelector = [NSString stringWithFormat:@"fake_%@", obj]; Method fakeMethod = class_getInstanceMethod([NSURLSession class], NSSelectorFromString(fakeSelector)); method_exchangeImplementations(originalMethod, fakeMethod); }]; } - (NSURLSessionDataTask *)fake_dataTaskWithRequest:(NSURLRequest *)request { NSURLSessionDataTask *task = [self fake_dataTaskWithRequest:request]; [WBURLSessionMapURLSessionMap recordSessionTask:task ofSession:self]; return task; } ... 其他的 task创建的方法 @end
@interface WBURLSessionMap() @property (nonatomic, strong) NSMapTable *map; @property (nonatomic, strong) dispatch_queue_t queue; @end @implementation WBURLSessionMap + (instancetype)sharedInstance { static WBURLSessionMap *instance = nil; static dispatch_once_t onceToken; dispatch_once(&onceToken, ^{ instance = [WBURLSessionMap new]; }); return instance; } - (instancetype)init { if (self = [super init]) { _map = [NSMapTable weakToWeakObjectsMapTable]; _queue = dispatch_queue_create("com.xxx.urlprotocol.sessionmap", DISPATCH_QUEUE_CONCURRENT); } return self; } - (NSURLSession *)fetchSessionOfTask:(NSURLSessionTask *)task { __block NSURLSession *session = nil; dispatch_sync(_queue, ^{ session = [self->_map objectForKey:task]; }); return session; } - (void)recordSessionTask:(NSURLSessionTask *)task ofSession:(NSURLSession *)session { dispatch_barrier_async(_queue, ^{ [self->_map setObject:session forKey:task]; }); } + (NSURLSession *)fetchSessionOfTask:(NSURLSessionTask *)task { return [[self sharedInstance] fetchSessionOfTask:task]; } + (void)recordSessionTask:(NSURLSessionTask *)task ofSession:(NSURLSession *)session { [[self sharedInstance] recordSessionTask:task ofSession:session]; } @end
但是!!! 这种实现方式非常危险, 需要严格关注上层代码的实现, 并且按照apple 官方demo的说法,
didReceiveChallenge
回调中的completionHandler
需要在client thread
中调用, 上述实现没有遵守, 这里可以做一个包装!!!因此, 这里还是建议使用Apple Demo官方的做法, 在delegate中去实现Pin证书等逻辑.
这里列出来只是为这种思想点赞, 后续可以扩展研究类似全proxy代理方法hook NSURLSession以及Task全部方法进行插装的思路
3. 上传和下载的progress问题
Apple的官方Demo中有一段解释, 意味着使用NSURLProtocol是无法监听上传和下载的progress的, :
Similarly, there is no way for your NSURLProtocol subclass to call the NSURLConnection delegate's -connection:needNewBodyStream: or -connection:didSendBodyData:totalBytesWritten:totalBytesExpectedToWrite: methods (<rdar://problem/9226155> and <rdar://problem/9226157>). The latter is not a serious concern--it just means that your clients don't get upload progress--but the former is a real issue.
4. NSURLProtocol的激活时机的问题
由于目前基本都使用NSULRSession, 网上有很多直接Hook系统的NSURLSessionConfiguration方法, 入侵性比较强, 对于普通NSURLSession或者AFNetworking中的类, 我们可以用如下方式激活我们自定义的
NSURLProtocol
, 注意由于NSURLSessionConfiguration
默认会一些系统的protocols
建议不要直接替代, 而是使用下面的方式, 这样当我们自定义的WBHTTPURLProtocol
没有生效时, NSURLSession会使用系统的默认实现的URLProtocol, 保持兼容性:NSURLSessionConfiguration *config = [NSURLSessionConfiguration defaultSessionConfiguration]; Class urlprotocol = NSClassFromString(@"WBHTTPURLProtocol"); if (urlprotocol) { NSMutableArray *protocols = [config.protocolClasses mutableCopy]; [protocols insertObject:urlprotocol atIndex:0]; config.protocolClasses = protocols; } AFURLSessionManager *sessionManager = [[AFURLSessionManager alloc] initWithSessionConfiguration:config];
系统默认实现的 URLProtocol 有_NativeProtocol, HTTPURLProtocol等等. 可以参考 swift-core-foundation
在NSURLProtocol中用libcurl转发Request
上面比较详细的参考了Apple Demo中的URLSession转发Request, 但是也没有解决类似Android 的OkHttp方式使用的多IP问题. 网上有人仿照swift-core-foundation使用 libcurl包装了一个类AFNetworking的库 --
YMHTTP
, 如果有需求可以代替底层使用NSURLSession从而导致的IP直连服务无法直接替换DNS模块的问题.HTTPDNS中的SNI问题
如果在你的业务中有SNI问题, 那么建议使用libcurl吧. 网上有人用CFNetwork实现, 效率太差了不建议.