Der Nachteil von Spring
Java-Anwendungen kommen von Haus aus mit einigem Overhead daher. Die JVM allein benötigt nach offiziellen Angaben bereits etwa 128 MB RAM und 124 MB Festplattenspeicher. Für traditionelle Anwendungen ist das voll und ganz vertretbar, bei Docker-Containern in einem Cluster oder gar als FaaS-Instanz sind solche Zahlen aber nicht mehr zeitgemäß. Zum Vergleich: Nichttriviale Anwendungen in der Programmiersprache Go sind nach der Kompiliation oftmals nur 20 bis 30 MB groß. Eine andere wichtige Metrik ist die Startzeit einer Anwendung. Durch den Laufzeit-Reflection-Ansatz von Spring sind Startzeiten jenseits der zwanzig Sekunden keine Seltenheit. Auch das ist besonders für Serverless-Anwendungen nicht hinnehmbar.
Was Micronaut von Spring unterscheidet
Micronaut geht einen anderen Weg als Spring und kann damit einige der Performanceeinbußen wettmachen. Besonders die Startzeit wird ungeheuer verringert, was Java-Entwicklern den Einstieg in die Serverless-Welt eröffnet. Aber auch der RAM-Verbrauch sinkt.
Wie erreicht Micronaut diese Verbesserungen? Die Antwort liegt in der Kompilation. Spring durchsucht zur Laufzeit per Reflection den Classpath nach Beans, initialisiert diese und lädt sie dann dynamisch in den Application Context. Dann werden die Beans dort injectet, wo sie benötigt werden. Auch wenn das ein sehr einfacher und erprobter Ansatz ist, verlängert er jedoch die Startzeit durch diesen Overhead. Die Startzeit leidet dabei umso mehr, je mehr Klassen die Anwendung enthält. Micronaut hingegen verwendet Annotation Processors, die die nötigen Informationen zur Compile-Zeit sammeln und ahead of time (AOT) die nötigen Transformationen für Dependency Injection (DI) und Aspect-oriented Programming (AOP) erledigen. Das verkürzt die Startzeit der Anwendung, erhöht jedoch die Compile-Zeit. Zudem fallen durch dieses Vorgehen etwaige Fehler wie eine nicht zu erfüllende Abhängigkeit schon zur Compile-Zeit auf. Außerdem ist die Startzeit nicht abhängig von der Größe der Anwendung; einmal kompiliert, ist die Startzeit dadurch relativ konstant. Die Implikation dieses Compile-Zeit-Ansatzes ist natürlich, dass die Libraries, die zusätzlich zum Framework in die Anwendung einfließen, ebenfalls auf das Nachladen von Beans per Reflection verzichten müssen. Das AOP Framework AspectJ ist beispielsweise ungeeignet für Micronaut, weshalb Micronaut selbst eine AOP-Lösung bereitstellt. Wie stark die durch das Framework erzielten Verbesserungen sind, wird in den folgenden Beispielen gezeigt.
Die Spring-Anwendungt
Als Beispiel lässt sich eine einfache Anwendung für einen Einkaufswagen verwenden. Der komplette Code ist auf GitHub verfügbar. Per HTTP lassen sich Produkte in den Einkaufswagen legen, abfragen oder wieder löschen. Zunächst kommt die Spring-Boot-Anwendung. Dazu besucht man die Seite https://start.spring.io/ und stellt eine Java-8-Anwendung mit Gradle, Spring Boot 2.1.2 und dem Webpaket zusammen. Das Archiv kann irgendwo auf dem Rechner entpackt werden.
Es folgt der Java-Code der Anwendung. Wer mit Spring Boot vertraut ist, sollte damit keine Probleme haben. Zunächst wird ein Controller mit dem Namen ShoppingCartController.java benötigt (Listing 1).
Listing 1
@RestController("/shoppingCart") public class ShoppingCartController { private final ShoppingCartService shoppingCartService; public ShoppingCartController(ShoppingCartService shoppingCartService) { this.shoppingCartService = shoppingCartService; } @GetMapping public List<Product> getAllProducts() { return shoppingCartService.getAllProducts(); } @PostMapping public void addProduct(@RequestBody Product product) { shoppingCartService.addProduct(product); } @DeleteMapping public Optional<Product> deleteProduct(@RequestBody Product product) { return shoppingCartService.deleteProduct(product); } }
Als Nächstes folgt ein Service unter ShoppingCartService.java (Listing 2).
Listing 2
@Service public class ShoppingCartService { private final ArrayList<Product> products = new ArrayList<>(); public List<Product> getAllProducts() { return products; } public void addProduct(Product product) { products.add(product); } public Optional<Product> deleteProduct(Product product) { Optional<Product> result = products.stream() .filter(p -> p.getId().equals(product.getId())) .findFirst(); result.ifPresent(products::remove); return result; } }
Der Service hält der Einfachheit halber alle Produkte in einer lokalen Liste. Fehlt noch ein POJO für das Produkt in Product.java (Listing 3).
Listing 3
public class Product { private final Long id; private final String description; ... Konstruktor, Getter, Setter ... }
Wenn die Anwendung mit ./gradle bootRun ausgeführt wurde, kann man mit einem Tool wie cURL die Endpunkte ansprechen, um die Funktion zu testen.
Ressourcenverbrauch: Einige interessante Metriken der Anwendung sind in Abbildung 1 zu sehen. Als Compile-Zeit wird die Zeit für den Gradle-Task bootJar nach einem vorherigen ./gradlew clean genommen. Die Startzeit beträgt laut Spring-Ausgabe 3,72 Sekunden. Die tatsächliche Startzeit enthält zusätzlich noch die Startzeit der JVM, womit sie in Summe etwa 5 Sekunden beträgt.
Die Micronaut-Anwendung
Die vorangegangene Anwendung dient als Vergleichspunkt für die nachfolgende Micronaut-Anwendung. Der komplette Code ist ebenfalls auf GitHub verfügbar. Anders als bei Spring Boot kommt Micronaut mit einem Kommandozeilentool daher, das die Erstellung von Projekten übernimmt. Für die Installation sei auf die offizielle Micronaut-Seite verwiesen.
Mit dem Tool mn lässt sich die Anwendung nun mittels $ mn erstellen. Der Befehl startet eine Shell, wo einige Micronaut-spezifische Befehle zur Verfügung stehen. Eine neue Anwendung lässt sich im aktuellen Verzeichnis mit create-app erstellen. Wenn man dahinter noch –features= eingibt und einmal auf TAB drückt, bekommt man eine Übersicht über die zusätzlichen Features, die Micronaut mitliefert. Darunter finden sich die JVM-Sprachen Groovy und Kotlin sowie mehrere Projekte aus dem Netflix-Stack für Microservices.
Zunächst reichen die Standardeinstellungen bis auf eine Kleinigkeit: GraalVM Native Image. Worum es sich dabei handelt, darauf wird später noch eingegangen. Der vollständige Befehl lautet:
mn> create-app --features=graal-native-image com.example.myshop.shoppingcart.shopping-cart-micronaut
Mit exit wird die Shell beendet.
Der Code
Zuerst kommt wieder der Controller, der sich über die Micronaut-Shell mit folgendem Befehl erstellen lässt:
mn> create-controller ShoppingCart
Dieser Befehl erstellt sowohl den Controller als auch einen dazugehörigen Test und erspart dem Programmierer etwas Zeit. Die Service Bean kann folgendermaßen erstellt werden:
mn> create-bean ShoppingCartService
Ein Vorteil für Spring-Entwickler: Der Code der Spring-Anwendung lässt sich fast eins zu eins kopieren; Micronaut will den Entwicklern kein neues Programmiermodell aufzwingen. Das Framework ändert jedoch einige Namen der Annotationen. Aus @RestController wird @Controller, aus @GetMapping wird @Get usw. Beim ShoppingCartService wird aus @Service @Singleton. Das Produkt-POJO benötigt im Konstruktor noch die @JsonProperty-Annotationen der Jackson Library (der Rest des Codes bleibt identisch):
... public Product(@JsonProperty("id") Long id, @JsonProperty("description") String description) { …
Ressourcenverbrauch: Die Zahlen der beiden Beispielanwendungen im Vergleich sind in Abbildung 2 zu sehen. Dies zeigt die Verbesserungen von Micronaut gegenüber Spring. Während die Compile-Zeit nun signifikant länger ist, kann das Framework bei anderen Metriken punkten. Dabei ist zu beachten, dass die Startzeit je nach Größe der Anwendung bei Spring immer länger werden wird, während die Startzeit der Micronaut-Anwendung relativ konstant bleibt.
GraalVM
Der Befehl zur Erstellung der Micronaut-Anwendung enthielt das Feature graal-native-image. Bei GraalVM handelt es sich um eine virtuelle Maschine mit Unterstützung für verschiedene Sprachen, die von Oracle entwickelt wird. Sie ermöglicht es Entwicklern, Code aus verschiedenen Sprachen innerhalb der gleichen Runtime laufen zu lassen. Aber das ist nur der Anfang: GraalVM bietet zudem die Möglichkeit, Java-Anwendungen in native Binaries kompilieren zu lassen. Diese können dann ohne JVM oder GraalVM ausgeführt werden. Dieser Schritt wird nur möglich, wenn die Anwendung wenig bis gar kein reflexives Nachladen von Klassen benutzt. Micronaut eignet sich daher sehr gut für diesen Anwendungsfall.
Micronaut-Anwendung binär kompilieren
Dieses Tool lässt sich an der zuvor erstellen Micronaut-Anwendung demonstrieren. Dazu benötigt man eine GraalVM-Installation nach der offiziellen Dokumentation. Nachdem man GraalVM installiert hat, erhält man einen „JDK-Ersatz“. Alle Programme wie java und javac sind enthalten und verhalten sich genau wie ihr ursprüngliches Gegenstück. Jedoch liefert GraalVM zusätzlich zu den normalen JDK-Programmen ein Programm native-image, das die Kompilierung zu einer nativen Binary vornehmen kann.
Das Micronaut CLI hat bereits das Bash Script build-native-image.sh im Projektverzeichnis generiert. Es enthält im Wesentlichen einen Gradle-Aufruf zur Generierung der JAR und den Aufruf von native-image. Der Nachteil an diesem Verfahren: Es benötigt eine Menge RAM. Wer nicht genug RAM bereitstellt, für den wird der Prozess mit dem ominösen Fehler 137 enden; 16 GB RAM sollten mindestens vorhanden sein. Die dadurch erzeugte Binary erscheint im Hauptverzeichnis und lässt sich bequem ohne eine JVM starten:
$ ./shopping-cart-micronaut 14:53:31.707 [main] INFO io.micronaut.runtime.Micronaut - Startup completed in 16ms. Server Running: http://localhost:8080
Eine Startzeit von 16 ms stellt eine erhebliche Verbesserung dar. Die restlichen Metriken sind in Abbildung 3 zu sehen.
Die Compile-Zeit ist verständlicherweise miserabel. Nicht nur, dass Micronaut die Beans zur Compile-Zeit auflöst. Darüber hinaus wird der resultierende Java Bytecode in nativen Code übersetzt. Vorteil für Entwickler: Der Schritt muss lokal eigentlich nie ausgeführt werden. Während man lokal auch die Java-Version zum Testen nutzen kann, führt lediglich der Build-Server den zeitfressenden Kompilierungsschritt aus. Auch der Größenunterschied ist nicht wirklich problematisch. Die JAR an sich ist zwar nur 11,3 MiB groß, jedoch benötigt man hierfür noch eine JRE, die noch einmal Platz verbraucht. Die Binary kommt auch ohne eine JRE aus und kann einzeln oder innerhalb eines minimalen Docker Image ausgeliefert werden. Besonders der geringe RAM-Verbrauch zeigt, wie wertvoll der Ansatz für die Serverless-Welt sein kann, in der jedes Megabyte RAM bares Geld kostet.
Fazit
Das noch junge Framework Micronaut bietet Java-Entwicklern die Möglichkeit, schlanke und schnelle Anwendungen für die Cloud zu schreiben, ohne dabei auf das vertraute Programmiermodell von Spring verzichten zu müssen. Sollten Java-Entwickler also Spring abschreiben und Micronaut verwenden? Meiner Meinung nach ist es noch nicht so weit. Bei der Entscheidung, welches Framework man für eine größere Anwendung verwenden will, kommt es nicht nur auf die Performance an. Auch die Community und Lehrmaterialien müssen stimmig sein und an dieser Stelle hängt Micronaut (noch) hinterher. Bei den meisten Projekten auf GitHub handelt es sich um kleinere Beispielanwendungen. Wie sich das Framework bei einer realen Anwendung verhält, ist also noch ungewiss.
Dennoch ist Micronaut für kleine Anwendungen einen Blick wert, gerade im schon so oft erwähnten Serverless-Umfeld. Und nicht zuletzt ist Wettbewerb gut für den Markt. Vielleicht halten ja einige Ideen der Micronaut-Entwickler Einzug ins Spring Framework.
Java-Dossier für Software-Architekten 2019
Mit diesem Dossier sind Sie auf alle Neuerungen in der Java-Community vorbereitet. Die Artikel liefern Ihnen Wissenswertes zu Java Microservices, Req4Arcs, Geschichten des DevOps, Angular-Abenteuer und die neuen Valuetypen in Java 12.