Резюме: Я использую класс 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.