Streszczenie: Używam klasy HttpsUrlConnection
w Android app wysłać liczbę żądań, w sposób szeregowy, przez TLS. Wszystkie żądania są tego samego typu i są wysyłane do tego samego hosta. Na początku otrzymam nowe połączenie TCP dla każdego żądania. Udało mi się to naprawić, ale nie bez powodowania innych problemów w niektórych wersjach Androida związanych z readTimeout. Mam nadzieję, że będzie lepszy sposób na ponowne wykorzystanie połączenia TCP.Ponowne wykorzystanie połączenia TCP z HttpsUrlConnection
Tło
Podczas inspekcji ruchu sieciowego aplikacji na Androida ja pracuję z Wireshark Zauważyłem, że każda prośba spowodowała nowego połączenia TCP powstają, a nowy TLS uzgadniania wykonywane. Skutkuje to sporym opóźnieniem, zwłaszcza jeśli korzystasz z sieci 3G/4G, gdzie każda podróż w obie strony może zająć stosunkowo dużo czasu. Następnie wypróbowałem ten sam scenariusz bez TLS (to jest HttpUrlConnection
). W tym przypadku widziałem tylko pojedyncze połączenie TCP, które zostało ustanowione, a następnie ponownie użyte dla kolejnych żądań. Tak więc zachowanie przy tworzeniu nowych połączeń TCP było specyficzne dla HttpsUrlConnection
.
Oto niektóre przykładowy kod do zilustrowania problemu (prawdziwy kod ma oczywiście sprawdzanie certyfikatów, obsługa błędów, itp):
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();
}
Uwaga: W moim prawdziwym kodzie używam żądania POST, więc używam zarówno strumień wyjściowy (aby napisać treść żądania) i strumień wejściowy (aby odczytać treść odpowiedzi). Ale chciałem, aby przykład był krótki i prosty.
Jeśli zadzwonię wielokrotnie metodę testRequest
I skończyć z następujących w Wireshark (skrócona):
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 ...
, czy nie zadzwonić conn.disconnect
nie ma wpływu na zachowanie.
Tak więc początkowo jednak "Ok, utworzę pulę obiektów HttpsUrlConnection
i ponownie wykorzystam ustanowione połączenia, jeśli to możliwe". Żadne kości, niestety, jako przykłady nie są przeznaczone do ponownego użycia. W rzeczywistości odczytanie danych odpowiedzi powoduje zamknięcie strumienia wyjściowego, a próba ponownego otwarcia strumienia wyjściowego wyzwala java.net.ProtocolException
z komunikatem o błędzie "cannot write request body after response has been read"
.
Następną rzeczą jaką zrobiłem było rozważenie, w jaki sposób konfigurowania HttpsUrlConnection
różni się od utworzenia HttpUrlConnection
, a mianowicie, że utworzyć SSLContext
i SSLSocketFactory
. Postanowiłem więc zrobić oba te static
i udostępnić je dla wszystkich żądań.
Wyglądało na to, że działa dobrze w tym sensie, że mam ponowne połączenie. Wystąpił jednak problem w niektórych wersjach systemu Android, w których wykonanie wszystkich próśb poza pierwszym wymagało bardzo dużo czasu. Po dalszej inspekcji zauważyłem, że wywołanie getOutputStream
zablokuje na czas równy limitowi czasu ustawionemu na setReadTimeout
.
Pierwsza próba naprawienia tego polegała na dodaniu kolejnego połączenia do setReadTimeout
z bardzo małą wartością po zakończeniu odczytu danych odpowiedzi, ale wydawało się, że nie ma to żadnego wpływu.
Wówczas ustawiłem znacznie krótszy czas oczekiwania na odczyt (kilkaset milisekund) i zaimplementowałem własny mechanizm ponawiania, który próbuje wielokrotnie odczytać dane odpowiedzi, aż wszystkie dane zostaną odczytane lub osiągnięty zostanie pierwotnie zamierzony limit czasu.
Niestety, teraz otrzymywałem limity czasu uzgadniania TLS na niektórych urządzeniach. Więc wtedy musiałem dodać rozmowę do setReadTimeout
z dość dużą wartością tuż przed wywołaniem getOutputStream
, a następnie zmienić czas odczytu z powrotem na kilkaset ms przed odczytaniem danych odpowiedzi. Wyglądało to na solidne i przetestowałem go na 8 lub 10 różnych urządzeniach, na różnych wersjach systemu Android, i uzyskałem pożądane zachowanie na wszystkich z nich.
Przewiń do przodu kilka tygodni i postanowiłem przetestować mój kod na Nexusie 5 z najnowszym obrazem fabrycznym (6.0.1 (MMB29S)). Teraz widzę ten sam problem, w którym getOutputStream
będzie blokować na czas trwania mojej readTimeout na każde żądanie z wyjątkiem pierwszego.
Aktualizacja 1: Skutkiem ubocznym wszystkich połączeń TCP tworzonych jest to, że w niektórych wersjach Androida (4.1 - 4.3 IIRC) jest możliwe napotkasz błąd w systemie operacyjnym, gdzie proces ostatecznie uruchamia (?) z deskryptorów plików. Jest to mało prawdopodobne w rzeczywistych warunkach, ale może być uruchomione przez automatyczne testowanie.
Aktualizacja 2:OpenSSLSocketImpl class ma publicznego setHandshakeTimeout
metody, które mogłyby być użyte do określenia limitu czasu handshake, który jest oddzielony od readTimeout. Jednak ponieważ metoda ta istnieje dla gniazda, a nie dla HttpsUrlConnection
, wywołanie go jest nieco trudne. I nawet jeśli jest to możliwe, w tym momencie polegasz na szczegółach implementacji klas, które mogą, ale nie muszą być używane w wyniku otwarcia HttpsUrlConnection
.
Pytanie
Wydaje się nieprawdopodobne mi się, że ponowne połączenie nie powinien „po prostu działa”, więc zgaduję, że robię coś złego. Czy jest ktoś, komu udało się niezawodnie uzyskać HttpsUrlConnection
, aby ponownie wykorzystać połączenia na Androidzie i wykryć wszelkie błędy, które popełniam? Naprawdę chciałbym uniknąć korzystania z bibliotek stron trzecich, chyba że jest to całkowicie nieuniknione.
Zauważ, że cokolwiek pomysły można myśleć o konieczności pracy z minSdkVersion
od 16.
Dlaczego nie wypróbujesz implementacji okHTTP? Zobacz link http://square.github.io/okhttp/ –
Poczekaj, aż Google zacznie korzystać ze źródła OpenJDK. Wtedy nastąpi to automatycznie. – EJP
@EJP: Być może, choć nie chciałbym w to zamieszkać. I to nie rozwiązuje mojego bezpośredniego problemu, ponieważ Android N jest wciąż daleko, a niektóre urządzenia nigdy nie dostaną aktualizacji. To nie jest kwestia słabej wydajności klienta; może to być problem dla serwera w czasach dużego obciążenia, jeśli każdy klient nawiąże wiele połączeń, gdy naprawdę potrzebują tylko 1 lub 2. – Michael