Der Enterprise-Java-Standard JEE ist nicht gerade bekannt für schnelle Start-up-Zeiten und geringen Speicherverbrauch. Genau das sind aber zwei der Schlüsseleigenschaften, die in modernen (Cloud-)Architekturen à la Microservices und Serverless zur Entwicklungs- und Laufzeit über Erfolg bzw. Misserfolg entscheiden. Start-up-Zeiten von wenigen Sekunden sind heutzutage Pflicht und nicht mehr nur Kür, wenn es darum geht, on the fly automatisierte, bedarfsgerechte Skalierung sowie häufige Deployments, inklusive der Durchführung zugehöriger Tests, zu realisieren. Geringer Speicherverbrauch spart Ressourcen und somit bares Geld. Das gilt insbesondere für die Cloud.
Leider sieht es auch bei der Verwendung von speziell auf servicebasierte Architekturen zugeschnittener Enterprise Frameworks wie Eclipse MicroProfile, Spring Boot oder Dropwizard nicht wirklich besser aus. Der Overhead bezüglich Speicherbedarf und Start-up-Zeiten ist hier zwar deutlich geringer als bei JEE, aber aufgrund der Mächtigkeit der Frameworks eben immer noch nicht wirklich zufriedenstellend. Kein Wunder also, dass sich in den letzten Jahren mehr und mehr Speziallösungen am Markt hervorgetan haben, die sich der beschriebenen Probleme annehmen und unter dem Begriff Microframeworks zusammengefasst werden können.
Microframeworks
Ein Microframework ist ein minimalistisches (Web-)Framework, das es dank seiner extremen funktionalen Fokussierung den Entwicklern auf sehr einfache Weise erlaubt, modulare und hocheffiziente (Web-)Anwendungen zu bauen. Dank eingebundenem Webserver lassen sich in der Regel sowohl Webinhalte ausliefern als auch REST Endpoints bereitstellen.
Der Funktionsumfang eines Microframeworks ist im Vergleich zu klassischen Fullstack-Frameworks wie Eclipse MicroProfile oder Spring Boot stark eingeschränkt. Das angebotene API ist entsprechend schlank und einfach zu nutzen, dafür aber hoch proprietär.
Natürlich gibt es bei dem angebotenen Funktionsumfang durchaus Unterschiede. Allen Microframeworks ist allerdings gemein, dass sie die Implementierung von Anwendungen bzw. Services mit sehr geringem Speicherverbrauch sowie kurzen Start-up-Zeiten erlauben. Das macht sie insbesondere für automatisch skalierende Containerumgebungen interessant. Die wohl bekanntesten Vertreter unter den JVM-basierten Microframeworks sind Javalin, Spark, Ktor und Micronaut.
Javalin bezeichnet sich selbst als „lightway REST API library for Java and Kotlin“ – damit ist eigentlich schon alles gesagt. Um einen eigenen Server zu starten und mit seiner Hilfe einen HTTP Call entgegenzunehmen, reichen bereits einige wenige Zeilen Code (Listing 1).
LUST AUF MEHR SOFTWARE ARCHITEKTUR?
Zahlreiche aktuelle Themen wie KI, LLMS und Machine Learning, sowie Frontend-Architektur, für Tools zur Softwarearchitektur, Cloudlösungen und Software beweisen.
import io.javalin.Javalin; public class HelloWorld { public static void main(String[] args) { Javalin app = Javalin.create().start(7000); app.get("/", ctx -> ctx.result("Hello World")) } }
Dem API von Javalin sieht man seinen funktionalen Fokus klar an. Neben verschiedenen HTTP Handlern findet sich dort unter anderem Unterstützung für WebSockets, Server-sent Events, Validierung, Views und Templates, File Uploads oder Access Management (aka Security). Und auch die Angabe von Default Responses sowie das Mapping von Exceptions auf HTTP-Status-Codes ist via API möglich. Für all dies kommt Javalin gänzlich ohne externe Abhängigkeiten aus, sieht man einmal von dem eingebundenen Webserver (Jetty) ab.
Seit Version 3 unterstützt Javalin zusätzlich einen Plug-in-Mechanismus. Zum Zeitpunkt des Schreibens existieren Plug-ins für Micrometer und OpenAPI. Der Einstieg in Javalin ist, dank guter Dokumentation und einer Reihe von Java- und Kotlin-Tutorials, die übrigens von Javalin-Nutzern geschrieben wurden, denkbar einfach.
Spark könnte man als den Urvater aller Microframeworks bezeichnen, denn das erste Release geht bereits auf 2011 zurück. Auch Spark ist extrem schlank aufgebaut und erlaubt die Entwicklung von Webanwendungen in Java 8 und Kotlin (Listing 2). Laut eigenen Angaben nutzen 50 Prozent aller User Spark für die Entwicklung von RESTful APIs und 25 Prozent für die Erstellung von Webseiten. Interessant ist ebenfalls, dass ca. 15 Prozent der produktiven Spark-Anwendungen mehr als 10 000 User am Tag bedienen.
import static spark.Spark.*; public class HelloWorld { public static void main(String[] args) { get("/hello", (req, res) -> "Hello World"); } }
Der grundlegende Baustein von Spark-Anwendungen ist ein Set von Routes, die angeben, wie eingehende Calls abgearbeitet werden sollen. Eine Route besteht aus drei Elementen:
- Verb: get, post, put, delete, …
- Path: /customers, /customers/:id
- Callback: (request, response) -> { … }
Routes werden in der Reihenfolge durchlaufen, in der sie deklariert wurden. Es wird also die erste Route, bei der die aufgerufene Kombination von Verb und Path passen, aufgerufen.
Das API von Spark ist im Vergleich zu Javalin relativ low-level und erinnert ein wenig an das Servlet API. Man findet dort Zugriffsmöglichkeiten auf Basiselemente wie Requests, Responses, Headers, Cookies oder Sessions. Auch die Angabe von Before- und After-Filtern sowie Redirects ist möglich. Neben diesen Low-level-Features existiert zusätzlich Unterstützung für Error Handling und Exception Mapping, Static Files, Response Transformation (z. B. von/nach JSON) sowie eine Vielzahl von Template Engines.
Die Dokumentation von Spark ist gut. Tutorials bzw. Demoanwendungen erleichtern den Einstieg. Die Spark-Community ist nach wie vor relativ aktiv und bringt mehrere Releases pro Jahr heraus. Allerdings bezieht sich die Aktivität eher auf Bug Fixes als auf neue Features. Die aktuelle Version ist 2.9.1.
Im Gegensatz zu den bisher beschriebenen Microframeworks setzt Ktor zu 100 Prozent auf Kotlin und ist nach eigenen Angaben „a framework for building asynchronous servers and clients in connected systems“. Unter Connected Systems versteht der Sponsor und Entwickler dieses Frameworks – kein geringerer als die Firma JetBrains – sowohl Webanwendungen und HTTP Services (z. B. RESTful Services) als auch Mobile- und Browser-Apps. In der aktuellen Version 1.2 unterstützt Ktor neben Netty und Jetty auch Tomcat als Embedded-Server. Darüber hinaus existieren Module für verschiedenste Securityanbindungen (Basic, Digest, Forms, OAuth 1 und 2, JWT, LDAP), Template Engines (FreeMarker, Velocity), JSON Content Negotiation, Metrics, WebSockets und Vieles mehr. Eine zusätzliche Besonderheit des Frameworks stellen die verschiedenen Module zur Unterstützung von Tests dar. So lassen sich z. B. mit den ktor-client-Modulen HTTP Requests in beliebigen Variationen absetzen und mit dem Modul ktor-server-test-host lassen sich Tests sehr performant durchführen.
Neben den bisher gezeigten Frameworks sind mit Micronaut und Quarkus in den letzten Monaten zwei weitere, sehr interessante Kandidaten am Microframework-Himmel erschienen. Anders als bei den bisher gezeigten Frameworks, die die gewünschte Minimierung von Start-up-Zeiten und Memory-Footprint vor allem durch ihre klare funktionale Fokussierung und einem damit einhergehenden schmalen API erreichen, bringen Micronaut und Quarkus einen deutlich breiteren Funktionsumfang mit und bezeichnen sich selbst als Full Stack Frameworks.
Micronaut
Die Macher von Micronaut – Object Computing – beschreiben ihr Framework mit „modern, JVM-based, full-stack framework for building modular, easily testable microservice and serverless applications“. Aktuell in der Version 1.1.3 verfügbar, bietet Micronaut neben klassischen Enterprise-Features (u. a. DB-Anbindung, Security, Messaging, Task Scheduling) vor allem auch Cloud-native-Support (u. a. Service Discovery, Load Balancing, Distributed Tracing, Resilience, OpenAPI, Messaging und Streamingsupport für Event-driven Services). Zusätzlich existiert eine Reihe von Submodulen zur Integration prominenter Drittprojekte (u. a. Kafka, Micrometer, MongoDB, Neo4j, Elasticsearch, Liquibase, Flyway, Netflix OSS).
Der funktionale Fokus des Frameworks liegt klar auf der Erstellung von Microservices bzw. Serverless Applications für die Cloud. Entsprechende Tutorials zeigen, wie sich Micronaut-Anwendungen in den verschiedenen Cloudwelten (Azure und Google) bzw. als AWS-Lambda-Funktionen deployen lassen. Listing 3 zeigt einen einfachen Hello World Service.
import io.micronaut.http.annotation.*; @Controller("/hello") public class HelloController { @Get("/") public String index() { return "Hello World"; } }
Auch wenn sich Micronaut aufgrund des enormen Feature-Sets eher mit prominenten Enterprise Frameworks wie Eclipse MicroProfile oder Spring Boot vergleichen lässt, wird es aufgrund seines geringen Memory-Footprints und der extrem guten Start-up-Werte in der Regel noch zu den Microframeworks gezählt. Der aufmerksame Leser stellt sich nun sicherlich die Frage, wie Micronaut diese guten Werte erreicht? Anders als die klassischen Enterprise Java Frameworks verzichtet Micronaut während der Start-up-Phase einer Anwendung fast gänzlich auf Annotation Scanning, Reflection und den Aufbau von Proxies. Stattdessen verlagert es diese Aufgaben mittels Annotation Processing und Ahead-of-Time-Compiler in die Compile-Zeit und ersetzt so den zugehörigen dynamischen Code durch statische Pendants. Bedenkt man, dass während des Starts einer Anwendung normalerweise Unmengen an Objekten – vor allem Proxies – erzeugt werden, nur um die gewünschte Dynamik von Annotationen und Dependency Injection zu gewähren, kann man sich leicht vorstellen, welchen Optimierungshebel der von Micronaut gewählte Ansatz in Hinblick auf Bootzeiten und Speicherverbrauch mit sich bringt.
Möchte man die ohnehin schon guten Start-up-Zeiten von wenigen Sekunden noch weiter optimieren, kann zusätzlich auf die GraalVM zurückgegriffen und eine native Anwendung erzeugt werden. Die Start-up-Zeiten minimieren sich so auf Werte im Bereich von Millisekunden, was Micronaut insbesondere für Container- und Serverless-Umgebungen extrem attraktiv werden lässt.
Natürlich bringt der Ansatz von Micronaut auch gewisse Nachteile mit sich. Zum einen verlängert sich der Compile-Vorgang. Das gilt insbesondere bei der Verwendung der GraalVM. Ein Nachteil, der in Hinblick auf die Laufzeitvorteile sicherlich zu verschmerzen ist, zumal die Verwendung der GraalVM im Normalfall nicht während der Entwicklung zum Einsatz kommt, sondern nur zum Zeitpunkt der Bereitstellung der Anwendung.
Ein weiterer Nachteil ergibt sich durch den Verzicht auf Reflection, da so nur 3rd Party Libraries eingebunden werden können, die ebenfalls vollständig auf Reflection verzichten. Das ist u. a. auch der Grund dafür, dass Micronaut maßgeblich auf eigene Libraries setzt bzw. spezielle Libraries zur Integration prominenter Drittprojekte anbietet (siehe oben).
Software Architecture Summit vom 11. - 13. März in München
Technische und methodische Themen, Kommunikationstrends, Cloudlösungen, MLOps, Design und Psychologie
Quarkus – ein neuer Stern am Himmel
Das ein geringer Memory-Footprint und stark optimierte Start-up-Zeiten nicht automatisch mit einem Verzicht auf liebgewonnene Enterprise Java APIs wie Eclipse MicroProfile, JAX-RS, CDI, JPA und Co., einhergehen müssen, zeigt das erst vor wenigen Monaten erschienene Framework Quarkus. Das durch Red Had getriebene Open-Source-Projekt bezeichnet sich selbst als „Supersonic Subatomic Java“ bzw. als „Kubernetes Native Java stack tailored for GraalVM & OpenJDK HotSpot, crafted from the best of breed Java libraries and standards“.
Quarkus ist mit dem Ziel angetreten, Java zur führenden Plattform für Kubernetes und Serverless-Umgebungen aufsteigen zu lassen, ohne dabei auf gewohnte Programmiermodelle verzichten zu müssen. Genau wie Micronaut setzt es dabei unter der Haube auf eine Menge Voodoo und erzeugt so während der Compile-Zeit ein leichtgewichtiges Runnable, das durch extrem kurze Start-up-Zeiten und geringen Memory-Footprint überzeugt.
Auch Quarkus setzt auf Annotation Processing und Ahead-of-Time-Compiler und unterstützt ebenfalls GraalVM und somit native Runnables. Entsprechend ähnlich sind die Einschränkungen zu denen von Micronaut. Anders als Micronaut hat man im Hause Red Hat allerdings seit Beginn viel Wert daraufgelegt, etablierte Enterprise Java Libraries zu unterstützen. Der in Listing 4 dargestellte Ausschnitt eines HTTP Microservice verzichtet beispielweise vollständig auf das Einbinden proprietärer Libraries und nutzt ausschließlich Standard-APIs (JAX-RS und JPA).
@Path("orders") @Produces("application/json") @Consumes("application/json") public class OrderResource { @Inject EntityManager entityManager; @GET public List<Order> get() { return entityManager.createNamedQuery("Orders.findAll", Orders.class) .getResultList(); } @GET @Path("{id}") public Order getSingle(@PathParam Integer id) { Order entity = entityManager.find(Order.class, id); if (entity == null) { throw new WebApplicationException("Order with id of " + id + " does not exist.", 404); } return entity; } @POST @Transactional public Response create(Order order) { if (order.getId() != null) { throw new WebApplicationException("Id was invalidly set on request.", 422); } entityManager.persist(order); return Response.ok(order).status(201).build(); } // ... }
Neben den bereits genannten Enterprise Java APIs erlaubt Quarkus die Einbindung weiterer bekannter 3rd Party Frameworks via Extension-Mechanismus. Eine Extension kann man sich dabei als eine Art Dependency vorstellen, die es erlaubt, das referenzierte Framework innerhalb einer Quarkus-Anwendung zu nutzen. Der Extension-Mechanismus übernimmt dabei u. a. die Bereitstellung der notwendigen Informationen, um via GraalVM eine native Anwendung erzeugen zu können. Bereits heute gibt es eine Vielzahl solcher Extensions (z. B. Kafka, Infinispan, Vert.x, Kogito, Kubernetes, AWS Lambda, Jaeger, Keycloak, Spring DI). Da der Extension-Mechanismus offen für jeden ist, werden sicherlich weitere folgen.
Fazit und Ausblick
Lange Zeit sah es so aus, als seien Enterprise Java Frameworks aufgrund ihres schlechten Start-up-Verhaltens und des nicht unerheblichen Memory-Footprints nicht wirklich für die hochdynamischen Container- oder Serverless-Umgebungen geeignet. Erst durch funktional fokussierte Frameworks wie Javalin, Spark, oder Ktor hat sich dies geändert.
Der Einsatz derartiger Microframeworks ist immer dann sinnvoll, wenn die eigenen Anforderungen mit der gebotenen stark eingeschränkten Funktionalität auskommen. Ist das nicht der Fall, findet man in den beiden Full Stack Frameworks Micronaut und Quarkus eine echte Alternative. Beide Frameworks verlagern Last von der Start-up-Phase in die Compile-Zeit und erzielen so eine drastische Minimierung des Laufzeitoverheads. Eine zusätzliche Optimierung kann bei beiden Frameworks durch die Verwendung der GraalVM und dem damit einhergehenden Erzeugen eines nativen Runnable erreicht werden.
Während Micronaut konsequent auf eigene APIs setzt und somit die Entwickler proprietäre und somit nicht protierbare APIs nutzen müssen, geht Quarkus bewusst den umgekehrten Weg und setzt maßgeblich auf etablierter Enterprise Java Frameworks und APIs wie Eclipse MicroProfile, JAX-RS, JPA und Co. Bereits die im ersten Relase von Quarkus bereitgestellte Unterstützung von bekannten Frameworks kann sich wirklich sehen lassen. Aufgrund des offenen Extension-Mechanismus werden definitiv weitere Frameworks folgen. In diesem Sinne: Stay tuned!