移动开发技术前沿iOS && AndroidJS 与 iOS

简书的NSURLProtocol踩坑总结

2017-12-25  本文已影响502人  xuning0

本文假设你已经对NSURLProtocol有所了解,已了解的建议阅读苹果的Sample Code CustomHTTPProtocol
简书使用NSURLProtocol在请求时添加ETag头信息、替换URL host为HTTPDNS得到的ip,在返回时进行SSL Pinning的证书校验,保证了网络请求的可用性和安全性。

简书的网络层结构
由于NSURLProtocol属于苹果的黑魔法,文档并不详细,有些教程和诸如“NSURLProtocol的坑”的文章本身也是有坑或不完善的,所以我们写下这篇文章来分享简书在NSURLProtocol的开发使用中遇到的误区和摸索出的更佳实践(注意:可能并不是最佳实践),欢迎在原文评论区指正。

+canonicalRequestForRequest:

canonical用于形容词时意为典范的、标准的,也就是说这个方法更希望返回的是一个标准的request,所以什么才算标准的request,这个方法到底用来干嘛


我们可以看下苹果示例CustomHTTPProtocol项目中的CanonicalRequestForRequest.h文件的注释

The Foundation URL loading system needs to be able to canonicalize URL requests for various reasons (for example, to look for cache hits). The default HTTP/HTTPS protocol has a complex chunk of code to perform this function. Unfortunately there's no way for third party code to access this. Instead, we have to reimplement it all ourselves. This is split off into a separate file to emphasise that this is standard boilerplate that you probably don't need to look at.

简单说就是要在这个方法里将request格式化,具体看它的.m文件,依次做了以下操作

正如注释中所表达的,在我们用NSURLProtocol接管一个请求后,URL loading system已经帮不上忙了,需要自己去格式化这个请求。那么这里就有几个问题:
我们实际项目中到底需不需要在这里去做一遍格式化的工作呢?
大部分项目中的API请求应该都是由统一的基类封装发出来的,其实已经保证了request格式的正确和统一,所以这个方法直接return request;就可以了。
如果我就是希望在这里格式化一下呢?
如注释中所说,CanonicalRequestForRequest文件可以视为标准操作,直接拿到项目中用就好。
我可以在这个方法里去做HTTPDNS的工作,替换host吗?
如果使用了NSURLCache,这个方法返回的request决定了NSURLCache的缓存数据库中request_key值(数据库的路径在app的/Library/Caches/<bundle id>/Cache.db

普通缓存
在此修改过request后的缓存
所以,如果在这里替换为HTTPDNS得到的host,就可能存在服务端数据不变,但由于ip改变导致request_key不同而无法命中cache的情况。

-startLoading

这也是个比较容易出问题的方法,下边讲三个易错点。
、不要在这个方法所在的线程里做任何同步阻塞的操作,例如网络请求,异步请求+信号量也不行。具体原因文档中没有提及,但这会使方法里发出的请求和startLoading本身的请求最终超时。
、很多使用NSURLProtocol做HTTPDNS的教程或demo里都教在该方法里直接创建NSURLSession,然后发出去修改后的请求,类似于

// 注意:这是错误示范
- (void)startLoading {
    ...
    重新构造request
    ...
    NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:self delegateQueue:nil];
    NSURLSessionDataTask *task = [session dataTaskWithRequest:request];
    [task resume];
}

当然,这个和NSURLProtocol本身关系不大了,而是NSURLSession的用法出现了严重错误。对于用途相同的request,应该只创建一个URLSession,可参考AFNetworking。每个request都创建一个URLSession是低效且不推荐的,可能会遇到各种无法预知的bug,而且最致命的是即使你在-stopLoading处调了finishTasksAndInvalidateinvalidateAndCancel,内存在短期内还是居高不下。
关于这个内存泄露的问题,推荐阅读苹果官方论坛的讨论StackOverFlow的回答。概括下来就是每个NSURLSession都会创建一个维持10min的SSL cache,这个cache由Security.framework私有,无论你在这里调什么方法都不会清掉这个cache,所以在10min内的内存增长可能是无限制的。
正确的姿势应该像CustomHTTPProtocol那样创建一个URLSession单例来发送里面的请求,或者像我一样依旧用NSURLConnection来发请求。
、如果问题二最后采用NSURLConnection发请求,那么在结合HTTPDNS获取ip时应该会出现形如以下的代码:

- (void)startLoading {
    ...
    [[HTTPDNSManager manager] fetchIp:^(NSString *ip) {
        ...替换host...
        self.connection = [NSURLConnection connectionWithRequest:theRequest delegate:self];
    }];
}

你会发现URLConnection能发出请求但回调并不会走,这个很好理解,因为URLConnection的回调默认和发起的线程相同,而发起是在-[HTTPDNSManager fetchIp:]的回调线程中,这个线程用完就失活了,所以解决这个问题的关键在于使URLConnection的回调在一个存活的线程中。乍一想有3种方案:1、将创建URLConnection放到startLoading所在的线程执行;2、用-[NSURLConnection setDelegateQueue:]方法设置它的回调队列;3、将创建URLConnection放到主线程执行,非常暴力,但是我确实见过这么写的。这3种方案其实只有第1种可用。先看下CustomHTTPProtocol的Read Me.txt(是的,NSURLProtocol的文档还没这个Sample Code的Readme详细),中间部分有一段:

In addition, an NSURLProtocol subclass is expected to call the various methods of the NSURLProtocolClient protocol from the client thread, including all of the following:

-URLProtocol:wasRedirectedToRequest:redirectResponse:
-URLProtocol:didReceiveResponse:cacheStoragePolicy:
-URLProtocol:didLoadData:
-URLProtocolDidFinishLoading:
-URLProtocol:didFailWithError:
-URLProtocol:didReceiveAuthenticationChallenge:
-URLProtocol:didCancelAuthenticationChallenge:

方案2的setDelegateQueue:显然是无法把delegateQueue精确到指定线程的,除非最后把URLConnection回调里面的方法再强行调到client线程上去,那样的话还不如直接用方案1。
继续看那个txt,还是上述引用的位置,往下几行有个WARNING:

WARNING: An NSURLProtocol subclass must operate asynchronously. It is not safe for it to block the client thread for extended periods of time. For example, while it's reasonable for an NSURLProtocol subclass to defer work (like an authentication challenge) to the main thread, it must do so asynchronously. If the NSURLProtocol subclass passes a task to the main thread and then blocks waiting for the result, it's likely to deadlock the application.

HTTPS请求在回调中需要验证SSL证书,离不开SecTrustEvaluate函数。可以看到SecTrustEvaluate的文档最后有个特别注意事项,第二段写道

Because this function might look on the network for certificates in the certificate chain, the function might block while attempting network access. You should never call it from your main thread; call it only from within a function running on a dispatch queue or on a separate thread.

所以使用方案3很有可能在SecTrustEvaluate时阻塞掉主线程。

看了这么多错误示范,下边来看方案1-startLoading:里做host替换的正确示范:

@property(atomic, strong) NSThread *clientThread;
@property(atomic, strong) NSURLConnection *connection;

- (void)startLoading {
    NSMutableURLRequest *theRequest = [self.request mutableCopy];
    [NSURLProtocol setProperty:@YES forKey:APIProtocolHandleKey inRequest:theRequest];
    
    self.clientThread = [NSThread currentThread];
    [[HTTPDNSManager manager] fetchIp:^(NSString *ip) {
        if (ip) {
            [theRequest setValue:self.request.URL.host forHTTPHeaderField:@"Host"];
            
            NSURLComponents *urlComponents = [NSURLComponents componentsWithURL:theRequest.URL
                                                        resolvingAgainstBaseURL:YES];
            urlComponents.host = ip;
            theRequest.URL = urlComponents.URL;
        }
        
        [self performBlockOnStartLoadingThread:^{
            self.connection = [NSURLConnection connectionWithRequest:theRequest delegate:self];
        }];
    }];
}

- (void)performBlockOnStartLoadingThread:(dispatch_block_t)block {
    [self performSelector:@selector(onThreadPerformBlock:)
                 onThread:self.clientThread
               withObject:[block copy]
            waitUntilDone:NO];
}

- (void)onThreadPerformBlock:(dispatch_block_t)block {
    !block ?: block();
}

request.HTTPBody

在NSURLProtocol中取request.HTTPBody得到的是nil,并不是因为body真的被NSURLProtocol抛弃了之类的,可以看到发出去的请求还是正常带着body的。
除非你的NSURLProtocol是用于Mock时根据HTTPBody中的参数来返回不同的模拟数据,否则大多数情况是不需要在意这点的。这也不是苹果的bug,只是body数据在URL loading system中到达这里之前就已经被转成stream了。如果必须的话,可以在request.HTTPBodyStream中解析它。

推荐阅读

上一篇下一篇

猜你喜欢

热点阅读