“ 提供的服务再好,用户连接不上也白搭。本文从理论出发,逐步阐述了小米有品技术团队在移动端对网络DNS方面的改进与尝试。”
01
—
DNS的工作原理
DNS,全称Domain Name System,即域名解析系统。它负责将域名,例如:www.baidu.com,转换成它对应的IP地址:182.61.200.6。
你可以尝试在浏览器中输入http://www.baidu.com或者http://182.61.200.6,它们都能访问百度的服务,但应该没有人会日常使用 IP 地址来上网。DNS 的作用有点类似电话通讯录,没人能记得住冗长且可能会变化的电话号码,但记住一个名称相对简单。
DNS 是互联网的基石之一,无论网页还是 APP,每一个网络请求的第一步,就是通过 DNS 查询 IP 地址。无论你是通过 WiFi,还是 4G/5G 移动数据上网,你的互联网服务提供商或称为运营商(ISP),例如:中国联通、中国移动,都会下发几个 DNS 服务的地址给你的手机或电脑,每次进行网络请求时,都会先向 DNS 服务查询访问域名的 IP 地址,之后再继续向拿到的 IP 地址发送你真正的想发送的请求。
我们以访问网页https://www.xiaomiyoupin.com为例,描述一次 DNS 查询的大致过程:
手机或电脑等客户端向运营商下发的 DNS 服务器地址发送www.xiaomiyoupin.com这个域名的DNS查询请求。
运营商 DNS 服务器会查询本地缓存记录,如果有有效IP地址则返回给请求客户端,没有则自己向上级服务器查询,如果没有会继续向再上级查询。这个过程被称为递归查询。
递归查询一直到权威服务器,返回IP地址。为了方便描述,此处可以简单地认为“权威服务器“保存了所有域名与IP地址的对应关系,所有的域名注册与更新都在此记录。实际上权威服务器与TLD服务器以及根服务器协同才能完成这个过程。
权威服务器返回有效IP地址,递归服务器一级一级缓存并将IP地址返回给请求客户端。缓存有效期内,当递归服务器再次收到相同域名请求时,可返回缓存结果,而无需再费力递归查询至权威服务器。
请求客户端拿到IP地址 220.181.52.160。
请求客户端向目标IP地址 220.181.52.160 发送 HTTPS 请求,获得结果。
02
—
尽管默认使用运营商提供的 DNS 服务是目前绝对主流的域名解析方式,每天支撑着几乎所有设备连接互联网,为海量信息导航到目标机器。但它也有不少问题:
可用性取决于当地的运营商运维水平,可能出现 DNS 服务器故障,越偏远的地区,出现问题的可能性越大,维护不够及时。
使用 UDP 明文通信,容易被人恶意劫持。
部分运营商可能为节约成本,减少服务器查询次数,超长使用缓存,导致域名如果已经在权威域名服务器变更了 IP 地址,无法及时生效,最终造成用户无法访问。尤其是遇到业务需要及时调度流量时,由于运营商的缓存问题,等待解析结果在全网生效的时间将十分漫长。
除了缓存 IP 地址,运营商还会缓存内容。比如域名A下的某段 json 数据,用户访问后,运营商为了加快访问速度,会将该数据保存至自己的服务器,后续有用户再次访问该数据时,返回的 IP 并不是域名A的,而是运营商自己的服务器。用户访问没有问题,但是域名A作为数据源头想及时更新这段数据,将变得非常困难。
获得的 IP 地址并不是最优。为方便用户更快速的访问,一个域名往往在不同运营商网段、不同地区部署了多个 IP 地址,理想情况下就近访问同网段的 IP 地址速度最佳。但存在部分运营商偷懒自己不部署 DNS 递归服务器,而是将 DNS 解析请求转发到其他运营商,或者利用 NAT 转换,将用户的出口公网 IP 不定向转换至其他运营商,导致无法准确判断请求来源,最终从权威服务器返回的往往是跨网段、跨地区的 IP 地址,用户访问相对较慢。
*图中用户 C 本应获得IP 3.3.3.3 才能访问速度最快
最后是解析延时问题。由于各地运营商服务质量参差不齐,线路、路由、DNS 服务器性能差异等等,可能进行一次 DNS 查询的延时严重,最终导致用户网络请求耗时增加。
一方面是当下 DNS 服务的隐忧,另一方面,黑客针对 DNS 的攻击也越来越频繁。根据 IDC 发布的《2020年全球 DNS 威胁报告》,2020年全球有 79% 的企业或组织受到了 DNS 攻击,每个组织平均遭受攻击9.5次,每次攻击造成的损失(包括补救成本、时间成本、商业损失)平均高达 92.4w 美金。
有品电商2020年7月抽样监控 iOS 与 Android 平台小米有品 APP ,统计显示,有 3.044 % 的网络请求无法通过 DNS 获取IP地址,同时有 0.314% 网络请求获取的 IP 地址不正确(被劫持)。
03
—
随着 DNS 问题的突出,越来越多的互联网服务商开始直接向用户提供 DNS 服务。毕竟如果用户因为自己网络的问题无法打开你的 APP,他们大多会抱怨你 APP 服务不行,运营商也不用承担责任。而使用 HTTPDNS 是移动端APP、PC客户端等应用在现阶段提高用户 DNS 服务质量非常有效的方法。
HTTPDNS 使用 HTTPS 协议绕过运营商 DNS,直接将用户的域名解析请求发送到自己或购买的第三方服务的服务器上,然后返回 IP 地址。原理并不复杂,但带来的收益却十分明显:
HTTPS 协议相比于传统 DNS 依旧使用 UDP 53 端口明文发送域名查询,具有极高的安全性与隐私性。能有效避免被劫持的问题。
直接绕过了运营商,服务可用性自己保障,并且直接解决了 DNS 缓存无法及时更新的问题。可根据自身业务需求快速更新 IP 地址,调度流量。
客户端在本地构建缓存,IP 地址有效期内不进行 DNS 网络请求,直接使用本地 IP。用户能获得比以往更快、更稳定的网络请求速度。
用户客户端发起 HTTPDNS 请求时,可直接获取客户端本地 IP 传输给服务器,服务器可结合 IP 地址库返回给客户端同网段、距离最近的 IP 地址。同时服务器可以返回多个 IP 地址,客户端在用户实际网络环境中测速后,选择最优 IP 地址。
对用户来说,无感知,无额外操作。对互联网服务商来说,扩展性强,业务可根据自身需求实现更精细化的流量调度,甚至是不同版本客户端、不同网络类型、不同地理位置都可以做差异化处理。
如果 HTTPDNS 出现任何故障,依然可以切换使用原运营商提供的 DNS 服务。
04
—
想要实现一套完整的 HTTPDNS 方案并充分发挥上述优势,需要不少工作并注重一些细节。现结合行业资料以及小米有品 APP 实现经验,总结如下:
一个高可用性、灵活的 HTTPDNS 后端服务是整个方案的基础。选型时必须考虑以下需求:
提供 HTTPDNS API,使用域名与客户端 IP 作为参数,返回距离客户端最近的 IP 地址或地址列表,以及有效时间(TTL)。
能及时同步权威域名服务器。
多节点分布,支持 AnyCast IP(见文章末尾拓展阅读:什么是AnyCast)访问,避免 API 自身遭遇 DNS 故障,以及可以节点就近访问,加快获取速度。
除自建服务外,目前市面有阿里云与腾讯云提供的 HTTPDNS 服务相对主流。两者除上述基础需求能满足之外,还支持扩展,方便流量的精准调度。
*阿里云 HTTPDNS 服务支持函数计算自定义域名解析
*HTTPDNS 交互流程
HTTPDNS SDK 涵盖交互图中的查询模块与更新模块。外部工程使用 SDK 接口,传入需要解析的域名,SDK内部根据域名查询缓存,如果当前缓存中有 TTL 未过期的 IP 地址,则直接返回。如果缓存中没有有效 IP,返回空,外部工程应立刻让此次请求降级走原生 DNS 解析,同时 SDK 内异步发起 HTTPDNS API 请求,获取结果,更新缓存,这样后续解析域名时就能命中缓存。
对于单域名对应多 IP 的情况,SDK 内实现 TCP Socket 连接测速功能,根据连接速度将 IP 地址排序,更新缓存。同时,可对外提供评价接口,外部工程通过接口反馈网络请求成功与否,SDK内根据测速与访问成功率综合评价排序。
考虑到目前普遍将IP TTL设定得相对较短:普通的600s,短至10s。而大多数情况下,业务不会频繁的更换 IP 地址,如果每次 TTL 过期则请求 API 刷新缓存会徒增服务器压力,SDK 可对外提供接口是否允许使用 TTL 过期的 IP 地址,让业务灵活使用。
提供预解析接口,让外部工程启动时既可传入域名,开始请求获取IP地址。以便在真实业务请求前,已获得 IP 地址,缓存直接命中。
提供缓存持久化功能,对于 IP 地址更换不频繁的业务,可以选择开启该功能,将缓存保存在本地,APP 启动时即可直接使用,可以加快首屏的加载速度。
监测用户网络类型,当用户切换网络,比如 Wi-Fi 与移动数据网络切换时,应立即异步请求 API,获取最新IP地址,更新缓存。
检测系统网络代理状态,当用户使用网络代理时,停止HTTP DNS解析,让外部工程使用原生网络请求。
如果使用第三方提供的HTTP DNS服务,他们大多会提供SDK,覆盖上述功能,可直接使用。而我们在参考阿里云、腾讯云等商用SDK的基础上,根据业务实际需求,完善加入了:IPv4、IPv6双栈优选、支持 Webview、用户常用WiFi缓存检测等功能,已在小米有品APP Android 与 iOS 两个平台上线。
完成 SDK,解决掉获取 IP 地址的问题后,客户端如何平滑地接入现有网络引擎,是个比构建 SDK 更值得研究、探讨的课题。我们分 Android 与 iOS 两个部分来叙述:
相比于 iOS,Android 简直太幸运,有优秀的 OkHttp 项目,并且已经成为事实上的“标准”网络库,被绝大多数应用广泛使用,从Android 4.4起,系统自身也内置使用了 OkHttp。更加幸运的是,OkHttp支持自定义 DNS。参考文档:
https://square.github.io/okhttp/4.x/okhttp/okhttp3/-ok-http-client/-builder/dns/
我们实现 Interface Dns
即可:
public class myDns implements Dns {
//复写lookup函数,这是OkHttp进行DNS查询的入口
public List<InetAddress> lookup(String hostname) throws UnknownHostException {
//从SDK获取IP地址列表
final String[] ips = myHttpDnsSDK.getIPByHostname(hostname);
//获取成功(SDK内缓存命中),则使用
if(ips !=null && ips.length > 0) {
List<InetAddress> inetAddresses = new ArrayList<>();
for (String ip : ips) {
inetAddresses.add(InetAddress.getByName(ip));
}
return inetAddresses;
}
//获取失败,则使用系统默认DNS
return Dns.SYSTEM.lookup(hostname);
}
}
OkHttpClient
对象初始化时传入:OkHttpClient client = new OkHttpClient().newBuilder().dns(myDns).build();
"call(* okhttp3.OkHttpClient.Builder.build(..))") (
public void beforeBuildOkHttpClient(JoinPoint joinPoint) {
((OkHttpClient.Builder)joinPoint.getTarget()).dns(myDns);
}
NSURLSeesion
或基于它的网络库,比如 AFNetworking
,由于缺乏DNS 扩展,那支持 HTTPDNS 将是相当繁琐的一件事。NSURLSeesion
,独立研发或使用开源网络引擎,对小团队来说,维护成本还是相当高的,而且面对升级 HTTP 版本、TLS 版本等等网络协议更新时,无法自动享受系统升级带来的新特性,需要额外的适配工作。此处可以参考一个非常有趣的开源项目:YMHTTP。为了接入 HTTPDNS,他们将开源网络引擎 libcurl 封装,在与 NSURLSession
API 保持高度一致的同时拓展了 DNS 的能力,用 iOS 开发者熟悉的使用方式使用 libcurl。当然,依然无法避免不使用原生网络引擎带来的诸多不便:比如目前不支持 HTTP/2;不支持 NSURLSessionTaskMetrics
,无法进行网络监控。NSURLSession
,似乎只能使用 IP 直连方案,在请求 URL 中直接用 IP地址替换域名,将https://www.domian.com/path/to/myfile?key=value替换为诸如https://www.1.2.3.4.com/path/to/myfile?key=value形式。
//IP直连方案,非SNI证书处理
-(void)URLSession:(NSURLSession *)session
task:(NSURLSessionTask *)task
didReceiveChallenge:(NSURLAuthenticationChallenge *)challenge
completionHandler:(void (^)(NSURLSessionAuthChallengeDisposition disposition, NSURLCredential * __nullable credential))completionHandler{
NSURLSessionAuthChallengeDisposition disposition = NSURLSessionAuthChallengePerformDefaultHandling;
NSURLCredential credential = nil;
//获取原域名来验证
NSString host = [[self.request allHTTPHeaderFields] objectForKey:@"host"];
if (!host) {
host = self.request.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);
}
NSURLSeesion
也未提供任何接口设置,发起 TLS 握手请求时,由于服务器下有多个域名的证书,而握手请求中是 IP 地址,无一能匹配。服务器一般会返回默认证书,大概率是其他域名的,像非 SNI 情景一样仅仅修改名称的方法也失效了。NSURLSeesion
,还能处理 SNI 吗?如果不考虑 App Store 过审要求,答案是:有的,使用私有 API。NSURLSeesion
正是基于此框架实现。我们随意发起一次 HTTPS 请求,看看他们之间的调用关系:NSURLSeesion
每次建立连接,经过私有类 NWconcrete_nw_connection
桥接,对应的是 Network Framework 中的建连方法 nw_connection_create
。该方法有两个关键参数:endpoint
目标连接点,包括目标服务器的地址与端口,如果地址是域名,则系统会进行 DNS 解析获得 IP 地址,地址是 IP 地址,则直接发起连接;parameters
连接参数,包括使用 TCP 还是 UDP 协议等详细参数;NWConcrete_nw_connection
中的initWithEndpoint:parameters:identifier:
方法,替换endpoint
参数,使我们HTTPDNS SDK 提供的IP地址,则相当于跳过了系统 DNS 过程,使用了我们自己的 DNS 服务,并且系统会自动使用原 endpoint 中的 hostname 进行TLS握手。
- (id)swizzled_nw_connection_initWithEndpoint:(id)arg1
parameters:(id)arg2
identifier:(unsigned int)arg3{
NSString *description = [arg1 description];
NSArray *components = [description componentsSeparatedByString:@":"];
//通过443端口判断是否是HTTPS请求
if (components.count == 2 && [components[1] isEqualToString:@"443"]) {
NSString *domain = components[0];
//通过HTTPDNS 获取IP地址
NSString *ip = [myHttpDnsSDK getIpByDomain:domain];
if (ip != nil) {
//使用公开方法创建新的 endpoint,替换原 endpoint 即可
id newEndpoint = nw_endpoint_create_host([ip UTF8String], "443");
if (newEndpoint != nil) {
arg1 = nil;
return [self swizzled_nw_connection_initWithEndpoint:newEndpoint parameters:arg2 identifier:arg3]
}
}
}
return [self swizzled_nw_connection_initWithEndpoint:arg1 parameters:arg2 identifier:arg3];
}
objc_msgSend
消息发送的过程。initWithEndpoint:parameters:identifier:
方法好被使用了该新特性,无法被Swizzle。URLSeesion
相关类中,增加了相当多的私有方法与属性。光在 NSURLSessionTask
中就增加了几十个,可以相当详细的定制每个网络 Task。令人怀疑,Apple 是不是在准备后续将他们真正开放给开发者。其中,我们看到了一个熟悉的身影:NSObject * <OS_nw_endpoint> _hostOverride
属性以及它的对应设置方法:- (void)set_hostOverride:(NSObject*<OS_nw_endpoint>)arg1 ;
endpoint
。NSURLSessionTask
后,通过HTTPDNS SDK获取 IP,创建新的 endpoint ,设置上即可,完美解决。
NSURLSessionDataTask *task = [NSURLSessionObject dataTaskWithRequest:request];
NSString *domain = request.URL.host;
NSString *scheme = request.URL.scheme;
if ([scheme isEqualToString:@"https"]) {
NSString *ip = [myHttpDnsSDK getIpByDomain:domain];
if (ip != nil) {
//如果能通过HTTPDNS获取到IP,则指定为task的目标IP
id newEndpoint = nw_endpoint_create_host([ip UTF8String], "443");
[task set_hostOverride:newEndpoint];
}
}
[task resume];