之前在做完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.
我们在接入一下第三方网络监控时, 发现上传或者下载进度信息丢失也是因为第三方库使用NSURLProtocol拦截了网络请求以后, 上传下载信息丢失的锅
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实现, 效率太差了不建议.