本文假設(shè)你已經(jīng)對NSURLProtocol有所了解,已了解的建議閱讀蘋果的Sample Code CustomHTTPProtocol意蛀。
簡書使用NSURLProtocol在請求時(shí)添加ETag頭信息涛碑、替換URL host為HTTPDNS得到的ip颊艳,在返回時(shí)進(jìn)行SSL Pinning的證書校驗(yàn)畜伐,保證了網(wǎng)絡(luò)請求的可用性和安全性吗讶。
由于NSURLProtocol屬于蘋果的黑魔法袖裕,文檔并不詳細(xì)曹抬,有些教程和諸如“NSURLProtocol的坑”的文章本身也是有坑或不完善的,所以我們寫下這篇文章來分享簡書在NSURLProtocol的開發(fā)使用中遇到的誤區(qū)和摸索出的更佳實(shí)踐(注意:可能并不是最佳實(shí)踐)急鳄,歡迎在原文評論區(qū)指正谤民。
+canonicalRequestForRequest:
canonical用于形容詞時(shí)意為典范的、標(biāo)準(zhǔn)的疾宏,也就是說這個(gè)方法更希望返回的是一個(gè)標(biāo)準(zhǔn)的request张足,所以什么才算標(biāo)準(zhǔn)的request,這個(gè)方法到底用來干嘛
我們可以看下蘋果示例CustomHTTPProtocol項(xiàng)目中的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.
簡單說就是要在這個(gè)方法里將request格式化坎藐,具體看它的.m文件为牍,依次做了以下操作
- 將scheme、host間的分隔符置為
://
- 將scheme置為小寫
- 將host置為小寫
- 如果host為空岩馍,置為
localhost
- 如果path為空吵聪,保證host最后帶上
/
- 格式化部分HTTP Header
正如注釋中所表達(dá)的,在我們用NSURLProtocol接管一個(gè)請求后兼雄,URL loading system已經(jīng)幫不上忙了吟逝,需要自己去格式化這個(gè)請求。那么這里就有幾個(gè)問題:
我們實(shí)際項(xiàng)目中到底需不需要在這里去做一遍格式化的工作呢赦肋?
大部分項(xiàng)目中的API請求應(yīng)該都是由統(tǒng)一的基類封裝發(fā)出來的块攒,其實(shí)已經(jīng)保證了request格式的正確和統(tǒng)一,所以這個(gè)方法直接return request;
就可以了佃乘。
如果我就是希望在這里格式化一下呢囱井?
如注釋中所說,CanonicalRequestForRequest
文件可以視為標(biāo)準(zhǔn)操作趣避,直接拿到項(xiàng)目中用就好庞呕。
我可以在這個(gè)方法里去做HTTPDNS的工作,替換host嗎?
如果使用了NSURLCache住练,這個(gè)方法返回的request決定了NSURLCache的緩存數(shù)據(jù)庫中request_key
值(數(shù)據(jù)庫的路徑在app的/Library/Caches/<bundle id>/Cache.db
)
所以地啰,如果在這里替換為HTTPDNS得到的host,就可能存在服務(wù)端數(shù)據(jù)不變讲逛,但由于ip改變導(dǎo)致request_key不同而無法命中cache的情況亏吝。
-startLoading
這也是個(gè)比較容易出問題的方法,下邊講三個(gè)易錯(cuò)點(diǎn)盏混。
一蔚鸥、不要在這個(gè)方法所在的線程里做任何同步阻塞的操作,例如網(wǎng)絡(luò)請求许赃,異步請求+信號(hào)量也不行止喷。具體原因文檔中沒有提及,但這會(huì)使方法里發(fā)出的請求和startLoading本身的請求最終超時(shí)混聊。
二弹谁、很多使用NSURLProtocol做HTTPDNS的教程或demo里都教在該方法里直接創(chuàng)建NSURLSession,然后發(fā)出去修改后的請求技羔,類似于
// 注意:這是錯(cuò)誤示范
- (void)startLoading {
...
重新構(gòu)造request
...
NSURLSession *session = [NSURLSession sessionWithConfiguration:[NSURLSessionConfiguration defaultSessionConfiguration] delegate:self delegateQueue:nil];
NSURLSessionDataTask *task = [session dataTaskWithRequest:request];
[task resume];
}
當(dāng)然,這個(gè)和NSURLProtocol本身關(guān)系不大了卧抗,而是NSURLSession的用法出現(xiàn)了嚴(yán)重錯(cuò)誤藤滥。對于用途相同的request,應(yīng)該只創(chuàng)建一個(gè)URLSession社裆,可參考AFNetworking拙绊。每個(gè)request都創(chuàng)建一個(gè)URLSession是低效且不推薦的,可能會(huì)遇到各種無法預(yù)知的bug泳秀,而且最致命的是即使你在-stopLoading
處調(diào)了finishTasksAndInvalidate
或invalidateAndCancel
标沪,內(nèi)存在短期內(nèi)還是居高不下。
關(guān)于這個(gè)內(nèi)存泄露的問題嗜傅,推薦閱讀蘋果官方論壇的討論和StackOverFlow的回答金句。概括下來就是每個(gè)NSURLSession都會(huì)創(chuàng)建一個(gè)維持10min的SSL cache,這個(gè)cache由Security.framework私有吕嘀,無論你在這里調(diào)什么方法都不會(huì)清掉這個(gè)cache违寞,所以在10min內(nèi)的內(nèi)存增長可能是無限制的。
正確的姿勢應(yīng)該像CustomHTTPProtocol那樣創(chuàng)建一個(gè)URLSession單例來發(fā)送里面的請求偶房,或者像我一樣依舊用NSURLConnection
來發(fā)請求趁曼。
三、如果問題二最后采用NSURLConnection
發(fā)請求棕洋,那么在結(jié)合HTTPDNS獲取ip時(shí)應(yīng)該會(huì)出現(xiàn)形如以下的代碼:
- (void)startLoading {
...
[[HTTPDNSManager manager] fetchIp:^(NSString *ip) {
...替換host...
self.connection = [NSURLConnection connectionWithRequest:theRequest delegate:self];
}];
}
你會(huì)發(fā)現(xiàn)URLConnection能發(fā)出請求但回調(diào)并不會(huì)走挡闰,這個(gè)很好理解,因?yàn)閁RLConnection的回調(diào)默認(rèn)和發(fā)起的線程相同,而發(fā)起是在-[HTTPDNSManager fetchIp:]
的回調(diào)線程中摄悯,這個(gè)線程用完就失活了赞季,所以解決這個(gè)問題的關(guān)鍵在于使URLConnection的回調(diào)在一個(gè)存活的線程中。乍一想有3種方案:1射众、將創(chuàng)建URLConnection放到startLoading所在的線程執(zhí)行碟摆;2、用-[NSURLConnection setDelegateQueue:]
方法設(shè)置它的回調(diào)隊(duì)列叨橱;3典蜕、將創(chuàng)建URLConnection放到主線程執(zhí)行,非常暴力罗洗,但是我確實(shí)見過這么寫的愉舔。這3種方案其實(shí)只有第1種可用。先看下CustomHTTPProtocol的Read Me.txt(是的伙菜,NSURLProtocol的文檔還沒這個(gè)Sample Code的Readme詳細(xì))轩缤,中間部分有一段:
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回調(diào)里面的方法再強(qiáng)行調(diào)到client線程上去贩绕,那樣的話還不如直接用方案1火的。
繼續(xù)看那個(gè)txt,還是上述引用的位置淑倾,往下幾行有個(gè)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請求在回調(diào)中需要驗(yàn)證SSL證書馏鹤,離不開SecTrustEvaluate函數(shù)〗慷撸可以看到SecTrustEvaluate
的文檔最后有個(gè)特別注意事項(xiàng)湃累,第二段寫道
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
時(shí)阻塞掉主線程。
看了這么多錯(cuò)誤示范碍讨,下邊來看方案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治力,并不是因?yàn)閎ody真的被NSURLProtocol拋棄了之類的,可以看到發(fā)出去的請求還是正常帶著body的勃黍。
除非你的NSURLProtocol是用于Mock時(shí)根據(jù)HTTPBody中的參數(shù)來返回不同的模擬數(shù)據(jù)宵统,否則大多數(shù)情況是不需要在意這點(diǎn)的。這也不是蘋果的bug覆获,只是body數(shù)據(jù)在URL loading system中到達(dá)這里之前就已經(jīng)被轉(zhuǎn)成stream了榜田。如果必須的話,可以在request.HTTPBodyStream
中解析它锻梳。