Suche

Von Enterprise zu Reactive: Wie sich „reaktiv“ im Code widerspiegelt

Lutz Hühnken

© Shutterstock/Nomad_Soul

Nicht mehr zu übersehen ist das zunehmende Interesse an der Entwicklung reaktiver Anwendungen – spätestens seit Ausgabe 5.2014 des Java Magazins, die es zum Titelthema machte. Viele Entwickler nehmen reaktiv aber bisher nur als vages Versprechen wahr, ohne eine konkrete Vorstellung davon zu haben, was auf Codeebene die wesentlichen Unterschiede zum gängigen Vorgehen, also in der Regel Java EE, ausmacht. Diesen Unterschieden wollen wir uns im folgenden Artikel widmen.

Die Grundlagen reaktiver Systeme sind im Reaktiven Manifest beschrieben und in vier Schlagworten schnell zusammengefasst: Ziel ist die Antwortbereitschaft des Systems, erreicht durch Elastizität und Widerstandsfähigkeit, auf Grundlage einer nachrichtenbasierten Kommunikation. Auf die erläuternden Hintergründe der einzelnen Begriffe soll hier nicht eingegangen werden, dafür sei auf das Manifest selbst verwiesen.

Nun liegt es in der Natur eines Manifests, dass es sehr grundsätzliche Forderungen aufstellt, aber darüber vage bleibt, wie diese technisch im Detail umzusetzen sind. Für den Java-Entwickler stellt sich also die Frage, wie sich reaktiv im Code widerspiegelt.

Serverseitige Java-Entwicklung, egal mit welchen Frameworks, spielt sich in der Regel im Java-EE-Umfeld ab. Zumindest das Servlet-API, das ja auch Teil von Java EE ist, wird höchstwahrscheinlich die Grundlage des verwendeten Web- oder REST-Frameworks sein. In der Tat gibt es nun in der reaktiven Entwicklung einige grundlegende Unterschiede dazu. Deren Unkenntnis führt zu Missverständnissen und Verwirrung, daher ist es sinnvoll, sich im Vorhinein mit einigen technischen Aspekten reaktiver Entwicklung vertraut zu machen. Ob bewusst oder nicht, die Erfahrung mit Java EE führt dazu, dass viele Dinge als gegeben angesehen werden, die aber in der reaktiven Welt so nicht mehr vorhanden sind. Es heißt also vor allem: Abschied nehmen von einigen vertrauten Konzepten.

Tschüss, Thread per Request

Das Servlet-API basiert im Grunde auf einem Thread-per-Request-Modell. Jedem Request, der eintrifft, wird ein eigener Thread zugewiesen – sei es ein neuer oder einer aus einem begrenzten Pool. Dieser Thread wird den Request abarbeiten (Abb. 1).

Um moderne Multicore-Prozessoren besser auszunutzen, ergänzen reaktive Systeme das Threadmodell um eine weitere Ebene, mit feinerer Granularität. Unterschiedliche Bibliotheken haben dafür unterschiedliche Ansätze. Prominenteste Vertreter auf der JVM sind Vert.x Verticles und Akkas Aktoren. Als allgemeiner Begriff für die Einheit einer Aufgabe unterhalb des Threadlevels hat sich „Task“ eingebürgert, in gewohnter Bevorzugung des Englischen wird also, unabhängig von der konkret zum Einsatz kommenden Bibliothek, von „Task Level Concurrency“ gesprochen.

Verdeutlichen lässt sich dies am einfachsten anhand einer Webanwendung. Der offensichtlichste Gegensatz zu „Thread per Request“ ist der Event Loop. In einem Event-Loop-Modell gibt es einen einzigen Event-Loop-Thread. Für jeden eingehenden Request wird ein Objekt angelegt, das in einer Queue abgelegt wird. Der Event-Loop-Thread arbeitet in einer Schleife alle diese Objekte ab (Abb. 2).

Reaktive Webframeworks wie Play kombinieren den asynchronen Ansatz des Event-Loop-Modells mit dem JVM-Multi-Threading. Auch hier wird jeder Request zunächst in einer Queue abgelegt. Es arbeitet sich aber nicht ein einzelner Event-Loop-Thread daran ab, sondern ein Pool von Threads (Abb. 3). So wird die Effizienz des Event-Loops genutzt, aber gleichzeitig alle Cores des Systems ausgenutzt. Die Anzahl der verwendeten Threads orientiert sich an der Menge der zur Verfügung stehenden Cores und ist in der Regel höchstens doppelt so groß.

Abb. 1: Thread per Request – zu viele Threads

Abb. 1: Thread per Request – zu viele Threads

Abb. 2: Event Loop – nur ein Thread

Abb. 2: Event Loop – nur ein Thread

Abb. 3: m Request x n Threads – mehrere Cores optimal nutzen

Abb. 3: m Request x n Threads – mehrere Cores optimal nutzen

Wichtig ist dies für Entwickler unter anderem, weil es im Bezug auf den Umgang mit Threads einige Veränderungen mit sich bringt. Im Thread-per-Request-Modell konnte man während der Verarbeitung eines Requests davon ausgehen, dass der Thread dem Request „exklusiv“ gehört. Viele Programmierer, und auch viel Bibliotheken, machen sich dies zunutze, indem sie Daten in einem ThreadLocal ablegen. Beispiele hierfür sind der Mapped Diagnostic Context von SLF4J, Spring (z. B. Spring Security, der OpenSessionInViewFilter), Hibernates ThreadLocalSessionContext und viele mehr.

ThreadLocals werden ohnehin schon von vielen kritisch gesehen, da ihre Verwendung eine gewisse Aufmerksamkeit erfordert. Bei dauerhafter Wiederverwendung der gleichen Threads kann leicht ein Speicherleck eingeführt werden. In einem Task-Level-Concurrency-Modell, das unterhalb von Threads ansetzt, wird die Verwendung von ThreadLocals nun endgültig zum Anti-Pattern und ist auf jeden Fall zu vermeiden. Viele Tasks teilen sich einen gemeinsamen Thread, sodass diese Ebene nicht mehr taugt, um den Kontext für einen einzelnen Task zu speichern.

In der Regel ist es leicht, Alternativen zu finden. Oft dient die Speicherung im ThreadLocal zum Aufbau eines Kontexts, der ebenso gut in Form von Parametern übergeben werden könnte. Falls aber auf eine Library, die ThreadLocal verwendet, nicht verzichtet werden kann oder soll, müssen Entwickler etwas zusätzliche Arbeit investieren und beispielsweise mit Bytecode-Instrumentierung dafür sorgen, dass es auch in einer Task-Level-Umgebung funktioniert. Ein Beispiel findet sich hier.

Ein positiver „Nebeneffekt“ dessen, dass Concurrency Libraries unterhalb des Threadlevels eingesetzt werden, ist das Programmiermodell. Das Entwickeln von threadbasiertem Code ist schwierig und fehlerträchtig. Leicht kann es zu Race Conditions kommen. Die nicht deterministische Ausführung macht das Debugging schwierig, manch einer ist schon „Heisenbugs“ begegnet: Fehlern, die in Produktion auftreten, aber wenn sie in einer Testumgebung reproduziert werden sollen, nicht – scheinbar verändert sich das Verhalten allein durch die Beobachtung.

Der gemeinsame Zugriff von Threads auf Variablen erfordert Beachtung des Java-Speichermodells. Variablen müssen als „volatile“ gekennzeichnet werden. Vielleicht ist auch Synchronisation notwendig, was in der Regel zu Zugriffsstaus führt und den Durchsatz des Systems ruiniert.

Bei der Verwendung reaktiver Toolkits wie Akka oder Vert.x ist der Entwickler von diesen Sorgen befreit. Mit Aktoren oder Verticles lässt es sich ohne solche unerwünschten Nebenwirkungen programmieren. Und in einem asynchronen System spielt der unbestimmte zeitliche Ablauf beim Multi-Threaded Code keine Rolle. Vielmehr kann dort in einem einfacheren „Ursache-Wirkung“-Modell über das Programm nachgedacht werden – eine Nachricht kommt hinein und löst eine bestimmte Aktion aus.

Servus, synchrone Ein/Ausgabe

Reaktive Applikationen zeichnen sich auch dadurch aus, dass alle Netzwerk- und Dateioperationen asynchron ausgeführt werden. In synchroner Ein-/Ausgabe entspricht ein Netzwerk- oder Dateizugriff einem Methodenaufruf. Dieser kehrt entweder direkt mit den gewünschten Daten zurück, wenn die Operation abgeschlossen ist, oder liefert einen Stream zurück, aus dem die Daten per Iterator in einem „Pull“-Verfahren gelesen werden. Solche Operationen belegen zwar nicht die CPU, blockieren aber den ausführenden Thread.

Nun wollen reaktive Systeme aber ja mit wenigen Threads auskommen. Schauen wir noch einmal auf unser Beispiel Webanwendung. Hier ist ja nun jeder Thread für eine Vielzahl, möglicherweise tausende von Requests zuständig. Mehrere Threads aus diesem Pool zu blockieren, würde erhebliche Folgen für den Durchsatz des Systems haben.

Um die verfügbaren Threads nicht länger als notwendig zu blockieren, ist daher die asynchrone Ein-/Ausgabe vorzuziehen. Hierbei bleibt der aufrufende Thread frei und wartet nicht auf das Ergebnis. Die tatsächlichen Daten kommen dann durch den Aufruf eines Callbacks zurück – ein Modell, das vielen aus der JavaScript-Programmierung bekannt sein dürfte.

Für Datei- und Netzwerkoperationen stehen asynchrone Bibliotheken zur Verfügung (NIO, Netty und darauf basierende HTTP-Clients). Nicht so einfach sieht es allerdings für relationale Datenbanken aus. JDBC setzt auf ein synchrones Protokoll, und mangels serverseitiger Unterstützung ist es auch nicht möglich, den JDBC-Treiber einfach durch eine asynchrone Variante zu ersetzen.

Der Workaround für diesen Fall heißt separate Threadpools. Um zu verhindern, dass der primäre Threadpool durch blockierende Aufrufe nicht mehr ausreichend arbeitsfähige Threads zur Verfügung hat, werden dezidierte Threads für die Datenbankzugriffe reserviert. Dies ist unbedingt zu beachten! Die Threads, die die Tasks abarbeiten, sollen die CPU auslasten und niemals durch synchrone Ein-/Ausgabe blockiert werden. Ist synchrone Ein-/Ausgabe, wie bei JDBC, nicht zu vermeiden, ist dafür in jedem Fall ein separater Threadpool zu verwenden. Spezialisierte Datenbank-Libraries wie Slick oder vertx-jdbc-client machen dies schon von Haus aus, sodass sich der Entwickler, abgesehen von ein paar Konfigurationsparametern, nicht darum kümmern muss.

Lebt wohl, verteilte Transaktionen

„Erwachsene verwenden keine verteilten Transaktionen“ ist ein Zitat von Pat Helland, dessen Artikel „Life Beyond Distributed Transactions“ ein grundlegendes Werk für reaktive Systeme ist. Ein Beispiel für eine verteilte Transaktion ist ein 2-Phase-Commit über zwei Systeme, die von einem Koordinator überwacht wird. Der gewünschte Effekt ist ein „Alles oder Nichts“ – es sollen entweder beide Operationen erfolgreich durchgeführt werden, oder keine. Ein gängiges Beispiel zeigt Listing 1.

@Transactional
  public static class TicketService {

    @Inject
    private JmsTemplate jmsTemplate;

    @PersistenceContext
    private EntityManager entityManager;

    public void createTicket(String code) {
      Ticket ticket = new Ticket(code);
      this.entityManager.persist(ticket);
      this.jmsTemplate.convertAndSend("tickets", ticket);
      …

Hier wird durch die Transaktionsklammer sichergestellt, dass die Nachricht nur in der JMS-Queue landet, wenn auch der Insert in der Datenbank erfolgreich ist, und umgekehrt. Durch die aufwändige Synchronisierung und die Empfindlichkeit gegenüber Ausfällen einzelner Komponenten sind verteilte Transaktionen für reaktive Systeme allerdings ungeeignet. Auch unabhängig von grundsätzlichen Überlegungen zeigt die Empirie, dass sie in der Praxis für hochskalierbare Systeme keine Anwendung finden.

Wenn die Anforderungen das „Alles oder Nichts“ nun aber verlangen? Wie kann ein reaktives System dem gerecht werden?

Diesem Problem kann auf verschiedenen Ebenen begegnet werden. Zunächst sind die Anforderungen kritisch zu hinterfragen. Vielleicht lassen sich Prozesse so anpassen, dass die systemübergreifende Transaktion unnötig wird? Manche Garantie besteht ohnehin nur zum Schein. Unser Beispiel mit der JMS-Queue ist zwar tatsächlich gängig, aus fachlicher Sicht bringt es aber wenig. Wir müssen schließlich davon ausgehen, dass die Nachricht am anderen Ende der Queue auch irgendwie verarbeitet wird. Was ist, wenn dabei ein Fehler auftritt? Davon ist unser Insert natürlich nicht betroffen. Betrachtet man das Gesamtsystem, handelt es sich hier im Grunde nicht um ein „Alles oder Nichts“, vielmehr lediglich um den Abgleich zweier Systeme – unserer Datenbank und dem System, das sich am anderen Ende der Message Queue befindet. Ein solcher Abgleich könnte aber auch asynchron geschehen, zum Beispiel indem die Daten, die wir in der Datenbank einfügen, als „noch nicht abgeglichen“ markiert werden und sich ein anderer Prozess um diesen Abgleich kümmert.

Wenn sich aber an der Fachlichkeit wirklich nichts drehen lässt und tatsächlich eine echte Transaktion über Systemgrenzen hinweg erforderlich ist? Unter anderem im oben erwähnten Artikel wird aufgezeigt, wie sich jede verteilte Transaktion durch asynchrones Messaging ausdrücken lässt. Allerdings lässt sich das – leider – nicht einfach mechanisch durch vordefinierte Umformungen erreichen. Insbesondere stellt es einige Anforderungen an das Design des nachrichtenbasierten Systems. So muss die Zustellung von Nachrichten garantiert sein (at-least-once Delivery). Des Weiteren muss das System damit umgehen können, dass Nachrichten mehrfach eintreffen können, oder in anderer Reihenfolge, als sie gesendet wurden (Idempotency, Out of Order Messages). Und zu guter Letzt muss es vorläufige Operationen geben (Tentative Operations), die, wenn sie unbestätigt bleiben, entweder zurückgenommen oder durch eine komplementäre Operation egalisiert werden können.

Anhand der langen Beschreibung lässt sich schon sehen, dass es vielleicht nicht ganz trivial ist. Zumindest um die garantierte Nachrichtenzustellung muss sich der Entwickler in der Regel nicht kümmern, dies stellt die Infrastruktur zur Verfügung (z. B. Akka Persistence). Die anderen Eigenschaften erfordern anwendungsspezifische Betrachtung. Allerdings ist es so, dass viele Geschäftsvorgänge sich tatsächlich so darstellen lassen und viele Kommunikation und Koordination zwischen Menschen in der nicht virtuellen Welt auch asynchron abläuft. Die Modellierung solcher Abläufe ist daher nicht so schwierig, wie es auf den ersten Blick scheinen mag.

Eine Anwendung dieser Prinzipien ist das „Saga“-Entwurfsmuster für „Alles-oder-Nichts“-Operationen, die aus beliebig vielen Suboperationen bestehen. Hier wird für jede Aktion eine Gegenaktion definiert, welche die Auswirkungen der ursprünglichen Aktion rückgängig macht. Die Gesamtheit der Aktionen, die erfolgreich ausgeführt werden müssen, ist die Saga. Ein Koordinator (Saga Execution Coordinator) kennt die notwendigen Schritte und überwacht ihre Ausführung. Sollte eine Aktion nicht erfolgreich durchgeführt werden können, werden alle bereits ausgeführten Aktionen durch die Gegenaktionen rückgängig gemacht, sodass immer entweder die gesamte Saga bis zum Ende durchgegangen oder keiner der Schritte dauerhaft ausgeführt wird.

Soll also ein Reisepaket gebucht werden, das aus Flug, Hotel und Mietwagen besteht, muss für jede Komponente eine entsprechende Stornooperation definiert werden. Die Saga besteht nun aus den Buchungen der drei Komponenten und kann nur als Ganzes durchgeführt werden (Abb. 4). Schlägt eine der Teiloperationen fehl, werden die vorherigen Operationen rückgängig gemacht (Abb. 5).

Abb. 4: Erfolgreiche Saga

Abb. 4: Erfolgreiche Saga

Abb. 5: Saga mit Fehler

Abb. 5: Saga mit Fehler

Adieu, Application Server

Wenn die schöne asynchrone, reaktive Anwendung fertig ist, muss sie irgendwann auch auf einem Server in Betrieb gehen. In der Java-EE-Welt hieß dies: Ein WAR oder EAR erzeugen und auf dem Servlet Container oder Application Server deployen. Auch von diesem Konzept heißt es Abschied nehmen.

Dies ist ein Ansatz, der sich auch schon unabhängig von der reaktiven Programmierung verbreitet hat und zum Beispiel auch von Dropwizard und Spring Boot verfolgt wird. Alle benötigten Komponenten werden in einer Standalone-Anwendung gebündelt, die dann allein als Serveranwendung auf ihrer eigenen JVM läuft.

Getrieben wird dieser Trend durch den Wunsch bestmöglicher Isolation. Selbst wenn in einer Anwendung ein Problem besteht, vielleicht sogar ein Speicherleck, welches über kurz oder lang die gesamte JVM, auf der es läuft, unbrauchbar machen wird, darf sich dieses nicht auf andere Anwendungen auswirken. Dies steht im Gegensatz zum Application-Server-Konzept, bei dem der Application Server als Container für verschiedene Applikationen dient. In der Praxis hat dies oft dazu geführt, dass einfach für jede Application ein eigener Server gestartet wurde.

Wenn ich aber ohnehin nur eine Applikation auf meinem Server einsetze – was unterscheidet dann den Server noch von einer Library, die ich als Abhängigkeit in meine Applikation einbinde? Statt als Server wird Tomcat als eine Bibliothek zur Verarbeitung von HTTP-Requests eingesetzt, deployt wird ein ausführbares JAR. Dieses „umgedrehte“ Vorgehen bringt die Flexibilität mit sich, nur die Dienste einzubinden, die die jeweilige Applikation wirklich benötigt. Auch können einzelne Updates nun unabhängig davon eingesetzt werden, wann der Hersteller des Application Servers sein Produkt aktualisiert. Ausführlich beschrieben ist diese Argumentation in Eberhard Wolffs Beitrag „The Java Application Server is dead“ [1].

Die reaktiven Toolkits haben diesen Ansatz von vornherein verfolgt. Play-, Akka- oder Vert.x-Applikationen laufen selbstständig ohne Application Server. Für den Betrieb ist dies natürlich zunächst eine Umstellung. Im Zuge des aktuellen Microservice-Trends entwickelt sich aber ein Toolumfeld, das den Betrieb solcher Standalone-Services, auch im Cluster, erheblich vereinfacht.

Fazit

Ein Wechsel von Java EE auf reaktiv bedeutet nicht nur, neue Bibliotheken und Toolkits zu lernen. Es bedeutet auch, auf eine vertraute Umgebung, oder zumindest große Teile davon, verzichten zu müssen. Wer sich frühzeitig bewusst macht, dass er sich der Denkweise, die durch grundlegende Architekturmuster wie Thread per Request, synchrone Ein-/Ausgabe und verteilte Transaktionen geprägt ist, entledigen muss, und er nicht versuchen sollte, das Ergebnis seiner Arbeit auf einem Java EE Application Server zu deployen, kann frustrierende Irrwege vermeiden.

Aufmacherbild: Night cityscape focused in glasses lenses von Shutterstock / Urheberrecht: Nomad_Soul

Geschrieben von
Lutz Hühnken
Lutz Hühnken
Lutz Huehnken ist Solutions Architect bei Lightbend. Aktuell beschäftigt er sich mit der Entwicklung von Microservices mit Scala, Akka und Lagom. Er tweeted als @lutzhuehnken und blogged unter https://huehnken.de.
Kommentare

Hinterlasse einen Kommentar

Hinterlasse den ersten Kommentar!

avatar
400
  Subscribe  
Benachrichtige mich zu: