Подтвердить что ты не робот

Повторное использование соединений TCP с HttpsUrlConnection

Резюме: Я использую класс HttpsUrlConnection в Android-приложении для отправки нескольких запросов в последовательном порядке по TLS. Все запросы одного типа и отправляются на один и тот же хост. Сначала я получал новое TCP-соединение для каждого запроса. Я смог это исправить, но не без других проблем на некоторых версиях Android, связанных с readTimeout. Я надеюсь, что будет более надежный способ повторного использования TCP-соединения.


Фон

При проверке сетевого трафика приложения Android, над которым я работаю с Wireshark, я заметил, что каждый запрос приводит к созданию нового 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, по-видимому, не предназначены для повторного использования. Действительно, чтение данных ответа приводит к закрытию выходного потока, и попытка повторного открытия выходного потока запускает a java.net.ProtocolException с сообщением об ошибке "cannot write request body after response has been read".

Следующее, что я сделал, это рассмотреть способ настройки HttpsUrlConnection отличается от настройки HttpUrlConnection, а именно того, что вы создаете SSLContext и SSLSocketFactory. Поэтому я решил сделать оба этих static и поделиться ими для всех запросов.

Это, похоже, отлично работает в том смысле, что я получил повторное использование соединения. Но в некоторых версиях Android возникла проблема, когда все запросы, кроме первого, занимали очень много времени. При дальнейшем осмотре я заметил, что вызов getOutputStream будет блокироваться в течение времени, равного таймауту, установленному с помощью setReadTimeout.

Моя первая попытка исправить это - добавить еще один вызов setReadTimeout с очень небольшим значением после того, как я закончил читать данные ответа, но это, казалось, не имело никакого эффекта.
Затем я установил гораздо более короткий тайм-аут чтения (пару сотен миллисекунд) и реализовал собственный механизм повтора, который пытается повторно считывать данные ответа до тех пор, пока все данные не будут прочитаны или не будет достигнут первоначальный намеченный тайм-аут.
Увы, теперь я получал тайм-ауты квитирования TLS на некоторых устройствах. Итак, что я сделал, это добавить вызов setReadTimeout с довольно большим значением прямо перед вызовом getOutputStream, а затем изменить тайм-аут чтения на пару сотен мс перед чтением данных ответа. Это на самом деле казалось твердым, и я тестировал его на 8 или 10 различных устройствах, запускал разные версии Android и получал желаемое поведение на всех из них.

Перемотка вперед на пару недель, и я решил протестировать свой код на Nexus 5 с последним factory изображением (6.0.1 (MMB29S)). Теперь я вижу ту же проблему, когда getOutputStream будет блокировать на протяжении всего моего readTimeout по каждому запросу, кроме первого.

Обновление 1: Побочный эффект всех установленных TCP-соединений заключается в том, что на некоторых версиях Android (4.1 - 4.3 IIRC) можно запустить ошибку в ОС (?), где ваш процесс в конечном итоге исчерпывает дескрипторы файлов. Это вряд ли произойдет в реальных условиях, но это может быть вызвано автоматическим тестированием.

Обновление 2: класс OpenSSLSocketImpl имеет общедоступный метод setHandshakeTimeout, который может использоваться для указания времени ожидания установления связи, которое является отдельным от readTimeout. Однако, поскольку этот метод существует для сокета, а не для HttpsUrlConnection, его немного сложно вызвать. И хотя это возможно, в этот момент вы полагаетесь на детали реализации классов, которые могут или не могут быть использованы в результате открытия HttpsUrlConnection.

Вопрос

Мне кажется невероятным, что повторное использование соединения не должно "просто работать", поэтому я предполагаю, что я делаю что-то неправильно. Есть ли кто-нибудь, кто сумел бы надежно получить HttpsUrlConnection для повторного использования соединений на Android и может определить любую ошибку (-ы), которую я делаю? Я бы очень хотел избежать использования каких-либо сторонних библиотек, если это совершенно неизбежно.
Обратите внимание, что любые идеи, о которых вы думаете, должны работать с minSdkVersion из 16.

4b9b3361

Ответ 1

Я предлагаю вам попробовать повторно использовать SSLContexts вместо создания нового каждый раз и изменить значение по умолчанию для HttpURLConnection ditto. Это обязательно препятствует объединению соединений, как у вас есть.

NB getAcceptedIssuers() не разрешено возвращать null.