使用HttpsUrlConnection重用TCP连接

23
执行摘要:我在Android应用中使用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。


1
为什么不尝试使用okHTTP实现?请查看链接http://square.github.io/okhttp/ - Silvio Lucas
只需等待谷歌开始使用OpenJDK源代码,那么它就会自动发生。 - user207421
@EJP:也许吧,但我不会拿我的生命去打赌。而且这并没有解决我的直接问题,因为Android N仍然遥远,有些设备永远不会得到升级。这不仅仅是客户端性能差的问题;如果每个客户端都建立了很多连接,而实际上他们只需要1或2个连接,那么在高负载时期,这可能会成为服务器的一个问题。 - Michael
1
@BNK:我不希望读取操作永远不会超时。如果有更多的响应数据需要读取,但我无法在X时间内读取它,则希望它超时。我不想让后续重用现有TCP连接的HttpsUrlConnections在执行之前等待读取超时的持续时间 - 即使我已经关闭了先前HttpsUrlConnection的输入流和输出流。 - Michael
1
@AkashKava 请出示证据。它们都使用相同的线协议,并且它们都受网络限制,速度受服务器的影响。没有理由认为一个库比另一个库快得多。而且,请提供“不建议使用”的来源。由谁说的? - user207421
显示剩余3条评论
1个回答

1
我建议您尝试重用SSLContexts,而不是每次创建一个新的并更改HttpURLConnection的默认值。这样做肯定会抑制您目前使用的连接池。
注意:getAcceptedIssuers() 不允许返回 null。

我建议您尝试重用SSLContexts而不是每次创建一个新的。接下来我考虑了设置HttpsUrlConnection与设置HttpUrlConnection的方式不同之处,即您需要创建一个SSLContext和一个SSLSocketFactory。因此,我决定将它们都设置为静态并在所有请求中共享。getAcceptedIssuers()不允许返回null。实际代码显然具有证书验证、错误处理等功能。 - Michael
@Michael 我在评论你发布的代码。如果那不是真正的代码,你的问题就毫无意义。请修正你的问题。 - user207421
我不拥有应用程序代码的版权,因此无法与任何人分享。问题中的代码是一个最小的示例,展示了如何重现完全没有连接重用的原始问题,如果有人有兴趣测试的话。然后我解释了我尝试改变的所有事情以及这些更改的结果。如果您觉得这些解释+示例不够清晰,那么我可以组合原始示例和这些更改来创建另一个示例。不过这将要等到周一才能完成。 - Michael
很不幸,我发现很难找到一个合适的服务器来测试更新后的示例。我尝试了 "https://posttestserver.com/post.php",但它似乎在我关闭输入流之后立即终止TCP连接(或者可能是在我执行conn.disconnect时,我不确定)。我还尝试了一些自托管的替代方案,如openssl s_server和MockServer,但无法使它们正确响应POST请求(其中“正确”意味着返回200响应代码和包含一些数据的响应正文)。 - Michael
由于保密原因,我在实际代码中请求的服务器不能用于此处发布的任何示例。 - Michael
你发布的代码没有证书验证,因此引起了相应的评论。你不能抱怨这一点。 - user207421

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接