iOS中NSURLProtocol 的简单研究

之前在做完HTTPDNS服务以后, 为了使用IP代替域名, 使用的方式是改造网络库, 也就是直接在网络库层改造NSURLRequest的相关内容, 用IP直连发送HTTP请求.

具体需要解决的问题如下:

  1. URL中的domain换IP
  2. 在NSURLRequest的http header的Host字段, 需要绑定原始的域名信息
  3. 在NSURLRequest的http header的增加Cookie字段, 在URL中host字段改造成IP以后, 系统底层不会帮你在http request header中添加Cookie信息
  4. 在NSURLSession的AuthManager Challenge回调中, 针对challenge.protectionSpace.serverTrust是HTTPS证书验证时, challenge.protectionSpace.host需要获取原始域名去与HTTPS证书中进行security验证.

这种直接在网络层进行改造会有如下问题:

  1. 使用Apple的NSURLSession中使用域名请求时的优化, 具体的各种优化方法可以参考libcurlhappy eyeball算法.
  2. 对现有网络库相关代码入侵较为明显.

注意:

1: 通过swift-core-foundation的源码能看到, NSURLSession的底层底层网络请求使用libcurl的包装

2: android中使用OkHttp网络库中可以直接在底层替换LocalDNS服务, 而且该服务可以支持一些列的IP列表, 并且OkHttp用于策略对多个IP进行轮询重试!!!

自定义NSURLProtocol的IP直连方案

iOS在URL Loading SystemNSURLProtocol占有很重要的位置,是一个中间人的角色, 对业务层网络库或者NSURLSession来说, 这个NSURLProtocol就是一个server, 在NSURLProtocol通过client属性将网络请求中关键流程的信息回调给业务层的NSURLSession.

本人在研究目前网站上的一些开源库对NSURLProtocol的常规用法, 大概分成三大类:

  1. 给UIWebView或者WKWebview做Cache的
  2. 网络监控和网络Mock的, 例如DebugTool, netfox, CocoaDebug, OHHTTPStubs, DoraemonKit等等
  3. 底层进行IP直连服务的比如腾讯云和阿里云关于HTTPDNS的最佳实践, 以及开源库 KIDDNS

以上内容中, 基本都是参考Apple官方Demo - CustomHTTPProtocol进行实现的, 其中实现中最关键的几个重点如下:

  1. NSURLProtocolself.client的相关API必须在client thread中调用, 因此Apple Demo中会在startLoading中缓存client threadrunloopMode, 在底层进行真正的网络请求以后, 通过以下方法与业务层进行数据交换, 这其中缺少与progress相关的API以及NSURLSessionDownloadDelegate的回调, 因此使用NSURLProtocol拦截网络请求, 然后自行转发时, 通过官方的API是无法完成上下行数据进度更新的. 另外对于以下的client回调方法, Apple 官方将他们分成3类:pre-response,response,post-response, 并对每个方法调用时机进行了解释, 可以参考项目的ReadMe

    1. -URLProtocol:wasRedirectedToRequest:redirectResponse:
    2. -URLProtocol:didReceiveResponse:cacheStoragePolicy:
    3. -URLProtocol:didLoadData:
    4. -URLProtocolDidFinishLoading:
    5. -URLProtocol:didFailWithError:
    6. -URLProtocol:didReceiveAuthenticationChallenge:
    7. -URLProtocol:didCancelAuthenticationChallenge:
  2. Demo里面构建了一个单例模式的QNSURLSessionDemux来作为转发请求的构造发起点, 然后统一处理转发Request的关键的NSURLSessionDelegate和NSURLSessionDataDelegate. 但是我们能看到实际里面的部分方法是没有去实现的. 这里建议使用Apple的这种实现方法.

  3. 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来完成, 具体来说步骤如下:

  1. HOOK NSURLSession的创建SessionTask的方法, 为了能在获取创建的task以及创建task的session
  2. 维护一个全局的URLSessionMap来缓存Hook中创建的task与session
  3. 在创建NSURLProtocol时, 缓存一个originalTask, 这样通过WBURLSessionMap就能获取到对应的session
  4. 在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实现, 效率太差了不建议.

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 161,780评论 4 369
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 68,424评论 1 305
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 111,397评论 0 254
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 44,576评论 0 218
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,997评论 3 295
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,945评论 1 224
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 32,107评论 2 317
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,850评论 0 208
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,625评论 1 250
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,804评论 2 253
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,285评论 1 265
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,613评论 3 261
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 33,291评论 3 242
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,164评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,963评论 0 201
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 36,096评论 2 285
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,886评论 2 278

推荐阅读更多精彩内容