Cloud native Java-Anwendungen mit Quarkus

Alles Quark?

Anfang März wurde meine Timeline auf Twitter mit Tweets über das neue Java-Framework Quarkus überrollt. Bei soviel erzeugter Aufmerksamkeit sollten wir uns doch einmal genauer ansehen, was daran so besonders ist und warum wir Quarkus einsetzen sollten.

Seit knapp einem Jahrzehnt schreibe ich nun serverseitige Anwendungen mit Java. Auch der aktuelle Trend rund um Microservices hat dem keinen Abbruch getan. Anstatt einer einzelnen großen Anwendung entwickle und warte ich nun mehrere kleine. Natürlich haben sich die Bibliotheken und Frameworks weiterentwickelt, aber im Großen und Ganzen hat sich für mich nicht viel verändert.

Ich sehe allerdings schon, dass sich durch neue Paradigmen wie Functions as a Service (FaaS) und die Verbreitung von Containern und Kubernetes die Anforderungen an moderne, für die Cloud ausgelegte Anwendungen ändern. Die Eigenschaften, auf die sich Java – und die JVM – bisher konzentriert haben, nämlich langlebige Prozesse, die auf eine große Menge von CPU und Arbeitsspeicher zurückgreifen, und das Konzept „Write once, run anywhere“, sind heute nicht mehr die primären Treiber. Heute werden schnelle Startzeiten und ein geringer Arbeitsspeicherverbrauch benötigt, um horizontal, also mit mehr Instanzen und nicht mit größerer Hardware, zu skalieren. Zudem laufen Anwendungen dank der Container-Technologie sowieso überall.

Natürlich könnte ich eine andere Programmiersprache einsetzen. Neuere Sprachen wie Go oder Node.js passen von ihren Eigenschaften besser. Allerdings möchte ich das große vorhandene Ökosystem rund um Java und die JVM nicht mehr missen. Für quasi jede Anforderung gibt es eine Bibliothek, die mir hilft, mich nicht auf Technik, sondern die Fachlichkeit zu konzentrieren. Zudem ist bei den meisten Teams in meinen Projekten bereits eine Menge Wissen rund um Java und die JVM vorhanden, und diese Investition soll nicht leichtfertig weggeworfen werden.

Quarkus zielt genau hierauf ab. Ziel ist es, einen Java-Stack zur Verfügung zu stellen, der dank kurzer Startzeiten und geringem Arbeitsspeicherverbrauch ideal in der Cloud betrieben werden kann.

Ein neues Projekt erstellen

Wie und ob Quarkus diese Versprechen halten kann, überprüfen wir am besten am lebenden Objekt, also indem wir eine Quarkus-Anwendung erstellen.

Um ein initiales Projektgerüst zu erzeugen, verwenden wir einen Maven-Archetype (s. Listing 1). Das so entstandene Projekt besteht aus einer POM, einer JAX-RS-Ressource, zwei Dockerfiles, einer Konfigurationsdatei, einer statischen HTML-Seite und zwei Tests (s. Listing 2).

mvn io.quarkus:quarkus-maven-plugin:0.13.3:create \
  -DprojectGroupId=com.innoq \
  -DprojectArtifactId=javaspektrum-quarkus \
  -DprojectVersion=0.0.1-SNAPSHOT \
  -DclassName="com.innoq.js.quarkus.HelloResource"
Listing 1: Quarkus-Projekt mit Maven-Archetype erstellen
.
├── pom.xml
└── src
    ├── main
    │   ├── docker
    │   │   ├── Dockerfile.jvm
    │   │   └── Dockerfile.native
    │   ├── java
    │   │   └── com
    │   │       └── innoq
    │   │           └── js
    │   │               └── quarkus
    │   │                   └── HelloResource.java
    │   └── resources
    │       ├── META-INF
    │       │   └── resources
    │       │       └── index.html
    │       └── application.properties
    └── test
        └── java
            └── com
                └── innoq
                    └── js
                        └── quarkus
                            ├── HelloResourceTest.java
                            └── NativeHelloResourceIT.java
Listing 2: Initiales Quarkus-Projekt

Nach dem Importieren in eine IDE der Wahl stellt sich die Frage, wie die Anwendung nun gestartet werden kann. Eine Main-Methode, wie etwa von Spring Boot bekannt, gibt es nicht. Brauchen wir nun noch zusätzliche Infrastruktur, etwa einen Applikationsserver? Betrachten wir doch einmal die pom.xml. Hier wird ein quarkus-maven-plugin konfiguriert. Genau dieses nutzen wir nun, um die Anwendung zu starten (s. Listing 3). Bereits nach wenigen Sekunden können wir die Anwendung nun unter http://localhost:8080/hello erreichen.

./mvnw compile quarkus:dev
Listing 3: Lokales Starten der Anwendung während der Entwicklung

An dieser Stelle läuft die Anwendung nicht nur, sondern Quarkus prüft auch bei jedem eingehenden HTTP-Request, ob sich an den Quelldateien etwas geändert hat. Ist dies der Fall, wird der Code automatisch neu kompiliert und der HTTP-Request durchläuft sofort den bereits geänderten Code.

Ändern wir zum Beispiel den zurückgegebenen Text der hello-Methode in der HelloResource-Klasse von hello auf Hello, World! (s.Listing 4) und laden anschließend die Seite im Browser neu, so sehen wir sofort den neuen Text.

@Path("/hello")
public class HelloResource {
    @GET
    @Produces(MediaType.TEXT_PLAIN)
    public String hello() {
        return "Hello, World!";
    }
}
Listing 4: HelloResource nach unserer Änderung

Hierdurch ist der Zyklus vom Durchführen einer Änderung bis zum Ansehen des Ergebnisses sehr kurz und befreit uns von der Bürde, die Anwendung regelmäßig neu zu starten.

Tests

Nun, da wir eine Änderung an unserem Code durchgeführt haben, sollten wir die Tests starten, um zu prüfen, ob wir nichts kaputt gemacht haben.

Hierzu können wir entweder mvn package ausführen oder den HelloResourceTest über unsere IDE als JUnit-Test starten. Das Ergebnis dieses Testlaufs, hier per Maven, zeigt Listing 5. Der Test schlägt, nicht ganz unerwartet, fehl. Schauen wir uns den Test doch einmal genauer an (s. Listing 6).

...
[ERROR] Failures:
[ERROR] HelloResourceTest.testHelloEndpoint:18 1 expectation failed.
Response body doesn't match expectation.
Expected: is "hello"
  Actual: Hello, World!
...
Listing 5: Ergebnis der Tests nach der HelloResource-Änderung
@QuarkusTest
public class HelloResourceTest {
    @Test
    public void testHelloEndpoint() {
        given()
            .when().get("/hello")
            .then()
                .statusCode(200)
                .body(is("hello")); // Zeile 11
    }
}
Listing 6: HelloResourceTest

Dadurch, dass der Test mit @QuarkusTest annotiert ist, wird vor der Ausführung der mit @Test annotierten Test-Methoden die komplette Anwendung gestartet. Somit können wir innerhalb der Test-Methoden mithilfe der Bibliothek REST-assured einen HTTP-Endpunkt der laufenden Anwendung aufrufen und das Ergebnis dieses Aufrufes anschließend überprüfen.

Damit der Test wieder erfolgreich durchläuft, ändern wir in Zeile 11 den Text von hello in Hello, World! und lassen ihn anschließend, zur Bestätigung, erneut laufen.

Quarkus macht es uns also auch einfach, Integrationstests gegen die laufende Anwendung zu schreiben. Kombiniert mit der schnellen Startzeit der Anwendung, der gesamte Test braucht bei mir ca. 1 Sekunde, kann so eine hohe Testabdeckung erreicht werden, ohne dass die Tests anschließend mehrere Minuten brauchen.

Extensions

Anstatt der Nachricht des Endpunktes als reinen Text wollen wir allerdings JSON ausliefern. Hierzu ändern wir den Media-Type auf APPLICATION_JSON und geben anstelle des Strings eine JAX-RS Response zurück. Für den eigentlichen Body der Response erstellen wir eine statische Klasse Greeting (s. Listing 7).

@Path("/hello")
public class HelloResource {
    @GET
    @Produces(MediaType.APPLICATION_JSON)
    public Response hello() {
        Greeting g = new Greeting("Hello, World!");
        return Response.ok(greeting).build();
    }

    public static class Greeting {
        private String text;

        public Greeting(String text) {
            this.text = text;
        }

        public String getText() {
            return text;
        }
    }
}
Listing 7: JSON Response

Versuchen wir nun jedoch, den Endpunkt aufzurufen, erhalten wir einen Fehler. Dieser besagt, dass kein MessageBodyWriter für unsere Greeting-Klasse gefunden werden konnte.

Quarkus bringt im initialen Zustand nur REST-Support, in Form von JAX-RS, mit. Um die Anwendung um zusätzliche Dinge wie JSON, Datenbankanbindung oder Sicherheitsfeatures zu erweitern, werden sogenannte Extensions genutzt. Diese entsprechen von ihrer groben Idee den eventuell von Spring Boot bekannten Startern.

Für unseren Fall benötigen wir die Extension reasteasy-jsonb. Diese muss dem Projekt als Abhängigkeit in der POM hinzugefügt werden. Um dies zu vereinfachen, können wir aber auch den Befehl aus Listing 8 nutzen.

./mvnw quarkus:add-extension -Dextension=resteasy-jsonb
Listing 8: JSONB Extension hinzufügen

Da über die Extension neue Bibliotheken hinzugefügt werden, müssen wir ausnahmsweise die Anwendung neu starten. Anschließend liefert uns ein Aufruf von http://localhost:8080/hello das erwartete JSON zurück.

Natürlich müssen wir nun auch den Test wieder anpassen. Hierzu nutzen wir den in REST-assured eingebauten JSON-Support und ändern die Zeile 11 aus Listing 6 erneut. Das Ergebnis ist in Listing 9 zu sehen.

@QuarkusTest
public class HelloResourceTest {
    @Test
    public void testHelloEndpoint() {
        given()
            .when().get("/hello")
            .then()
                .statusCode(200)
                .body("text", is("Hello, World!")); // Zeile 11
    }
}
Listing 9: Test für JSON-Endpunkt

Paketierung der Anwendung

Nachdem wir nun einen JSON-Endpunkt haben und die Tests erfolgreich durchlaufen, wollen wir die Anwendung paketieren. In Produktion wollen wir diese nämlich nicht per Maven und im Entwicklungsmodus von Quarkus starten. Dementsprechend müssen wir mit Maven ein Paket bauen, welches wir anschließend deployen können.

Hierzu rufen wir, wie von vielen Java-Frameworks gewohnt, mvn clean package auf. Anschließend befindet sich unter target/javaspektrum-quarkus-0.0.1-SNAPSHOT-runner.jar eine JAR-Datei, die wir per java -jar starten können. Wie in Listing 10 zu sehen ist, startet die Applikation in der Tat sehr schnell und ist auch direkt danach über HTTP erreichbar.

java -jar target/javaspektrum-quarkus-0.0.1-SNAPSHOT-runner.jar
2019-04-17 09:10:45,366 INFO [io.quarkus] (main) Quarkus 0.13.3 started in 0.639s. Listening on: http://[::]:8080
2019-04-17 09:10:45,373 INFO [io.quarkus] (main) Installed features: [cdi, resteasy, resteasy-jsonb]
Listing 10: Ausgabe und Startzeit der paketiert gestarteten Anwendung

Diese schnelle Startzeit erreicht Quarkus dadurch, dass bereits beim Bauen und Paketieren der Anwendung so viele Metadaten wie möglich ausgewertet werden. Zudem verzichtet Quarkus, wo immer es geht, auf den Einsatz von Reflection.

Die so gestartete Anwendung verbraucht allerdings bei mir auf dem Rechner um die 100 MB Arbeitsspeicher. Das reine Paketieren als ausführbare JAR-Datei erfüllt somit das Ziel eines deutlich reduzierten Arbeitsspeicherverbrauchs noch nicht. Um dieses zu erreichen, müssen wir die Anwendung nativ paketieren. Hierzu bedient Quarkus sich der GraalVM.

GraalVM

Über Graal und die GraalVM wurde bereits in JavaSPEKTRUM ausführlich geschrieben (s. „Polyglot Operations in Java mit GraalVM“ von M. Hunger in JAVASPEKTRUM 01/19 und „Vorcompilierte Programme mit GraalVM AOT“ von M. Hunger in JavaSPEKTRUM 02/19). Trotzdem möchte ich hier kurz die Grundlagen wiederholen.

Die Hauptidee von Graal war es, einen in Java geschriebenen Just-in-Time (JIT)-Compiler für die JVM zu entwickeln. Es hat sich allerdings herausgestellt, dass es mit Graal auch möglich ist, Java-Quelldateien Ahead-of-Time (AOT) in nativen Code zu übersetzen. Der so erzeugte native Code wird anschließend mittels SubstrateVM, einer verschlankten Version der Hotspot JVM, ausgeführt.

Die GraalVM entspricht dabei einer, auf JDK 8 basierenden, JVM, die bereits per Default Graal als JIT verwendet und um Tools zur Erstellung von nativen Images erweitert wurde. Java-Anwendungen, die mittels der GraalVM in nativen Code übersetzt werden, starten anschließend deutlich schneller und verbrauchen zur Laufzeit auch deutlich weniger Arbeitsspeicher. Um dies zu erreichen, gibt es allerdings ein paar Einschränkungen.

Während der Erstellung geht der AOT-Compiler davon aus, dass bereits alle zur Laufzeit benötigten Informationen vorhanden sind und der wirklich benötigte Java-Code auch erreichbar ist. So werden statische Initializer nun bereits zur Compile-Zeit ausgeführt und deren Ergebnis mit in den nativen Code gepackt, was der Startzeit der Anwendung zugutekommt. Nicht verwendeter Code wird erst gar nicht mit in die native Anwendung gepackt, sondern direkt verworfen. Somit stellen Classloader, Reflection und dynamische Proxies eine Herausforderung dar. All diese Features sind nur begrenzt nutzbar und erfordern häufig zusätzliche Informationen, die dem AOT-Compiler mitgeteilt werden müssen. Zudem stehen Komponenten wie JMX oder ein JIT-Compiler nicht mehr zur Verfügung.

Doch genug von Graal. Schauen wir uns an, was passiert, wenn wir unsere Quarkus-Anwendung mittels GraalVM zu einem nativen Image kompilieren.

Native Paketierung der Anwendung

Quarkus ist speziell darauf optimiert, mittels GraalVM ein natives Image der Anwendung zu erstellen. Dazu gibt es in der pom.xml bereits ein Profil native. Aktivieren wir beim Aufruf von Maven dieses Profil, entsteht als Ergebnis des Builds ein natives Image (s. Listing 11).

./mvnw package -Pnative
...
[INFO] [io.quarkus.creator.phase.nativeimage.NativeImagePhase] Running Quarkus native-image plugin on Java HotSpot(TM) 64-Bit Server VM
...
[INFO] --------------------------------------------------------------
[INFO] BUILD SUCCESS
[INFO] --------------------------------------------------------------
[INFO] Total time: 01:32 min
[INFO] Finished at: 2019-04-17T09:45:24+02:00
[INFO] --------------------------------------------------------------
Listing 11: Bauen des nativen Images mit Maven

Damit dies funktioniert, müssen wir jedoch vorab Maven per GRAALVM_HOME-Umgebungsvariable mitteilen, wo die GraalVM installiert wurde, oder aber wir fügen beim Aufruf -Dnative-image.docker-build=true hinzu, damit das Erstellen des nativen Images per Docker erledigt wird.

Das Erzeugen des nativen Images dauert dann eine Weile, bei mir braucht ein solcher Build etwa ein bis zwei Minuten. Anschließend können wir die Anwendung allerdings direkt über das native Image starten und die Startzeit hat sich noch einmal verbessert (s. Listing 12). Zudem wurde der Verbrauch von Arbeitsspeicher auf etwa 8 MB reduziert.

./target/javaspektrum-quarkus-0.0.1-SNAPSHOT-runner
2019-04-17 09:45:40,737 INFO [io.quarkus] (main) Quarkus 0.13.3 started in 0.007s. Listening on: http://[::]:8080
2019-04-17 09:45:40,749 INFO [io.quarkus] (main) Installed features: [cdi, resteasy, resteasy-jsonb]
Listing 12: Ausgabe und Startzeit der nativ paketiert gestarteten Anwendung

Ein Problem gibt es jedoch noch. Rufen wir nun http://localhost:8080/hello auf, so erhalten wir ein leeres JSON-Objekt als Antwort. Scheinbar kann unser Objekt nicht mehr sauber serialisiert werden.

Der Grund für dieses Verhalten liegt darin, dass Graal beim Analysieren der Anwendung nicht sehen kann, dass das Feld text der Greeting-Klasse zur Laufzeit per Reflection ausgelesen wird. Dies müssen wir Graal also gesondert mitteilen. Quarkus bietet hierzu die Annotation @RegisterForReflection an. Annotieren wir also die Greeting-Klasse damit, bauen das native Image neu und starten es anschließend, sehen wir die erwartete Ausgabe.

Um solche Probleme nicht erst zur Laufzeit festzustellen, ermöglicht es Quarkus uns, auch Tests gegen das native Image laufen zu lassen. Hierzu dient die bereits vorhandene Testklasse NativeHelloResourceIT. Diese erbt alle Testfälle von der bisher genutzten Testklasse HelloResourceTest und ist mit @SubstrateTest annotiert. Wenn wir nun mit Maven die verify-Phase anstelle von package nutzen, werden die Tests zusätzlich gegen eine über das native Image gestartete Anwendung ausgeführt (s. Listing 13).

./mvnw clean verify -Pnative
...
[INFO] Running com.innoq.js.quarkus.NativeHelloResourceIT
...
Listing 13: Ausführen der Tests gegen das native Image

Fazit

Mithilfe von Quarkus und der GraalVM lassen sich Java-Anwendungen bauen, die durch geringe Startzeit und geringen Arbeitsspeicherverbrauch auffallen. Auch während der Entwicklung bietet die Möglichkeit, bei eingehenden Requests „on the fly“ zu kompilieren, ein bequemes Arbeiten.

Da Quarkus auf Java-Standards und bereits länger vorhandene Bibliotheken aufsetzt, ist das Wissen für die Entwicklung schon bei vielen Menschen vorhanden. Außerdem profitiert Quarkus davon, dass die Bibliotheken bereits sehr verbreitet und dadurch in Produktion ausführlich getestet sind.

Die sich selbst gesteckten Ziele „Container First“ und „Developer Joy“ erreicht Quarkus somit meiner Meinung nach durchaus.

Allerdings befinden sich sowohl Quarkus als auch die GraalVM aktuell noch in einem sehr jungen Entwicklungsstadium. Der Einsatz für wichtige Produktivanwendungen sollte also gut bedacht werden. Auch die Anzahl der verfügbaren Extensions ist überschaubar. Mir persönlich fehlt zum Beispiel die Möglichkeit, serverseitiges HTML zu generieren.

Alles in allem bleibt Quarkus und vor allem die GraalVM aber ein spannendes Thema. Ich denke, wir werden in der Zukunft noch von beiden einiges hören.

TAGS

Comments

Please accept our cookie agreement to see full comments functionality. Read more