背景
- 省去DNS解析这一步,减少耗时
- 就近接入甚至就快接入,减少耗时
- 避免DNS劫持
- 当终端有多个IP接入选择时,有一定容灾能力
HTTPS IP直连问题与解决
问题1. 证书HOST校验问题
终端在SSL握手过程会校验当前请求URL的HOST是否在服务端证书的可选域名列表里。举个例子,假设原本想要请求的URL为https://v.html5..com
,而使用IP直连后实际请求的URL为:https://183.61.38.230:443
。此时服务端返回的证书可选域名列表如下
由于请求的HOST被替换成了IP,导致底层在进行证书的HOST校验时失败,最终请求失败。
问题2. SNI问题
解决了问题1后,请求可以成功是“纯属意外”。事实上,183.61.38.230:443
这个转发层部署了多个域名的证书,除了问题1中的v.html5..com
,还有https://ag..com
等域名。此时如果用https://ag..com
进行IP直连,请求会失败,因为当终端使用IP直连时,服务端SSL握手阶段获取到的域名为调度后的IP,服务端无法找到匹配的证书,只能返回默认的证书或者不返回。喵喵问题1中的图,默认返回的证书的拓展域名列表是不包含ag..com
的,所以证书的HOST校验还是会失败,导致请求失败。
这个问题的解决也并不复杂:
- 系统提供接口,允许终端传入自定义SSLSocketFactory。SSLSocketFactory是创建SSLSocket的工厂,SSLSocket是Socket拓展,有SSL握手功能。
- 系统提供解决SNI问题的实现类SSLCertificateSocketFactory
SSLSocketFactory接口有很多个创建Socket的方法,但是底层回调的就是Socket createSocket(Socket s, String host, int port, boolean autoClose)
,后文也会提到。
进行了这一步操作后,对https://ag..com
进行IP直连也可以成功了。因为SSL握手时,会在SNI拓展字段中传入实际请求域名:
而服务端也能返回正确的非默认的证书了:
问题3. 连接复用问题
解决了证书HOST校验问题与SNI问题后,请求确实可以成功了,但是却不知不觉中引入了一个巨大的性能问题,即连接复用失效的问题。
在HTTP1.0时代,每一个请求都必须经过三步,即建立连接,请求响应数据,然后就断开连接了,下一次请求又重新建立连接。如果是HTTPS就更加糟糕,建立连接后还要多一个更加耗时耗资源的SSL握手过程。到了HTTP1.1时代,引入了连接复用,当CS/BS都支持连接复用时,握手后,会在同一个连接上不断收发数据,直到一方断开。
如果服务端支持连接复用,即响应头不带有Connection:close
时,HttpURLConnection使用域名接入时,连接复用运作正常:
而使用IP直连时,连接复用却失效:
不解决这个问题,单从耗时来说,使用IP直连还不如直接使用域名,因为每次握手的耗时是非常明显的,所以需要研究下底层是怎么去进行连接复用的。
因为Android#HttpURLConnection从4.4开始就将底层实现切换到了OkHttp,所以可以直指矛头,直接分析OkHttp的源码(当时我找的是最新版本的OkHttp代码,因为感觉连接复用这一块是比较基础的代码应该不会有较大变动,其实每一个Android版本对应的OkHttp版本都不同)。
沿着请求接口Debug追溯可知:
- 当一个请求连接建立完成后,会将这个连接缓存到连接池中;
- 一个新的请求过来时,会先判断连接池中是否已有可复用连接,有则复用,无则新建连接。
连接复用底层要求,两个请求的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直连连接复用效果与使用域名接入一致了。
问题4. 兼容性问题
解决了上述三个问题,HTTPS使用IP直连应该是初见成效了,由于给底层设置了两个自定义的对象,而不同系统版本底层网络这块是有较大变化的,所以有必要进行一下兼容性测试。不测不知道,一测又吓一跳了。
当API小于23时,之前进行的SNI测试,即用https://ag..com
进行IP直连请求,又无法成功。但是使用阿里云的HTTPDNS+SNI参考实现,仍然成功。其实SNI问题的解决当时就是参考的阿里的方案,就是问题2中设置自定义SSLSocketFactory,不过他们在代理SSLCertificateSocketFactory时,调用的是这个接口(后文称接口1):
而本文的实现则是调用:Socket createSocket(Socket s, String host, int port, boolean autoClose)
这个接口(后文称接口2)。
OkHttp层ConnectInterceptor有固定操作:
- 创建一个普通rawSocket
- 回调SDK层创建一个SSLSocket
sslSocket = (SSLSocket) sslSocketFactory.createSocket(
rawSocket, address.url().host(), address.url().port(), true);
接口2比接口1多调用一个verifyHostname
方法:
而verifyHostname
方法里是会进行握手和证书HOST校验的
当SSLCertificateSocketFactory调用接口2时,会在此类进行握手,并且此时并没有为SSLScoket设置域名,所以是不支持SNI的,证书HOST校验又一次失败…而API23以后,OpenSSL层的实现有变化,使得在SSLCertificateSocketFactory层的握手也支持SNI,HOST校验成功。
当SSLCertificateSocketFactory调用接口1时,SSL握手会在SDK层进行,握手前会为SSLSocket设置域名,所以可以支持SNI。
问题5. Session复用问题
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复用会话。
- 握手完成后服务端返回Session Ticket
- 连接断开,再次SSL握手时,Client Hello携带Session Ticket。此时服务端不会下发证书,省去协商过程。
本来到此整个过程就结束了,但是直觉告诉我,还是要做一下兼容性测试…发现当API>=23,HTTPS IP直连时,不管是否设置SSLSessionCache,服务端均不会返回Session Ticket与Session ID。而使用域名接入时,则正常返回Session Ticket。底层实现变化,尚未查明原因~
最终优化效果
参考
更新
发布者:全栈程序员-站长,转载请注明出处:https://javaforall.net/233780.html原文链接:https://javaforall.net