DNS
解析本質上是localDNS
的解析,說白了,你給它一個域名,它返回給你一個IPlist
DNS(Domain Name System)
即域名解析系統,這個東西說對于開發者來說,應該是沒有不知道的。說簡單點,這個系統的作用就是將域名解析成IP地址。我們的每一次網絡請求,如果是使用域名,那么就是進行域名解析。
一個優秀的域名服務應該能夠滿足兩點要求:
-
能夠正確的返回IP地址
, -
就是能夠根據網絡情況返回所請求的域名最近的服務器IP
。
一: DNS解析路線
LocalDNS
一個DNS查詢,會先從本地緩存查找,如果沒有或者已經過期,就從DNS服務器查詢,如果客戶端沒有主動設置DNS服務器,一般是從服務商DNS服務器上查找。這就出現了不可控。因為如果使用了IPS的LocalDNS域名服務器,那么基本都會或多或少地無法避免在有中國特色的互聯網環境中遭遇到各種域名被緩存、用戶跨網訪問緩慢等問題。
我們先來看看普通域名服務會有什么問題:
-
1. 域名劫持:
一些小服務商以及小地方的服務商非常喜歡干這個事情。根據騰訊給出的數據,DNS劫持率7%,惡意劫持率2%。網速給的劫持率是10-15%。- 把你的域名解析到競爭對手那里,然后哭死都不知道,為什么流量下降了。
- 在你的代碼當中,插入廣告或者追蹤代碼。這就是為什么在淘寶或者百度搜索一下東西,很快就有人聯系你。
- 下載APK文件的時候,替換你的文件,下載一個其他應用或者山寨應用。
- 打開一個頁面,先跳轉到廣告聯盟,然后跳轉到這個頁面。無緣無故多花廣告錢,以及對運營的誤導。
2.智能DNS策略失效
智能DNS
,就是為了調度用戶訪問策略,但是這些因素會導致智能DNS策略失效。
- 小運營商,沒有DNS服務器,直接調用別的服務商,導致服務商識別錯誤,直接跨網傳輸,速度大大下降。
- 服務商多長NAT,實際IP,獲得不了,結果沒有就近訪問。
- 一些運營商將IP設置到開卡地,即使漫游到其他地方,結果也是沒有就近訪問。
目前國內大多數企業對于域名解析這塊問題沒有進行特殊處理,這導致了上述說的那些問題,其中域名劫持的問題相當普遍。那么有沒有一種方法能夠避免上述的情況呢?有,當然有。那就是使用HTTPDNS
。
HttpDNS
其實也是對DNS
解析的另一種實現方式,只是將域名解析的協議由DNS協議換成了Http協議,并不復雜。使用HTTP
協議向D+
服務器的80
端口進行請求,代替傳統的DNS協議向DNS服務器的53端口進行請求,繞開了運營商的Local DNS
,從而避免了使用運營商Local DNS
造成的劫持和跨網問題。
接入HttpDNS
也是很簡單的,使用普通DNS時,客戶端發送網絡請求時,就直接發送出去了,有底層網絡框架進行域名解析。當接入HttpDNS
時,就需要自己發送域名解析的HTTP
請求,當客戶端拿到域名對應的IP
之后,就向直接往此IP發送業務協議請求。
這樣,就再也不用再考慮傳統DNS解析會帶來的那些問題了,因為是使用HTTP協議,所以不用擔心域名劫持問題了;而且,如果選擇好的DNS服務器提供商,還保證將用戶引導的訪問速度最快的IDC節點上。
HTTPDNS
是使用http
請求替換域名解析的過程,但一般這個http請求
都是基于https
的,且是IP直連
的,這樣我們就保證了,這個解析域名的http
請求不會被劫持并且內容安全.
二:HTTPDNS服務
我們一般在客戶端上做HTTPDNS
服務的解決方案的時候,策略可簡單可復雜,但大體要圍繞以下幾個問題:
- 1.數據要預先獲取,
- 2.運營商變化的時候要更新數據,
- 3.為了提升獲取ip的成功率要有過期數據的預取策略,
- 4.當由于種種原因,獲取不到
HTTPDNS
的IP
時,要降級為獲取localDNS
的IP
iOS
網絡庫沒有Android
的網絡庫訂制的那么深度,其實就是AFNetworking
和okhttp
的差距,所以針對HTTPDNS
的網路庫適配(才是今天的最佳實踐)iOS
會更加原始一些,它處理的問題大致有以下幾類:
域名替換IP
,防止劫持的關鍵就是不采用域名請求,取而代之的是IP直連
**
https
的處理,由于替換了IP
,https
可信任域名檢驗機制獲取不到域名**HTTP Proxy
的處理,當iOS網絡切換成HTTP
代理后,由于替換了IP會導致連接失敗,一般的處理方式是關閉HTTPDNS
服務Cookie
的處理,由于替換了IP,導致Cookie
的domain
獲取不到,從而使Cookie
失效IPV6
的處理,蘋果在2016年強制推行的IPV6
,由于我們切換成IP直連的方式,所以會在iOS8.4
以下的版本,IPV6-only
的網絡環境下連接失敗,蘋果官方的建議采用getAddressInfo
方法解決這個問題,但這個方法在iOS9.2后才支持將一個IPV4的IP轉成一個IPV6的IP,所以我們一般會在這種情況下降級UIWebView/WKWebView
的處理,不管是哪種WebView
,我們都可以采用蘋果的黑科技NSURLProtocol
進行網絡層面的攔截,從而接管WebView的網絡能力,從而支持WebView
的DNS
反劫持
iOS端
的網絡層是基于AFNetworking
進行封裝實現的,iOS
端的網絡框架NSURLSession
沒有提供DNS解析相關的接口供使用者進行自定義修改DNS
解析結果,因此在iOS端接入HTTPDNS
有幾個通用的問題需要處理,如請求的URL的域名替換為IP地址、請求頭中設置原始HOST、SSL證書校驗處理、Cookie問題處理、重定向、SNI場景下的問題處理,以及對應的SNI場景下的數據編解碼和鏈接復用等問題,上述這些問題都需要有一個統一的解決方案。
因此,我們在騰訊云HTTPDNS
的SDK作為提供HTTPDNS
的基礎能力之上,單獨封裝了iOS端HTTPDNS
的接入層SDK,主要用來實現一些定制的策略和解決上述問題,同時也方便后續更換SDK或者接入自部署的HTTPDNS
方案,讓上層各業務方能夠無感知底層HTTPDNS
服務的存在,減少業務入侵性。
iOS端接入層SDK架構圖如下圖所示:
接口層
接口層提供的部分接口:
// 開啟HTTPDNS服務
- (void)startHTTPDNS;
// 白名單列表,如果設置了白名單,則只有在白名單內域名走httpdns服務
@property (nonatomic, copy) NSArray *whiteDomainList;
// 黑名單列表,如果設置了黑名單,黑名單內域名都不走httpdns,黑名單的優先級最高
@property (nonatomic, copy) NSArray *blackDomainList;
// 是否允許緩存ip,允許緩存的情況下,在通過第三方服務無法獲取ip的情況下,允許使用上次解析成功的ip進行請求,默認YES
@property (nonatomic, assign) BOOL enableCachedIP;
策略層
策略層主要提供不同的策略組合和配置,能夠使得SDK能夠穩定的對外提供HTTPDNS
服務,下面簡單介紹一下每個策略的內容:
容災策略:SDK
內部優先使用HTTPDNS
服務,當HTTPDNS
服務不可用時,即無法獲得有效ip時,服務自動降級為運營商的LocalDNS
服務,確保不受HTTPDNS
服務不可用時導致系統故障無法發出網絡請求。注:目前階段沒有接入內置ip策略,后續會考慮
。
黑白名單策略:APP內的網絡請求域名眾多,目前并不是所有的網絡請求都走HTTPDNS
服務,設置了白名單或者黑名單后,會根據黑白名單中的域名去執行HTTPDNS
,如果設置了白名單,則只有白名單內的域名走HTTPDNS
服務;如果設置了黑名單,黑名單內的域名不走HTTPDNS
服務,黑名單的優先級高于白名單。
緩存策略:緩存策略
除了基礎服務層中騰訊云HTTPDNS
SDK提供的基于TTL
的緩存策略外,我們自己封裝的接入層SDK中還存在一份內存緩存和本地化持久緩存,持久化緩存主要用來解決啟動APP時無法獲取HTTPDNS
中的IP的問題,內存緩存主要為查詢策略提供服務。當某個基于HTTPDNS
的IP地址導致請求失敗后,會清除當前域名和IP的緩存數據。同時外部可控制是否使用緩存。
查詢策略:查詢策略
主要是為了解決,短時間內同一個域名多次調用基礎服務層的域名查詢服務,當狀態是正在查詢中時,后來者不再調用查詢服務,直接從緩存策略中的內存緩存中讀取可用的IP,如果緩存內也無可用的IP,則直接降級為運營商的LocalDNS
查詢。查詢策略可在確保服務可用的同時,有效減少和HTTPDNS
服務器交互的次數。
注入層
注入層在iOS端是依賴
NSURLProtocol
進行攔截網絡請求,在這里不再具體介紹NSURLProtocol
的用法。基于NSURLProtocol攔截網絡請求,我們分別實現了兩套方案,在不需要處理SNI場景的情況下,基于NSURLSession
實現;在需要處理SNI
(Server Name Indication
,單IP多HTTPS證書)場景的情況下,基于CFNetwork
實現。下面我們看一下兩種方案:
- (1)SNI場景下基于CFNetwork的實現方案:
SNI(Server Name Indication)
是為了解決一個服務器使用多個域名和證書的SSL/TLS擴展。它的工作原理如下:
在連接到服務器建立SSL鏈接之前先發送要訪問站點的域名(Hostname)。
服務器根據這個域名返回一個合適的證書。
上述過程中,當客戶端使用HTTPDNS
解析域名時,請求URL中的host
會被替換成HTTPDNS
解析出來的IP,導致服務器獲取到的域名為解析后的IP
,無法找到匹配的證書,只能返回默認的證書或者不返回,所以會出現SSL/TLS
握手不成功的錯誤。
由于iOS上層網絡庫NSURLSession
沒有提供接口進行SNI字段的配置,因此可以考慮使用NSURLProtocol
攔截網絡請求,然后使用CFHTTPMessageRef
創建NSInputStream
實例進行Socket
通信,并設置其kCFStreamSSLPeerName
的值。
注:上述文字來自于騰訊HTTPDNS官方文檔。
基于CFHTTPMessageRef
和NSInputStream
設置SNI
關鍵代碼如下:
// 設置SNI host信息
NSString *host = [self.sniRequest.allHTTPHeaderFields objectForKey:@"Host"];
if (!host) {
host = self.originalRequest.URL.host;
}
[self.inputStream setProperty:NSStreamSocketSecurityLevelNegotiatedSSL forKey:NSStreamSocketSecurityLevelKey];
NSDictionary *sslProperties = @{ (__bridge id) kCFStreamSSLPeerName : host };
[self.inputStream setProperty:sslProperties forKey:(__bridge_transfer NSString *) kCFStreamPropertySSLSettings];
基于CFNetwork
的實現方案,除了設置SNI
信息外,還需要考慮的數據編解碼的問題,在我們看到的眾多的開源代碼和文章中很少有人提及這一點,因此我們在處理響應數據時需要添加類似如下代碼進行響應數據的解碼操作:
//檢查`Content-Encoding`,返回數據是否需要進行解碼操作;
//此處僅做了gzip解碼的處理,業務場景若確定有其他編碼格式,需自行完成擴展。
NSString *contentEncoding = [self.response.headerFields objectForKey:@"Content-Encoding"];
if (contentEncoding && [contentEncoding isEqualToString:@"gzip"]) {
[self.delegate task:self didReceiveData:[self ungzipData:self.resultData]];
} else {
[self.delegate task:self didReceiveData:self.resultData];
}
此外還有非常重要的一點,基于CFNetwork的實現方案,需要考慮連接復用的問題,不能每次請求都重新創建,重新連接的成本非常高。這也是我們在看開源代碼和文章從來不會提及的部分,如果此處不處理,性能消耗非常嚴重。
尤其我們目前大部分請求都已經是HTTP2.0(也就是H2)了,性能對比會更加明顯。但由于蘋果的CFNetwork
框架是不支持HTTP2.0
的,也就是我們很難基于CFNetwork
實現到HTTP2.0
的相關特性。我們目前是實現了HTTP1.1
協議中連接復用這一部分功能,不需要每次請求都重新建立連接。
基本原理為相同host、port、scheme
的請求,在請求發起時如果有可用的沒過期的連接可以復用,就不需要重新建立連接,直接復用連接即可,如果連接在本地過期,或者服務端通過響應頭主動關閉連接,則連接不復用,進行連接關閉。判斷服務端是否連接復用,可通過響應頭的Connection
為keep-alive
還是close
進行判斷。
- (2)非SNI場景下基于CFNetwork的實現方案:
基于NSURLSession
的實現比較簡單,在通過NSURLProtocol
進行攔截請求后,只需要將Request
中的域名替換成IP,在請求頭中設置原始Host
字段和Cookie
字段,重新構建dataTask
任務,發起請求即可,簡單的示例代碼如下:
//處理url和host dnsResultURL為替換ip后的URL
NSMutableURLRequest *ipRequest = [originRequest mutableCopy];
ipRequest.URL = [NSURL URLWithString:dnsResultURL];
[ipRequest setValue:url.host forHTTPHeaderField:@"Host"];
//處理cookie,由于url變了,系統并不會攜帶原域名下的cookie
NSString *cookieString = [[MFSNICookieManager sharedManager] requestCookieHeaderForURL:url];
[ipRequest setValue:cookieString forHTTPHeaderField:@"Cookie"];
self.ipRequest = ipRequest;
self.clientThread = [NSThread currentThread];
self.ipTask = [[[self class] sharedDemux] dataTaskWithRequest:ipRequest delegate:self modes:self.modes];
if(self.ipTask){
[self.ipTask resume];
}
在HTTPS
的證書校驗流程中,由于我們修改了請求URL中的Host
為IP地址,因此證書驗證流程無法通過,因此需要修改證書的驗證流程,在證書驗證時,將IP替換為原來的域名,再進行證書驗證。示例代碼如下:
- (void)URLSession:(NSURLSession *)session task:(NSURLSessionTask *)task didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition, NSURLCredential *_Nullable))completionHandler {
if (!challenge) {
return;
}
NSURLSessionAuthChallengeDisposition disposition = NSURLSessionAuthChallengePerformDefaultHandling;
NSURLCredential *credential = nil;
//獲取原始域名host,用原始請求即可獲取
NSString *host = [[self.originRequest allHTTPHeaderFields] objectForKey:@"Host"];
if (!host) {
host = self.originRequest.URL.host;
}
if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust]) {
if ([self evaluateServerTrust:challenge.protectionSpace.serverTrust forDomain:host]) {
disposition = NSURLSessionAuthChallengeUseCredential;
credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
} else {
disposition = NSURLSessionAuthChallengePerformDefaultHandling;
}
} else {
disposition = NSURLSessionAuthChallengePerformDefaultHandling;
}
// 對于其他的challenges直接使用默認的驗證方案
completionHandler(disposition, credential);
}
- (BOOL)evaluateServerTrust:(SecTrustRef)serverTrust forDomain:(NSString *)domain {
//創建證書策略
NSMutableArray *policies = [NSMutableArray array];
if (domain) {
[policies addObject:(__bridge_transfer id) SecPolicyCreateSSL(true, (__bridge CFStringRef) domain)];
} else {
[policies addObject:(__bridge_transfer id) SecPolicyCreateBasicX509()];
}
//綁定校驗策略到服務端的證書上
SecTrustSetPolicies(serverTrust, (__bridge CFArrayRef) policies);
/*
* 評估當前serverTrust是否可信任,
* 官方建議在result = kSecTrustResultUnspecified 或 kSecTrustResultProceed
* 的情況下serverTrust可以被驗證通過,https://developer.apple.com/library/ios/technotes/tn2232/_index.html
* 關于SecTrustResultType的詳細信息請參考SecTrust.h
*/
SecTrustResultType result;
#pragma clang diagnostic push
#pragma clang diagnostic ignored "-Wdeprecated-declarations"
SecTrustEvaluate(serverTrust, &result);
#pragma clang diagnostic pop
return (result == kSecTrustResultUnspecified || result == kSecTrustResultProceed);
}
基礎服務層
基礎服務層目前階段主要依賴騰訊云HTTPDNS
SDK提供基礎查詢服務,主要提供基于TTL
的緩存存儲和過期處理邏輯,同時這一層還提供SDK
的內部緩存存儲以及日志和基礎校驗等功能;
? ? ? ? ? ?如果你對
性能
有這很高的要求,同時又需要處理SNI
場景的問題,我建議不要直接主動使用HTTPDNS
,而是在運營商LocalDNS
獲取的IP請求失敗的情況下,可以在底層直接使用基于CFNetwork
的網絡請求進行重試,這樣就能在請求DNS劫持
和性能
中間得到一個平衡,既能保證在運營商的LocalDNS
解析出現問題時能夠走HTTPDNS
,保證成功率和可用性;同時又能夠在運營商的LocalDNS
可用時,使用基于NSURLSession
的請求,享受系統實現的HTTP2.0
特性帶來的性能提升。如果,不需要處理SNI
的問題,就老老實實使用基于NSURLSession
的實現方案