APP網絡優化之DNS優化實踐

>文 掘金號 董寶君 @每日優鮮
html


1、前言

移動端APP網絡優化是客戶端技術優化方向中比較重要的一個方向之一,絕大多數APP都須要有網絡請求這一步,大多數APP在發起請求以前第一步要作的事情就是DNS域名解析,只有將域名解析成正確的IP後,才能進行後續的HTTP或HTTPS請求,所以DNS優化是移動端APP網絡優化中首要的一步。前端

2、背景

隨着APP用戶量不斷增長,不一樣地域和運營商的用戶覆蓋範圍不斷增大,陸續有用戶反饋APP在某些地區出現網絡不可用,通過一段時間的定位和排查,肯定爲運營商DNS劫持和運營商DNS故障所致,所以DNS優化刻不容緩,下圖爲運營商DNS劫持和故障實例圖。ios


3、HTTPDNS

爲了解決DNS劫持和DNS故障問題,須要從DNS解析的根源入手,既然運營商的LocalDNS存在劫持和故障的機率和風險,那麼咱們就使用HTTPDNS進行DNS解析從而繞開運營商的LocalDNS解析,從而下降域名劫持率,提升域名解析效率。緩存

關於HTTPDNS的實現方案,咱們以前有一個完整的方案:APP域名容災方案,根據各自團隊的狀況能夠選擇自建或者第三方SDK的方案。根據目前DNS劫持和故障的嚴重程度,以及實現方案的成本對比。咱們現階段選擇使用騰訊雲HTTPDNS的SDK進行集成,集成後的總體簡圖以下:bash


4、HTTPDNS的最佳實踐

因爲咱們現階段選擇的方案是使用騰訊雲HTTPDNS的SDK,所以下面咱們更多的介紹HTTPDNS在端上的最佳實踐。服務器

HTTPDNS在Android端的最佳實踐

Android端目前的網絡層的是基於OkHttp進行封裝的,OkHttp提供了DNS的接口,用於向OkHttp注入DNS實現。得益於OkHttp的良好設計,實現DNS接口便可接入HTTPDNS進行DNS解析,在較複雜場景(HTTPS + SNI)下也不須要作額外的處理,入侵性極小,所以在這裏不作過多的介紹,具體參見:騰訊雲HTTPDNS在Android端的接入文檔cookie

HTTPDNS在iOS端的最佳實踐

iOS端的網絡層是基於AFNetworking進行封裝實現的,iOS端的網絡框架NSURLSession沒有提供DNS解析相關的接口供使用者進行自定義修改DNS解析結果,所以在iOS端接入HTTPDNS有幾個通用的問題須要處理,如請求的URL的域名替換爲IP地址、請求頭中設置原始HOST、SSL證書校驗處理、Cookie問題處理、重定向、SNI場景下的問題處理,以及對應的SNI場景下的數據編解碼和連接複用等問題,上述這些問題都須要有一個統一的解決方案。網絡

所以,咱們在騰訊雲HTTPDNS的SDK做爲提供HTTPDNS的基礎能力之上,單獨封裝了iOS端HTTPDNS的接入層SDK,主要用來實現一些定製的策略和解決上述問題,同時也方便後續更換SDK或者接入自部署的HTTPDNS方案,讓上層各業務方可以無感知底層HTTPDNS服務的存在,減小業務入侵性。session

iOS端接入層SDK架構圖以下圖所示:架構


接口層

接口層主要爲了對外提供簡潔的接口,下降使用者的接入成本,提升開發效率,如接口層提供的部分接口以下:

/// 開啓HTTPDNS服務
- (void)startHTTPDNS;

/// 白名單列表,若是設置了白名單,則只有在白名單內域名走httpdns服務
@property (nonatomic, copy) NSArray<NSString *> *whiteDomainList;

/// 黑名單列表,若是設置了黑名單,黑名單內域名都不走httpdns,黑名單的優先級最高
@property (nonatomic, copy) NSArray<NSString *> *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實現。下面咱們看一下兩種方案:

非SNI場景下基於NSURLSession的實現方案

基於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);
}複製代碼

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了,性能對比會更加明顯。但因爲蘋果的CFNetwork框架是不支持HTTP2.0的,也就是咱們很難基於CFNetwork實現到HTTP2.0的相關特性。咱們目前是實現了HTTP1.1協議中鏈接複用這一部分功能,不須要每次請求都從新創建鏈接。

基本原理爲相同host、port、scheme的請求,在請求發起時若是有可用的沒過時的鏈接能夠複用,就不須要從新創建鏈接,直接複用鏈接便可,若是鏈接在本地過時,或者服務端經過響應頭主動關閉鏈接,則鏈接不復用,進行鏈接關閉。判斷服務端是否鏈接複用,可經過響應頭的Connection爲keep-alive仍是close進行判斷。

基礎服務層

基礎服務層目前階段主要依賴騰訊雲HTTPDNS SDK提供基礎查詢服務,主要提供基於TTL的緩存存儲和過時處理邏輯,同時這一層還提供SDK的內部緩存存儲以及日誌和基礎校驗等功能。

最佳實踐小結

所以,若是你對性能有這很高的要求,同時又須要處理SNI場景的問題,我建議不要直接主動使用HTTPDNS,而是在運營商LocalDNS獲取的IP請求失敗的狀況下,能夠在底層直接使用基於CFNetwork的網絡請求進行重試,這樣就能在請求DNS劫持和性能中間獲得一個平衡,既能保證在運營商的LocalDNS解析出現問題時可以走HTTPDNS,保證成功率和可用性;同時又可以在運營商的LocalDNS可用時,使用基於NSURLSession的請求,享受系統實現的HTTP2.0特性帶來的性能提高。

若是,不須要處理SNI的問題,就老老實實使用基於NSURLSession的實現方案。

5、收益

DNS優化自上線以來,取得了比較明顯的優化效果,接口錯誤率總體降低超過20%左右。全站未知主機錯誤降低80%(全站不少域名,目前只有核心域名切換了HTTPDNS,所以優化效果是遠遠大於80%),同時在模擬DNS劫持的狀況下,APP核心功能都可正常使用。

6、結語

DNS優化是一件持續的事情,基於目前的現狀和問題咱們採用了上述的優化方案,該方案目前不必定是完美的方案,可能還存在着必定的問題,在方案設計中爲後續的擴展迭代保留了良好的擴展性,咱們會在如今方案基礎上去不斷的優化和演進。

最後,感謝你們的辛苦閱讀,但願能對你們有一點小小的幫助,很是感謝。

7、參考資料


著做權歸做者全部。商業轉載請聯繫本帳號得到受權,非商業轉載請註明每日優鮮大前端團隊以及原文地址。

相關文章
相關標籤/搜索