Du möchtest ein Microservice-Framework für dein nächstes Projekt einsetzen, bist dir aber unsicher, welches du verwenden sollst? Dann lies diesen Beitrag und entscheide anhand der Test-Ergebnisse selbst. Es werden vier Microservice-Frameworks hinsichtlich der Startzeit, der Antwortdauer und der Antwortdauer unter Last – anhand eines Streaming-Beispiels – erklärt und getestet. Viel Spaß!
In meinem letzten Beitrag hatte ich über RxJava geschrieben und darüber, warum du Microservices reaktiv entwickeln solltest. Microservices können, je nach Last, hoch- beziehungsweise runtergefahren werden.
Ist ein Service stark belastet, kann ein Autoscaler einfach weitere Instanzen starten. Die Last wird dadurch verteilt und die Reaktionszeit wieder verringert. Sind die Services wenig belastet, werden einige Instanzen gestoppt und somit Ressourcen für andere Services freigemacht.
Dieses Vorgehen reduziert deine Kosten, wenn du einen Cloud-Service verwendest.
Services sind selten frei von Lastspitzen. Hier ist es von Vorteil, wenn das Starten und Stoppen weiterer Einheiten schnell geht und die neu gestarteten Instanzen schnell auf Anfragen reagieren. Schließlich möchtest du die Antwortzeiten für alle Anfragen gering halten.
Daher werde ich in diesem Beitrag zunächst vier Microservice-Frameworks betrachten und auf den Prüfstand stellen. Außerdem werde ich kurz darauf eingehen, wie einfach es ist (oder nicht), in das jeweilige Microservice-Framework einzusteigen.
Im ersten Schritt werde ich mich mit folgenden Microservice-Frameworks beschäftigen:
Die Frameworks Micronaut, Wildfly, Dropwizard und Spark sollen zu gegebener Zeit folgen.
Meine ersten Erfahrungen habe ich mit Spring Boot gesammelt, da ich dieses Framework bereits in meinem ersten Beitrag verwendet habe und produktiv damit entwickle.
Im Folgenden lehne ich mich an den Beitrag zum Thema Reactive Stream mit Spring Boot an. Hier habe ich bereits einen Stream-REST-Endpunkt angelegt und ein kleines Frontend zur Veranschaulichung entwickelt. Der Code für die folgenden Backends findest du im selben GitHub-Repo.
Spannenderweise gibt es bei Spring Boot einen Standard für application/stream+json. Dies ist allerdings kein offizieller MIME-Type. Für mich ist das unverständlich, da das Format durchaus sinnvoll erscheint.
Da Quarkus und Helidon JAX-RS verwenden, welches den MIME-Standard verwendet, muss hier entweder der Typ selbst definiert oder ein application/octet-stream verwendet werden.
Damit ich das Frontend nicht verändern muss, habe ich sowohl den application/octet-stream als auch application/stream+json verwendet. Die jeweiligen Endpunkte sind per Header erreichbar. Ohne Header wird application/json zurückgegeben.
Ich selbst habe vorher nur Spring Boot produktiv verwendet. Daher sind die anderen Microservice-Frameworks auch für mich Neuland. Ich werde also kurz darauf eingehen, welche Schwierigkeiten ich hatte, um in den Frameworks den entsprechenden Endpunkt zu erstellen.
Spring Boot ist wohl das bekannteste unter den Microservice-Frameworks. Die 39.000+ Sterne auf GitHub sprechen für sich.
Der Einstieg in Spring Boot ist durch spring.io sehr einfach gehalten. Hier wird eine fertige pom.xml mit allen notwendigen Abhängigkeiten erstellt. Du kannst sofort loslegen. Außerdem ist die Community sehr groß, und du findest zu den meisten Problemen eine Anleitung, wie du die Probleme bewältigt.
Spring Boot war das einzige Framework, welches von Haus aus application/stream+json unterstützt. Auch sonst bietet es viele Features und viel Hilfe für ein übersichtliches und einfaches Entwickeln.
Wie du im Folgenden sehen kannst, benötigt es nicht viel, um einen Response-Stream zu erstellen:
@Controller class RestEndpoint { @Autowired lateinit var dataProvider: DataProvider @Autowired lateinit var streamResponse: CarStreamResponseOutput @GetMapping(path = ["cars"], produces = [MediaType.APPLICATION_STREAM_JSON_VALUE]) fun getCarsAsStream(): StreamingResponseBody { return streamResponse } @GetMapping(path = ["cars"], produces = [MediaType.APPLICATION_JSON_VALUE]) fun getCarsAsJson(): List { return dataProvider.getDataStream().toList().blockingGet() } }
Für den Stream Response wird allerdings trotz dessen ein StreamingResponseBody benötigt:
@Component class CarStreamResponseOutput : StreamingResponseBody { @Autowired lateinit var dataProvider: DataProvider override fun writeTo(os: OutputStream) { val writer = BufferedWriter(OutputStreamWriter(os)) val countDownLatch = CountDownLatch(1) dataProvider.getDataStream().subscribe({ writer.write(Klaxon().toJsonString(it)) writer.write("\n") writer.flush() }, ::println, { os.close() countDownLatch.countDown() }) countDownLatch.await() writer.flush() } }
Das war’s im Prinzip auch schon. Also geht’s zum nächsten Framework.
Vert.x verfügt über eine recht gute Dokumentation. Es wurde von der Eclipse Foundation entwickelt und ist direkt für reaktive Applikationen auf der JVM konzipiert.
Allerdings konnte ich nicht einfach mein Observable (beziehungsweise Flowable) an den Response-Handler übergeben. Du kannst zwar, wie in der Dokumentation beschrieben, direkt ein Flowable zurückgeben, doch schreibt dieser nicht direkt bei jedem neuen Event in den Stream.
Vert.x scheint die Elemente im Flowable zu puffern und den Stream erst zu schreiben, wenn das Event ein „done“ vom Flowable bekommt.
Um nun allerdings einen kontinuierlichen Stream zu erhalten, muss ein eigener Handler geschrieben werden. Diese Aufgabe war jedoch nicht sehr komplex. Der Handler ähnelt sehr stark dem von Spring Boot.
class AsyncCarResponse : Handler { override fun handle(rtx: RoutingContext) { val response = rtx.response() response?.setChunked(true) val flow: Flowable = DataService.getDataStream(TIMEOUT).map { Klaxon().toJsonString(it) }.toFlowable(BackpressureStrategy.BUFFER) flow.subscribe({ response.write(it) response.write("\n") response.writeContinue() }, ::println, {response.end()}) } }
Auch sonst ist die Dokumentation von Vert.x gut und die Community mit über 9.700 GitHub-Stars stetig steigend.
Kompiliert wird die Applikation durch ./mvnw clean compile
,
gestartet durch ./mvnw exec:java
.
Die Befehle können auch einfach kombiniert werden: ./mvnw clean compile exec:java
.
Alles in allem findet man sich gut in das Microservice-Framework Vert.x hinein und kann schnell anfangen, zu entwickeln. Du musst dich allerdings an das Entwickeln auf einem „Main Thread“ gewöhnen, da du diesen nicht blockieren darfst.
Hier hatte ich anfangs den Fehler, Thread.sleep zu verwenden und somit die Performance stark einzuschränken. Dies ist allerdings auch als DON'T auf der Webseite beschrieben.
Nachdem ich dies behoben hatte, konnte Vert.x wieder mit Performance punkten. Die anderen Microservice-Frameworks kamen mit Thread.sleep klar. Da diese Anweisung jedoch im Domain-Teil der Anwendung war, den sich alle Frameworks teilen, habe ich es global entfernt.
Helidon setzt wie Quarkus auf den JAX-RS Standard. Somit konnte ich hier den selben Code wie bei Quarkus verwenden. Es muss lediglich ein JerseySupport registriert werden und los geht's.
Das Positive an Helidon ist, dass es keine eigenen Befehle im Terminal benötigt, um gestartet zu werden. Hier ist der IDE-Support sehr einfach und angenehm. Alle notwendigen Abhängigkeiten sind in der pom.xml.
So kann auch das Bauen einfach per mvn clean install
durchgeführt und das gebaute Jar-Archiv mit java -jar
ausgeführt werden. Dazu ist zu sagen, dass ein mvn clean install
auch bei allen anderen Frameworks zum Bauen eines ausführbaren Jar reicht.
Vert.x und Quarkus bringen weitere Skripte mit und benötigen eine zusätzliche Klasse, um aus der IDE heraus gestartet zu werden. Diese Klasse ist bei beiden Microservice-Frameworks nicht per Voreinstellung dabei.
fun main(args: Array<String>) { val serverConfig = ServerConfiguration.builder() .port(8080).build() val webServer = WebServer .create(serverConfig, Routing.builder() .register("/cars", JerseySupport.builder().register(CarService::class.java).build()) .build()) .start() .toCompletableFuture() .get(10, TimeUnit.SECONDS) }
Quarkus ist ein noch recht junges Framework. Dennoch erhält es bereits große Aufmerksamkeit in der Community. Es steht gerade bei knapp 2.000 Sternen auf GitHub.
Entwickelt wurde und wird Quarkus von Red Hat. Nativ kompiliert es für die GraalVM, kann aber auch für die klassischen JVM übersetzt werden. Hier spielt es aber nicht seine Stärken aus, die sich durch den schlanken RAM-Verbrauch und das extrem schnelle Starten ergeben, wenngleich es, wie du noch sehen wirst, auf der JVM trotzdem in unter einer Sekunde startet.
Quarkus nutzt verschiedene Standards, unter anderem JAX-RS Netty und Eclipse MicroProfiles.
Quarkus schreibt sich ein sehr schnelles Starten und somit Skalieren sowie einen geringen Speicherverbrauch auf die Fahne. Außerdem setzen die Hersteller auch auf den reaktiven Ansatz, um stark nebenläufige und responsive Anwendungen zu entwickeln.
Hierzu gibt es einen ausführlicheren Artikel in der JavaSpektrum (7/2019), in welcher Quarkus genauer beleuchtet wird. Dort wird unter anderem gezeigt, dass die Anwendung auf der JVM 100 MB RAM verbraucht, wohingegen sie auf der GraalVM nur 8 MB benötigt.
Die Dokumentation von Quarkus ist ausführlich und gut lesbar. Leider gibt es bisher aber nur wenige Tutorials und Erklärungen. Das liegt zum einen daran, dass die Community noch nicht so groß ist, zum anderen ist das Quarkus-Microservice-Framework noch nicht lange genug in Verwendung.
Treten Probleme auf, muss man lange suchen oder eigene Fragen an die Community stellen. Aber allein aufgrund der Tatsache, dass Quarkus von Red Hat kommt, wird die Community nicht lange auf sich warten lassen.
Von Vorteil ist, dass du eine schnelle und schlanke Anwendung mit dem vorhandenen Java- beziehungsweise Kotlin-Wissen entwickeln kannst.
@Path("/cars") class CarResource { @Inject lateinit var responseStream: CarStreamResponseOutput @GET @Produces(MediaType.APPLICATION_JSON) fun getCarsAsList() = DataService.getDataStream(0).toList().blockingGet() @GET @Produces("application/stream+json") fun loadCarsAsJsonStream(): Response { return Response.ok().entity(responseStream).build() } }
Nachdem du nun weißt, wie du den Einstieg in die einzelnen Microservice-Frameworks schaffst, geht es an den eigentlichen Vergleich und an die Auswertung meiner Benchmarks.
Spring Boot, Quarkus und Helidon verwenden nahezu den gleichen ResponseWriter. Vert.x nutzt einen Handler.
In Helidon und Quarkus kannst du dich auf den klassischen JAX-RS-Ansatz verlassen. Hier gibt es durch die Java-EE-Entwicklung viel Dokumentation. Bei Vert.x gibt es hingegen eine gute eigene Dokumentation.
Alles in allem fiel mir das Entwickeln in Spring Boot am leichtesten. Dies liegt zum einen an der Erfahrung, zum anderen an der derzeit größten Community. Die Vorteile der anderen Frameworks lassen sich allerdings nicht von der Hand weisen, wie du gleich an den Zahlen sehen wirst.
Im ersten Schritt werden alle Backends auf der JVM (Java-Version 11.0.2) gestartet. Danach werden die entsprechenden Endpunkte mit curl angesprochen. Die First-Response-Zeiten werden durch ein Format-File ermittelt (dieses befindet sich in meinem GitHub-Repo).
curl -w "@curl-format.txt" -o /dev/null -s "http://localhost:8080/cars" -H"Accept:application/stream+json"
Mittelwert und Median der Response-Zeiten werden mit k6 ermittelt. Hierbei werden mit zehn simulierten Usern 30 Sekunden lang Anfragen an den Endpunkt gesendet. Die Ergebnisse sind in der folgenden Tabelle dokumentiert.
Kriterium | Spring Boot | Vert.x | Helidon | Quarkus |
---|---|---|---|---|
Startzeit | 2.226s | 0.200s | 0.619s | 0.562s |
First Response | 0.190s | 0.350s | 0.540s | 0.523s |
RPS Small | 8712 | 5372 | 7082 | 9269 |
RPS Large | 79 | 99 | 98 | 98 |
Average Response Time Small Data | 1.120ms | 1.320ms | 1.390ms | 1.050ms |
Median Response Time Small Data | 1.030ms | 1.700ms | 1.130ms | 0.914ms |
Average Response Time Slow Data | 126ms | 101ms | 102ms | 101ms |
Median Response Time Slow Data | 118ms | 101ms | 101ms | 101ms |
Die Startzeit der vier Microservice-Frameworks ist in nachfolgendem Bild erkennbar. Hier siehst du, dass Spring Boot deutlich von den anderen Frameworks geschlagen wird:
Die langsame Startzeit bringt immerhin den Vorteil, dass bereits beim Starten mehr Abhängigkeiten geladen werden und die erste Antwort auf eine Anfrage schneller kommt. Dies ist nachfolgend zu erkennen:
Die folgenden beiden Grafiken stellen die Antworten pro Sekunde dar. Small sind Daten ohne Verzögerung, large sind Daten mit einer Verzögerung von 100 ms.
Wie du siehst, sind alle Frameworks bei Antworten mit Verzögerung relativ ähnlich, wobei Spring Boot circa 20 Prozent langsamer war. Ist die Antwort allerdings schnell und klein, sind die Unterschiede größer. Hier ist Vert.x um fast 50 Prozent langsamer als Quarkus.
Unter Last reagieren alle Microservice-Frameworks ähnlich schnell, sofern die Antwort klein ist. Bei der ersten Reaktion lag Spring Boot zwar deutlich vorn, jedoch verliert es bei der Startzeit um Längen. Vert.x hat in diesem Vergleich die Nase deutlich vorn, obwohl es bei einer einzelnen Instanz und sehr vielen Anfragen nicht so viele gleichzeitig bearbeiten kann wie Quarkus oder Spring Boot. Allerdings lassen sich problemlos mehrere Instanzen von Vert.x auf einem Rechner starten, da es single-threaded ist.
Alles in allem sind Helidon und Quarkus im Gesamtbild am schnellsten, wobei Quarkus noch einen Tick schneller ist.
Geht es um den Umfang der Dokumentation, um Hilfe im Netz und um die Zahl der Entwickler, sollte man wohl auf Spring Boot setzen. Soll ein Service jedoch schnell gestartet und gestoppt werden können, lohnt es sich, die Zeit zu investieren und eines der neueren Microservice-Frameworks wie Helidon, Quarkus oder Vert.x zu verwenden. Dies ist besonders spannend, wenn auf eine Microservice-Architektur gesetzt wird.
Der komplette Code ist auf meinem GitHub-Repo zu finden.
Hat dir der Artikel geholfen? Hast du Fragen?
Hinterlasse mir unterhalb deinen Kommentar!