任何一个移动APP项目都离不开网络接入功能,提升网络接入的质量几乎是所有移动项目的需求。很多项目都会引入HTTP DNS作为网络接入最基础也是最重要的优化之一。HTTP DNS的核心是后台下发某个域名对应的最优IP,基础点的可做到就近接入,即下发该域名终端就近地同运营商的IP,好一点的则根据线上用户实际测速数据下发最优的IP。而终端只需在HTTP接入时,将URL中的HOST从域名直接替换为后台下发的IP即可。
IP直连相对于域名接入的好处有:
接入时后端常常都会存在转发层做一些诸如负载均衡的工作,由转发层将请求分发到真实服务器。HTTP接入的转发层一般是根据请求头中的HOST字段来转发到各个RS。所以对于HTTP接入,使用IP直连时需要设置一个HOST请求头,值为请求域名,在绝大部分情况下就可以获得上面提到的IP直连的好处。
但是对于HTTPS接入,情况会变得稍微复杂一些。本文主要是对Android平台使用HttpURLConnection(SDK开发由于包大小的限制,HTTP接入基本只能用这个了…)进行HTTPS IP直连时,遇到的一些问题及其解决方法的小结。
首先先用一张图回顾下HTTPS接入的大致流程
本文描述的问题都发生在服务端公钥证书的校验这一步。
终端在SSL握手过程会校验当前请求URL的HOST是否在服务端证书的可选域名列表里。举个例子,假设原本想要请求的URL为https://v.html5.qq.com
,而使用IP直连后实际请求的URL为:https://183.61.38.230:443
。此时服务端返回的证书可选域名列表如下
由于请求的HOST被替换成了IP,导致底层在进行证书的HOST校验时失败,最终请求失败。
这个问题的解决还比较简单。系统提供了接口,允许终端设置证书HOST校验实现。所以直接将底层默认实现中取终端传入URL的HOST(此处即IP)替换回,IP直连替换前的域名即可。
解决了问题1后,请求可以成功是“纯属意外”。事实上,183.61.38.230:443
这个转发层部署了多个域名的证书,除了问题1中的v.html5.qq.com
,还有https://ag.qq.com
等域名。此时如果用https://ag.qq.com
进行IP直连,请求会失败,因为当终端使用IP直连时,服务端SSL握手阶段获取到的域名为调度后的IP,服务端无法找到匹配的证书,只能返回默认的证书或者不返回。喵喵问题1中的图,默认返回的证书的拓展域名列表是不包含ag.qq.com
的,所以证书的HOST校验还是会失败,导致请求失败。
这个问题的解决也并不复杂:
所以设置一个自定义SSLSocketFactory并代理SSLCertificateSocketFactory即可解决SNI问题:
SSLSocketFactory接口有很多个创建Socket的方法,但是底层回调的就是Socket createSocket(Socket s, String host, int port, boolean autoClose)
,后文也会提到。
进行了这一步操作后,对https://ag.qq.com
进行IP直连也可以成功了。因为SSL握手时,会在SNI拓展字段中传入实际请求域名:
而服务端也能返回正确的非默认的证书了:
解决了证书HOST校验问题与SNI问题后,请求确实可以成功了,但是却不知不觉中引入了一个巨大的性能问题,即连接复用失效的问题。
在HTTP1.0时代,每一个请求都必须经过三步,即建立连接,请求响应数据,然后就断开连接了,下一次请求又重新建立连接。如果是HTTPS就更加糟糕,建立连接后还要多一个更加耗时耗资源的SSL握手过程。到了HTTP1.1时代,引入了连接复用,当CS/BS都支持连接复用时,握手后,会在同一个连接上不断收发数据,直到一方断开。
如果服务端支持连接复用,即响应头不带有Connection:close
时,HttpURLConnection使用域名接入时,连接复用运作正常:
而使用IP直连时,连接复用却失效:
不解决这个问题,单从耗时来说,使用IP直连还不如直接使用域名,因为每次握手的耗时是非常明显的,所以需要研究下底层是怎么去进行连接复用的。
因为Android#HttpURLConnection从4.4开始就将底层实现切换到了OkHttp,所以可以直指矛头,直接分析OkHttp的源码(当时我找的是最新版本的OkHttp代码,因为感觉连接复用这一块是比较基础的代码应该不会有较大变动,其实每一个Android版本对应的OkHttp版本都不同)。
分析源码不可能面面俱到吃透每一个细节,摸清大致框架结构,根据问题去找切入点,很明显连接复用相关的代码就在ConnectIntercetor那一环:
沿着请求接口Debug追溯可知:
判断是否有可复用连接,就是遍历连接池中的缓存连接,每一个缓存连接都判断一下当前请求是否可用。
至此可知连接能否复用的条件就在RealConnection#isEligible()方法里。
看注释可以知道:底层判断连接可否复用并不仅需要判断HOST equals,还有一些参数也要equals。
我们解决问题1、2时设进来的两个对象赫然在列…
看下这个静态equal方法
连接复用底层要求,两个请求的HostnameVerifier对象要么是同一个对象,要么是两个相等的对象。为了防止同一个对象的内部数据结构有变化产生影响,采取重写equals方法使条件成立。SSLSocketFatcory同理。
public class MyHostnameVerifier implements HostnameVerifier{
public String bizHost;
public MyHostnameVerifier(String bizHost) {
this.bizHost = bizHost;
}
@Override
public boolean verify(String hostname, SSLSession session) {
return HttpsURLConnection.getDefaultHostnameVerifier().verify(bizHost, session);;
}
@Override
public boolean equals(Object o) {
if (TextUtils.isEmpty(bizHost) || !(o instanceof MyHostnameVerifier)) {
return false;
}
String thatHost = ((MyHostnameVerifier) o).bizHost;
if (TextUtils.isEmpty(thatHost)) {
return false;
}
return bizHost.equals(thatHost);
}
}
两个请求传入相等的HostnameVerifier对象与SSLSocketFatcory对象后,HTTPS IP直连连接复用效果与使用域名接入一致了。
解决了上述三个问题,HTTPS使用IP直连应该是初见成效了,由于给底层设置了两个自定义的对象,而不同系统版本底层网络这块是有较大变化的,所以有必要进行一下兼容性测试。不测不知道,一测又吓一跳了。
当API小于23时,之前进行的SNI测试,即用https://ag.qq.com
进行IP直连请求,又无法成功。但是使用阿里云的HTTPDNS+SNI参考实现,仍然成功。其实SNI问题的解决当时就是参考的阿里的方案,就是问题2中设置自定义SSLSocketFactory,不过他们在代理SSLCertificateSocketFactory时,调用的是这个接口(后文称接口1):
而本文的实现则是调用:Socket createSocket(Socket s, String host, int port, boolean autoClose)
这个接口(后文称接口2)。
接口1它先用底层创建的Socket得到请求的IP地址,然后重新再创建一个SSLSocket,所以会多一次握手挥手过程。
而接口2则是直接复用先前底层创建好的Socket,不会多这一次握手挥手过程。
所以调用接口2效果更佳
而现在要分析的是,为什么API23以下,接口2会校验失败。当前调用链为:
SDK -> HttpsURLConnection -> OkHttp -> SDK(回调SDK传入的SSLSocketFactory) -> SSLCertificateSocketFactory(SDK代理此实现解决SNI问题) -> OpenSSL(最终的底层实现是OpenSSL库)
,
OkHttp层ConnectInterceptor有固定操作:
SDK层操作:
在OkHttp的回调接口里调用SSLCertificateSocketFactory创建SSLSocket,并设置域名以解决SNI问题。
而verifyHostname
方法里是会进行握手和证书HOST校验的
当SSLCertificateSocketFactory调用接口2时,会在此类进行握手,并且此时并没有为SSLScoket设置域名,所以是不支持SNI的,证书HOST校验又一次失败…而API23以后,OpenSSL层的实现有变化,使得在SSLCertificateSocketFactory层的握手也支持SNI,HOST校验成功。
当SSLCertificateSocketFactory调用接口1时,SSL握手会在SDK层进行,握手前会为SSLSocket设置域名,所以可以支持SNI。
综上,最终实现为:
API<23:
SDK层使用SSLCertificateSocketFactory# createSocket(InetAddress addr, int port)接口,在SDK层握手,握手前设置SSLSocket域名,支持SNI,不过会多一次握手挥手过程。
API>=23:
SDK层使用SSLCertificateSocketFactory#createSocket(Socket k, String host, int port, boolean close)接口,在SSLCertificateSocketFactory层握手,默认支持SNI。
SSL握手过程中,密钥协商是其中最耗资源和时间的过程,Session复用能节省协商的消耗。Session复用同样需要CS双方支持。
Session复用有两种方式:
通过Session ID
SSL握手过后,服务端可以将协商后的信息存起来,生成 Session ID ,终端也可以保存 Session ID,并在后续的 Client Hello 握手中带上它,如果服务端能找到与之匹配的信息,就可以快速完成握手。
通过Session Ticket
Session ID机制有一些弊端,例如:1)集群多机之间往往没有同步 Session ID信息;2)服务端存储的Session ID会不断增加,消耗内存等资源
Session Ticket 是用只有服务端知道的安全密钥加密过的会话信息,最终保存在终端端。终端如果在 Client Hello 时带上了 Session Ticket,只要服务端能成功解密,就可以完成快速握手。
自定义SSLSocketFatroy在之前实现的基础上,为SSLCertificateSocketFactory对象添加系统对象SSLSessionCache,即可实现Session Ticket复用会话。
本来到此整个过程就结束了,但是直觉告诉我,还是要做一下兼容性测试…发现当API>=23,HTTPS IP直连时,不管是否设置SSLSessionCache,服务端均不会返回Session Ticket与Session ID。而使用域名接入时,则正常返回Session Ticket。底层实现变化,尚未查明原因~
http://km.oa.com/group/11879/articles/show/249476?kmref=search&from_page=1&no=1(HTTPS原理探讨)
https://help.aliyun.com/document_detail/30143.html?spm=5176.doc36009.6.146.CMlVCh(HTTPS(含SNI)业务场景“IP直连”方案说明)
https://imququ.com/post/optimize-tls-handshake.html(TLS 握手优化详解)
《图解HTTP》
https://android.googlesource.com/
最后附上源码
https://github.com/leelit/SdkSNISocketFactory
问题4里面提到,SSLSocket sslSocket = (SSLSocket) sslSocketFactory.createSocket(Socket k, String host, int port, boolean close)接口会多调用一个verifyHostname方法,而这个方法里是会进行握手和证书HOST校验的。所以导致了在SDK层对这个对sslSocket对象调用的setHostname方法解决SNI,以及设置sessionCache,实际上都是没有起到作用。
当使用SSLCertificateSocketFactory.getInsecure(10 * 1000, sslSessionCache)接口获取SSLCertificateSocketFactory实例时,createSocket(Socket k, String host, int port, boolean close)接口内部不会进行握手,当回到SDK层设置参数后再进行握手,就能解决SNI与Session复用问题了。
更换SSLCertificateSocketFactory实例获取接口后,HTTPS IP直连的效果与使用域名效果一致:
1. 全部版本支持SNI
2. API19以上均支持连接复用与SessionTicket,不产生任何多余的三次握手。