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