HttpDns 在 iOS 端的接入方案

前言

最近在公司作網絡相關的優化,從新整理了下以前對 HttpDNS 的認知並編寫了本編文章,以自建 HttpDNS 方案爲基準,講解實際的移動端接入代碼,因爲每一個人的實現方案都有所不一樣,這裏只是拋轉引玉,不必定適合全部項目。html

介紹

當咱們發起一個有域名的請求時,須要先通過 DNS 解析成 IP 地址再發起請求,因此 DNS 的域名解析的穩定性很關鍵,是網絡請求的第一步。git

域名解析流程

默認狀況下,域名都是先通過運營商的 LocalDNS 查詢,例如電信用戶查詢的就是電信的 LocalDNS,移動用戶查詢的是移動的 LocalDNS,一般二者之間解析的 IP 地址是不相同的。github

LocalDNS 未命中再轉發到權威 DNS 服務器上,以訪問 www.163.com 爲例,先找尋 DNS根服務器 獲取 .com 域服務器的地址,再查詢 .com 服務器獲得 163.com 域服務器地址,最後經過 163.com 域服務器獲得準確的 IP 地址,並緩存到 LocalDNS 服務器中。objective-c

總體流程以下圖:算法

更多關於 DNS 內容可查看 DNS 原理入門 - 阮一峯的網絡日誌數據庫

LocalNDS帶來的問題

隨着 App 不一樣地區和運營商的用戶不斷擴大,常常會出現沒法訪問或者訪問慢的問題,通過定位發現瞭如下問題。json

LocalDNS 故障

運營商服務器故障,沒法向權威服務器發起遞歸查詢,致使解析失敗。緩存

DNS 劫持

第三方劫持了 DNS 服務器,篡改了解析結果,使客戶端訪問錯誤 IP 地址,實現資料竊取或惡意訪問。安全

DNS 解析經過緩存返回

LocalDNS 緩存了以前的解析結果,當再次收到解析請求時再也不訪問權威 DNS 服務器,從而保證用戶訪問流量在本網消化或插入廣告。若是權威服務器的 IP 或端口發生改變時,LocalDNS 未更新會致使訪問失敗。服務器

小運營商的解析轉發

小運營商爲了節省資源考慮,不向權威 DNS 服務器發起解析,而直接將請求發送到其餘運營商進行遞歸解析,形成跨網訪問,使用戶訪問變慢。

什麼是 HttpDNS

因爲 LocalDNS 存在種種問題並且不可控,是否能夠繞過它本身進行解析?答案是確定,經過在本身服務器維護一套域名與 IP 的映射關係,再也不通過 LocalDNS 的 53 端口進行 DNS 解析,而是直接向本身服務器的 80 端口發起 HTTP 請求來獲取 IP,再經過 IP 直接進行網絡請求,這種方式即是 HttpDNS。

發起業務請求的步驟:

  1. 客戶端直接訪問 HttpDNS 接口,獲取與該業務請求的域名匹配的最優 IP 地址反饋給客戶端。
  2. 客戶端向獲取到的 IP 後就向直接往此IP發送業務協議請求。以 Http 請求爲例,經過在 header 中指定 host 字段,向 HttpDNS 返回的IP發送標準的Http請求便可。
  3. 基於容錯的考慮,保留 LocalDNS 請求方式做爲備用方案。

HttpDNS 優勢

  • 因爲再也不向 LocalDNS 發起解析,從根本上避免了DNS劫持
  • 直接經過 IP 訪問,省了了域名解析過程,提高用戶訪問速度
  • 可在本身服務器經過算法對 IP 請求成功率高低的進行排序,篩選出優質 IP,增長了請求的成功率

iOS 端的網絡請求實現

HttpDNS 總體方案須要服務器和移動端互相配合,在移動端主要是對網絡請求進行封裝,替換域名請求,作到對用戶無感知,作好緩存和容錯處理,並對成功/失敗請求記錄日誌上傳到服務器;服務器則須要維護域名與 IP 映射關係表並提供下發接口,並經過客戶端日誌進行優化排序。

接下來咱們探討一些實現的步驟。

服務器下發 IP 配置

在 App 啓動時或者合適的時間向服務器請求配置表,這裏的請求能夠用固定 IP 替代域名,免去域名解析的過程。這裏要注意的點是,若是使用 IP 請求,須要在 header 指定 host 字段

NSString *host = "a.test.com";
[request setValue:host forHTTPHeaderField:@"Host"];
複製代碼

具體下發的配置表格式根據實際需求而定便可,例如:

{
    "service" : "深圳移動",
    "enable" : 1,
    "domainlist" : [
                {
                "domain": "a.test.com",
                "ips" :  [
                        "222.66.22.111",
                        "222.66.22.102"
                        ]
                },
                {
                "domain": "b.test.com",
                "ips" :  [
                        "202.29.13.214"
                        ]
                }
    
}



複製代碼

封裝網絡請求

這裏使用的網絡框架 AFNetworking,咱們的封裝是基於該框架進行的。

/**
 請求後返回的block
 */
typedef void(^YENetworkManagerResponseCallBack)(NSDictionary *response, NSDictionary *error);



@interface YENetworkManager : NSObject

+ (nonnull instancetype)shareInstance;

/**
 獲取服務器的DNS數據
 */
- (void)requestRemoteDNSList;

/**
 *  網絡請求
 *  @param url             請求地址
 *  @param paraDic         請求入參 {key: value}
 *  @param method          請求類型 GET|POST
 *  @param timeoutInterval 請求超時時間
 *  @param headersDic      請求頭 {key: value}
 *  @param callBack        請求結果回調
 */
- (void)requestWithUrl:(NSString *)url
                  body:(NSDictionary *)paraDic
                method:(NSString *)method
               timeOut:(NSTimeInterval)timeoutInterval
               headers:(NSDictionary *)headersDic
              callBack:(YENetworkManagerResponseCallBack)callBack;

@end

複製代碼

對外暴露兩個接口,分別用於拉取 DNS 配置和網絡請求,網絡請求部分區別在於需在正式發起請求前先用 IP 替代域名,先看下簡單的實現。

拉取配置這裏先直接從本地讀取,實際項目的仍是應該去請求後臺接口獲取數據。

- (void)requestRemoteDNSList  {
    // 具體的實現根據服務端要求
    NSError*error = nil;
    NSData *data = [[NSData alloc] initWithContentsOfFile:[[NSBundle mainBundle] pathForResource:@"dns.json" ofType:nil]];
    NSDictionary *dic = [NSJSONSerialization JSONObjectWithData:data options:NSJSONReadingAllowFragments error:&error];
    NSArray *domainlist = dic[@"domainlist"];
    NSMutableArray *tempDNSEntityArray = [[NSMutableArray alloc] initWithCapacity:0];
    for (NSDictionary *domainDict in domainlist)
    {
        //建立實體並保存
        YEDNSEntity *cdnsEntity = [YEDNSEntity yy_modelWithDictionary:domainDict];
        [tempDNSEntityArray addObject:cdnsEntity];
    }
    self.dnsEntityListCache = tempDNSEntityArray;
    // TODO: 根據實際需求是否須要存入本地數據庫
}
複製代碼

轉換 ip 是,將域名做爲鍵,在緩存中查來相應的地址,若命中則建立新的 request,並完成:

  1. 以 ip 替換接口域名
  2. 添加域名到頭部 host 字段
  3. 將原 request 的 Cookie 設置到新 request
// 判斷是否支持
- (BOOL)supportHTTPDNS:(NSURLRequest*)request {

    //無DNS數據不處理
    if (self.dnsEntityListCache.count == 0) {
        return NO;
    }

    //本地請求不處理
    if ([request.URL.scheme rangeOfString:@"http"].location == NSNotFound)
    {
        return NO;
    }


    //IP不處理
    if ([self isIPAddressString:request.URL.host])
    {
        return NO;
    }
    return YES;
}

// HTTPDNS轉換
- (NSURLRequest *)transfromHTTPDNSRequest:(NSURLRequest *)request {
    if ([self supportHTTPDNS:request]) {
        YEDNSEntity *entity = [self queryDNSEntityWithDomain:request.URL.host];
        if (entity == nil) {
            return request;
        }
        // 建立ip請求
        NSMutableURLRequest *newURLRequest = request.mutableCopy;
        NSString *ipAddress = nil;
        if (entity.ips && entity.ips.count > 0 && (ipAddress = entity.ips.firstObject))
        {
            //原始host替換爲IP
            NSString *originalHost = request.URL.host;
            NSString *newUrlString = [newURLRequest.URL.absoluteString stringByReplacingFirstOccurrencesOfString:originalHost withString:ipAddress];
            newURLRequest.URL = [NSURL URLWithString:newUrlString];
            
            //添加host頭部
            NSString *realHost = originalHost;
            [newURLRequest setValue:realHost forHTTPHeaderField:@"host"];
            
            //添加原始域名對應的Cookie
            NSString *cookie = [self getCookieHeaderForRequestURL:request.URL];
            if (cookie)
            {
                [newURLRequest setValue:cookie forHTTPHeaderField:@"Cookie"];
            }
        }
        return newURLRequest;
        
    }
    return request;
}
複製代碼

這樣咱們就拿到了新的 ip 的請求體,經過 AFNetworking 發出請求便可。

- (void)requestWithUrl:(NSString *)url
                  body:(NSDictionary *)paraDic
                method:(NSString *)method
               timeOut:(NSTimeInterval)timeoutInterval
               headers:(NSDictionary *)headersDic
              callBack:(YENetworkManagerResponseCallBack)callBack {
    // 參數異常處理
		// ....
    
    // 序列化工具
    AFHTTPRequestSerializer *requestSerializer = [AFJSONRequestSerializer serializer];
    // 設置超時時間
    requestSerializer.timeoutInterval = timeoutInterval < 0 ? 10 :timeoutInterval;
    // 設置請求頭
    for (NSString *headerName in headersDic.allKeys)
    {
        NSString *headerValue = [headersDic objectForKey:headerName];
        [requestSerializer setValue:headerValue forHTTPHeaderField:headerName];
    }
    
    // 構建原始request
    NSURLRequest *originalRequest =  [requestSerializer requestWithMethod:method
                                                                 URLString:url
                                                                parameters:[paraDic count] == 0 ? nil : paraDic
                                                                     error:nil];

    // HTTPDNS處理
    NSURLRequest *ipRequest = [self transfromHTTPDNSRequest:originalRequest];
    
    // SessionManager
    [[YESessionTool shareInstance] getSessionManagerWithRequest:ipRequest callBack:^(YESessionManager * _Nonnull sessionManager) {
        [sessionManager dataTaskWithRequest:ipRequest uploadProgress:^(NSProgress * _Nonnull uploadProgress) {
            //不處理
        } downloadProgress:^(NSProgress * _Nonnull downloadProgress) {
            //不處理
        } completionHandler:^(NSURLResponse * _Nonnull response, id  _Nullable responseObject, NSError * _Nullable error) {
            if (callBack) {
                if (error) {
                        NSDictionary *errorDic = [NSDictionary dictionaryWithObject:error.description forKey:@"message"];
                        callBack(@{}, errorDic);
                    
                } else {
                    // 數據解析
                    NSDictionary *responseDict = [responseObject objectFromJSONData];
                    
                    if (responseDict != nil && [responseDict isKindOfClass:[NSDictionary class]]) {
                        callBack(responseDict, @{});
                    } else {
                        NSDictionary *errorDic = [NSDictionary dictionaryWithObject:@"數據解析錯誤" forKey:@"message"];
                        callBack(@{}, errorDic);
                    }
                }
            }
        }];
    }];
    
}
複製代碼

容錯處理&埋點

使用 IP 請求出現問題時,咱們須要降級處理,使用備用 ip 或者域名再次嘗試請求,除此以外,再請求結束後最好上傳成功或失敗的日誌,便於服務器分析 IP 的可用性,咱們改造下上面的請求響應部分:

// SessionManager
    [[YESessionTool shareInstance] getSessionManagerWithRequest:ipRequest callBack:^(YESessionManager * _Nonnull sessionManager) {
        [sessionManager dataTaskWithRequest:ipRequest uploadProgress:^(NSProgress * _Nonnull uploadProgress) {
            //不處理
        } downloadProgress:^(NSProgress * _Nonnull downloadProgress) {
            //不處理
        } completionHandler:^(NSURLResponse * _Nonnull response, id  _Nullable responseObject, NSError * _Nullable error) {
            if (callBack) {
                if (error) {
                    
                    //TODO: 失敗埋點上傳
                    // 降級請求
                    if ([self canDegradeForRequest:ipRequest.URL error:error]) {
                        // 移除該IP
                        [self removeIpInCacheWithDomain:originalRequest.URL.host ip:ipRequest.URL.host];
                        // 從新發起
                        [self requestWithUrl:url body:paraDic method:method timeOut:timeoutInterval headers:headersDic callBack:callBack];
                    } else {
                        NSDictionary *errorDic = [NSDictionary dictionaryWithObject:error.description forKey:@"message"];
                        callBack(@{}, errorDic);
                    }
                    
                } else {
                    //TODO: 成功埋點上傳
                    // 保存Cookie
                    if (![self isIPAddressString:originalRequest.URL.host] && ![originalRequest.URL.host isEqualToString:ipRequest.URL.host]) {
                        NSDictionary *responseHeaderDict = ((NSHTTPURLResponse *)response).allHeaderFields;
                        [self storageHeaderFields:responseHeaderDict forURL:ipRequest.URL];
                    }
                    // 數據解析
                    NSDictionary *responseDict = [responseObject objectFromJSONData];
                    
                    if (responseDict != nil && [responseDict isKindOfClass:[NSDictionary class]]) {
                        callBack(responseDict, @{});
                    } else {
                        NSDictionary *errorDic = [NSDictionary dictionaryWithObject:@"數據解析錯誤" forKey:@"message"];
                        callBack(@{}, errorDic);
                    }
                }
               

            }
        }];
    }];
複製代碼

解決安全證書校驗問題

證書校驗分爲 IP 請求和域名請求,對於普通的域名請求,咱們只須要設置 SessionManager 安全策略便可。

// 域名請求的證書校驗設置
- (void)setDomainNetPolicy: (YESessionManager *)manager request:(NSURLRequest *)request {
    AFSecurityPolicy *securityPolicy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModeCertificate];
    securityPolicy.validatesDomainName = YES;
    securityPolicy.allowInvalidCertificates = YES;
    // 從本地獲取cer證書,僅做參考
    NSString * cerPath = [[NSBundle mainBundle] pathForResource:CerFile ofType:@"cer"];
    NSData * cerData = [NSData dataWithContentsOfFile:cerPath];
    securityPolicy.pinnedCertificates = [NSSet setWithObject:cerData];
    manager.securityPolicy = securityPolicy;
    
}
複製代碼

IP 請求部分稍微複雜點,咱們在收到服務器安全認證請求時,再用真實域名和本地證書去進行校驗,AFNetworking 提供了 setSessionDidReceiveAuthenticationChallengeBlocksetTaskDidReceiveAuthenticationChallengeBlock 方法可讓咱們設置認證請求時的回調。

// IP請求的證書校驗設置
- (void)setIPNetPolicy: (YESessionManager *)manager request:(NSURLRequest *)request {
    // 判斷是否存在域名
    NSString *realDomain = [request.allHTTPHeaderFields objectForKey:@"host"];
    if (realDomain == nil || realDomain.length == 0) {
        //無域名不驗證
        return;
    }
    // 經過客戶端驗證服務器信任憑證
    [manager setSessionDidReceiveAuthenticationChallengeBlock:^NSURLSessionAuthChallengeDisposition(NSURLSession * _Nonnull session, NSURLAuthenticationChallenge * _Nonnull challenge, NSURLCredential *__autoreleasing  _Nullable * _Nullable credential) {
        return [self handleReceiveAuthenticationChallenge:challenge credential:credential host:realDomain];
    }];
    [manager setTaskDidReceiveAuthenticationChallengeBlock:^NSURLSessionAuthChallengeDisposition(NSURLSession * _Nonnull session, NSURLSessionTask * _Nonnull task, NSURLAuthenticationChallenge * _Nonnull challenge, NSURLCredential *__autoreleasing  _Nullable * _Nullable credential) {
        return [self handleReceiveAuthenticationChallenge:challenge credential:credential host:realDomain];
    }];
}


// 處理認證請求發生的回調
- (NSURLSessionAuthChallengeDisposition)handleReceiveAuthenticationChallenge:(NSURLAuthenticationChallenge*)challenge
                                                       credential:(NSURLCredential**)credential
                                                             host:(NSString*)host
{
    NSURLSessionAuthChallengeDisposition disposition = NSURLSessionAuthChallengePerformDefaultHandling;
    if ([challenge.protectionSpace.authenticationMethod isEqualToString:NSURLAuthenticationMethodServerTrust])
    {
        //驗證域名是否被信任
        if ([self evaluateServerTrust:challenge.protectionSpace.serverTrust forDomain:host])
        {
            *credential = [NSURLCredential credentialForTrust:challenge.protectionSpace.serverTrust];
            if (*credential)
            {
                disposition = NSURLSessionAuthChallengeUseCredential;
            }
            else
            {
                disposition = NSURLSessionAuthChallengePerformDefaultHandling;
            }
        }
        else
        {
            disposition = NSURLSessionAuthChallengeCancelAuthenticationChallenge;
        }
    }
    else
    {
        disposition = NSURLSessionAuthChallengePerformDefaultHandling;
    }
    return disposition;
}

//驗證域名
- (BOOL)evaluateServerTrust:(SecTrustRef)serverTrust forDomain:(NSString *)domain
{
    
    AFSecurityPolicy *securityPolicy = [AFSecurityPolicy policyWithPinningMode:AFSSLPinningModeCertificate];
    securityPolicy.validatesDomainName = YES;
    securityPolicy.allowInvalidCertificates = YES;
    // 從本地獲取cer證書,僅做參考
    NSString * cerPath = [[NSBundle mainBundle] pathForResource:CerFile ofType:@"cer"];
    NSData * cerData = [NSData dataWithContentsOfFile:cerPath];
    securityPolicy.pinnedCertificates = [NSSet setWithObject:cerData];
    
    return [securityPolicy evaluateServerTrust:serverTrust forDomain:domain];
}
複製代碼

總流程圖

總結

本文簡單介紹了 HttpDNS 和域名解析帶來的問題,代碼部分已放在 IOSDevelopTools-Network,僅做參考,還需根據實際項目來接入功能。

目前實現跟網絡請求耦合在一塊兒,還不算是完美的解決方案,後續有時間再補充 HTTPDNS模塊的解耦WKWebview及AVplayer的處理,敬請期待吧 😂。

About Me

參考連接

相關文章
相關標籤/搜索