本文假設你已經對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文件,依次做了以下操作
- 將scheme、host間的分隔符置為
://
- 將scheme置為小寫
- 將host置為小寫
- 如果host為空,置為
localhost
- 如果path為空,保證host最后帶上
/
- 格式化部分HTTP Header
正如注釋中所表達的,在我們用NSURLProtocol接管一個請求后,URL loading system已經幫不上忙了,需要自己去格式化這個請求。那么這里就有幾個問題:
我們實際項目中到底需不需要在這里去做一遍格式化的工作呢?
大部分項目中的API請求應該都是由統一的基類封裝發出來的,其實已經保證了request格式的正確和統一,所以這個方法直接return request;
就可以了。
如果我就是希望在這里格式化一下呢?
如注釋中所說,CanonicalRequestForRequest
文件可以視為標準操作,直接拿到項目中用就好。
我可以在這個方法里去做HTTPDNS的工作,替換host嗎?
如果使用了NSURLCache,這個方法返回的request決定了NSURLCache的緩存數據庫中request_key
值(數據庫的路徑在app的/Library/Caches/<bundle id>/Cache.db
)
所以,如果在這里替換為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
處調了finishTasksAndInvalidate
或invalidateAndCancel
,內存在短期內還是居高不下。
關于這個內存泄露的問題,推薦閱讀蘋果官方論壇的討論和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
中解析它。