Blog: Java, Software-Entwicklung & mehr

Reactive Application Toolkit: Vert.x - Erste Erfahrungen

09.01.2017 Thomas Mohme

Reactive Application Toolkit: Vert.x - Erste Erfahrungen

In diesem Artikel möchte ich meine Erfahrungen, die ich in einigen Tagen mit einem Ausprobier-Web-Projekt mit Vert.x gemacht habe, teilen.

Was genau ist Vert.x?

Auf der Hompage von Vert.x schlägt einem der folgende Satz entgegen:

Eclipse Vert.x is a tool-kit for building reactive applications on the JVM.

Diese Beschreibung ist gleichermaßen knapp, offen und zutreffend.

Vert.x bezieht sich auf das Reactive Manifesto in dem bestimmte Eigenschaften von Anwendungen gefordert werden, damit sie u.a. (nahezu beliebig) skalierbar sind.
Eine der im Reactive Manifesto geforderten Eigenschaften (event driven & non blocking) ist tief im Kern von Vert.x verankert.
Für die Erfüllung der anderen Eigenschaften (responsiveness, resilience, elasticity) muss man als Entwickler schon noch selber sorgen.

Event driven

In konventionellen Application-Servern wird für die Bearbeitung eines Requests grundsätzlich während der gesamten Zeit ein Thread benötigt. Alles was zur Berarbeitung des Requests erforderlich ist, läuft innerhalb dieses Threads prinzipiell synchron und damit sequentiell ab.
Man kann den Thread auch als sehr grob-granulare Event-Verarbeitung auffassen: Ein Event (= HTTP-Request) tifft in einem Framework (= Application-Server) ein, woraufhin dieses einem Stück Software mit einer definierten Schnittstelle (= Servlet) die Kontrolle übergibt => Ein Servlet verarbeitet ein Event.
Weil es für die Art der durchgeführten Verarbeitung keine Einschränkungen gibt und diese regelmäßig sehr lange dauern, wird als Ausgleich eine entsprechend Große Anzahl von Verarbeitungs-Threads vorgesehen. Auch, wenn man (noch) nicht von dem berühmten C10k Problem betroffen ist, ist dies ein verschwenderischer Umgang mit Ressourcen.

Vert.x nutzt ein wesentlich restriktiveres Verarbeitungsmodell:
In der Vert.x Welt sind während der Verarbeitung eines Events keine direkten Zugriffe auf externe Ressourcen (z.B. Netzwerk-, File-I/O) erlaubt. Ein Event-Handler darf in Vert.x nur CPU-Zeit und Speicher benötigen.
Da man in Business-Anwendungen ohne externe Ressourcen jedoch meist nicht weit kommt, stellt Vert.x eine API bereit, um solche Ressourcen trotzdem nutzen zu können.
Der wesentliche Unterschied zu den herkömmlichen APIs ist, dass die von Vert.x bereitgestellte API asynchron arbeitet. Das bedeutet, dass man das Ergebnis nicht direkt erhält, sondern (wenn es denn irgendwann bereit steht) wiederum ein Event-Handler mit dem Ergebnis angesprochen wird.

Dies ermöglicht es Vert.x (und anderen, auf dem Reactor Pattern aufbauende Lösungen), mit vergleichsweise wenig Ressourcen auszukommen, was sich wiederum sehr positiv auf die Effizienz auswirkt:
Java Threads werden auf OS Threads abgebildet. Wenn ein OS Thread (z.B. wegen I/O) blockiert und ein anderer Thread dispatched wird, ist dies ein sehr teurer Vorgang, u.a. weil die CPU-Caches nun erstmal "kalt" sind, was alleine schon eine Verringerung des Befehlsdurchsatzes um den Faktor 100 zur Folge haben kann.
Im Kern verwendet Vert.x eine sogenannte Event-Loop. Genau genommen ist es nicht eine (wie bei Node.js), sondern im Normalfall eine je CPU-Core.
Hierdurch bleibt (im optimalen Fall) nahezu immer der gleiche Thread auf dem gleichen CPU-Core, was die Verluste durch Thread-Wechsel verschwinden lässt.

Umgekehrt bedeutet dies natürlich auch, dass man in einem Event Handler - der in der Event Loop ausgeführt wird - nie herkömmliche bockierende APIs benutzen darf, da man daurch nicht nur den eigenen Event Handler blockiert, sondern auch alle anderen in der gleichen Event Loop.

Die ganze Technik der Verwendung von Event Loops ist übrigens nicht neu.
Die meisten (nicht nur Java) GUI-Frameworks arbeiten auf dieser Basis.

Toolkit vs. Framework

Vert.x versteht sich als offenes Toolkit und nicht als geschlossenes Framework oder Container.
Obwohl ich Vert.x bei meinen ersten Gehversuchen im Wesentlichen nur "Lehrbuchmäßig" genutzt habe, macht die API auf mich den Eindruck, als könne man sie auch in gänzlich anderen technischen Kontexten verwenden.
Auch die Verwendung anderer Technologien aus einem Vert.x Umfeld heraus scheint kaum problematisch zu sein.
insofern ist Vert.x wirklich offen und erlegt einem keine künstlichen Beschränkungen auf.
Allerdings muss man hierbei immer beachten, dass Vert.x mit einem gänzlich anderen Thread-Modell (dazu später mehr) arbeitet als dies in einer "konventionellen" Java Anwendung üblich ist.
Selbst, wenn man für die eigene Anwendungslogik sicher sagen kann, dass sie zu Vert.x' Thread-Modell passt, wird es einem schwer fallen, dies für alle genutzten Libraries (inklusive transitiver Abhängigkeiten) zu klären.

Vert.x ist polyglott

Ein wesentlicher Vorteil von Vert.x ist, dass die APIs in verschiedenen Sprachen bereitstehen: Java, JavaScript, Groovy, Ruby und Ceylon werden mit idiomatischen APIs unterstützt.
Natürlich kann man auch von anderen JVM-Sprachen aus (Scala, Clojure), die Vert.x APIs nutzen.
Nur entspricht dies dann nicht mehr der natürlichen Ausdrucksweise in der jeweiligen Sprache und wird ggf. syntaktisch umständlich sein.

General Purpose

Vert.x ist nicht auf ein bestimmtes fachliches Problemfeld festgelegt oder auch nur spezialisiert.
Ich würde Vert.x immer dann als potenziellen Kandidaten betrachten, wenn man es mit vielen parallelen ein- oder ausgehenden Netzwerkverbindungen zu tun hat.
Komplizierte Business-Logik mit vielen DB-Zugriffen zählt aus meiner Sicht nicht zu den Stärken - doch dazu später mehr.

Unopinionated

In Vert.x gibt es nicht die eine richtige Art, eine Anwendung zu bauen.
Das ist ein Vorteil, weil Anwendungen nicht in einen engen Rahmen gepresst werden müssen, der nicht zu ihnen passt.
Gleichzeitig erschwert es den Einstieg, weil man in dem neuen Umfeld zunächst keine Orientierung hat und sehr unsicher ist, ob ein gewählter Weg langfristig eine gute Wahl ist. Das Vert.x Projekt stellt zwar viele Beispiele bereit, die auch mir sehr geholfen haben, aber diese Beispiele beschränken sich im Kern meist auf 10, 20 Zeilen Code.
Damit sind sie gut geeignet, eine Lösung für ein punktuelles Problem aufzuzeigen, reichen aber nicht für eine Architektur-Empfehlung.

Mentales Modell

Das mentale Modell, dass Vert.x zu Grunde liegt, erinnert sehr an Akka.
Im Kern steckt ein Vertx Objekt, dass zentral für die Koordination zuständig ist, mit dem man aber wenig direkt zu tun hat.
Die Anwendungslogik wird in sogenannten Verticles untergebracht.

Verticles

Aus der Vert.x Core Dokumentation:

Vert.x comes with a simple, scalable, actor-like deployment and concurrency model out of the box that you can use to save you writing your own.

This model is entirely optional and Vert.x does not force you to create your applications in this way if you don’t want to.

The model does not claim to be a strict actor-model implementation, but it does share similarities especially with respect to concurrency, scaling and deployment.

To use this model, you write your code as set of verticles.
. . .
You can think of a verticle as a bit like an actor in the Actor Model.

Vert.x kennt verschiedene Arten von Verticles:

  • Standard Verticles
    Für diese garantiert Vert.x, dass der Code eines Verticles immer in der gleichen Event Loop ausgeführt wird. (Außer natürlich, wenn man selber Threads erzeugt und verwendet!)
    Da eine Event Loop einem Thread entspricht bedeutet dies im Umkehrschluss, dass man in einem Verticle so programmieren kann, wie in einer Single-Threaded Welt. Diese Verticles unterliegen den o.g. Einschränkungen und dürfen nicht blockieren.

  • Worker Verticles
    Im Unterschied zu Standard Verticles werden Worker Verticles in einem eigenen Thread-Pool ausgeführt und dürfen daher blockierende APIs nutzen.
    Hierdurch bieten sie sich als "Brücke in die existierende, synchrone Welt" an.
    Auch für normale Worker Verticles garantiert Vert.x, dass ihr Code nie parallel ausgeführt wird.

  • Multithreaded Worker Verticles
    Diese Variante muss ohne die o.g. Garantien auskommen.
    Dies ist quasi der Fallback in die normale Java-Welt, in der man konkurrierende Zugriffe auf Daten selber absichern muss.

Wenn in einem Standard-Verticle für eine kleine Aufgabe eine blockierende API benutzt werden muss, hat man zusätzlich die Möglichkeit, dies in einen speziellen Aufruf zu verpacken, wodurch die Aufgabe in einem Thread aus dem Worker-Pool abgearbeitet wird:


vertx.executeBlocking(future -> {
  // Call some blocking API that takes a significant amount of time to return
  String result = someAPI.blockingMethod("hello");
  future.complete(result);
}, res -> {
  System.out.println("The result is: " + res.result());
});

Laut der Vert.x Core Dokumentation ist die Verwendung von Verticles in keiner Weise zwingend.
Formal mag dies richtig sein. Wenn man jedoch die asynchronen Vert.x APIs benutzt und der entsprechende Callback (ohne die Garantien der Verticles) in irgendeinem Thread stattfindet, dann muss man für jeglichen Datenzugriff die komplette Synchronisierung selber organisieren.
Dieser Aufwand verbietet einen großflächigen Einsatz.

Verticles kommunizieren untereinander über den EventBus.

EventBus

Laut der Dokumentation ist der EventBus das Nervensystem von Vert.x.
Der EventBus ist im Kern sehr einfach gehalten:

  • Nachrichten können entweder an einen oder beliebig viele Empfänger verschickt werden.
  • Nachrichten werden über einen simplen String adressiert.
  • Es gibt keine Garantie für die Zustellung.
  • In Nachrichten können nur wenige grundlegende Datentypen transportiert werden. (Zitat: "Out of the box Vert.x allows any primitive/simple type, String, or buffers to be sent as messages.")

Das wars.
Insbesondere gibt es keine Transformations- oder andere Regeln, die auf dem EventBus angewendet werden.

Im einfachsten Fall reichen wenige Zeilen Code für die Nutzung aus:


EventBus eb = vertx.eventBus();
eb.consumer("my.message.channel", message -> {
  System.out.println("I have received a message: " + message.body());
});

eb.publish("my.message.channel", "Sending some message");

Hiermit sind dann die wesentlichen Bausteine, aus denen man seine Anwendung zusammensetzen kann, aufgezählt.

Vert.x vs. Akka

Beiden ist gemeinsam, dass sowohl die Garantien für die Verticles bzw. Aktoren, als auch die Anforderungen an den jeweiligen Anwendungs-Code (keine Nutzung blockierender APIs) praktisch identisch sind.

Beide bieten die automatische Verteilung ihrer Verticles/Aktoren im Cluster an.
In diesem Bereich bin ich während meiner Versuche jedoch nicht tief genug vorgedrungen, um detailliertere Vergleiche anstellen zu können.

Während man bei Akka von Anfang an auf das Aktormodell festgelegt ist, bleibt Vert.x hier wesentlich offener. Dies bedeutet aber auch, dass Akka für das Aktormodell mehr Unterstützung bietet. Beispielsweise ist es einfach möglich, Domain-Objekte durch Aktoren zu repräsentieren und zu addressieren. Bei Vert.x ist hier Eigenentwicklung mit eigenen (Verticle-)Registries, etc. erforderlich.

Akka lässt sich gut mit Scala nutzen, aber schon die Nutzung mit Java fühlt sich "clumsy" an.
Vert.x steht hier mit der idiomatischen Unterstützung von fünf Sprachen besser da.

Für typische Web-Anwendungen gestaltet sich die Einrichtung des erforderlichen Routings mit Akka imho ebenfalls suboptimal. Die Routing Deklarationen mit dem Vert.x Modul Vert.x-Web finde ich deutlich übersichtlicher.

Umgekehrt sehe ich bei Akka eine umfangreichere Unterstützung für das Testen der Aktoren, als dies bei Vert.x für die Verticles gegeben ist.
Auch ist Akka sehr fokussiert auf den Umgang mit Fehlern (der 'Resilience' Aspekt des Reactive Manifesto), während ich in diesem Bereich bei Vert.x nichts wahrgenommen habe.

Mit Akka-Persistence bietet Akka "ab Werk" ein Verfahren, Daten zu persistieren, dass zwar sehr auf Event-Sourcing ausgerichtet (und damit konzeptionall eingeschränkt) ist, sich aber ohne große Umstände nutzen lässt.
Die Nutzung der asynchronen JDBC-Variante von Vert.x scheint mir dagegen kaum empfehlenswert zu sein - dazu unten mehr. Hier scheint mir die Nutzung einer MongoDB sinnvoller - wenn dies denn zum Domain-Modell der Anwendung passt. Die Akka-Persistence zusammen mit der Möglichkeit, Domain-Objekte als Aktoren zu implementieren stellt für mich ein riesigen Vorteil von Akka gegenüber Vert.x dar.

Schließlich glänzt Vert.x noch mit weiteren Modulen und asynchronen APIs z.B. für File-I/O, die Akka nicht bietet.

Einen eigenen kleinen Eindruck kann man sich zum Beispiel bei ToDo-Backend verschaffen.
Auf dieser Website werden verschiedene Implementierung einer einfachen Web-Anwendung ("ToDo Liste") präsentiert.
Hier finden sich sowohl eine Akka Variante, als auch mehrere Vert.x Varianten. (Hier ist insbesondere der Branch "stamina-serialization" interessant.)

Wichtige Eigenschaften

DB Access

Vert.x bietet DB-Client APIs für MongoDB, Redis und JDBC.
Die JDBC-Variante ist eine asynchroner Wrapper um die normale, synchrone JDBC Funktionalität herum. Die reaktive Event-Loop Archtektur wird hierdurch ausgehebelt, da je parallelem JDBC-Zugriff wieder ein eigener Thread erforderlich ist.
Für MySQL /PostgreSQL gibt es einen "wirklich" asynchronen Client, der z.Z. jedoch nur als "Technical Preview" deklariert ist.

Für die JDBC-Variante lässt sich sagen, dass ein ohnehin bereits umständliches API durch die Erweiterung um asynchrone Funktionalität nochmals erheblich aufwändiger wird!
Das Vert.x Code-Beispiel für nicht viel mehr als eine simple Transaktion umfasst 87 sloc!

Für Dokumenten-Datenbanken wie MongoDB hat dies in der Praxis wahrscheinlich weniger Auswirkungen, da weniger DB-Zugriffe gemacht werden müssen.

Web

Für die "Brot-und-Butter" Web-Anwendungen mit Server-seitigem Rendering bietet Vert.x mit dem Modul Vert.x-Web eine gute Unterstützung. Essentielle Dinge wie vielseitiges Routing, Path-Expressions, Content-Negotiation, Authorization, etc. werden Out-of-the-box geboten. Ebenso werden fünf Template-Engines unterstützt. Zumindest bei dem näher betrachteten Thymeleaf muss man jedoch mit Einschränkungen gegenüber dem Original leben. Vermutlich ist dies auch bei den anderen Angeboten der Fall, da File-I/O in der Event-Loop nicht erlaubt ist.
Beispielsweise funktioniert bei Thymeleaf das I18N-Feature nicht mehr.

Debugging

Wenn man sich auf ein reaktives Anwendungsdesign einlässt muss einem klar sein, das herkömmliche, für synchron-prozedurale Anwendungen entworfene Debugger hier nur noch bedingt weiterhelfen.

Insbesondere versagt die bisherige Methode, anhand des Call-Stacks den Verursacher eines Problems zu finden.
Man kann nicht mehr "mal eben einen Breakpoint setzen" und davon ausgehen, dass die gesamte betroffene Verarbeitung stehenbleibt.
Vielmehr hält natürlich der Thread mit dem Breakpoint an. Es gibt aber häufig keinen synchronen Caller, der wartet und den man irgendwie identifizieren kann, um näher zur Ursache eines Problems zu gelangen. Stattdessen bekommt in Vert.x die "auslösende Aktivität" nach wenigen Sekunden einen Timeout.

Hier ist auf jeden Fall neues Know-How (und vielleicht neue Tools?) erforderlich, um wieder auf das gewohnte Produktivitätsniveau zu gelangen.
Die gängigen Tools sind auf einen mehr oder weniger sequentiellen Programmablauf hin ausgelegt. Bei einem beständigen Wechsel zwischen Java-Lambdas helfen aktuell hauptsächlich Log-Ausgaben weiter, um die Zusammenhänge zu verstehen.

Testen

Wegen der asynchronen Natur von Vert.x müssen spezielle Vert.x-Methoden verwendet werden, da sonst die Korrelation zum ursprünglichen JUnitTest-Thread nicht gegeben ist. Beispielsweise muss in einem (asynchronen) Handler nach erfolgreicher Absolvierung "async.complete()" aufgerufen werden. Andernfalls wird der Test nach einem Timeout als fehlerhaft deklariert.

Sobald auch nur ein einziger asynchroner Vert.x-API Aufruf in einem Test enthalten ist, reicht ein "normales" JUnit Test-Setup nicht mehr aus. Stattdessen ist man darauf angewiesen, die Vert.x-spezifische Test-Maschinerie zu nutzen.

Beispiel:


TestSuite suite = TestSuite.create("the<em>test</em>suite");
suite.before(context -> {
  // Test suite setup
})

suite.after(context -> {
  // Test suite cleanup
});

suite.test("value is 5", context -> {
  String s = "value";
  context.assertEquals("value", s);
})

suite.test("asyn consumer received message", context -> {
  Async async = context.async();
  eventBus.consumer("the-address", msg -> {
    async.complete();
  });
  eventBus.publish("the-address", "some message");
});

Clustering

Vert.x bietet von sich aus Unterstützung für Clustering mit automatischem Fail-Over, wenn ein Cluster-Node unerwartet beendet wird.
Die Grundlagen für horizontale Skalierbarkeit sind damit vorhanden. Eine genaue Betrachtung der Fähigkeiten und Grenzen würde jedoch den Rahmen des Artikels sprengen.

Einschätzung

Insgesamt erfolgt der Umgang mit HTTP-Request auf einem relativ niedrigen Abstraktionsniveau. Das ist gut, weil man viele Details selber bestimmen kann. Gleichzeitig ist es aber auch mühsam und möglicherweise fehleranfällig, weil man Details selber bestimmen muss (und dies überhaupt erst Wissen muss! => Brauche ich einen BodyHandler???).

Der Umgang mit (relationalen) Datenbanken ist sehr mühsam: Hibernate fällt als OR-Mapper grundsätzlich aus. Es bleiben nur einfache Lösungen, wie OrmLite. Für einzelne Entities ohne Relationen ist das noch machbar. Bei Relationen wird es dann sehr schnell sehr kompliziert. Außerdem muss man nicht einfach nur auf "plain JDBC"-Niveau arbeiten, sondern muss sich zusätzlich noch damit auseinandersetzen, dass das gesamte API asynchron ist. Das heißt, man schachtelt immer wieder Callbacks in Callbacks in Callbacks . . . und bitte niemals den Connection.close()-Aufruf im richtigen Callback vergessen!

Solange man eine Datenbank verwendet, deren Interface synchron arbeitet, erscheint mir der ganze Aufwand für die Nutzung der "asynchronen" Vert.x-JDBC-API ziemlich nutzlos: Letztendlich wird der Call zur Datenbank in einem eigenen Thread aus einen Thread-Pool durchgeführt, der während dieser Zeit blockiert ist. Da die DB-Zugriffe bei kommerziellen Anwendungen meist den größten zeitlichen Anteil ausmachen, wird also insgesamt wenig gewonnen. Auf der anderen Seite erkauft man sich dies durch ein viel komplexeres API. Es erscheint mir einfacher, komplexe Business-Logik mit vielen DB-Zugriffen nicht zu zerstückeln, sondern "konventionell" zu belassen und ggf. in einem Worker-Verticle ablaufen zu lassen. Die gesamte Problematik der Transaktionssteuerung wurde hierbei noch nicht einmal betrachtet.

Wenn man statt der Vert.x-spezifischen AsyncResults die RxJava Observables verwendet, dann werden dadurch einige Probleme abgemildert: Einige der CallBacks verschwinden in dem chaining - eine Error-Handling ist nur am Ende erforderlich.

Die Alternative zu diesen Vorgehen ist, die komplette Anwendungslogik & DB-Zugriffe nach konventioneller Art in einem (Multithreaded-)Worker-Verticle zu bündeln, welches in einem Worker Threadpool ausgeführt wird.
Dieses Modell entspricht dann jedoch im Wesentlichen dem, was man auch z.B. mit Spring-Boot oder Dropwizard machen kann.
Die Performance-Vorteile, die man sich durch die Verwendung einer reaktiven Architektur mit Event-Loop erhofft hat, werden dann nicht eintreten.

Durch die vielen Callbacks entsteht eine extrem enge Verzahnung zwischen der eigenen Anwendungslogik und Vert.x Ich würde zumindest versuchen, wo immer es möglich ist, auf die RxJava API auszuweichen, da dies zu einer geringeren Verzahnung im Code führt. Außerdem dürften zumindest Teile der Anwendungslogik einfache in andere Umgebungen (z.B. Ratpack) übertragbar sein, die ebenfalls RxJava untertützen.

In Summe bleibt der Eindruck eines Toolkits, dass zwar immense Möglichkeiten bietet, von dem man sich in einem Projekt aber auch in maximaler Weise abhängig macht.
Bei diesen Voraussetzungen sollte man vor einem ernsthaften Projekt auf jeden Fall zunächste in einer kleinen Studie prüfen, ob die erhofften Vorteile wirklich eintreten und diese die mitgekauften Nachteile überwiegen.
Andernfalls ist man vielleicht mit einem langweiligeren, konventionelleren Ansatz besser beraten.

tl;dr

In Umgebungen, in denen parallelisierbar Daten aus verschiedenen Quellen herangezogen werden kann der Einstieg in die asynchron-reaktive Programmierung unmittelbaren Nutzen schaffen. Beispiel: Welcome-WebPage mit Rückgriff auf voneinander unabhägige (Micro-)Services für einzelne Themenbereiche.

Wenn die zu bauende Anwendung aber durch serielle Requests an synchrone Quellen (herkömmliches DBMS) dominiert ist, dann bringt der asynchron-reaktive Ansatz vor allem Nachteile.

Info-Quellen für den Einstieg in Vert.x