iOS中NSURLProtocol 的简单研究

1,805 阅读7分钟

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:
  1. Demo里面构建了一个单例模式的QNSURLSessionDemux来作为转发请求的构造发起点, 然后统一处理转发Request的关键的NSURLSessionDelegate和NSURLSessionDataDelegate. 但是我们能看到实际里面的部分方法是没有去实现的. 这里建议使用Apple的这种实现方法.
  2. 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. 
    

    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实现, 效率太差了不建议.