最近在公司作網絡相關的優化,從新整理了下以前對 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 原理入門 - 阮一峯的網絡日誌數據庫
隨着 App 不一樣地區和運營商的用戶不斷擴大,常常會出現沒法訪問或者訪問慢的問題,通過定位發現瞭如下問題。json
運營商服務器故障,沒法向權威服務器發起遞歸查詢,致使解析失敗。緩存
第三方劫持了 DNS 服務器,篡改了解析結果,使客戶端訪問錯誤 IP 地址,實現資料竊取或惡意訪問。安全
LocalDNS 緩存了以前的解析結果,當再次收到解析請求時再也不訪問權威 DNS 服務器,從而保證用戶訪問流量在本網消化或插入廣告。若是權威服務器的 IP 或端口發生改變時,LocalDNS 未更新會致使訪問失敗。服務器
小運營商爲了節省資源考慮,不向權威 DNS 服務器發起解析,而直接將請求發送到其餘運營商進行遞歸解析,形成跨網訪問,使用戶訪問變慢。
因爲 LocalDNS 存在種種問題並且不可控,是否能夠繞過它本身進行解析?答案是確定,經過在本身服務器維護一套域名與 IP 的映射關係,再也不通過 LocalDNS 的 53 端口進行 DNS 解析,而是直接向本身服務器的 80 端口發起 HTTP 請求來獲取 IP,再經過 IP 直接進行網絡請求,這種方式即是 HttpDNS。
發起業務請求的步驟:
HttpDNS 總體方案須要服務器和移動端互相配合,在移動端主要是對網絡請求進行封裝,替換域名請求,作到對用戶無感知,作好緩存和容錯處理,並對成功/失敗請求記錄日誌上傳到服務器;服務器則須要維護域名與 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,並完成:
// 判斷是否支持
- (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 提供了 setSessionDidReceiveAuthenticationChallengeBlock
和 setTaskDidReceiveAuthenticationChallengeBlock
方法可讓咱們設置認證請求時的回調。
// 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的處理,敬請期待吧 😂。