Quarkus: Der Blick über den Tellerrand

Seite 5: Wie werden die Ziele erreicht?

Inhaltsverzeichnis

Quarkus ist an vielen Stellen auf Oracles GraalVM, insbesondere die SubstrateVM, maßgeschneidert. Die GraalVM ist eine universelle, virtuelle Machine für Anwendungen, die in JavaScript, Python, Ruby, R oder eben in JVM-basierten Sprachen geschrieben sind. Sie enthält eine zum JDK 1.8 kompatible Java Virtual Machine, kommt aber mehr einem Ökosystem, denn einer monolithischen Laufzeitumgebung gleich. GraalVM umfasst unter anderem:

  • den Graal-Compiler,
  • ein Native-Image-Werkzeug zur Ahead-Of-Time-Kompilierung,
  • die SubstrateVM und
  • das Truffle-Sprachframework.

Der Graal Compiler ist in Java implementiert und setzt auf dem JVM Compiler Interface (JVMCI) auf. Er ersetzt in der vollständigen GraalVM den C2-Compiler der JVM. Die SubstrateVM ist eine spezielle, abgespeckte Version der JVM, die in einem nativen Image relevante Aufgaben der JVM übernimmt – unter anderem Garbage Collection und Thread-Management.

Das Truffle-Framework ist eine auf Abstract-Syntax-Trees bestehende Sprachabstraktion und setzt auf dem Graal Compiler auf. Truffle ermöglicht polyglotte sowie polypolyglotte Anwendungen: In Java geschriebene Programme können JavaScript, Python und andere Skriptsprachen ausführen. Die Gastsprachen können wiederum auf den Host zurückgreifen oder auf andere Gastsprachen. Truffle und die JavaScript-Implementierung werden gelegentlich als Ersatz der in JDK 11 als deprecated markierten JavaScript-Engine Nashorn angesehen.

Für Quarkus sind die Funktionen der SubstrateVM relevant: Java gilt in der Regel als interpretierte Sprache. Funktionen der Laufzeitumgebung, insbesondere Reflection und stellenweise auch Bytecode-Modifikation, gelten als "immer verfügbar". Die SubstrateVM unterstützt aber in einem nativen Image genau diese Funktionen – und einige andere hingegen nicht. Dynamisches Nachladen oder Modifikation von Bytecode lassen sich mit der SubstrateVM nicht umsetzen. Code, der auf die Nutzung von Reflection angewiesen ist, ist gesondert zu behandeln.

Viele Frameworks und Bibliotheken, insbesondere Anwendungsframeworks und Object-Mapper, setzen maßgeblich auf Reflection. Etwa um dynamisch Methoden an URLs zu binden, Injection-Punkte zu nutzen oder um Ergebnisse von Datenbankabfragen auf ihnen unbekannte Domänen-Klassen abzubilden. Diese Frameworks müssen speziell angepasst werden.

Das klingt erstmal abschreckend, und es stellt sich die Frage, warum die Quarkus-Macher diesen Ansatz gewählt haben. Ein nativ ausführbares Executable, möglichst klein und kompakt, ohne externe Abhängigkeiten, lässt sich optimal in einen Container verpacken und ausführen. Natürlich spielen Startup-Zeiten eine Rolle, aber gerade die Größe eines Containers reduziert sich drastisch.

Quarkus besteht aus einer Vielzahl von Erweiterungen. Fast allen gemein ist, dass sie aus einem Build- und einem Runtime-Modul bestehen. Die Aufgabe der Build-Module ist es, so viele Informationen wie möglich bereits während der Paketierung der Anwendung zusammenzutragen.

Für Hibernate-ORM bedeutet das zum Beispiel, dass die Suche nach Klassen, die mit @Entity oder vergleichbarem annotiert sind, zur Build-Zeit stattfindet und ein Index auf Basis von JBoss Jandex erstellt wird, der die Domänen-Klassen auflistet. Dieser lässt sich dann zur Laufzeit auch ohne den Einsatz von Reflection lesen. Die Hibernate-Erweiterung beschränkt sich nicht nur auf die Suche nach Domain-Klassen, auch das Bootstrapping der gesamten Persistenz-Unit lässt sich damit vorbereiten. Zur Laufzeit ist im besten Fall nur noch die Datenbankverbindung zu öffnen. Auf das Laden von Klassen, die nur für die Konfiguration zuständig waren, lässt sich verzichten.

Die Informationen werden bereits zur Build-Zeit als Bytecode geschrieben. Autoren von Extensions müssen sich aber zum Glück nicht mit der Generierung von Bytecode beschäftigen, denn sie können die entsprechenden Templates beziehungsweise Recording-Fähigkeiten nutzen.

In einer Quarkus-Erweiterung hängt das entsprechende Build-Modul von der Laufzeit ab. Entwickler, die mit Quarkus eine Anwendung erstellen möchten, bekommen davon nichts mit, da Quarkus-Tools wie das Maven- oder Gradle-Plug-in die entsprechenden Abhängigkeiten füllen.

Das Tooling ermöglicht die Nutzung von Bytecode-Recordern. Dies sind mit @Recorder annotierte Klassen, die im Build-Modul leben. Aufrufe ihrer Methoden werden aufgezeichnet und als Bytecode gespeichert. Zur Laufzeit lassen sie sich in der Reihenfolge der Aufzeichnung wieder abspielen.

Das folgende Listing zeigt, wie die vom Autor entwickelte Neo4j-Erweiterung den Recorder nutzt:

@Recorder
public class Neo4jDriverRecorder {

private static final Logger log = Logger.getLogger(Neo4jDriverRecorder.class);

public void configureNeo4jProducer(BeanContainer beanContainer, Neo4jConfiguration configuration,
ShutdownContext shutdownContext) {

Driver driver = initializeDriver(configuration, shutdownContext);

Neo4jDriverProducer driverProducer = beanContainer.instance(Neo4jDriverProducer.class);
driverProducer.initialize(driver);
}

private Driver initializeDriver(Neo4jConfiguration configuration,
ShutdownContext shutdownContext) {

// Some configuration

Driver driver = GraphDatabase.driver(uri, authToken, configBuilder.build());
shutdownContext.addShutdownTask(driver::close);
return driver;
}
}

Der Recorder stellt zur Laufzeit über eine Instanz des BeanContainer einen CDI-Producer für den Neo4j-Treiber bereit. Der BeanContainer, die Neo4jConfiguration und der ShutdownContext stammen dabei aus einer Klasse im Build-Modul:

class Neo4jDriverProcessor {

@BuildStep
FeatureBuildItem createFeature(BuildProducer<ExtensionSslNativeSupportBuildItem> extensionSslNativeSupport) {

// Indicates that this extension would like the SSL support to be enabled
extensionSslNativeSupport.produce(new ExtensionSslNativeSupportBuildItem(FeatureBuildItem.NEO4J));

return new FeatureBuildItem(FeatureBuildItem.NEO4J);
}

@BuildStep
AdditionalBeanBuildItem createDriverProducer() {
return AdditionalBeanBuildItem.unremovableOf(Neo4jDriverProducer.class);
}

@BuildStep
@Record(ExecutionTime.RUNTIME_INIT)
void configureDriverProducer(Neo4jDriverRecorder recorder, BeanContainerBuildItem beanContainerBuildItem,
Neo4jConfiguration configuration,
ShutdownContextBuildItem shutdownContext) {

recorder.configureNeo4jProducer(beanContainerBuildItem.getValue(), configuration, shutdownContext);
}
}

Der Prozessor kennt insgesamt drei Build-Schritte. Zunächst gibt er statisch an, welche Features zur Verfügung stehen und, dass er den CDI-Producer als zusätzliches Build-Item bereitstellt. Die Annotation BuildStep ohne weitere Angaben signalisiert schließlich eine statische Initialisierung mittels statischer Methoden, ausgehend von einer main-Klasse während des Builds. Alle Objekte, die ein solcher Build-Schritt erzeugt und zurückgibt, fließen in serialisierter Form in das native Image ein.

Die Konfiguration des Producers muss in einem Build-Schritt zur Laufzeit @Record(ExecutionTime.RUNTIME_INIT) erfolgen, da der Neo4j-Treiber eine Verbindung zur Datenbank öffnet, die sich nicht in einem nativen Image serialisieren lässt.

Entwickler einer Erweiterung müssen sich nicht darum kümmern, den Recorder zu initialisieren, sondern können sich darauf verlassen, dass er bereitsteht. Der Default-Modus @Record(STATIC_INIT) ist hierbei eindeutig zu bevorzugen. Denn alle damit erzeugten Aufzeichnungen fließen in das Artefakt ein, das letztlich deployt wird. Davon profitieren sowohl Programme auf der JVM als auch native Binaries.