Mit Java auf dem HTTP/2-Zug

HTTP/2 löst nun auch in Java den veralteten Vorgänger ab, der aktuellen Anwendungen nicht mehr gerecht wird.

In Pocket speichern vorlesen Druckansicht 5 Kommentare lesen
Mit Java auf dem HTTP/2-Zug
Lesezeit: 20 Min.
Von
  • Jan Weinschenker
Inhaltsverzeichnis

Kurz nachdem im September 2017 das lang ersehnte Java Development Kit 9 (JDK9) erschien, hat Oracle die Enterprise-Variante in der aktuellen Version 8 veröffentlicht. Einer der zentralen Punkte des neuen Java EE 8 ist die Servlet-API, die nun erstmals HTTP/2 unterstützt. Version 2 des Hypertext Transfer Protokolls wurde im Mai 2015 als Standard verabschiedet. Das Ziel des neuen HTTP ist, die Bandbreite im World Wide Web besser auszunutzen und die Netzwerk-Latenz zu verringern. Webserver und -browser setzen HTTP/2 seit langem um. Zwei Jahre später springt endlich auch Java EE auf diesen Zug auf.

Java hat im Enterprise-Umfeld eine weite Verbreitung, und viele umfangreiche Applikationen basieren auf HTTP, seien es REST-basierte Microservices, SOAP-Webservices oder Webanwendungen auf der Basis von Servlets, Java Server Pages oder JavaServer Faces.

Somit lohnt sich ein Blick auf die Vorteile von HTTP/2. Der direkte Vorgänger HTTP/1.1 stammt noch aus dem Jahr 1999, und seitdem hat sich das Web deutlich weiterentwickelt. Wer dieser Entwicklung von fast zwei Jahrzehnten Rechnung tragen will, muss auf HTTP/2 umsteigen.

Nur zur Erinnerung: im Jahre 1999 war der bedeutendste Hersteller für Mobiltelefone ein Unternehmen aus Finnland mit dem Namen Nokia. Der Präsident der USA hieß Bill Clinton. Apple lieferte seine Rechner mit fest verbauten Bildröhren aus, und deutsche Kunden bezahlten dafür damals nicht in Euro, sondern in D-Mark. Ein Blick in das Internet-Archiv archive.org bringt die Ausgabe von heise.de aus Abbildung 1 zutage. Die Erinnerungen verdeutlichen das Alter von HTTP/1.1. Das Protokoll entstand für die Anforderungen seiner Zeit – also für Webseiten wie die zu den Anfangszeiten von Heise Online. Für Webseiten wie heute üblich oder für serviceorientierte Anwendungsarchitekturen war es nie ausgelegt.

So sah heise.de am 25. Februar 1999 aus (Abb. 1).

Dieser Artikel stellt deswegen zunächst die einzelnen Neuerungen von HTTP/2 vor und zeigt im Anschluss mit Codebeispielen, wie sich einfache Java-Anwendungen auf Basis von Servlet 4 implementieren lassen. Dabei kommt das Framework Spring Boot in der Version 2.0.0.M6 zum Einsatz, um einen einfachen REST-Webservice und -Client zu bauen. Als Beispiel dient ein Servlet, das eine dynamische Webseite ausliefert. Die Basis bildet der Applikationsserver Glassfish in der Version 5.0.

HTTP/2 nutzt die bestehende Bandbreite bei der Datenübertragung besser aus als seine Vorgänger. Das erreicht der neue Standard durch drei Neuerungen. Zunächst ist das Request-Multiplexing zu nennen. Es sorgt dafür, dass mehrere Request-/Response-Konversationen gleichzeitig über ein und dieselbe TCP-Verbindung erfolgen können. Das Konzept des Server-Push ermöglicht, dass ein HTTP-Client einen HTTP Request in Richtung Server absetzen und Letzterer darauf mit mehr als einer Response antworten kann. Schließlich verwendet HTTP/2 eine verbesserte Implementierung zur Datenkompression speziell für die Protokoll-Metadaten – den HTTP Header.

HTTP/1.1 bietet keine direkte Möglichkeit, Requests zu parallelisieren. Über eine TCP-Verbindung kann jeweils nur ein HTTP-Request und daraufhin nur eine HTTP-Response fließen. Das ist für zeitgemäße Webseiten problematisch. Die Startseiten großer Online-Portale erfordern nicht selten den Transfer mehrerer Megabyte an Daten vom Server an den Client. Um diesen Download in Gang zu setzen und abzuschließen, sind mitunter hunderte von HTTP-Requests notwendig.

Die eingangs genannte Einschränkung durch HTTP/1.1 von genau einer Request-Response-Konversation pro TCP-Verbindung wird zum Nadelöhr. Typische Workarounds waren bisher, dass Webbrowser mehrere TCP-Verbindungen zum Server aufbauen, über die sich parallel mehrere HTTP-Verbindungen abwickeln lassen. Der dadurch entstehende Protokoll-Overhead fällt jedoch vergleichsweise stark ins Gewicht, da jede TCP-Verbindung einen initialen Aufbau erfordert. Abbildung 2 zeigt den notwendigen Nachrichtenaustausch zwischen Client, DNS-Service und Server für einen solchen Verbindungsaufbau.

Austausch von Nachrichten zum Aufbau einer TCP-Verbindung (Abb. 2)

Ein weiterer Workaround war das Verringern von HTTP-Requests durch das Zusammenfassen von Ressourcen. Entwickler kombinierten viele Bilder zu einer großen Grafik und zeigten über CSS-Spriting jeweils einzelne Ausschnitte an. Einzelne CSS- und Javascript-Dateien fassten sie ebenfalls zu größeren Dateien zusammen. Der Nachteil dieser Verfahren ist, dass die einzelnen Ressourcen, also die Bilder, CSS- und Javascript-Dateien, sich nicht mehr separat im Cache des Clients verwahren lassen.

Eine Änderung an einer einzelnen kleinen Bilddatei erfordert einen kompletten Neubau eines Sprites auf der Serverseite. Der Server muss das Sprite anschließend komplett neu an den Client übertragen, obwohl sich nur ein kleiner Teil verändert hat. Dasselbe gilt für zusammengefasste CSS- und JavaScript-Dateien.

Webbrowser-Implementierungen erlauben nur eine begrenzte Anzahl paralleler TCP-Verbindungen zum selben Hostnamen. Je nach Browser ist diese Höchstzahl unterschiedlich, aber meist sind es weniger als zehn. Ist also der Download einer HTML-Datei, mehrere Bilder und weiterer Ressourcen notwendig, umging man die Begrenzung durch das Verteilen der Ressourcen auf mehrere Hostnamen:

  • Die HTML-Datei liegt auf www.site.de. Das ist der Hostname, den der Besucher in der Adresszeile des Browsers eingibt.
  • Bilder bietet der Server img.site.de an. Bei einer großen Zahl an Bildern ist es üblich, weitere Hosts wie img2.site.de und img3.site.de einzusetzen.
  • CSS- und Javascript-Dateien liegen auf static.site.de.

Entwickler und Administratoren betreiben damit einen gewaltigen Aufwand, um Einschränkungen eines Protokolls zu umgehen, das aus dem Jahre 1999 stammt.

Hinzu kommt ein weiteres Problem, das man durchaus als Konstruktionsfehler bezeichnen kann: In HTTP/1.1 lassen sich zwar mehrere Request-Response-Konversation über eine TCP-Verbindung abwickeln, aber nur sequenziell. Die Bezeichnung für dieses Vorgehen lautet HTTP-Pipelining. Es erzwingt, dass der Server über eine TCP-Verbindung zu einem Zeitpunkt nur eine Response an einen Client übertragen kann.

Sendet der Client mehrere GET-Requests über die Pipeline zum Server, kann er die zugehörigen Responses nur sequenziell in der Reihenfolge der ursprünglichen GET-Requests empfangen und verarbeiten. Wenn die Response auf den ersten GET-Request viel Zeit benötigt, blockiert sie alle nachfolgenden Responses, die nicht vor der ersten Response beim Client ankommen können. Das Problem heißt auch "Head-of-Line-Blocking".

HTTP/2 bringt zunächst eine harte Einschränkung bezüglich der Verbindungsressourcen. Pro Gegenstelle, also pro Server, darf der Client genau eine TCP-Verbindung öffnen. Das klingt zunächst nach einer Verschlechterung gegenüber HTTP/1.1, bei dem das Öffnen mehrerer TCP-Verbindungen die Grundlage der parallelisierten Datenübertragung war. Das neue Protokoll bringt jedoch die entscheidende Verbesserung des Multiplexing. Über die eine zulässige TCP-Verbindung lassen sich simultan beinahe beliebig viele HTTP-Konversationen, sogenannte Streams, zwischen Client und Server abwickeln. RFC 7540 empfiehlt, die Anzahl simultaner Streams pro TCP-Verbindung nicht unter einen Wert von 100 zu konfigurieren, um den Parallelisierungsgrad nicht zu sehr einzuschränken.

In der Konsequenz sind alle zuvor genannten Workarounds hinfällig, die mit HTTP/1.1 nötig waren. Das Verwenden mehrerer TCP-Verbindungen ist nicht mehr möglich und das Zusammenfassen von Bild-, Skript- und CSS-Dateien nicht mehr nötig. Ebenso sind unterschiedliche Hostnamen auf der Serverseite zum Ermöglichen der Parallelisierung überflüssig.

Dabei bleibt die HTTP-Semantik unverändert. Die bekannten HTTP-Schlüsselwörter wie GET, POST, PUT, DELETE oder OPTIONS bleiben erhalten. Auf der Anwendungsschicht ändert sich nichts. Neu ist die Abwicklung des Transports der Header und Nutzdaten. HTTP-Requests und -Responses sind in Streams, Messages und Frames kodiert. Das HTTP/2-Protokoll besteht aus folgenden Bausteinen:

  • Stream: Ein bidirektionaler Austausch von Daten zwischen Server und Client, der aus einer oder mehreren Messages besteht. Die Übertragung eines Streams erfolgt innerhalb einer TCP-Verbindung. Mehrere Streams lassen sich simultan über dieselbe TCP-Verbindung übertragen.
  • Message: Eine komplette Abfolge von Frames, die zu einer logischen Message gehören. Eine Message gehört zu einem Stream.
  • Frame: Die kleinste Kommunikationseinheit innerhalb von HTTP/2. Sie enthält binär kodierte Header- oder Nutzdaten.

Multiplexing durch die Verwendung von Frames in HTTP/2 (Abb. 3)

Abbildung 3 zeigt eine beispielhafte Client-Server-Kommunikation. Alles läuft über genau eine TCP-Verbindung ab. Die dabei versendeten sechs Frames erläutert folgende Auflistung:

  1. Der Client beginnt, indem er einen Request für die Datei "index.html" per HEADERS-Frame an den Server schickt. Dafür eröffnet er einen neuen Stream – einen in sich abgeschlossenen Nachrichtenaustausch mit dem Server –, der die ID 41 erhält. Der Client setzt weiterhin zwei Flags in dem Frame. +END_HEADERS bedeutet, dass der Client im aktuellen Stream keine weiteren HEADERS-Frames mehr versenden wird. +END_STREAM bedeutet, dass der Client nicht beabsichtigt, weitere Frames im aktuellen Stream zu versenden.
  2. Der Server kann die Datei "index.html" ausliefern und schickt innerhalb desselben Streams 41 ebenfalls einen HEADERS-Frame mit dem Statuscode 200 an den Client zurück. Der Code ist bereits aus HTTP/1.1 bekannt und besagt, dass die Datei verfügbar ist und der Client sie demnächst erhalten wird. Neu ist, dass der Server die eigentliche Nutzlast in Form der Datei "index.html" mit einem separaten DATA-Frame verschickt.
  3. Der Server schickt einen PUSH_PROMISE-Frame, eine Neuerung, mit der er von sich aus eine Datenübertragung an den Client initiiert. Im Fall des Beispiels von Abbildung 3 antizipiert der Server, dass der Client direkt nach seinem Request für die Datei "index.html" die Datei "style.css" benötigen wird. Deshalb bietet er an, diese Datei über den neuen Stream 42 an den Client zu versenden.
  4. Wie bei Punkt 2 schickt der Server nun im Stream 42 einen HEADERS-Frame mit dem Statuscode 200 bezogen auf die Datei "style.css" an den Client. Letzterer kündigt die baldige Übertragung der Datei an. Ohne weiteren Widerspruch des Clients würde als nächstes im Stream 42 die Übertragung eines DATA-Frame mit der "style.css" erfolgen.
  5. Im Beispiel benötigt der Client die Datei "style.css" nicht, weil er noch eine Kopie davon in seinem Cache zur Verfügung hat. Er sendet deshalb im Stream 42 einen RST_STREAM-Frame. Damit schließt er den Stream 42, und der Server wird darüber keinerlei Daten mehr versenden.
  6. Bleibt noch die eingangs durch den Client im Stream 41 angefragte Datei "index.html", die der Server nun in einem DATA-Frame an den Client versendet. Damit beendet der Server seinerseits die Kommunikation im Stream 41.

Der beispielhafte Ablauf aus dem letzten Abschnitt zeigt die simultane Übertragung von zwei Streams innerhalb einer TCP-Verbindung. Im dritten Frame versendet der Server einen PUSH_PROMISE-Frame innerhalb des Streams mit der ID 41. Das passiert, weil der Server den nächsten Request des Clients vorhersagt und daraufhin eine Response ankündigt. Im genannten Beispiel sieht der Server eine Anfrage für eine Datei "style.css" kommen und kündigt deren Übertragung an den Client mit einem HEADERS-Frame an, den er im neuen Stream mit der ID 42 versendet.

Der Client kann nun zwei Dinge tun. Um die angebotene Datei zu akzeptieren, muss der Client aktiv nichts weiter tun. Nach dem HEADERS-Frame wird der Server im Stream 42 einen DATA-Frame mit besagter Datei versenden. So erhält der Client die Datei "style.css" ohne sie jemals aktiv angefordert zu haben.

Alternativ kann der Client zu der Entscheidung kommen, dass er die Datei "style.css" nicht benötigt. In diesem Fall schließt der Client den Stream 42, indem er einen RST_STREAM-Frame an den Server schickt. Das Protokoll verbietet es dem Server, über Streams zu kommunizieren, für die der Client einen solchen Frame gesendet hat.

Server-Push verringert die Latenz somit durch das Einsparen von Client-Requests. Dafür muss der Server jedoch in der Lage sein, die Anfragen des Clients zuverlässig zu antizipieren. Er muss den auszuliefernden Content und das Verhalten seiner Clients gut kennen, um nachfolgende Requests nach weiteren Inhalten vorherzusehen.

Es ist Aufgabe der Webserver-Administratoren oder der Webentwickler, die Push-Funktionalität durch Konfiguration und Implementierung zu gewährleisten.

HTTP/1.1 komprimiert lediglich die Nutzlast von Requests und Responses – meist mit dem gzip-Algorithmus. HTTP/2 verwendet gzip weiterhin für ebendiesen Zweck, komprimiert aber neuerdings zusätzlich die Header-Daten. Hierfür kommt ein neuer Algorithmus mit dem Namen HPACK zum Einsatz, der zweistufig arbeitet.

  1. Für 61 bekannte Header existiert ein fest definiertes, statisches Kompressionswörterbuch. Header-Namen, teilweise sogar in Kombination mit häufig vorkommenden Werten, werden als eine einfache Zahl kodiert – beispielsweise der Header :method:GET[i] auf den Wert 2, die Kombination [i]:method:POST auf 3. Der Header-Name :referer: entspricht dem Wert 51. Auf die Weise lassen sich HTTP-Header auf bis zu ein Byte reduzieren.
  2. Für Header, die nicht Teil des statischen Kompressionswörterbuchs sind, handeln Client und Server dynamisch zum Zeitpunkt der Verbindungsaufnahme ein weiteres, dynamisches Wörterbuch aus, das nur für die aktuelle Verbindung gültig ist.

HPACK verringert den Umfang der Header-Daten dramatisch. Die Standard-Header zeichnen sich von Natur aus durch eine hohe Redundanz aus. Die Header der überwiegenden Mehrheit aller HTTP-Verbindungen beginnt mit :method:GET[i]. Weiterhin sind die Header [i]:content-type:application/html sowie :accept:application/html Teil nahezu jeder Kommunikation zwischen Webbrowser und -server. HPACK verkürzt die zu übertragende Datenmenge auf jeweils ein Byte.

Die in den Headern verpackten HTTP Cookies machen üblicherweise den Großteil der Header-Daten in der HTTP-Kommunikation aus. HPACK sorgt erstmals für eine Komprimierung der Cookies.

Um innerhalb einer Java-Applikation die Vorteile von HTTP/2 zu nutzen, ist es zunächst notwendig, entsprechende Bibliotheken oder Applikationsserver zu verwenden. Wenn das der Fall ist, bekommt man zwei der neuen Funktionen von HTTP/2 geschenkt, ohne dass dafür eine eigene Implementierung notwendig ist: Das Multiplexing und die Komprimierung mit HPACK erledigt der Java-HTTP/2-Client beziehungsweise -Server automatisch im Hintergrund, ohne dass Anwendungsentwickler es explizit anstoßen. Was dabei genau zu tun ist, erläutern die nachfolgenden Codebeispiele.

Der komplette und lauffähige Quellcode der Beispiele ist auf GitHub verfügbar. Er basiert auf den nachfolgend genannten Frameworks und dem Java Development Kit 9.

Glassfish 5.0 gehört zu den ersten Java-EE-Applikationsservern, die Servlet4 unterstützen. Die passende Dependency in Maven sieht folgendermaßen aus:

<dependency>
<groupId>javax.servlet</groupId>
<artifactId>javax.servlet-api</artifactId>
<version>4.0.0</version>
<scope>provided</scope>
</dependency>

Damit steht HTTP/2 zur Verfügung und läuft automatisch im Hintergrund. Lediglich den Server Push müssen Entwickler eigenhändig implementieren. Der folgende Code erzeugt ein Servlet, das beim Aufruf der Seite https://localhost:8181/Servlet4Push/http2 HTML ausliefert:

import javax.servlet.http.HttpServlet;
import javax.servlet.http.PushBuilder;
import ...

@WebServlet(value = {"/http2"})
public class Http2Servlet extends HttpServlet {

@Override
protected void doGet(HttpServletRequest req,
HttpServletResponse resp)
throws ServletException, IOException {

PushBuilder pushBuilder = req.newPushBuilder();
if (pushBuilder != null) {
pushBuilder
.path("images/cat.png")
.addHeader("content-type", "image/jpeg").push();
pushBuilder.path("http2-json")
.addHeader("content-type", "application/json").push();
}
try (PrintWriter respWriter = resp.getWriter();) {
respWriter.write("<html>" +
"<img src='images/cat.jpg'>" +
"<p>Image by <a href=\"https://flic.kr/p/HPf9R1\">" +
"Andy Miccone</a></p>" +
"<p>License: <a href=\"https://creativecommons.org/" +
"publicdomain/zero/1.0/\">" +
"CC0 1.0 Universal (CC0 1.0) \n" +
"Public Domain Dedication</a></p>" +
"</html>");
}
}
}

Es sendet zwei Push Promises an den Client. Zuerst bietet es eine Bilddatei an und versendet anschließend ein Push Promise für ein kurzes Stück JSON-Code. Zum Implementieren lässt sich über die Methode newPushBuilder() der Klasse HttpServletRequest ein passender PushBuilder erzeugen. Dieser erhält einen Pfad sowie einen Content Type. Der Aufruf der Methode push() schickt ein Push Promise an den Client.

Das Java-Framework Spring Boot ist ab der aktuellen Version 2.0.0.M6 in Kombination mit Tomcat 9 ebenfalls in der Lage, Servlets der Version 4 bereitzustellen. Der folgende Code zeigt die Konfiguration des eingebetteten Tomcat für HTTP/2:

@Bean
public TomcatServletWebServerFactory tomcatCustomizer() {
TomcatServletWebServerFactory factory =
new TomcatServletWebServerFactory();

factory.addConnectorCustomizers((connector -> {
connector.addUpgradeProtocol(new Http2Protocol());
}));
return factory;
}

Anschließend lässt sich ein simpler RestController konfigurieren, der im folgenden Beispiel wiederum einen einfachen REST-Endpunkt anbietet. Beim Aufruf dieses Endpunktes sendet der Server ein Push Promise an den Client, in dem er ihm eine weitere JSON-Nachricht anbietet:

@RestController
public class GreetingController {
private static final String TEMPLATE = "Hello, %s!";
private final AtomicLong counter = new AtomicLong();

@RequestMapping("/greeting")
public Greeting greeting(
HttpServletRequest request,
@RequestParam(value = "name",
defaultValue = "World") String name) {

PushBuilder pushBuilder = request.newPushBuilder();
pushBuilder.path("/push-greeting?name=push");
pushBuilder.push();

return new Greeting(counter.incrementAndGet(),
String.format(TEMPLATE, name));
}

@RequestMapping("/push-greeting")
public Greeting pushGreeting(
@RequestParam(value = "name",
defaultValue = "World") String name) {
return new Greeting(counter.incrementAndGet(),
String.format(TEMPLATE, name));
}
}

Die Implementierung eines HTTP/2-Clients verlangt noch weniger Codezeilen, wie im folgenden Listing zu sehen ist:

public void getRespResponse(){
RestTemplate okHttpRestTemplate =
new RestTemplate(new OkHttp3ClientHttpRequestFactory());
Greeting greeting = okHttpRestTemplate.getForObject(
"https://localhost:8443/greeting",
Greeting.class);
}

Entwickler müssen lediglich eine Instanz der Klasse RestTemplate erzeugen und verwenden dafür eine RequestFactory eines HTTP/2-fähigen Clients. Obiges Beispiel verwendet die OkHttp-Bibliothek.

Anschließend folgt der Aufruf der Instanz des RestTemplates mit der URL des Serverendpunkts. Das Ergebnis ist die handlich in einem Datenobjekt verpackte Antwort des Servers.

Im Zusammenhang mit Webservices, die HTTP/2 anbieten, ist immer daran zu denken HTTPS-verschlüsselte Dienste anzubieten. Somit ist es notwendig, ein TLS-Zertifikat zu erstellen und es dem Server zur Verfügung zu stellen. Für die lokale Entwicklungsumgebung lässt sich ein selbst signiertes Zertifikat erstellen und in einem Java Keystore speichern.

Folgende Properties-Datei konfiguriert ein TLS-Zertifikat für die Spring-Boot-Anwendung aus dem vorangegangenen Abschnitt:

server.port                   = 8443
server.port.http = 8080
server.ssl.key-store = classpath:sample.jks
server.ssl.key-store-password = secret
server.ssl.key-password = secret

HTTP/2 bietet ausgefeilte Mechanismen, um die Kommunikation zwischen Server und Client zu beschleunigen. Neuere Enterprise-Java-Frameworks und Applikationsserver wie Glassfish 5 und Spring Boot 2 sorgen dafür, dass Applikationen von den wichtigen Neuerungen profitieren. Dabei müssen sich Entwickler nur um wenige Details des HTTP/2-Protokolls kümmern. Lediglich der Server Push erfordert eine explizite Implementierung. Wer sich im Rahmen von HTTP/1.1 noch keine Gedanken um SSL- beziehungsweise TLS-Verschlüsselung gemacht hat, wird sich spätestens jetzt damit auseinandersetzen müssen: Mit HTTP/2 ist Verschlüsselung obligatorisch.

Letztlich spricht alles dafür, neu zu entwickelnde Webservices und andere Online-Inhalte direkt über HTTP/2 anzubieten. Das Mehr an Web-Performance und Effizienz bei der Ausnutzung der Bandbreite rechtfertigt jeden Aufwand beim Einstieg in oder beim Umstieg auf HTTP/2.

Jan Weinschenker
arbeitet bei der Holisticon AG in Hamburg. Er ist dort als Berater im Geschäftsfeld Architektur tätig. Er beschäftigt sich mit dem Design, der Entwicklung und der Verbesserung von verteilten Unternehmens- und Webanwendungen. Er ist außerdem Mitorganisator des Web-Performance-Meetups Hamburg
(rme)