Das Wissensportal für IT-Professionals. Entdecke die Tiefe und Breite unseres IT-Contents in exklusiven Themenchannels und Magazinmarken.

SIGS DATACOM GmbH

Lindlaustraße 2c, 53842 Troisdorf

Tel: +49 (0)2241/2341-100

kundenservice@sigs-datacom.de

Java-HTTP-Clients im Vergleich

Neben der Einbindung von Datenbanken findet sich die Kommunikation per HTTP zu anderen Diensten mittlerweile in fast jedem Projekt wieder. Innerhalb von Java hat sich hierzu mittlerweile eine große Menge an Bibliotheken gebildet, die wir einsetzen können. In dieser Kolumne wollen wir uns darum vier dieser Kandidaten anschauen und sehen, in welchen Aspekten sich diese unterscheiden.
Author Image
Michael Vitz

Senior Consultant


  • 24.09.2021
  • Lesezeit: 13 Minuten
  • 142 Views

Nicht nur, aber auch durch die Tendenz, in neuen Projekten auf schwergewichtige Infrastruktur zur Integration verschiedener Services zu verzichten, habe ich in all meinen letzten Projekten mindestens eine Integration per HTTP, meistens in Verbindung mit JSON, umgesetzt. Dabei stellt sich immer wieder die Frage, welche HTTP-Bibliothek wir denn nun für diese Integration nutzen möchten oder sollten.

Auch aufgrund des Alters von Java gibt es hier mittlerweile eine Vielzahl an Möglichkeiten, die uns zur Verfügung stehen. In dieser Kolumne wollen wir uns deswegen eine Auswahl an Bibliotheken anschauen. Dabei werden wir Unterschiede erkennen und können hoffentlich anschließend eine qualifizierte Auswahl für unseren konkreten Anwendungsfall treffen.

Anwendungsfall

Der hierzu implementierte Anwendungsfall besteht darin, die Mitglieder einer öffentlichen Liste auf Twitter [Twitter] auszulesen. Insgesamt müssen wir hierzu zwei HTTP-Requests ausführen. Im ersten Request erhalten wir von der Programmierschnittstelle einen OAuth Access Token [TwitterOAuthToken]. Hierzu teilen wir dem API per Basic-Auth den uns bekannten statischen, geheimen API Key und das API Key Secret mit. Als Antwort erhalten wir dann ein Access Token. Dieses müssen wir anschließend bei jedem weiteren Request als HTTP-Header mitschicken.

Der zweite Request dient dann dazu, die Mitglieder der gewählten Liste abzufragen [TwitterGetListMembers]. Um das Beispiel einfach zu halten, parsen wir lediglich das JSON der Antwort und geben es anschließend auf der Standardausgabe aus. Auch auf die Verwendung von Paginierung verzichten wir, um das Beispiel einfach zu halten.

Um JSON zu parsen und verwenden zu können, um beispielsweise das Access Token auszulesen, verwenden wir die org.json-Implementierung [orgjson]. Natürlich könnten wir hier aber auch jede andere Implementierung wählen. Unterstützt eine der Bibliotheken nativ JSON oder mittels anderer Implementierung, werde ich darauf separat hinweisen.

Zum Ausführen aller, außer dem letzten Beispiel wird jeweils die main-Methode aus Listing 1 genutzt. Diese koordiniert die beiden benötigten Aufrufe und gibt das Ergebnis letztlich auch aus.

public static void main(String[] args) {
 var accessToken = getAccessToken(
   TWITTER_API_KEY, TWITTER_API_KEY_SECRET);
 var listMembers = getListMembers(accessToken, "171867803");
 System.out.println(listMembers.toString(2));
}
Listing 1: main-Methode

HttpURLConnection

Bereits seit Java 1.1 und damit schon fast 25 Jahre lang bringt Java selbst mit HttpURLConnection eine Implementierung für HTTP-Requests mit. Ergänzend wurde dann in Java 1.4 im Februar 2002 auch noch HttpURLConnection für HTTPS-Verbindungen hinzugefügt.
Um eine solche Connection zu erzeugen, müssen wir mit einer URL starten. Auf dieser können wir per openConnection nun die Referenz auf eine Connection erhalten. Anders als der Name implizieren könnte, ist noch keine wirkliche Verbindung entstanden. Diese entsteht erst, wenn wir auf der Connection explizit connect aufrufen oder eine Methode, wie beispielsweise getResponseCode, aufrufen, die implizit erfordert, dass der Request ausgeführt wurde.

Bevor der eigentliche Request startet, können wir über diverse Methode noch weitere Informationen wie die zu verwendende HT-TP-Methode oder Header setzen. Wollen wir zusätzlich einen Body mitschicken, müssen wir dies vorab mit setDoOutput ankündigen. Anschließend können wir per connect eine Verbindung aufbauen. Haben wir vorher angekündigt, einen Body zu schicken, können wir diesen nun per getOutputStream schreiben. Zuletzt prüfen wir den Response-Code und lesen per getIntputStream den Body der HTTP-Response aus und parsen diese in ein JSONObjeck. In Listing 2 ist das noch mal als Code, inklusive minimalem Logging von Request und Response, zu sehen.

JSONObject performRequest(String method, String uri,
   Map<String, String> headers, String body) throws IOException {
 final URL url = new URL(uri);

 final var con = (HttpURLConnection) url.openConnection();

 con.setRequestMethod(method);
 headers.entrySet().forEach(header ->
    con.setRequestProperty(header.getKey(), header.getValue()));

 if (body != null) {
   con.setDoOutput(true);
 }

 System.err.println("Performing request: " +
    con.getRequestMethod() + " " + con.getURL() +
    " with headers: " + con.getRequestProperties());

con.connect();

if (body != null) {
  try (var w = new PrintWriter(con.getOutputStream())) {
    w.print(body);
  }
 }

 System.err.println("Got response: " +
    con.getResponseCode() + " " + con.getResponseMessage() +
    " with headers: " + con.getHeaderFields());

 if (con.getResponseCode() != HTTP_OK) {
  throw new IllegalStateException("Error: " +
      con.getResponseCode() + " " + con.getResponseMessage());
 }

 try (var in = connection.getInputStream()) {
  return new JSONObject(new JSONTokener(in));
 }
}
Listing 2: Hilfsmethode für HTTP-Requests mit der HttpURLConnection

Aus heutiger Sicht wirkt der Code auf mich ein wenig sperrig. Anstatt von Headern wird von Request-Properties gesprochen, und auch das gegebenenfalls implizite Aufbauen der eigentlichen Verbindung je nach Methode kann überraschen. Zudem verwirrt mich persönlich immer die Nomenklatur von getIntputStream und getOutputStream, da ich immer aus Sicht des Requests denke und für mich somit der Body, den ich schicke, Input, der den ich erhalte, Output ist. Das Ganze ist aber natürlich aus Sicht von einem Stream andersherum und somit korrekt.

Trotzdem lässt sich mit dieser Hilfsmethode unser Anwendungsfall ohne größere Probleme, wie in Listing 3 zu sehen ist, umsetzen. Die Hilfsmethode deckt nur den für unseren Anwendungsfall benötigten Teil ab. Bei komplexeren Szenarien ist es hier sicherlich notwendig, noch weiteren Code zu schreiben. Der aber wohl größte Nachteil liegt darin, dass es keinen Support für HTTP/2 gibt. Dies mag aktuell noch kein Blocker sein, wird sich aber zu einem entwickeln.

JSONObject getListMembers(String accessToken, String listId) {
 return performRequest(
  "GET",
  "https://.../1.1/lists/members.json?list_id=" + listId,
  Map.of("Accept", "…",
   "Authorization", "Bearer " + accessToken),
  null);
}

String getAccessToken(String apiKey, String apiKeySecret) {
 var encodedSecret = Base64.getEncoder()
  .encodeToString((apiKey + ":" + apiKeySecret).getBytes(UTF_8));
 return performRequest(
    "POST",
    "https://.../oauth2/token",
    Map.of("Accept", "…",
     "Authorization", "Basic " + encodedSecret,
     "Content-Type", "…"),
    "grant_type=client_credentials")
  .getString("access_token");
}
Listing 3: Nutzung der Hilfsmethode für unseren Anwendungsfall

java.net.http.HttpClient

Neben der Unterstützung von HTTP/2 war die Verbreitung von nicht blockierender Ein-/Ausgabe und asynchroner Programmierung der Antreiber für einen neuen HTTP-Client im JDK [JavaHttpClient]. Dieser wurde final mit Java 11 zur Verfügung gestellt und ist somit die aktuell präferierte Variante für HTTP-Aufrufe in Java ohne Fremdbibliothek.

Wie in Listing 4 zu sehen ist, setzt dieses API vor allem auf das bekannte Erzeugungsmuster Builder. Hierdurch entsteht meiner Meinung nach kompakter, aber immer noch lesbarer und gut verständlicher Code. Neben der Verwendung von send hätten wir auch mit sendAsync auf die asynchrone Variante wechseln können.

JSONObject getListMembers(String accessToken, String listId) {
 var client = HttpClient.newBuilder()
     .build();

 var uri = "https://.../1.1/lists/members.json?list_id=" + listId;
 var request = HttpRequest.newBuilder()
     .GET()
     .uri(URI.create(uri))
     .header("Accept", "…")
     .header("Authorization", "Bearer " + accessToken)
     .build();

 var response =
   client.send(request, BodyHandlers.ofInputStream());

 ...

 return new JSONObject(new JSONTokener(response.body()));
}

String getAccessToken(String apiKey, String apiKeySecret) {
 var client = HttpClient.newBuilder()
     .build();

 var encodedSecret = Base64.getEncoder()
     .encodeToString((apiKey + ":" +
     apiKeySecret).getBytes(UTF_8));

var request = HttpRequest.newBuilder()
    .POST(BodyPublishers.ofString("grant_type=client_credentials"))
    .uri(URI.create("https://.../oauth2/token"))
    .header("Accept", "…")
    .header("Authorization", "Basic " + encodedSecret)
    .header("Content-Type", "…")
    .build();

var response =
  client.send(request, BodyHandlers.ofInputStream());

...

return new JSONObject(new JSONTokener(response.body()))
    .getString("access_token");
}
Listing 4: java.net.http.HttpClientImplementierung

Ich halte diese Variante für besser als die auf HttpURLConnection basierende. Jedoch fehlen mir hier trotzdem ein paar Kleinigkeiten. Zum einen gibt es leider keine Konstanten für die üblichen HTTP-Header und Mediatypen. Hier muss ich also entweder jedes Mal den konkreten Wert schreiben, und häufig vorher nachschlagen, oder selbst in jedem Projekt Konstanten definieren. Außerdem wäre es super gewesen, bereits einen fertigen BodyPublisher für HTML-Formulare zu haben. Dieser, oder auch Publisher für andere Typen, lässt sich jedoch bei Bedarf nachrüsten. Zuletzt fehlt mir auch die Möglichkeit, global auf einem HttpClient-Filter beziehungsweise Interceptoren zu registrieren. Dies lässt sich zwar über eine externe Bibliothek [InterceptableHttpClient] lösen, es wäre aber nett gewesen, auch das direkt im JDK mitzuliefern.

Dass wir für den ersten HTTP-Request den Authorization-Header selbst Base64 encodieren und zusammensetzen müssen, liegt an einer Eigenheit des Twitter-API. Eigentlich ist es möglich, die Autorisierung über die Klasse Authenticator global und transparent zu lösen. Der Client hält sich jedoch sehr genau an die Spezifikation und nutzt diese Klasse erst, nachdem der Server einmal durch den Statuscode 401 mitgeteilt hat, dass eine Autorisierung notwendig ist. Das Twitter-API antwortet uns in diesem Fall allerdings mit dem Statuscode 403 und verhindert somit die Nutzung des Authenticators.

Apache HttpClient

Dem adaptierten Werbespruch „Gibt es da nicht auch was von Apache Commons?“ folgend, gibt es natürlich auch dort einen HT-TP-Client. Dieser existiert zwar nicht so lange wie die ursprüngliche Implementierung im JDK, aber auch schon seit 20 Jahren. Mittlerweile ist er dem Apache Commons-Projekt [ApacheCommons] entwachsen und lebt als eigenes Projekt HttpComponents bei Apache [ApacheHttpComponents] weiter.

Auch dieser Client verwendet in seiner Programmierschnittstelle das Builder-Muster, um vor allem den gewünschten Request zu erzeugen. Eine Besonderheit, wie auch in Listing 5 zu sehen ist, besteht darin, dass wir einige der erzeugten Objekte, wie den Client selbst oder auch die Response, mittels close nach der Benutzung schließen sollten. Dank try-with-resources [TryWithResources] ist das jedoch recht kompakt zu schreiben.

JSONObject getListMembers(String accessToken, String listId) {
 try (var client = HttpClients.custom()
    .build()) {
 var request = ClassicRequestBuilder.get()
     .setUri("https://.../1.1/lists/members.json?list_id=" +
        listId)
     .addHeader(ACCEPT, JSON)
     .addHeader(Authorization", "Bearer " + accessToken)
     .build();

 try (var response = client.execute(request)) {
   if (response.getCode() != HttpStatus.SC_OK) {
    throw new IllegalStateException(…);
   }
   try (var in = response.getEntity().getContent()) {
    return new JSONObject(new JSONTokener(in));
   }
  }
 }
}

String getAccessToken(String apiKey,
   String apiKeySecret) {
 try (var client = HttpClients.custom()
     .addRequestInterceptorFirst((request, details, ctx) -> {
     System.out.println("Executing request: " + request +
        " with headers: " +
        Arrays.asList(request.getHeaders()));
  }).build()) {

 var basicAuth = new BasicScheme();
 basicAuth.initPreemptive(new UsernamePasswordCredentials(
     apiKey, apiKeySecret.toCharArray()));

 var context = HttpClientContext.create();
 context.resetAuthExchange(
     new HttpHost("https", "api.twitter.com", 443), basicAuth);

 var request = ClassicRequestBuilder.post()
    .setUri("https://.../oauth2/token")
    .addHeader(ACCEPT, APPLICATION_JSON.getMimeType())
    .addHeader(
        CONTENT_TYPE, APPLICATION_FORM_URLENCODED.getMimeType())
    .addParameter("grant_type", "client_credentials")
    .build();

 try (var response = client.execute(request, context)) {
  if (response.getCode() != HttpStatus.SC_OK) {
     throw new IllegalStateException(…);
   }
   try (var in = response.getEntity().getContent()) {
     return new JSONObject(new JSONTokener(in))
       .getString("access_token");
   }
  }
 }
}
Listing 5: Apache HttpClient-Implementierung

Für das Prüfen des Statuscodes, der verwendeten Mediatypen und auch Header bringt der Apache HttpClient viele Konstanten, in den Klassen HttpStatus, ContentType und HttpHeaders, mit, die es uns leichter machen, die passenden Werte zu finden. Zudem gibt es, wie in Listing 5 zu sehen, die Möglichkeit, beim Erzeugen eines Clients Request- und Response-Interceptoren zu registrieren.

Auch hier stoßen wir wieder auf das Problem mit dem Twitter-API und Basic-Auth. Im Gegensatz zum neuen Java-Client wird uns hier jedoch die Möglichkeit geboten, preemptive Authentication zu konfigurieren. Diese sorgt dafür, dass der Client den Authorization-Header auch dann schickt, wenn der Server diesen vorher nicht angefordert hat.

Natürlich unterstützt der Apache HttpClient mittlerweile auch HTTP/2 und bietet uns daneben eine hohe Konfigurierbarkeit. Wir können beispielsweise die Art, wie Netzwerksockets erzeugt werden, austauschen, der Client unterstützt das Merken und beim nächsten Request Mitschicken von Cookies, ähnlich zum Verhalten eines Browsers, und auch die Einbindung eines Cache für Responses ist möglich. Eine asynchrone Programmierschnittstelle gibt es ebenfalls.

Retrofit

Alle drei bisher gezeigten HTTP-Clients haben gemeinsam, dass diese auf ein sehr klassisches Programmiermodell setzen. Wir erzeugen einen Client, spezifizieren, was für einen Request wir ausführen wollen, setzen diesen ab und verarbeiten anschließend die Antwort.
Retrofit [Retrofit] hat sich entschlossen, einen anderen Weg zu gehen. Hier definieren wir die auszuführenden HTTP-Requests über ein Interface und bei Bedarf mit erweiternden Annotationen. Für die beiden Requests unseres Beispiels sieht das dann aus wie in Listing 6.

interface Twitter {

 @POST("/oauth2/token")
 @FormUrlEncoded
 @Headers("Accept: application/json; charset=utf-8")
 Call<JSONObject> getAccessToken(
    @Header("Authorization") String authorization,
    @Field("grant_type") String grantType);

 @GET("1.1/lists/members.json?count=5000")
 @Headers("Accept: application/json; charset=utf-8")
 Call<JSONObject> getListMembers(
    @Header("Authorization") String authorization,
    @Query("list_id") String listId);
}

JSONObject getListMembers(
   Twitter client, String accessToken, String listId) {
 var request = client.getListMembers("Bearer " +
   accessToken, listId);

 var response = request.execute();

 if (!response.isSuccessful()) {
   throw new IllegalStateException(…);
 }

 return response.body();
}

String getAccessToken(
   Twitter client, String apiKey, String apiKeySecret) {
 var request = client.getAccessToken(
   Credentials.basic(apiKey, apiKeySecret),
   "client_credentials");

 var response = request.execute();

 if (!response.isSuccessful()) {
   throw new IllegalStateException(…);
 }

 return response.body().getString("access_token");
}
Listing 6: Retrofit-Implementierung

Herzstück hier ist das eigene Interface Twitter, in dem das eigentliche API abgebildet ist. Die beiden Methoden getListMembers und getAccessToken dienen hier lediglich dazu, alle Beispiele für diesen Artikel gleich zu organisieren, und übernehmen hier nur noch die Prüfung, ob der Request erfolgreich war.
Da wir die Programmierschnittstelle mittels eines Interface definieren und uns Retrofit dafür dynamisch zur Laufzeit eine Implementierung erzeugt, müssen wir hierzu, im Gegensatz zu den vorherigen Beispielen, noch unsere main-Methode um den Code aus Listing 7 erweitern. Hier ist auch zu sehen, dass wir einen Converter registrieren. Diese Konverter werden verwendet, um den Body der HTTP-Response in den Rückgabetypen oder Methodenparameter in den Body des HTTP-Requests oder Strings für HTTP-Header, Query- oder Pfadparameter umzuwandeln. In unserem Fall handelt es sich dabei um die in Listing 8 zu sehende Implementierung, um mit JSONObjeck als Rückgabewert umgehen zu können. Retrofit selbst bietet bereits durch eigene Submodule einige fertige Konverter an, die vor allem Bibliotheken mit Databinding, wie Jackson [Jackson], unterstützen.

var retrofit = new Retrofit.Builder()
    .baseUrl("https://api.twitter.com")
    .addConverterFactory(new OrgJsonFactory())
    .client(new OkHttpClient.Builder()
       .addNetworkInterceptor(chain -> {
        System.out.println("Executing request: " +
          chain.request());
       var response = chain.proceed(chain.request());
       System.out.println("Got response: " + response);
       return response;
     })
     .build())
    .build();
var twitter = retrofit.create(Twitter.class);
Listing 7: Erzeugung unserer Retrofit-Implementierung
class OrgJsonFactory extends Converter.Factory {
 @Override
 public Converter<ResponseBody, ?> responseBodyConverter(
    Type type, Annotation[] annotations, Retrofit retrofit) {
   return (Converter<ResponseBody, Object>) value -> {
    try (InputStream in = value.byteStream()) {
      return new JSONObject(new JSONTokener(in));
    }
  };
 }
}
Listing 8: Eigene Retrofit-Converter-Implementierung für JSONObject

Für die eigentliche Kommunikation mittels HTTP nutzt Retrofit OkHttp [OkHttp]. Darüber haben wir dann auch die Möglichkeit, Interceptoren [OkHttpInterceptors] zu definieren oder weitere Konfiguration, wie beispielsweise die Nutzung von HTTP-Proxies oder Response-Caches, vorzunehmen.

Fazit

In dieser Kolumne haben wir mit HttpURLConnection, java.net. http.HttpClient, Apache HttpClient und Retrofit vier Möglichkeiten kennengelernt, um in Java-Projekten per HTTP zu kommunizieren. Neben diesen vieren gibt es noch viele weitere Kandidaten, die wir verwenden können. Beispielsweise lässt sich das von Retrofit genutzte OkHttp auch einzeln nutzen. Feign [Feign] bietet uns ein zu Retrofit ähnliches Programmiermodell und auch größere Frameworks, wie beispielsweise Spring, bringen HTTP-Clients [SpringHttpClients] mit.

Die größten Unterschiede befinden sich hierbei in den APIs: programmatisch oder „deklarativ“ per Interface und Annotationen. Aber auch, ob der Client ein asynchrones Programmiermodell unterstützt und ob wir Interceptoren, für beispielsweise Request- und Response-Logging, definieren können, kann relevant sein. Zuletzt sollten wir auch drauf achten, ob der Client HTTP/2 unterstützt.

Ich persönlich präferiere in der Regel das programmatische gegenüber dem Interface basierten Programmiermodell, da ich hier mehr direkte Kontrolle habe, und ich versuche in der Regel, einen bereits vorhandenen Client zu nutzen, ohne eine neue Abhängigkeit im Projekt einzuführen.
Den vollständigen Beispielcode, inklusive Code für die hier nicht im Detail vorgestellten Clients, gibt es unter https://github.com/mvitz/javaspektrum-http-clients.

Weitere Informationen

[ApacheCommons]
https://commons.apache.org

[ApacheHttpComponents]
https://hc.apache.org

[Feign]
https://github.com/OpenFeign/feign

[InterceptableHttpClient]
https://github.com/raphw/interceptable-http-client

[Jackson]
https://github.com/FasterXML/jackson

[JavaHttpClient]
https://openjdk.java.net/groups/net/httpclient/intro.html

[OkHttp]
https://square.github.io/okhttp/

[OkHttpInterceptors]
https://square.github.io/okhttp/interceptors/

[orgjson]
https://github.com/stleary/JSON-java

[Retrofit]
https://square.github.io/retrofit/

[SpringHttpClients]
https://spring.io/blog/2021/01/11/ymnnalft-http-clients

[TryWithResources]
https://docs.oracle.com/javase/tutorial/essential/exceptions/tryResourceClose.html

[Twitter]
https://twitter.com/home

[TwitterGetListMembers]
https://developer.twitter.com/en/docs/twitter-api/v1/accounts-and-users/create-manage-lists/api-reference/get-lists-members

[TwitterOAuthToken]
https://developer.twitter.com/en/docs/authentication/api-reference/token

. . .

Author Image

Michael Vitz

Senior Consultant
Zu Inhalten

Michael Vitz verfügt über mehr als zehn Jahre Erfahrung in der Entwicklung, Wartung und im Betrieb von Anwendungen auf der JVM. Als Senior Consultant bei INNOQ hilft er Kunden, wartbare und wertschaffende Software zu entwickeln und zu betreiben. Daneben bringt er sich in Open-Source-Projekten ein, schreibt Artikel, hält Vorträge und ist seit 2021 Java Champion.


Artikel teilen