执行摘要:我在Android应用中使用
接下来,我考虑了设置
从表面上看,这似乎可以正常工作,因为我得到了连接重用。但是,在某些Android版本上存在一个问题,除第一个请求外,所有请求执行时间都非常长。进一步检查后,我注意到调用
我的第一次尝试是,在完成读取响应数据后添加另一个具有非常小值的
然后我做的是设置一个更短的读取超时时间(几百毫秒),并实现自己的重试机制,反复尝试读取响应数据,直到所有数据都被读取或达到原始预期的超时时间为止。
不幸的是,现在我在某些设备上遇到TLS握手超时问题。因此,我接着在调用
快进几周,我决定在运行最新工厂镜像(6.0.1(MMB29S))的Nexus 5上测试我的代码。现在,我发现除第一个请求外,每个请求的
HttpsUrlConnection
类以序列方式通过TLS发送多个相同类型请求到同一主机。最初,我为每个请求获得一个新的TCP连接。虽然我已经解决了这个问题,但在某些Android版本上会出现与readTimeout相关的其他问题。我希望有一种更可靠的方法来实现TCP连接重用。
背景
当使用Wireshark检查我正在处理的Android应用程序的网络流量时,我发现每个请求都导致新建立一个TCP连接并执行新的TLS握手。这会导致相当大的延迟,特别是在3G/4G网络下,每个往返时间可能需要相对较长的时间。
然后我尝试了相同的场景,但没有使用TLS(即HttpUrlConnection
)。在这种情况下,我只看到一个TCP连接被建立,然后被重用于后续请求。因此,建立新TCP连接的行为是特定于HttpsUrlConnection
的。
以下是一些示例代码,以说明该问题(实际代码显然包括证书验证、错误处理等):
class NullHostNameVerifier implements HostnameVerifier {
@Override
public boolean verify(String hostname, SSLSession session) {
return true;
}
}
protected void testRequest(final String uri) {
new AsyncTask<Void, Void, Void>() {
protected void onPreExecute() {
}
protected Void doInBackground(Void... params) {
try {
URL url = new URL("https://www.ssllabs.com/ssltest/viewMyClient.html");
try {
sslContext = SSLContext.getInstance("TLS");
sslContext.init(null,
new X509TrustManager[] { new X509TrustManager() {
@Override
public void checkClientTrusted( final X509Certificate[] chain, final String authType ) {
}
@Override
public void checkServerTrusted( final X509Certificate[] chain, final String authType ) {
}
@Override
public X509Certificate[] getAcceptedIssuers() {
return null;
}
} },
new SecureRandom());
} catch (Exception e) {
}
HttpsURLConnection.setDefaultHostnameVerifier(new NullHostNameVerifier());
HttpsURLConnection conn = (HttpsURLConnection) url.openConnection();
conn.setSSLSocketFactory(sslContext.getSocketFactory());
conn.setRequestMethod("GET");
conn.setRequestProperty("User-Agent", "Android");
// Consume the response
BufferedReader reader = new BufferedReader(new InputStreamReader(conn.getInputStream()));
String line;
StringBuffer response = new StringBuffer();
while ((line = reader.readLine()) != null) {
response.append(line);
}
reader.close();
conn.disconnect();
} catch (Exception e) {
e.printStackTrace();
}
return null;
}
protected void onPostExecute(Void result) {
}
}.execute();
}
注意:在我的真实代码中,我使用POST请求,因此我同时使用输出流(写入请求体)和输入流(读取响应体)。但是我想让示例保持简短易懂。
如果我反复调用testRequest
方法,我最终会在Wireshark中看到以下内容(省略部分):
TCP 61047 -> 443 [SYN]
TLSv1 Client Hello
TLSv1 Server Hello
TLSv1 Certificate
TLSv1 Server Key Exchange
TLSv1 Application Data
TCP 61050 -> 443 [SYN]
TLSv1 Client Hello
TLSv1 Server Hello
TLSv1 Certificate
... and so on, for each request ...
无论我是否调用conn.disconnect
,都不会影响其行为。于是我最初的想法是“好的,我将创建一个HttpsUrlConnection
对象池,并在可能时重用已建立的连接”。然而,不幸的是,Http(s)UrlConnection
实例显然不适合重用。事实上,读取响应数据会导致输出流关闭,尝试重新打开输出流会触发一个带有错误消息“cannot write request body after response has been read”的java.net.ProtocolException
异常。接下来,我考虑了设置
HttpsUrlConnection
与设置HttpUrlConnection
的方式之间的差异,即创建一个SSLContext
和一个SSLSocketFactory
。因此,我决定将它们都设置为static
并为所有请求共享它们。从表面上看,这似乎可以正常工作,因为我得到了连接重用。但是,在某些Android版本上存在一个问题,除第一个请求外,所有请求执行时间都非常长。进一步检查后,我注意到调用
getOutputStream
将阻塞一段时间,该阻塞时间等于使用setReadTimeout
设置的超时时间。我的第一次尝试是,在完成读取响应数据后添加另一个具有非常小值的
setReadTimeout
调用,但是这似乎根本没有效果。然后我做的是设置一个更短的读取超时时间(几百毫秒),并实现自己的重试机制,反复尝试读取响应数据,直到所有数据都被读取或达到原始预期的超时时间为止。
不幸的是,现在我在某些设备上遇到TLS握手超时问题。因此,我接着在调用
getOutputStream
之前添加了一个具有相当大值的setReadTimeout
调用,然后在读取响应数据之前将读取超时时间改回几百ms。这看起来非常稳定,在8或10个不同的设备上进行了测试,在不同的Android版本上运行,并且在所有设备上都获得了所需的行为。快进几周,我决定在运行最新工厂镜像(6.0.1(MMB29S))的Nexus 5上测试我的代码。现在,我发现除第一个请求外,每个请求的
getOutputStream
都会阻塞整个读取超时时间。
更新1: 所有TCP连接建立的一个副作用是,在某些Android版本(4.1 - 4.3 IIRC)上,你的进程最终会耗尽文件描述符这是不太可能在实际情况下发生,但可能会被自动测试触发。更新2:OpenSSLSocketImpl类具有公共setHandshakeTimeout
方法,可用于指定与readTimeout分开的握手超时时间。但由于此方法存在于套接字而不是HttpsUrlConnection
中,因此调用它有些棘手。即使可以这样做,在这一点上,您仍然依赖于可能会或可能不会因打开HttpsUrlConnection
而使用的类的实现细节。
问题
我认为重用连接不太可能“只是工作”,所以我猜想我肯定做错了什么。是否有人成功地可靠地获得了在Android上重用HttpsUrlConnection
并能够发现我所犯的任何错误?除非完全避免不可避免,否则我真的不想求助于任何第三方库。
请注意,无论您想到什么想法,都需要使用minSdkVersion
16。
HttpsUrlConnections
在执行之前等待读取超时的持续时间 - 即使我已经关闭了先前HttpsUrlConnection
的输入流和输出流。 - Michael