>文 掘金号 董宝君 @每日优鲜
html
移动端APP网络优化是客户端技术优化方向中比较重要的一个方向之一,绝大多数APP都须要有网络请求这一步,大多数APP在发起请求以前第一步要作的事情就是DNS域名解析,只有将域名解析成正确的IP后,才能进行后续的HTTP或HTTPS请求,所以DNS优化是移动端APP网络优化中首要的一步。前端
随着APP用户量不断增长,不一样地域和运营商的用户覆盖范围不断增大,陆续有用户反馈APP在某些地区出现网络不可用,通过一段时间的定位和排查,肯定为运营商DNS劫持和运营商DNS故障所致,所以DNS优化刻不容缓,下图为运营商DNS劫持和故障实例图。ios
为了解决DNS劫持和DNS故障问题,须要从DNS解析的根源入手,既然运营商的LocalDNS存在劫持和故障的几率和风险,那么咱们就使用HTTPDNS进行DNS解析从而绕开运营商的LocalDNS解析,从而下降域名劫持率,提升域名解析效率。缓存
关于HTTPDNS的实现方案,咱们以前有一个完整的方案:APP域名容灾方案,根据各自团队的状况能够选择自建或者第三方SDK的方案。根据目前DNS劫持和故障的严重程度,以及实现方案的成本对比。咱们现阶段选择使用腾讯云HTTPDNS的SDK进行集成,集成后的总体简图以下:bash
因为咱们现阶段选择的方案是使用腾讯云HTTPDNS的SDK,所以下面咱们更多的介绍HTTPDNS在端上的最佳实践。服务器
Android端目前的网络层的是基于OkHttp进行封装的,OkHttp提供了DNS的接口,用于向OkHttp注入DNS实现。得益于OkHttp的良好设计,实现DNS接口便可接入HTTPDNS进行DNS解析,在较复杂场景(HTTPS + SNI)下也不须要作额外的处理,入侵性极小,所以在这里不作过多的介绍,具体参见:腾讯云HTTPDNS在Android端的接入文档。cookie
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服务,下面简单介绍一下每一个策略的内容:
注入层在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扩展。它的工做原理以下:
上述过程当中,当客户端使用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的实现方案。
DNS优化自上线以来,取得了比较明显的优化效果,接口错误率总体降低超过20%左右。全站未知主机错误降低80%(全站不少域名,目前只有核心域名切换了HTTPDNS,所以优化效果是远远大于80%),同时在模拟DNS劫持的状况下,APP核心功能都可正常使用。
DNS优化是一件持续的事情,基于目前的现状和问题咱们采用了上述的优化方案,该方案目前不必定是完美的方案,可能还存在着必定的问题,在方案设计中为后续的扩展迭代保留了良好的扩展性,咱们会在如今方案基础上去不断的优化和演进。
最后,感谢你们的辛苦阅读,但愿能对你们有一点小小的帮助,很是感谢。
著做权归做者全部。商业转载请联系本帐号得到受权,非商业转载请注明每日优鲜大前端团队以及原文地址。