JAX 2022 https://jax.de Java, Architecture & Software Innovation Thu, 31 Mar 2022 09:50:44 +0000 de-DE hourly 1 https://wordpress.org/?v=5.6.2 Alles im Blick https://jax.de/blog/alles-im-blick/ Fri, 11 Mar 2022 13:59:28 +0000 https://jax.de/?p=85794 Wie funktionieren APM Agents in der Java Virtual Machine im Detail? Was ist bei der Instrumentierung zu beachten und welche Besonderheiten von APM-Agenten muss man berücksichtigen? Und warum werden oftmals nur die bekanntesten Java-Frameworks unterstützt? Diesen Fragen gehen wir in diesem Beitrag auf den Grund.

The post Alles im Blick appeared first on JAX 2022.

]]>
APM steht für Application Performance Management und erlaubt es als Teil der Observability, die eigene Anwendung genauer zu durchleuchten. Wie lange dauern bestimmte SQL-Abfragen? Welche Microservices oder Datenbanken werden innerhalb eines HTTP Requests abgefragt? Welcher Teil eines HTTP Requests ist der eigentliche Flaschenhals und weist die längste Antwortzeit auf? Um diese Fragen zu beantworten, reicht es nicht, Logs oder Metriken zu betrachten, sondern man muss sich die Laufzeit einzelner Methoden oder Aufrufe innerhalb der eigenen Anwendung ansehen. Hier kommt APM ins Spiel.

APM als Säule der Observability

APM gehört zum Tracing, das neben Logs und Metriken als eine der drei Säulen der Observability gilt. Dabei geht es allerdings nicht um die Daten selbst oder die Art der Datengenerierung, sondern allein um die Möglichkeit, aus vielen Signalen (Logs, Metriken, Traces, Monitoring, Health Checks) diejenigen herauszufiltern, die auf eine mögliche Einschränkung eines eigenen oder fremden Service wie Antwortzeit oder Verfügbarkeit hindeuten. Einzelne Bereiche der Observability sollten nicht isoliert betrachtet und idealerweise auch nicht mit unterschiedlichen Tools bearbeitet werden, damit man nicht nachts um drei Uhr mit mehreren Browsertabs und manueller Korrelation bei einem Ausfall eingreifen muss. Eine Kerneigenschaft des APM ist die Darstellung der Laufzeit von Komponenten der eigenen Anwendung. Die zwei wichtigsten Begriffe sind hier Transaction und Span. Eine Transaction ist eine systemübergreifende Zusammenfassung einzelner Spans, welche die Laufzeit konkreter Methoden oder Aktionen innerhalb eines Systems zusammenfassen. Eine Transaction kann sich über mehrere Systeme ziehen und beginnt bei einer Webanwendung im besten Fall im Browser des Users (Abb. 1).

Abb. 1: Transaction über mehrere Systemgrenzen (farbig gekennzeichnet)

Instrumentierung innerhalb der JVM

Bei APM-Agenten liegt der Fall etwas anders als bei Logging und Metriken, da sie in die Anwendung hineinschauen müssen und diese unter Umständen auch verändern. Bugs in diesen Agenten sind gefährlich und können sich auf die Anwendung auswirken – unabhängig von der Programmiersprache. Java hat im Gegensatz zu vielen anderen Sprachen eine standardisierte Schnittstelle zur Instrumentierung. Der durch Kompilierung erstellte Bytecode kann verändert werden und die Anwendung kann mit diesen Änderungen weiterlaufen. Um diese Veränderung so einfach wie möglich zu implementieren, gibt es Bibliotheken wie ASM oder Byte Buddy, mit denen Methodenaufrufe abgefangen werden können, um beispielsweise die Laufzeit zu messen. Wenn ich als Entwickler eines Agenten also alle Aufrufe des in der JVM eingebauten HTTP-Clients abfange und die Laufzeit sowie den Endpunkt als Teil eines Spans logge, kann ich danach einfach im APM UI sehen, wie viel Zeit diese Anfrage benötigt und ob lokales Request Caching beim Einhalten möglicher SLAs hilft. Durch Aktivieren des Agenten darf kein oder nur geringer Einfluss auf die Geschwindigkeit der Anwendung genommen werden (Overhead). Das Gleiche gilt für die Garbage Collection. Beides lässt sich nicht völlig verhindern, jedoch stark reduzieren.

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

Der Zeitpunkt der Instrumentierung ist unterschiedlich. Die bekannteste und beliebteste Variante ist das Setzen des Agents als Parameter beim Starten der JVM:

java -javaagent:/path/to/apm-agent.jar -jar my-application.jar

Dieser Aufruf führt die Instrumentierung aus, bevor der eigentliche Code geladen wird. Alternativ kann die Instrumentierung auch bei einer bereits laufenden JVM stattfinden.

Doch was genau bedeutet Instrumentierung eigentlich? Die Laufzeit von Methoden kann nur gemessen werden, wenn diese Methoden abgefangen werden und Code des Agenten um den eigentlichen Code herum ausgeführt wird – zum Beispiel das Anlegen eines Spans oder einer Transaction, wenn ein HTTP Request abgeschickt wird. Dieser Ansatz ist aus der aspektorientierten Programmierung bekannt. Eine Voraussetzung muss gegeben sein: Der jeweilige APM-Agent muss die Methode inklusive Signatur kennen, die instrumentiert wird. Für Java Servlets ist zum Beispiel die Methode service(HttpServletRequest req, HttpServletResponse resp) im Interface HttpServlet die wichtigste Methode, um jeden HTTP-Aufruf zu überwachen, unabhängig vom Pfad oder der HTTP-Methode. Das bedeutet gleichzeitig, dass bei einer Änderung der Methodendeklaration der Agent ebenfalls angepasst werden muss. Und hier liegt eines der größten Probleme mit dieser Form der Überwachung: Es muss sichergestellt sein, dass sowohl möglichst viele Frameworks (Spring Boot, JAX-RS, Grails, WildFly, Jetty etc.) und deren Methoden instrumentiert werden, als auch ständig überprüft wird, ob die Instrumentierung bei einem neuen Release noch funktioniert, besonders bei Major Releases. Das ist eine der großen Maintenance-Aufgaben bei der Entwicklung von APM-Agenten. Während es einige Standards im Java-Bereich gibt, die einfach zu instrumentieren sind, wie Servlets, JDBC oder JAX-RS, gibt es ebenfalls eine Menge Frameworks, die keinen solchen Standards folgen, wie beispielsweise Netty. Das heißt auch, dass jeder Agent, dessen Instrumentierung eines Frameworks nicht funktioniert hat, weil eventuell Methoden aus einer anderen Major-Version mit anderer Signatur existieren, sicherstellen muss, dass dies kein Problem darstellt.

Bisher haben wir nur APM Agents im Kontext von Java Agents erwähnt; es gibt aber noch weitere Anwendungsfälle. Ganz aktuell hat das AWS-Corretto-Team um Volker Simonis einen Agent zum Patchen der Log4Shell-Sicherheitslücke entwickelt [1]. Der Agent verhindert das Sicherheitsproblem der Remote Code Execution bei einer bereits laufenden JVM. Ein anderer Use Case sind Agents, die Sicherheitsfeatures wie das automatische Setzen von HTTP Headern oder WAF-Funktionalität bereitstellen.

Treffen Sie unsere Java-Speaker

Agents und ihre Features

Der bekannteste Standard in der Observability-Welt ist OpenTelemetry [2]. Die Idee von OpenTelemetry ist, den einen, herstellerneutralen, universellen Standard zur Verfügung zu stellen, den alle verwenden, unabhängig von der Programmiersprache. Mit dem OpenTelemetry Agent gibt es einen JVM Agent, den viele Observability-Anbieter als eigene Distribution veröffentlichen, wie zum Beispiel Lightstep oder Honeycomb. Dieser kommt bereits mit einigen Instrumentierungen für bekannte Frameworks [3].

Eine weitere Standardisierung in Zeiten von Microservices und der Möglichkeit, einen Request vom Laden der Webseite im Browser bis zur SQL-Query zu identifizieren, ist das Distributed Tracing. Um Distributed Tracing zwischen verschiedenen Programmiersprachen und Umgebungen zu implementieren, existiert die OpenTracing-Spezifikation und -Implementierung. Viele APM Agents folgen dieser Spezifikation, um Kompatibilität sicherzustellen, unter anderem Lightstep, Instana, Elastic APM, Apache Skywalking und Datadog.

Vor OpenTelemetry gab es einige wenige Agents, die aus JVM-Sicht eigentlich gar keine waren, weil sie keinerlei Instrumentierung vorgenommen, sondern lediglich Interfaces in bestimmten Frameworks implementiert haben, um Monitoringdaten auszulesen. Ich gehe davon aus, dass es über kurz oder lang nur noch Agents geben wird, die auf dem OpenTelemetry Agent basieren, und dass die Alleinstellungsmerkmale nicht im Sammeln der Daten, sondern ausschließlich in der Auswertung liegen.

Elastic APM Agent

Wenn es einen OpenTelemetry Agent gibt, wieso gibt es dann zum Beispiel auch einen Elastic-APM-spezifischen Agent? Zum einen gibt es eben doch mehr Features als einen universellen Standard. Beispiel: das Erfassen interner JVM-Metriken (Speicherverbrauch, Garbage-Collection-Statistiken) oder auch das Auffinden langsam ausführender Methoden ohne das Wissen um konkrete Methoden oder Code mit Hilfe des async-profilers [4] – eine Technologie, die bei Datadog, Elastic APM oder Pyroscope [5] verwendet wird. Des Weiteren existieren eine Menge Agenten bereits länger als die OpenTelemetry-Implementierung und bringen mehr Unterstützung für bestimmte Frameworks mit, die erst in den OpenTelemetry-Agenten portiert werden müssen.

Der Elastic Agent bietet zudem ein weiteres sehr interessantes Feature, und zwar das programmatische Konfigurieren des Agent anstatt der Verwendung des JVM-Agent-Mechanismus als Parameter beim Starten der JVM. Das bedeutet, man bindet den Agent als Dependency in den Code ein, und versucht die folgende Zeile Code beim Start der Anwendung so früh wie möglich auszuführen:

ElasticApmAttacher.attach();

Jetzt geschieht prinzipiell dasselbe wie bei der Agent-spezifischen Konfiguration: Der Agent attacht sich selbst an den laufenden Code. Diese Art der Einrichtung hat einen großen Vorteil: Die Dependency ist bereits Teil des Deployments und muss nicht als Teil des Build-Prozesses oder der Container-Image-Erstellung heruntergeladen werden. Gleichzeitig ist der Entwickler für das fortlaufende Aktualisieren verantwortlich.

Bisher ungeklärt ist die Frage, was ein APM Agent mit den erhobenen Daten eigentlich machen soll. Im Fall von Elastic APM werden diese an einen APM-Server geschickt, der sie wiederum im nächsten Schritt in einem Elasticsearch Cluster speichert. Der APM-Server kommuniziert nicht nur mit den anderen Elastic APM Agents (Node, Ruby, PHP, Go, iOS, .NET, Python), sondern kann auch Daten puffern und als Middleware für Source Mapping bei JavaScript-Anwendungen agieren.

Wie bereits erwähnt, liegt der Mehrwert weniger im Sammeln als im Auswerten von Daten. Im Fall von Elastic APM ist das unter anderem die Integration mit Machine Learning, genauer der Time Series Anomaly Detection, um automatisiert Laufzeiten von Transaktionen zu erkennen, die im Vergleich zu vorher gemessenen Ergebnissen überdurchschnittlich lange brauchen, dem automatischen Annotieren von Deployments im APM UI oder auch der Korrelation von plötzlich auftretenden Transaktionslatenzen und Fehlerraten in allen von der Anwendung generierten Logs. So wird sichergestellt, dass die Grenzen zwischen den anfangs erwähnten Observability-Säulen nicht existieren.

Programmatische Spans und Transactions

Nicht jeder Entwickler möchte ein eigenes APM-Agent-Plug-in schreiben, damit die eigene Java-Anwendung Spans und Transactions innerhalb der eigenen Geschäftslogik verwendet. Ein alle 30 Sekunden laufender Job im Hintergrund sollte als eigene Transaktion und jede der darin aufgerufenen Methoden als eigener Span konfiguriert werden. Hier gibt es zwei Möglichkeiten der Konfiguration. Entweder werden die Methodennamen über die Agentenkonfiguration angegeben oder man wählt die programmatische Möglichkeit. Ein Beispiel innerhalb von Spring Boot zeigt Listing 1.

@Component
public class MyTask {
 
  @CaptureTransaction
  @Scheduled(fixedDelay = 30000)
  public void check() {
    runFirst();
    runSecond();
  }
 
  @CaptureSpan
  public void runFirst() {
  }
 
  @CaptureSpan
  public void runSecond() {
  }
}

Die Annotation @CaptureTransaction legt eine neue Transaktion an und innerhalb dieser Transaktion werden die beiden Spans für die Methoden via @CaptureSpan angelegt. Sowohl Transaktion als auch Spans können mit einem eigenen Namen konfiguriert werden, der im UI einfacher identifiziert werden kann. Unter [6] gibt es ein GitHub Repository, das sowohl die Instrumentierung des Java-HTTP-Clients zeigt als auch das Verwenden von programmatischen Transaktionen und Spans im eigenen Java-Code.

Elastic APM Log Correlation

Wie erwähnt, ist es sinnvoll, Logs, Metriken und Traces miteinander zu verbinden. Wie aber kann eine bestimmte Logzeile mit einer bestimmten Transaktion verbunden werden? In Elastic APM heißt dieses Feature Log Correlation. Der erste (optionale) Schritt ist, Logdateien ins JSON-Format zu überführen. Das macht es wesentlich einfacher, weitere Felder zu den Logdaten hinzuzufügen. Eben diese Felder werden für die Korrelation benötigt. Wenn man im Agent die Option log_ecs_reformatting verwendet, werden im sogenannten MDC der jeweiligen Logger-Implementierung die Felder transaction.id, trace.id und error.id hinzugefügt, nach denen dann sowohl in Transactions und Spans als auch in einzelnen Lognachrichten gesucht werden kann. So können Lognachrichten unterschiedlichster Services miteinander korreliert und durchsucht werden; Logmeldungen eines Service sind einer konkreten eingehenden HTTP-Anfrage zuzuordnen.

Automatische Instrumentierung mit K8s

Will man Container mit Java-Anwendungen unter Kubernetes instrumentieren, kann man jederzeit die verwendeten Images/Pod-Konfigurationen anpassen und innerhalb dieser den Agent konfigurieren sowie APM-Konfigurationen einstellen, zum Beispiel APM-Endpunkte, API-Token (zum Beispiel via HashiCorp Vault [7]) oder die erwähnte Log Correlation. Es gibt eine weitere Möglichkeit, und zwar die Verwendung eines Init-Containers, der vor den eigentlichen Anwendungscontainern in einem Pod ausgeführt wird [8]. Dieser Container konfiguriert Umgebungsvariablen, die dann beim Starten des regulären Containers ausgelesen werden und somit zusätzlich den passenden JVM-Agenten starten [9]. Dieser Ansatz kann sinnvoll sein, wenn man keine Kontrolle über die erstellten Container hat oder sicherstellen möchte, dass ein Agent in einer bestimmten Version für alle Java-Anwendungen läuft.

SIE LIEBEN JAVA?

Den Core-Java-Track entdecken

Distributed Tracing mit RUM

In Zeiten von Microservices und APIs ist es in vielen Systemarchitekturen wahrscheinlich, dass ein einzelner Aufruf eines Anwenders sich zu mehreren Aufrufen in der internen Architektur multiplext und mehrere Services abgefragt werden. Hier ist es besonders wichtig, verfolgen zu können, wie ein initialer Request durch die unterschiedlichen Services weitergereicht und verändert wird. Eine Transaktion kann mehrere Spans haben, die in unterschiedlichen Systemen auftreten, unter Umständen auch gleichzeitig. Hier kommt Distributed Tracing mit Hilfe von Trace IDs ins Spiel, die durch alle Requests hindurch – zum Beispiel mit Hilfe von HTTP-Headern – an die jeweiligen Spans angehängt werden und somit durch den Lebenszyklus des initialen Request rückverfolgbar sind. Ein weiterer Vorteil von Distributed Tracing ist die Möglichkeit, aus diesen Daten eine Service Map zu erstellen, da man weiß, welche Services miteinander kommunizieren (Abb. 2).

Abb. 2: Service Map, um Kommunikationsflüsse einzelner Komponenten zu visualisieren

Bei der Entwicklung von Webanwendungen ist es ebenfalls nicht ausreichend, erst an den eigenen Systemgrenzen mit dem Anlegen von Transactions und Spans zu beginnen, da man sonst keinen Überblick über die komplette Performance der eigenen Anwendung hat. Wie lange dauert das Aufbauen der Verbindung im Browser zum Webserver? Ist die Latenz hier eventuell so hoch, dass es irrelevant ist, 50 ms bei einer komplexen SQL-Query zu sparen? Um dieses Problem anzugehen, gibt es das Real User Monitoring, kurz RUM. Zum einen können Transaktionen an der richtigen Stelle begonnen werden, zum anderen werden auch Browserereignisse geloggt, um festzustellen, wie lange das initiale Rendern der Seite braucht, sodass der Anwender mit der Anwendung interagieren kann (Abb. 3).

Abb. 3: RUM-Dashboard mit Ladezeiten und Browserstatistiken

APM in der Zukunft

Das Bedürfnis, für Anwendungen eine Art Röntgengerät zu bekommen, wird in Zukunft noch zunehmen – vielleicht werden sich die Methoden etwas ändern. Zeit für einen kleinen Ausblick. In den vergangenen Jahren ist eine neue Art von Agents auf den Markt gekommen, die eine neue, sprachunabhängige Technologie verwenden: eBPF. Mit Hilfe von eBPF kann man Programme im Kernelspace laufen lassen, ohne den Kernel zu verändern oder ein Linux-Kernel-Modul laden zu müssen. Alle eBPF-Programme laufen innerhalb einer Sandbox, sodass das Betriebssystem Stabilität und Geschwindigkeit garantiert. Da eBPF Syscalls überwachen kann, ist es ein idealer Einstiegspunkt für jegliche Observability-Software. Der weitaus wichtigere Teil ist allerdings die Möglichkeit, diese Syscalls auf Methodenaufrufe in die jeweilige Programmiersprache des überwachten Programms zu übersetzen. eBPF-basierte Profiler haben generell einen geringen Overhead, da sie sehr tief im System verankert sind. Des Weiteren müssen keine Deployments angepasst werden, da diese Profiler auch innerhalb eines Kubernetes-Clusters für alle Pods konfiguriert werden können. Beispiele für diese Art von Profiler sind prodfiler [10] von Elastic, Pixie [11], Parca [12] oder Cilium Hubble [13].

Ein weiteres neues Themenfeld ist das Überwachen auf Serverless-Plattformen. Hier braucht man etwas andere Lösungen, da nicht garantiert ist, dass nach dem Verarbeiten einer Anfrage noch Rechenkapazität zur Verfügung gestellt wird. Methoden wie etwa Spans und Traces als Batch zu sammeln und periodisch an den APM-Server zu verschicken, funktionieren hier also nicht. Für AWS Lambda steht mit opentelemetry-lambda [14] ein eigenes GitHub-Projekt zur Verfügung. Die grundlegende Idee ist ein sogenannter Lambda-Layer, der diese Observability-Aufgaben übernimmt. Wenn man also in diese Art von Plattformen eintaucht, sollte man sicherstellen, dass die eigene Observability-Plattform diese Technologien unterstützt.

Ein weiterer wichtiger Baustein abseits vom Sammeln und Auswerten der Livedaten ist der Trend zu Shift Left – nicht nur in der Security. Hier bietet JfrUnit [15] von Gunnar Morning einen interessanten Ansatz aus dem Umfeld des Java Flight Recorders. Als Teil des Unit Testings werden JFR Events herangezogen, um bestimmte Constraints wie Garbage Collection, erhöhte Memory Allocation oder I/O bereits in Tests festzustellen und vor dem eigentlichen Deployment zu korrigieren.

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

Schlusswort

Wie überall, so ist auch in der Welt der JVM Agents für APM nicht alles rosig. Einige Agents unterstützen zum Beispiel nur die bekanntesten Web-Frameworks wie Spring oder Spring Boot bzw. auch innerhalb eines Frameworks nur synchrones Request Processing. Es gilt daher, anfangs in Ruhe mögliche Agents zu testen. Fast alle Agents sind Open Source, sodass man im Fall der Fälle auch ein eigenes Plug-in schreiben kann. Je nach Sicherheitseinstellungen der Plattform, auf der Services betrieben werden, ist es eventuell nicht erlaubt, einen Agent programmatisch an den Java-Prozess anzuhängen – zum Beispiel ist mir das bei der Digital-Ocean-Apps-Plattform, einem PaaS, nicht gelungen. Der -javaagent-Parameter innerhalb des Docker Image hat hingegen einwandfrei funktioniert.

Ein weiteres Thema, das man in den aktuellen Java-Trends wahrscheinlich schon entdeckt hat, ist GraalVM. Falls mit Hilfe der GraalVM die Anwendungen in native Binaries umgewandelt werden, existiert der Mechanismus zum Anhängen von Java Agents nicht. Das heißt nicht, dass keinerlei Instrumentierung möglich ist. Die programmatische Erstellung von Spans und Traces könnte allerdings bei einigen APM-Lösungen noch funktionieren, die nicht auf reines Bytecode Enhancement setzen. Da viele bekanntere Frameworks wie Spring und Quarkus inzwischen native Extensions und Module haben, um möglichst einfache Binaries zu erstellen, erwarte ich in den nächsten Monaten, dass auch die APM-Plattformen nachziehen werden. Quarkus hat bereits Support für OpenTracing und DataDog im native Mode.

Um es noch einmal abschließend zu wiederholen: Observability ersetzt kein Monitoring und APM ersetzt kein effizientes Entwickeln performanter Software. Viele Probleme können durch Testing, Reviews oder Pair Programming sehr viel früher im Lebenszyklus der Software gefunden werden und sind dann weitaus ökonomischer zu fixen. Wesentlich komplizierter ist das bereits bei Distributed Tracing und dessen Intersystemkommunikation, um mögliche Bottlenecks vor dem Produktionsbetrieb zu identifizieren. Nichtsdestoweniger ist ein so tiefer Einblick in die selbstgeschriebene Software, wie APM ihn bietet, von Vorteil und sollte auch genutzt werden, wenn der zusätzliche Aufwand der initialen Einrichtung einmal erledigt ist.

 

Links & Literatur

[1] https://github.com/corretto/hotpatch-for-apache-log4j2

[2] https://opentelemetry.io

[3] https://github.com/open-telemetry/opentelemetry-java-instrumentation/blob/main/docs/supported-libraries.md#libraries–frameworks

[4] https://github.com/jvm-profiling-tools/async-profiler

[5] https://pyroscope.io

[6] https://github.com/spinscale/observability-java-samples

[7] https://www.vaultproject.io/

[8] https://kubernetes.io/docs/concepts/workloads/pods/init-containers/

[9] https://www.elastic.co/blog/using-elastic-apm-java-agent-on-kubernetes-k8s

[10] https://prodfiler.com

[11] https://px.dev

[12] https://www.parca.dev

[13] https://github.com/cilium/hubble

[14] https://github.com/open-telemetry/opentelemetry-lambda

[15] https://github.com/moditect/jfrunit

The post Alles im Blick appeared first on JAX 2022.

]]>
Die Untiefen reaktiver Programmierung https://jax.de/blog/die-untiefen-reaktiver-programmierung/ Tue, 22 Feb 2022 11:40:38 +0000 https://jax.de/?p=85630 Vor acht Jahren wurde das Reactive Manifesto [1] veröffentlicht, und ganz allmählich beginnt die reaktive Programmierung auch in der Java-Welt Fuß zu fassen. Deswegen lohnt es sich, die Erfahrungen und Best Practices aus einer anderen Community anzuschauen. Angular hat von Anfang an sehr stark auf RxJS gesetzt und sich damit eine ganze Menge Komplexität eingehandelt. Wie kann man sie beherrschbar machen und was können Java-Entwickler daraus lernen?

The post Die Untiefen reaktiver Programmierung appeared first on JAX 2022.

]]>
Haben Sie sich schon einmal gefragt, warum Quarkus oder Netty so schnell sind? Bei Quarkus könnte die Antwort GraalVM lauten, und das wäre sogar richtig. Aber wie sieht es mit Netty aus? Oder Vert.x? Oder warum fühlt sich ein modernes UI mit Angular oder React.js flotter an als die meisten JSF- oder Spring-MVC-Anwendungen? Die Gemeinsamkeit ist die non-blocking IO. Dahinter steckt die Erkenntnis, dass unser Computer die meiste Zeit mit Warten verbringt. Unsere modernen Gigahertz-CPUs können ihr Potenzial gar nicht ausspielen. Kurze Sprints werden abgelöst von schier endlosen Wartezeiten. Irgendwo ist immer ein Stau. Jeder Datenbankzugriff bedeutet mehrere Millisekunden Pause, jeder Zugriff auf ein REST-Backend dauert oft 10 bis 100 Millisekunden, und das summiert sich schnell auf mehrere Sekunden. Die Idee der reaktiven Programmierung ist, die Wartezeiten sinnvoll zu nutzen. Dafür zerschneidet man einen Algorithmus in zwei Teile. Alles, was nach dem REST-Call kommt, wird in eine eigene Funktion ausgelagert. Euer Framework ruft diese Callback-Funktion auf, wenn die Daten angekommen sind. In der Zwischenzeit kann die CPU andere Aufgaben erledigen. Sie kann zum Beispiel einfach mit der nächsten Zeile weitermachen. Beim traditionellen, blockierenden Programmiermodell definiert die nächste Zeile, was wir mit dem Ergebnis der REST-Calls machen wollen. Aber das ist jetzt nicht mehr der Fall. Dieser Teil des Algorithmus wurde ja in die Callback-Funktion verschoben. Es gibt also keinen Grund mehr, mit der Abarbeitung der nächsten Zeile zu warten. Damit können sehr viele Performanceprobleme gelöst werden. Solche Callback-Funktionen sind erst seit Java 8 sinnvoll möglich, als die Lambdafunktionen eingeführt wurden. Vorher konnte man sie mit anonymen inneren Klassen simulieren, aber das war so unattraktiv, dass es kaum jemand gemacht hat. Dadurch erklärt sich, warum reaktive Frameworks wie Quarkus oder Spring Reactor erst seit relativ kurzer Zeit populär werden. Die JavaScript-Welt hat hier einige Jahre Vorsprung, und deswegen soll jetzt RxJS betrachtet werden.

Nichtlineare Programmierung mit RxJS

Auf den nächsten Seiten bewegen wir uns ausschließlich im JavaScript Frontend. Das hat einige Konsequenzen. JavaScript kennt kein Multi-Threading. Das macht aber nichts, es muss ja nur noch ein einziger Anwender bedient werden. Wir reden der Einfachheit halber jetzt auch nur noch über REST-Calls.

Es wird also ein REST-Call zum Backend geschickt. Die Idee ist, die Wartezeit, bis die Antwort eintrudelt, zu nutzen. Das bedeutet, dass sofort mit der nächsten Zeile im Programmcode weitergemacht werden kann. Erfahrene RxJS-Entwickler werden jetzt mit den Achseln zucken und „Ja, und?“ sagen. Für Neueinsteiger ist das aber ein erhebliches Problem. Die Reihenfolge, in der der Programmcode ausgeführt wird, ist nicht mehr linear. In vielen Fällen ist er auch nicht mehr deterministisch. Schauen wir uns ein einfaches Beispiel an (Listing 1).

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

Alles neu?

Wir sind alle Entwickler und Technologieenthusiasten und daher kommen wir sehr schnell zu der Überzeugung, dass mit Framework X oder Technologie Y alle Probleme schnell gelöst werden und damit auch ganz neuen Herausforderungen und Anforderungen leicht entsprochen werden kann. Einige wollen auch dafür eigene Technologien entwickeln, was gar nicht mal ungewöhnlich ist – man liest und hört nur sehr wenig von diesen Ansätzen. Die meisten Seniorentwickler schreiben die gleiche Art von Anwendung oder lösen die gleiche Art von Problemen Tag für Tag, Jahr um Jahr. Das ist manchmal langweilig, und was gibt es Spannenderes, als ein eigenes Framework zu schreiben? Vielleicht sogar mit der Absicht, es danach als Open-Source-Software zu veröffentlichen? Die Antwort ist: Wahrscheinlich ist es superspannend, aber bringt es unsere Anwendung wirklich weiter? Und viel wichtiger: Lösen wir damit überhaupt die aktuellen Probleme?

Um diese Fragen zu beantworten, müssen die Anwendung und ihre Architektur erst einmal überprüft werden. Bei einer solchen Architekturreview, die idealerweise von Externen durchgeführt wird, hat sich in der Praxis ATAM [1] als Methode bewährt. Dieser Artikel ist zu kurz, um auf ATAM einzugehen, aber ganz kompakt formuliert, definiert man via Szenarien Qualitätsanforderungen an den Sollzustand der Architektur und vergleicht diesen dann mit dem Istzustand des Systems. Das klingt erst einmal furchtbar aufwendig und teuer, daher wird die Investition oft gescheut. Jedoch stehen die Kosten aus unserer Erfahrung in keinem Verhältnis zu den Kosten (über den Lifecycle) einer nicht passenden Architektur oder Technologiewahl. Ähnlich wie bei Fahrzeugen sollte daher ein regelmäßiger TÜV der Architektur zu einer reifen Produktentwicklung dazugehören.

console.log(1);
httpClient.get<BOOK>('https://example.com/books/123').subscribe(
  (book) => console.log(`2 ${book}`));
httpClient.get<BOOK>('https://example.com/books/234').subscribe(
  (book) => console.log(`3 ${book}`);
console.log(4);

Dieser Quelltext schickt zwei GET Requests an einen fiktiven REST-Server ab, um Informationen über zwei Bücher zu bekommen. Sie werden kurzerhand auf der Entwicklerkonsole ausgegeben, zusammen mit einer durchnummerierten Zahl. Wir drucken also zunächst die Zahl 1, rufen dann das erste Buch ab, dann das zweite, und zum Schluss noch die Zahl 4.

In welcher Reihenfolge werden die Konsolenausgaben erscheinen? Die naive Antwort wäre 1, 2, 3, 4. Tatsächlich ist dieser Programmcode aber nichtdeterministisch. Es kommt darauf an, welcher REST-Call zuerst eine Antwort liefert. Es ist nur klar, dass die Zahl 4 vor der 2 und der 3 kommt.

Das ist auch gut so. Genau das soll erreicht werden. Die Informationen über beide Bücher werden gleichzeitig abgefragt, und als Sahnehäubchen ist die Anwendung auch während des Server-Requests bedienbar. In welcher Reihenfolge der Server die Requests beantwortet, ist nicht klar. Die Requests laufen eben wirklich parallel. Und es kommt noch besser: Egal ob der REST-Call 50 Millisekunden oder 50 Sekunden braucht, die Anwendung ist in der Zwischenzeit für weitere Benutzeraktivitäten verfügbar. Geschickt eingesetzt, fühlt sich die Anwendung dadurch sehr viel flüssiger an als beim traditionellen Ansatz, bei dem die Anwendung während der gesamten Wartezeit einfriert. Reactive Programming ist der natürliche Feind des Wait Cursors.

Nichtsdestotrotz gibt es einen gravierenden Nachteil. Das Programm wird nicht mehr Zeile für Zeile abgearbeitet. Stattdessen gibt es einen nichtlinearen Programmfluss. Die Callbacks stehen normalerweise oberhalb der Zeile, die zuerst abgearbeitet werden. Für Neueinsteiger kann das eine nicht zu unterschätzende Hürde sein. An den nichtlinearen Programmfluss muss man sich erst einmal gewöhnen. Und auch wenn die alten Hasen das selten zugeben: Wenn es komplizierter wird, verliert jeder von uns den Überblick. Manche früher, manche später. Wenn man beruflich programmiert, ist das ein wichtiger Punkt. Es gibt ein Mantra, das viele auswendig mitsprechen können: „Es ist nicht so wichtig, dass ihr euren eigenen Quelltext versteht. Es kommt darauf an, dass jeder eurer Kollegen euren Programmcode versteht“. Der Abstraktionslevel, der im aktuellen Team genau richtig ist, kann das nächste Team überfordern. Man muss sich also ständig nach den jeweiligen Teammitgliedern richten, und sie entweder coachen oder das eigene Abstraktionslevel an das Team anpassen.

Die Kunst des Beobachtens

Gehen wir einen Schritt weiter. Als Nächstes wollen wir erst die Liste aller Bücher holen und dann die Einzelheiten für jedes einzelne Buch. Das Prinzip wurde hier schon beschrieben. Es gibt eine subscribe()-Methode, die aufgerufen wird, wenn der Server Daten liefert. Was wäre also einfacher, als den Algorithmus wie in Listing 2 zu formulieren?

httpClient.get<Array<number>>('https://example.com/books')
    .subscribe((isbns) => {
  ids.forEach((isbns: number) => {
    httpClient.get<Book>(`https://example.com/books/${ isbns }`)
      .subscribe((book) => console.log(`${book}`));
  });
});

Das funktioniert, sollte aber besser so nicht umgesetzt werden. Ich habe bei diesem Minibeispiel mehrere Minuten damit verbracht, die Klammern richtig zu setzen. Und reale Projekte sind komplizierter, da ist die Klammersetzung das kleinste Problem. Es sollte also ein einfacher Quelltext geschrieben werden. RxJS bietet dafür ein reiches Repertoire an Operatoren. Meistens muss man überhaupt keinen Quelltext in die subscribe()-Methode schreiben. Ein Aufruf ohne Callback-Funktion reicht. Um die Idee zu illustrieren, beschränken wir uns zunächst auf den REST-Call, der die Liste der ISBNs (oder IDs) der Bücher liefert (Listing 3).

const promisedIsbns: Observable<Array<number>> =
  httpClient.get<Array<number>>('https://example.com/books');
 
promisedIsbns.pipe(
  tap((isbns: Array<number>) => console.log(isbns))
)
promisedIsbns.subscribe();

Listing 3 macht deutlich, dass http.get() ein Observable zurückliefert. Das ist eine sehr schöne Metapher: Man schickt den REST-Call los, und danach wird beobachtet, was der Server macht. Während des Beobachtens hat man Zeit für andere Dinge. Das ist ähnlich wie im wirklichen Leben, etwa beim Bügeln während des Fernsehens. Wenn es im Fernsehen spannend wird, wird das Bügeleisen kurz zur Seite gelegt, und während der Werbepause macht man mit dem Bügeln weiter. Als Nächstes definieren wir eine Pipeline. Auch das ist eine sehr schöne Metapher. Wenn man reaktiv programmiert, sollte man aufhören, Daten als etwas Statisches zu betrachten. Die Daten müssen als Datenstrom betrachtet werden. Wird die Sache weitergedacht, sind die Daten in der Datenbank so gut wie nie interessant. Sie werden immer nur dann interessant, wenn sie sich verändern. Dieser Gedanke führt dann zu Systemen, wie z. B. Apache Kafka. Das ist ein sehr mächtiger und fruchtbringender Paradigmenwechsel. Aber ich greife vor – noch sind wir ja im Frontend unterwegs.

Die Pipeline besteht aus einer Reihe von Operatoren, die der Reihe nach ausgeführt werden. In unserem Fall ist es der Operator tap, der auch Seiteneffektoperator genannt wird. Er lässt den Datenstrom unverändert passieren, erlaubt es aber, mit den Daten zu arbeiten. In diesem Fall wird die Liste der ISBNs einfach auf der Konsole ausgegeben. Als letztes Kommando kommt noch der Aufruf von subscribe(). RxJS evaluiert Ausdrücke erst dann, wenn sie gebraucht werden – das heißt, wenn sie jemand abonniert. Ohne den Aufruf von subscribe() startet der Algorithmus also nicht.

Wenn Beobachter Beobachter beobachten

Zurück zu unserer ursprünglichen Aufgabe. Es sollten die Information über alle Bücher gesammelt werden. Wir können also eine Pipeline mit den Operatoren map() und tap() aufbauen (Listing 4).

const ids: Observable<Array<number>> = httpClient.get<Array<number>>('https://example.com/books');
ids.pipe(
  map(
    (isbns: Array<number>) => ibns.map((isbn) => 
       httpClient.get<Book>(`https://example.com/books/${isbn}`)),
  tap((books: Array<Observable<Book>>) => console.log('Hilfe!'))
  )
);
ids.subscribe();

Tja, das hatten wir uns einfacher vorgestellt. Jetzt gibt es zwei Probleme. Zum einen enthält unsere Pipeline ein Array von ISBNs. Die Pipeline funktioniert aber am besten, wenn das Array in einen Strom von Einzelwerten aufgelöst wird. Das wird am doppelten map() deutlich. Und zum anderen liefert der verschachtelte Aufruf von http.get() ein Observable. Das Interessante ist aber der elementare Datentyp. Die Liste der Bücher wird benötigt, denn mit den Beobachtern der Bücher lässt sich nichts anfangen. Keine Panik, das Internet ist voll mit Ratschlägen, wie das Problem zu lösen ist. Und alle preisen euphorisch, dass die Lösung sehr einfach ist: „Du musst nur den forkJoin()-Operator und den flatMap()-Operator verwenden“. Das stimmt, weckt bei mir aber Zweifel, ob das hier der richtige Weg ist. RxJS hat eine sehr große Zahl von Operatoren – und meine Theorie ist, dass die meisten Operatoren Probleme lösen, die es ohne RxJS nicht gäbe. Die Umstellung auf die reaktive Programmierung stellt sich als verblüffend vertrackt heraus. Wohlgemerkt: Ich bin ein sehr großer Fan von RxJS. Es bietet ein sehr elegantes API für viele Anwendungsfälle. Ich bezweifele aber, dass Observables die richtigen Metaphern für REST-Calls sind. Ein Observable erlaubt es, einen potenziell unendlichen Strom von Daten zu beobachten. Observables sind die perfekte Lösung für WebSockets, Tastatureingaben oder auch für einen Apache-Kafka-Stream. Bei REST-Calls kommt die Metapher an ihre Grenzen. Ein REST-Call liefert maximal ein Ergebnis.

SIE LIEBEN JAVA?

Den Core-Java-Track entdecken

Ein Internet voller Versprechungen

Den Entwicklern von RxJS ist das auch klar, und sie bieten eine passende Lösung dafür an. Sie hat, völlig zu Unrecht, einen schlechten Ruf in der Angular-Welt. Ihr könnt ein Observable in ein Promise umwandeln. Und wenn man das mit den JavaScript-Schlüsselwörtern async und await verknüpft, wird der Quelltext fast immer dramatisch einfacher (Listing 5).

const isbns: Array<number> = await firstValueFrom(
  httpClient.get<Array<number>>('https://example.com/books'));
const books: Array<Book> = await isbns.map(
  async (isbn) => await firstValueFrom(
    httpClient.get<Book>(`https://example.com/books/${isbn}`)));
books.forEach((book) => console.log(book));

Unser Ziel ist erreicht. Es ist wieder ein linearer Quelltext entstanden, der leicht verständlich ist. Aber da fehlt doch noch etwas. In der map()-Methode steht das Schlüsselwort await. Im Endeffekt gibt es eine for-Schleife, die den Programmfluss blockiert. Aber wir wollten doch eine non-blocking IO haben.

Blockadebrecher

Zum Glück legt JavaScript ohnehin sein Veto ein und bricht mit einer Fehlermeldung ab. Das await vor dem map() kann nicht funktionieren, weil map() kein einzelnes Promise, sondern gleich ein ganzes Array von Promises liefert (Listing 6). Also rufen wir die Funktion Promise.all() zu Hilfe.

const isbns: Array<number> = await firstValueFrom(
  httpClient.get<Array<number>>('https://example.com/books'));
const promisedBooks: Array<Promise<Book>> = 
  isbns.map((isbn) => firstValueFrom(
    httpClient.get<Book>(`https://example.com/books/${isbn}`)));
const books = await Promise.allSettled(promisedBooks);
books.forEach((book) => console.log(book));

Retries, Timeouts und take(1)

In meinem Projekt verwende ich diesen Ansatz sehr erfolgreich und mit großer Begeisterung. Umso erstaunlicher, dass der Rest der Angular-Welt die Möglichkeit, Observables in Promises zu verwandelt, ignoriert oder sogar leidenschaftlich bekämpft. Ein beliebtes Gegenargument ist, dass async/await den Vorteil der reaktiven Programmierung verspielt, indem es alles künstlich wieder linearisiert. Die Gefahr besteht, doch man kann, wie gerade gesehen, leicht gegensteuern. Hierfür packen wir einfach Promise.allSettled() in den Werkzeugkoffer. Auf der Suche nach Gegenargumenten bin ich noch darauf gestoßen, dass firstValueFrom() weder Retries noch Timeouts unterstützt. Das ist aber ein Scheinargument. Auch wenn sie in Promises verwandelt werden, man hat es immer noch mit Observables zu tun. Damit haben wir die gesamte Power von RxJS zur Verfügung (Listing 7).

const isbns: Array<number> = await firstValueFrom(
  httpClient.get<Array<number>>('https://example.com/books')
    .pipe(
      timeout(1000), 
      retry(5))
);

Wem das zu umständlich ist, der kann einfach eine Funktion definieren, die sowohl firstValueFrom() als auch Operatoren timeout() und retry() aufruft. Etwas unangenehmer ist eine Eigenschaft der Methode toPromise(), die in der älteren Version RxJS 6 anstelle von firstValueFrom() verwendet werden muss. toPromise() feuert erst, wenn das Observable das complete Event schickt. Bei REST-Calls ist das kein Problem, aber wenn man das Ergebnis in einem Subject zwischenspeichert, wird das complete Event niemals geschickt. Dieses Pattern ist umständlich, löst aber das Problem:

const isbns: Array<number> = 
      await books$.pipe(take(1)).toPromise();

Treffen Sie unsere Java-Speaker

Wenn das alles so einfach ist – warum verwendet es nicht jeder?

Jetzt wird es philosophisch. Observables erzwingen einen funktionalen Programmierstil, und das wiederum hat eine magische Anziehungskraft auf viele Entwickler. Funktional geschriebene Algorithmen bestehen im besten Fall aus einer langen Kette kurzer Zeilen. Jede Zeile kümmert sich um genau eine Aufgabe. Das fühlt sich einfach gut an und hinterlässt den Eindruck, den Algorithmus optimal strukturiert zu haben. Dieser Eindruck verfliegt schnell, wenn man denselben Algorithmus prozedural neu schreibt und ihn danebenlegt. Man könnte den prozeduralen Algorithmus genauso gut in viele kleine Einzeiler aufteilen. Oft genug finde ich den Quelltext wesentlich übersichtlicher. An dieser Stelle wird die Diskussion meistens sehr emotional. Viele Entwickler finden den prozeduralen Programmierstil unübersichtlich und schwer verständlich. Vielleicht macht es sich bemerkbar, dass ich den funktionalen Programmierstil erst sehr spät kennengelernt habe. Die Idee bei JavaScript – und auch bei Java – ist, einfach beides anzubieten. Es kann der gute alte prozedurale Programmierstil verwendet werden, wenn es passt, und wenn das funktionale Paradigma besser passt, verwendet man eben das. Es ist sogar so, dass meine TypeScript- und JavaScript-Programme deutlich mehr funktionale Anteile enthalten als meine Java-Programme, weil ich das funktionale API von JavaScript für wesentlich eleganter halte. Mein Verdacht ist, dass es einfach eine Frage der Übung, der Mode und des Geschmacks ist. Bei meinen Vorträgen, in denen ich das async/await-Pattern propagiere, bekomme ich fast immer Gegenwind. Wenn ich dann zurückfrage, woher der Widerstand kommt, höre ich selten substanzielle Argumente. Diese Argumente gibt es allerdings durchaus. Lest euch nur mal das leidenschaftliche und gut begründete Plädoyer gegen async/await von Daniel Caldas [2] durch. Das sind aber selten die Argumente, die mir spontan genannt werden. Ich befürchte, das Hauptproblem ist, dass alle bei ihrer Einarbeitung in Angular verinnerlicht haben, dass RxJS und Observables das Mittel der Wahl und ein großer Fortschritt sind. Was auch richtig ist, nur setzt sich dadurch im Unterbewusstsein die Überzeugung fest, dass async/await schlecht ist. Der Witz ist nur, dass sich das Angular-Team zu einem denkbar ungünstigen Zeitpunkt für Observables entschieden hat. AngularJS 1.x hatte noch Promises verwendet, allerdings mit der alten umständlichen Syntax. Wenn man diesen Programmierstil mit dem Programmierstil von RxJS vergleicht, ist beides in etwa gleich unhandlich, aber RxJS bietet sehr viel mehr Möglichkeiten als die native Verwendung von Promises. Die Abkehr von Promises war folgerichtig. Rund ein Jahr später wurde das neue Schlüsselwortpaar async/await in JavaScript eingeführt. Ob sich das Angular-Team für RxJS entschieden hätte, wenn async/await schon Mainstream gewesen wäre?

Wie sieht das in Java aus? Dieser Artikel basiert auf einem Vortrag für Angular-Entwickler. Als ich den Vortrag dem Java Magazin angeboten hatte, dachte ich mir, dass es interessant sein könnte, die Erkenntnisse auf Java zu übertragen. Leider war das nicht so fruchtbringend wie erhofft. In erster Linie habe ich beim Recherchieren festgestellt, dass Java eben anders tickt als JavaScript.

Fangen wir mit dem Offensichtlichen an. In Java gibt es kein async/await. Stattdessen gibt es in den Java-Frameworks, die ich mir angeschaut habe, den Operator block(), der aus einem Mono wieder den eigentlichen Wert machen kann. Analog gibt es bei Java den Operator collectList() für ein Flux. Was sich hinter den Begriffen Mono und Flux verbirgt, erzähle ich euch gleich. Die Operatoren block()und collectList() blockieren den Programmfluss. Das klingt schlimmer als es ist: Im Gegensatz zu JavaScript versteht sich Java auf die Kunst des Multi-Threadings. Man blockiert sich also nur selbst, aber nicht gleich den ganzen Server. Es könnte auch sein, dass das Framework gezwungen ist, von einer optimalen Multi-Threading-Strategie auf eine weniger effiziente Strategie umzuschalten. Aber das ist auch alles. Das Rezept, das ich oben vorgestellt habe, funktioniert in Java also nicht. Man muss sich auf die funktionale Programmierung einstellen. Übertreiben sollte man es aber nicht. Faustregel: Wenn man ein if-Statement braucht, extrahiert man den Code einfach in eine Methode. Ich hatte schon kurz Flux und Mono (oder Uni in Quarkus) angesprochen. Das ist ein interessanter Unterschied zu RxJS. Quarkus und Spring Reactive unterscheiden zwischen Datenströmen, die viele Ergebnisse liefern (Flux) und einfachen Requests, die nur ein Ergebnis liefern (Mono bzw. Uni). Als ich das gelesen habe, fand ich das verblüffend. In der Angular-Welt gibt es eine lebhafte Diskussion, ob Promises oder Observables besser sind, und gleichzeitig unterstützen die Java-Frameworks kurzerhand beides gleichzeitig. Uni bzw. Mono entsprechen in etwa dem Promise und Flux entspricht dem Observable.

 

Zustände

Möglicherweise liegt der Hauptunterschied zwischen der reaktiven Programmierung mit Angular und mit Java ganz woanders. Heutzutage entwickeln die meisten Java-Entwickler REST-Services. Diese sind stateless. Alles ist im Fluss. Man muss sich nicht um statische Daten kümmern. Der Datenstrom, den die reaktiven APIs liefern, reicht vollkommen. In Angular hingegen dreht sich am Ende des Tages alles um den State der Anwendung. Das ist ein Aspekt, den ich in diesem Artikel noch nicht gezeigt hatte und der Angular-Entwickler – und vor allem die Neueinsteiger unter ihnen – immer wieder vor Probleme stellt. Es gehört zu den Best Practices der Angular-Welt, möglichst den kompletten State der Anwendung in Observables zu speichern. So ein Observable ist jedoch nur dafür gedacht, Änderungen des Zustands zu kommunizieren, nicht aber, den aktuellen Zustand zu speichern. Wenn man den aktuellen Zustand braucht, man erst einmal schauen, wo man ihn herbekommt. Für Java-Entwickler entfällt diese Notwendigkeit von vorneherein. Das macht das Leben für die Java-Entwickler leichter. Sie müssen viel seltener zwischen dem reaktiven und dem synchronen Code wechseln. Als Gemeinsamkeit gibt es natürlich die Notwendigkeit, Methoden wie flatMap() zu verwenden. Die Quarkus Sandbox [3] von Hantsy Bai enthält ein paar Beispiele dafür. Und was ist mit Annotationen? Ein komplett anderer Weg, reaktive Programmierung zu vereinfachen, ist mir bei Apache Kafka aufgefallen. Schaut euch mal das Tutorial von Baeldung [4] an. Dort wird eine Annotation verwendet, um eine Methode aufzurufen, wenn der Server ein Ergebnis liefert (Listing 8).

@KafkaListener(topics = "topicName", groupId = "foo") 
public void listenGroupFoo(String message) {
   System.out.println("Received Message in group foo: " + message); 
}

Das ist das Reaktive Pattern konsequent zu Ende gedacht. Die Kafka-Topics bieten eine sehr lose Kopplung zwischen Nachrichtenquelle und Nachrichtenempfänger. Das funktioniert natürlich nicht immer. Im ursprünglichen Beispiel, dem Select-Statement oder dem REST-Call, ist das zu unpraktisch. Es würde auch ein falsches Signal aussenden. Eine Angular-Anwendung ist keineswegs lose mit dem Backend gekoppelt. Wenn das Backend nicht verfügbar ist, ist ein Angular-Frontend nutzlos.

Aber lassen wir das mal kurz außer Acht. Dann kann man sehen, dass Annotationen ein schönes Stilmittel sind, um Algorithmen reaktiv zu implementieren. Der Clou ist, dass es hier nicht um Observables, Monos oder Fluxes geht. Die Parameterliste und der Rückgabetyp der Funktion sind die reinen Datentypen. Das vereinfacht die Programmierung enorm. Das Framework übernimmt die komplette Abstraktion. Wir brauchen nicht zu wissen, dass ein @KafkaListener im Grunde genommen auch nichts anderes ist als ein Observable.

Klingt das verrückt? Dann schaut es euch noch einmal genau an. In Angular hättet ihr vermutlich eine Methode listenToKafka(topic, groupId), die ein Observable zurückliefert. Analog hättet ihr in Spring Reactive eine Methode listenToKafka(topic, groupId), die ein Flux zurückliefert. In beiden Fällen kann in der subscribe()-Methode definiert werden, was mit den Daten passieren würde. Und dieser Algorithmus wiederum ist exakt der Inhalt der Methode listenToFoo() aus Listing 7.

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

Resumée

Das war ein wilder Ritt durch die Untiefen der reaktiven Programmierung. Die Kernbotschaft, die ich vermitteln will, ist aber ziemlich einfach. Reactive Programming ist etwas, mit dem man sich beschäftigen sollte. Die Anwender werden es euch danken. Bessere Performance kommt immer gut an.

Das heißt aber nicht, dass ihr euch blindlings in das Abenteuer stürzen solltet. Oder, doch, das solltet ihr schon. Mut zur Lücke ist immer gut. Irgendwo muss man ja anfangen. Ihr werdet dann aber schnell feststellen, dass reaktive Programmierung ihre Tücken hat. An diesem Punkt angekommen, ist der richtige Zeitpunkt, diesen Artikel (noch einmal) zu lesen. Es gibt Strategien, reaktive Programmierung beherrschbar zu machen. Im Internet wird meistens empfohlen, sich mit Haut und Haaren darauf einzulassen. Das funktioniert für viele Teams ziemlich gut. Man kann aber auch versuchen, die Komplexität zu reduzieren. Bei Angular ist async-await das Mittel der Wahl. Bei Java gibt es diese Möglichkeit nicht. Sie wird auch nicht benötigt. Beim reaktiven Programmieren wird es meistens erst dann schwierig, wenn auf den aktuellen Zustand der Anwendung zugegriffen werden soll. Solange zustandslose REST Services entwickelt werden, habt ihr diese Anforderung nicht. Und falls doch, hat auch Java noch das eine oder andere As im Ärmel. Apache Kafka zeigt, wie mit Annotationen den Abstraktionsgrad der Algorithmen deutlich reduziert werden kann.

 

Links & Literatur

[1] https://www.reactivemanifesto.org

[2] https://goodguydaniel.com/blog/why-reactive-programming

[3] https://hantsy.github.io/quarkus-sandbox/reactive.html

[4] https://www.baeldung.com/spring-kafka

The post Die Untiefen reaktiver Programmierung appeared first on JAX 2022.

]]>
Mit Quarkus gegen Monolithen https://jax.de/blog/mit-quarkus-gegen-monolithen/ Wed, 12 Jan 2022 12:35:44 +0000 https://jax.de/?p=85447 Firmen stehen häufig vor dem Problem, dass über lange Zeit gewachsene Softwareprojekte unwartbar und immer unverständlicher werden. Irgendwann stellt sich die Frage: Alles hinschmeißen und neu entwickeln? Wir möchten euch einen Einblick in unser Vorgehen, die Entscheidungsprozesse und unsere künftige Architektur geben. Insbesondere gehen wir auf das Set-up, das auf Quarkus basiert, ein und zeigen, dass sich damit nicht nur kleine Services bauen lassen.

The post Mit Quarkus gegen Monolithen appeared first on JAX 2022.

]]>
Wer sind wir? Die LucaNet AG hat sich auf den Bereich Finanzsoftware für Konzerne für deren Konsolidierung, Planung, Analyse und Reporting spezialisiert. Seit 1999 entwickelt LucaNet die Softwarelösungen stetig weiter und versucht, den stets wachsenden Anforderungen der Kunden gerecht zu werden und die bestmögliche Performance zu liefern. Die Softwarelösung der LucaNet AG ist eine Client-Server-Anwendung, in der je nach Unternehmensgröße bis zu mehreren Hundert Personen parallel auf einem sehr komplexen Datenmodell arbeiten, das die ganze Zeit im Speicher gehalten wird.

Der Monolith

Am Anfang steht immer eine Idee. Dann schreibt man etwas Software und daraus entwickelt sich eine Applikation gemäß den Paradigmen und Möglichkeiten der Zeit. In den folgenden Jahren werden mehr und mehr Features entwickelt, um die schnell wachsenden Kundenanforderungen zu bedienen. Es bleibt keine Zeit (oder es wird sich keine Zeit genommen) für Wartungsarbeiten oder ein klassisches Refactoring. Die Kunden sind zufrieden mit dem Umfang und der Leistungsfähigkeit der Anwendung – warum sollte man also etwas anders machen? Einige Jahre und viele Personenjahre Aufwand später ist aus der kleinen Applikation eine ausgewachsene Businessanwendung geworden. Unzählige Lines of Code, Klassen, Module usw., die zwar wunderbar funktionieren, aber nur von wenigen „alten Hasen“ wirklich verstanden und gewartet werden können. Aber auch eine ganze Reihe von Hacks und Bugfixes, die das ursprüngliche Architekturpattern unterwandern. Häufig fällt in so einem Umfeld die Floskel „Das ist historisch gewachsen“. Das Muster dürfte den meisten von euch bekannt vorkommen. Denn diese Situation gibt es bei (fast) jedem Softwaresystem, das sehr erfolgreich ist und über viele Jahre weiterentwickelt wurde.

Doch warum nennen wir so etwas Monolith? Das Wort Monolith kommt aus dem Griechischen und heißt so viel wie „einheitlicher Stein“, und wir finden die Analogie sehr passend für Softwaresysteme, die von außen betrachtet undurchdringbar und schwer veränderlich sind. Daraus resultiert auch, dass sich einzelne Teile nur schwer aktualisieren lassen und so gut wie unmöglich herauszutrennen sind.

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

Alles neu?

Wir sind alle Entwickler und Technologieenthusiasten und daher kommen wir sehr schnell zu der Überzeugung, dass mit Framework X oder Technologie Y alle Probleme schnell gelöst werden und damit auch ganz neuen Herausforderungen und Anforderungen leicht entsprochen werden kann. Einige wollen auch dafür eigene Technologien entwickeln, was gar nicht mal ungewöhnlich ist – man liest und hört nur sehr wenig von diesen Ansätzen. Die meisten Seniorentwickler schreiben die gleiche Art von Anwendung oder lösen die gleiche Art von Problemen Tag für Tag, Jahr um Jahr. Das ist manchmal langweilig, und was gibt es Spannenderes, als ein eigenes Framework zu schreiben? Vielleicht sogar mit der Absicht, es danach als Open-Source-Software zu veröffentlichen? Die Antwort ist: Wahrscheinlich ist es superspannend, aber bringt es unsere Anwendung wirklich weiter? Und viel wichtiger: Lösen wir damit überhaupt die aktuellen Probleme?

Um diese Fragen zu beantworten, müssen die Anwendung und ihre Architektur erst einmal überprüft werden. Bei einer solchen Architekturreview, die idealerweise von Externen durchgeführt wird, hat sich in der Praxis ATAM [1] als Methode bewährt. Dieser Artikel ist zu kurz, um auf ATAM einzugehen, aber ganz kompakt formuliert, definiert man via Szenarien Qualitätsanforderungen an den Sollzustand der Architektur und vergleicht diesen dann mit dem Istzustand des Systems. Das klingt erst einmal furchtbar aufwendig und teuer, daher wird die Investition oft gescheut. Jedoch stehen die Kosten aus unserer Erfahrung in keinem Verhältnis zu den Kosten (über den Lifecycle) einer nicht passenden Architektur oder Technologiewahl. Ähnlich wie bei Fahrzeugen sollte daher ein regelmäßiger TÜV der Architektur zu einer reifen Produktentwicklung dazugehören.

Den Monolithen erwürgen

Wenn man bei der Review zu der Erkenntnis gelangt, dass die aktuelle Architektur nicht mehr lange weiterentwickelt und gewartet werden kann, muss man handeln. Aus der Definition oben lässt sich schon ableiten, dass man den Monolithen nicht einfach in ein paar Teile zerlegen und diese dann separat weiterentwickeln kann. Es ist aber auch so gut wie nie möglich, die Entwicklung an dem aktuellen System zu stoppen, zwei bis drei Jahre lang eine neue Architektur zu entwickeln, um dann das neue System präsentieren zu können. Wir müssen also das bestehende System, den Monolithen, zumindest vorübergehend in das neue Gesamtsystem integrieren.

Bei der Umstellung von Monolithen auf Microservices gibt es das sogenannte Strangler Pattern (Kasten: „Strangler Pattern ) [2]. LucaNet entwickelt ein Produkt, das auch on-premise, das heißt direkt beim Kunden, installiert werden kann. Hier kommt für uns ein Microservices-Ansatz nicht infrage. Die Strategie für die Umstellung ist, dass das Produkt technologisch und architektonisch so einfach wie möglich gehalten wird. Das Produkt muss von der Kunden-IT, aber natürlich auch durch uns leicht integrierbar und leicht zu betreiben sein. Das Pattern ist trotzdem sehr interessant, und wir werden es leicht abgewandelt in unsere Strategie einbringen.

Strangler Pattern

Das Strangler Pattern beschreibt kurz gesagt ein Vorgehen, bei dem Stück für Stück Teile eines Monolithen herausgetrennt und als Microservices in die Gesamtarchitektur eingebracht werden. Für unser Vorhaben wandeln wir dieses Vorgehen leicht ab. Den Monolithen selbst können wir nicht umbauen und nur schwer verändern. Wir werden den neu zu entwickelnden Server vor den Monolithen setzen und alle Requests darüber laufen lassen. Damit können transparent Funktionen in den neuen Server eingebaut werden und die alten Funktionen werden einfach nicht mehr angesteuert. An einem bestimmten Punkt in der Entwicklung wird der alte Monolith schließlich überflüssig sein – er wurde quasi Stück für Stück „erwürgt“ (engl. to strangle).

Das grundsätzliche Vorgehen war damit recht schnell gesetzt. Es wird ein neues User Interface entwickelt und der bisherige Monolith muss um notwendige Schnittstellen für den Zugriff erweitert werden. An dieser Stelle kommt dafür nur REST infrage, da wir damit im Front-end komplett technologieunabhängig sind. Das neue Frontend soll natürlich nicht einfach an das bestehende Backend angebunden werden, da wir sonst so gut wie keine Fortschritte gemacht haben. Schlimmstenfalls ist eine weitere Komponente von dem Big Ball of Mud abhängig.

SIE LIEBEN JAVA?

Den Core-Java-Track entdecken

Evolution statt Revolution

Es müssen jetzt zwei Dinge gleichzeitig entwickelt werden: zum einen das neue Frontend und zum anderen die entsprechenden Schnittstellen im Backend. Damit beides wirklich parallel und ohne große Störungen geschehen kann, haben wir uns für einen API-First-Ansatz entschieden. Da jetzt schon klar ist, dass der Monolith nicht so bleibt, wie er ist, muss er vom Frontend entkoppelt werden. Auch muss das neue JavaScript Frontend von einem Server ausgeliefert werden. Die erste Entscheidung war also auch eine der wichtigsten: Welchen Server bzw. welches Framework sollen wir verwenden (Kasten: „Entscheidungsprozess“)?

Entscheidungsprozess

Als Entwickler sind wir bei der Technologiewahl schnell bei den neuesten Technologien und Frameworks (aka „der neueste heiße Scheiß“). Anders als bei Start-ups, kleineren Projekten oder reinen Webapplikationen müssen wir hier von vornherein über viel größere Zeithorizonte nachdenken. Einer der wichtigsten Punkte bei der Szenariendefinition für die neue Architektur war Langlebigkeit, die zukünftige Architektur soll mindestens zehn Jahre halten. Natürlich kann heute nicht alles für die nächsten zehn Jahre antizipiert werden, aber die Architektur muss in der Lage sein, Veränderungen zu unterstützen [3], dabei aber so stabil bleiben, dass wir nicht befürchten müssen, dass es das Framework in drei Jahren nicht mehr gibt, die Maintainer keine Lust mehr haben oder ein Versionswechsel ein halbes Jahr Arbeit nach sich zieht. Häufig bedeutet das, je langweiliger die Technologie, desto besser. Außerdem sollten bei der Technologiewahl auch der Einsatz und die Wahrung von konzeptionellen Standards Berücksichtigung finden. Jakarta EE (früher Java EE) und insbesondere der MicroProfile-Standard erfüllen den Anspruch von Stabilität, Flexibilität und – entsprechend den Architekturzielen – auch der Nachhaltigkeit. Mittlerweile gibt es einige sehr spannende Umsetzungen des MicroProfile-Standards, weshalb die Lösung gar nicht so langweilig sein muss.

Wir haben uns nach der Evaluation für Quarkus [4] entschieden. Auch wenn das ein relativ neuer Server ist, haben uns hier mehrere Aspekte überzeugt. Zum Teil machte die Entscheidung aus, dass Quarkus ein MicroProfile-Server ist, wir uns also nicht direkt an ein Produkt binden, sondern an einen Standard. Im schlimmsten Fall können wir einen anderen MicroProfile-Server (z. B. Payara, Open Liberty, … ) nehmen und unsere Anwendung mit wenigen Anpassungen damit laufen lassen. Weiterhin ist die Dokumentation dieses Servers exzellent und die Verwendbarkeit denkbar einfach. Es wurde auch nicht alles neu erfunden, sondern man hat sich dediziert für ausgereifte Bibliotheken und Frameworks, wie z. B. Vert.x, entschieden. Das ermöglicht uns wiederum, bei einem potenziellen Serverwechsel diese Bibliotheken oder Frameworks als Third-party Dependency einzubinden, falls wir deren Features verwenden.

Die nächste große Herausforderung ist die Tatsache, dass wir auch bisher keine Microservices hatten bzw. das Produkt direkt beim Kunden im eigenen Datacenter läuft. Die Idee ist, dass wir den MicroProfile-Server zwischen das Frontend und den Monolithen setzen. Quarkus fungiert nun als Anti-corruption Layer (Kasten: „Anti-corruption Layer). Egal wie sehr sich die Schnittstellen im Monolithen ändern werden, nach außen hin wird immer noch das gleiche REST-Interface bedient. Wir können mit diesem Ansatz die Frontend-Entwicklung sogar beschleunigen, indem wir eine Pseudoimplementierung der Schnittstelle Testdaten ausliefern lassen. Das Frontend kann also schon alle Methoden mit unterschiedlichen Parametern aufrufen und wir erhalten in einem sehr begrenzten Umfang Testdaten als Antwort.

Anti-corruption Layer

Das Konzept des Anti-corruption Layers kommt ursprünglich aus dem Domain-driven Design [5]. Hier geht es darum, einen Bounded Context von einem anderen Bounded Context abzugrenzen. Der Anti-corruption Layer sorgt dann dafür, dass Änderungen in Bounded Context A keine ungewollten Änderungen im Bounded Context B verursachen.

Wir wollen mit dem Anti-corruption Layer nicht nur einzelne Bounded Contexts voneinander abgrenzen, sondern den bisherigen Monolithen über neue Schnittstellen isolieren. Mit diesem neuen Layer sind wir auch in der Lage, das Backend jederzeit teilweise oder sogar ganz auszutauschen. Features können so jeweils Stück für Stück in Quarkus selbst wandern und dadurch die Aufrufe in den Monolithen überflüssig machen (Abb. 1). Das hat für uns den weiteren Vorteil, dass wir bei der Entwicklung bereits Erfahrungen mit dem neu zu entwickelnden Backend sammeln können, ohne ein Risiko für den Kunden zu generieren. Somit steuern wir nicht auf ein Big Bang Release zu, sondern erarbeiten eine Umstellung auf ein gut getestetes Backend. Die Umstellung selbst wurde bis dahin vielfach getestet, was uns zusätzliche Sicherheit für den finalen Schritt gibt.

Abb. 1: Schrittweise Ablösung des Monolithen

Nieder mit dem Monolithen

Was bedeutet das jetzt alles für die Entwicklung des neuen Backend? Die größte Herausforderung für uns ist, die Architektur möglichst einfach zu konzipieren. Weiter oben wurde bereits festgestellt, dass ein Microservices-Ansatz derzeit für uns nicht infrage kommt. Entwickeln wir jetzt den nächsten Big Ball of Mud? Sicher nicht. Unsere Architekturstrategie für die Zukunft sieht einen modularen Monolithen vor, dessen einzelne Module vollständig voneinander getrennt sind. Das soll so weit gehen, dass es möglich sein muss, die Module auf verschiedene einzelne Server aufzuteilen. Dieses Vorgehen hat gleich mehrere Vorteile:

  1. Wir verhindern einen neuen Big Ball of Mud

  2. Einzelne Module sind einfacher zu verstehen, einfacher zu debuggen und zu warten

  3. Jedes Modul kann von einem anderen Team entwickelt werden, ohne dass sich die Teams in die Quere kommen

  4. Es bietet die Option, in Zukunft evtl. auf eine Microservices-/SCS-Architektur umzusteigen

Trotz der sehr guten Quarkus-Dokumentation gibt es leider nur sehr wenige Informationen darüber, wie dieser MicroProfile-Server für mehr als einen Microservice eingesetzt werden kann. Die Standardtutorials zeigen, wie schön einfach sich einzelne Services oder kleine Anwendungen ohne Untermodule bauen lassen, aber so gut wie nie, wie eine mögliche Projektstruktur für eine größere Anwendung aussieht. Den Proof of Concept für unsere Zwecke zu erstellen, hat uns etwas Zeit gekostet, deshalb wollen wir unsere Ergebnisse gern hier teilen.

Treffen Sie unsere Java-Speaker

Der zukünftige Server soll neben dem Backend auch das neue Frontend ausliefern. Getreu dem Motto „KISS – Keep it simple, stupid“ sparen wir uns damit ein weiteres Stück Komplexität in der Auslieferung und im Betrieb. Das Projekt-Set-up soll so einfach wie möglich bleiben und dabei gut erweiterbar sein. Als Build-Tool haben wir uns für Maven entschieden. Hier ist eine Projektstruktur durch das Konzept „Convention over Configuration“ vorgegeben und kann nur schwierig geändert werden, was wir als großen Vorteil ansehen. Gradle ist in einigen Punkten flexibler, hat aber damit auch den Nachteil, dass der Code für den Build selbst ebenfalls über die Jahre aus dem Ruder laufen kann.

Jedes funktionale Modul im Projekt entspricht in unserem Beispiel dann eins zu eins einem Maven-Modul. In der einfachsten Ausführung haben wir drei Module: Frontend, Backend und Runnable. Das Frontend-Modul enthält sämtlichen Code für das Frontend und produziert ebenfalls ein jar, damit dieses später als Dependency referenziert werden kann. Das Backend-Modul enthält entsprechend den Code für das Backend. Das Runnable-Modul ist ein reines Metamodul, es ist nur dafür da, das Executable Jar (Native Executable, Docker-Container) am Ende zusammenzubauen. In der Beispielgrafik (Abb. 2) sieht man schon, wie sich weitere Backend-Module in die Struktur einfügen.

Abb. 2: Projektstruktur in der Übersicht

Frontend

Die erste Frage, die sich bei dem Frontend-Modul stellt, ist: „Muss man Node, npm/yarn etc. separat installiert haben?“ Einfacher ist es natürlich, wenn jeder Entwickler einfach das Projekt auschecken und direkt damit arbeiten kann. Das macht die automatische Integration, zum Beispiel mit Jenkins, einfacher, weil auch hier Node.js etc. nicht extra installiert werden müssen, was auch potenzielle Fehler durch unterschiedliche Versionen in Development und Build von vornherein verhindert. Da wir ja alles so einfach wie möglich haben wollen, werden wir diesen Build-Prozess ebenfalls integrieren. Hierbei hilft uns das Frontend-Maven-Plug-in [6]. Dieses Plug-in unterstützt die Verwendung von yarn, npm, bower und anderen gängigen Tools im Frontend-Development-Bereich. Mit dessen Hilfe können wir jetzt die Struktur des Front-end-Moduls (Abb. 3) so gestalten, dass ein Frontend-Entwickler sich dort zu Hause fühlt und alle notwendigen Build-Tools zur Verfügung hat. Je nach Betriebssystem wird in unserem Fall die richtige Node.js-Version heruntergeladen und es werden sämtliche Dependencies mit npm aufgelöst und in ein separates Verzeichnis gepackt.

Abb. 3: Frontend-Projektstruktur in IntelliJ

An dieser Stelle ist es wichtig zu erwähnen, dass man beide Verzeichnisse unbedingt der .gitignore-Datei hinzufügen sollte, damit nicht aus Versehen bis zu mehreren Hundert Megabyte an Bibliotheken eingecheckt werden. Auch wollten wir verhindern, dass die Dependencies ständig aufgelöst und heruntergeladen werden, da dies eine Weile dauern kann. Hierfür verwenden wir verschiedene Maven-Profile (Listing 1). Ein Profil genau für den eben beschriebenen Schritt und eins zum reinen Bauen der Sources. Das spart uns bei der täglichen Arbeit eine Menge Zeit. Bei der Backend-Entwicklung braucht man häufig den Frontend-Part gar nicht oder nicht aktuell und kann ihn beim Build weglassen, indem die entsprechenden Profile nicht angegeben bzw. aktiviert werden.

<profiles>
  <profile>
    <id>Install node and npm</id>
    <activation>
      <property>
        <name>ui.deps</name>
      </property>
    </activation>
    <build>
      <plugins>
        <plugin>
          <groupId>com.github.eirslett</groupId>
          <artifactId>frontend-maven-plugin</artifactId>
          <version>${version.frontend-maven-plugin}</version>
          <executions>
            <execution>
              <id>install node and npm</id>
              <goals>
                <goal>install-node-and-npm</goal>
              </goals>
              <phase>generate-sources</phase>
              <configuration>
                <nodeVersion>v14.17.0</nodeVersion>
              </configuration>
            </execution>
            <execution>
              <id>npm install</id>
                <goals>
                  <goal>npm</goal>
                </goals>
                <configuration>
                  <arguments>install</arguments>
                </configuration>
            </execution>
            </executions>
        </plugin>
      </plugins>
    </build>
  </profile>
  <profile>
    <id>Build the UI with AOT</id>
    <activation>
      <property>
        <name>ui</name>
      </property>
    </activation>
    <build>
      <plugins>
        <plugin>
          <groupId>com.github.eirslett</groupId>
          <artifactId>frontend-maven-plugin</artifactId>
          <version>${version.frontend-maven-plugin}</version>
          <executions>
            <execution>
              <id>npm run build</id>
              <goals>
                <goal>npm</goal>
              </goals>
              <configuration>
                <arguments>run build</arguments>
              </configuration>
              <phase>generate-resources</phase>
            </execution>
          </executions>
        </plugin>
      </plugins>
    </build>
  </profile>
</profiles>

Das Frontend-Plug-in muss noch wissen, in welchem Verzeichnis sich die Frontend-Sources befinden. Das Maven Resources-Plug-in muss auch konfiguriert werden, damit die fertigen Dateien im richtigen Verzeichnis im Target-Ordner landen. Hier muss vor allem darauf geachtet werden, den Applikationsnamen in der Ordnerstruktur nicht zu vergessen: <directory>webapp/dist/my-app</directory> (Listing 2).

Die Konfiguration des Maven Clean-Plug-in ist reine Bequemlichkeit, damit man nicht von Hand die Unterverzeichnisse löschen muss, wenn man das Frontend komplett neu bauen möchte.

<build>
  <plugins>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-clean-plugin</artifactId>
      <version>${clean.plugin.version}</version>
      <configuration>
        <filesets>
          <fileset>
            <directory>webapp/dist</directory>
          </fileset>
        </filesets>
      </configuration>
    </plugin>
    <!-- Used to run the angular build -->
    <plugin>
      <groupId>com.github.eirslett</groupId>
      <artifactId>frontend-maven-plugin</artifactId>
      <version>${version.frontend-maven-plugin}</version>
      <configuration>
        <workingDirectory>webapp</workingDirectory>
      </configuration>
    </plugin>
    <plugin>
      <groupId>org.apache.maven.plugins</groupId>
      <artifactId>maven-resources-plugin</artifactId>
      <version>${resources.plugin.version}</version>
      <executions>
        <execution>
          <id>copy-resources</id>
          <phase>process-resources</phase>
          <goals>
            <goal>copy-resources</goal>
          </goals>
          <configuration>
            <outputDirectory>${basedir}/target/classes/META-INF/resources/</outputDirectory>
            <resources>
              <resource>
                <directory>webapp/dist/my-app</directory>
                <filtering>false</filtering>
              </resource>
            </resources>
          </configuration>
        </execution>
      </executions>
    </plugin>
  </plugins>
</build>

Backend

Der Backend-Teil ist relativ straight forward, hat aber auch ein, zwei Punkte, die nicht offensichtlich sind. Das Verzeichnislayout bleibt so, wie wir es von Maven-Projekten gewohnt sind. Wenn man bei seinem Projekt CDI verwendet, muss man die CDI Bean Discovery durch die Verwendung des jandex-maven-plugins [7] explizit aktivieren. Interessant wird es auch bei der Konfiguration durch die application.properties-Datei. Diese wird in dem Runnable-Modul in das final ausführbare jar eingefügt. Quarkus, wie andere MicroProfile-Server auch, liest Properties von mehreren Quellen ein. Nach Standard werden die System- und Umgebungsvariablen gelesen und dann die META-INF/microprofile.properties-Datei. Hier wird noch eine zusätzliche Quelle eingefügt, die application.properties-Datei. Mit Hilfe dieser Datei lassen sich viele Serveroptionen sehr einfach konfigurieren, sie sind allerdings Quarkus-spezifisch und können nicht alle eins zu eins auf andere Server übertragen werden.

Für das Backend-Modul selbst ist diese Datei quasi nicht existent, weil sie im Runnable-Modul liegt. Dieser Umstand fällt erst so richtig auf, wenn man versucht, für Tests mit Wiremock die Ports zu ändern, und sich wundert, dass die geänderten Ports nicht verwendet werden (Listing 3).

# This port is being used e.g. for the WireMock server
# 0 means random port assigned by the OS
quarkus.http.test-port=0
quarkus.http.test-ssl-port=0

Da diese Einstellungen nur für die Tests gebraucht werden, können wir eine separate application.properties-Datei einfach in den src/test/resources-Pfad packen und mit den entsprechenden Werten für die Tests bestücken. Die Ausführung der Tests erfolgt nur im Kontext dieses Moduls, daher ist es völlig irrelevant, wo andere Konfigurationen hinterlegt sind. Wenn das Projekt später weiterwächst und mehrere Backend-Module enthält, kann somit jedes Backend-Modul seine eigenen Test-Properties mitbringen, ohne dass diese sich gegenseitig stören.

Runnable

Das Runnable-Modul hat nur eine Aufgabe, und zwar, eine ausführbare Datei zu bauen. Das kann eine ausführbare jar-Datei sein, ein Docker-Container oder ein Native Executable mit Hilfe des Graal Native Image Compilers [8]. Man muss sich auch nicht exklusiv für eine Methode entscheiden, sondern kann alle drei Möglichkeiten vorbereiten und dann das gewünschte Target beim Bauen auswählen. In diesem Modul befindet sich schließlich auch application.properties von Quarkus, welche die Laufzeitkonfiguration für das zu bauende Runnable enthält.

Etwas fehlt noch. Der Development Mode von Quarkus lässt sich mit dem Multi-Module-Set-up nicht mehr so einfach aktivieren. Ein etwas komplizierteres Maven Goal hilft hier weiter. Am besten legt man es direkt als Run-Konfiguration für Maven in der IDE an oder speichert es als Shell Script:

mvn -pl runnable -am compile quarkus:dev

Dieser Aufruf im Projekt-Root sorgt dafür, dass alle Module, welche als Dependency im Runnable-Modul eingetragen sind, kompiliert werden, und zwar im Quarkus-Dev-Modus. Damit der Dev-Modus auch wirklich aktiviert werden kann, muss noch das entsprechende Maven Plug-in im Runnable-Modul konfiguriert werden (Listing 4).

<plugin>
  <groupId>io.quarkus</groupId>
  <artifactId>quarkus-maven-plugin</artifactId>
  <version>${quarkus-plugin.version}</version>
  <extensions>true</extensions>
  <executions>
    <execution>
      <goals>
        <goal>build</goal>
        <goal>generate-code</goal>
        <goal>generate-code-tests</goal>
      </goals>
    </execution>
  </executions>
</plugin>

Das komplette Beispiel, aus dem die Code-Snippets stammen, ist auf GitHub [9] verfügbar.

 

Ausblick

Wir befinden uns erst am Anfang der Umstellung unserer Softwarearchitektur. Die Überlegungen, die Evaluation und die Vorbereitungen waren sehr umfangreich und haben auch schon einige Prototypen hervorgebracht. Die Erkenntnisse, die wir bis hierhin gewonnen haben, haben wir in diesem Artikel beschrieben. Für uns ist das aber nur der Start in ein sehr großes Unterfangen. Wir wollen auch in Zukunft unsere Erkenntnisse, Entwicklungen und Erfahrungen teilen und werden bestimmt mit dem einen oder anderen Artikel oder auch Vortrag wiederkommen.

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

Links & Literatur

[1] https://resources.sei.cmu.edu/library/asset-view.cfm?assetid=5177

[2] Fowler, Martin: https://martinfowler.com/bliki/StranglerFigApplication.html

[3] Parsons, R., Kua, P., Ford, N.: „Building Evolutionary Architectures: Support Constant Change“, O’Reilly, 2017

[4] https://quarkus.io

[5] Evans, Eric: „Domain-Driven Design: Tackling Complexity in the Heart of Software“, Addison-Wesley Professional, 2003

[6] https://github.com/eirslett/frontend-maven-plugin

[7] https://quarkus.io/guides/maven-tooling#multi-module-maven

[8] https://www.graalvm.org/reference-manual/native-image/

[9] https://github.com/Mr-Steel/quarkus_multimodule

The post Mit Quarkus gegen Monolithen appeared first on JAX 2022.

]]>
Modularisierung und kognitive Psychologie https://jax.de/blog/modularisierung-und-kognitive-psychologie/ Mon, 06 Dec 2021 09:56:02 +0000 https://jax.de/?p=85348 Über Modularisierung wird viel und häufig gesprochen, aber die Gesprächspartner:innen stellen nach einiger Zeit fest, dass sie nicht dasselbe meinen. Die Informatik hat uns in den letzten fünfzig Jahren zwar eine Reihe guter Erklärungen geliefert, was Modularisierung ausmacht – aber reicht das, um wirklich zu den gleichen Schlüssen und Argumenten zu kommen?

The post Modularisierung und kognitive Psychologie appeared first on JAX 2022.

]]>
Die wirkliche Begründung, warum Modularisierung so wichtig ist, habe ich erst bei der Beschäftigung mit der kognitiven Psychologie gefunden. In diesem Artikel werde ich deshalb Modularisierung und kognitive Psychologie zusammenführen und Ihnen die entscheidenden Argumente an die Hand geben, warum Modularisierung uns bei der Softwareentwicklung tatsächlich hilft.

Parnas hat immer noch Recht!

In den letzten 20 bis 30 Jahren haben wir viele sehr große Softwaresysteme in Java, C++ und auch in C# und PHP entwickelt. Diese Systeme enthalten sehr viel Business Value und frustrieren Ihre Entwicklungsteams, weil sie nur noch mit immer mehr Aufwand weiterentwickelt werden können. Das inzwischen 50 Jahre alte Rezept von David Parnas, um einen Ausweg aus dieser Situation zu finden, heißt Modularisierung. Haben wir eine modulare Architektur, so heißt es, dann haben wir unabhängige Einheiten, die von kleinen Teams verstanden und zügig weiterentwickelt werden können. Zusätzlich bietet eine modulare Architektur die Möglichkeit, die einzelnen Module getrennt zu deployen, sodass unsere Architektur skalierbar wird. Genau diese Argumente tauschen wir in Diskussionen unter Architekt:innen und Entwickler:innen aus und sind uns doch immer wieder nicht einig, was wir genau mit Modularität, Modulen, modularen Architekturen und Modularisierung meinen.

In meiner Doktorarbeit habe ich mich mit der Frage beschäftigt, wie man Softwaresysteme strukturieren muss, damit Menschen bzw. unser menschliches Gehirn sich darin gut zurechtfinden. Das ist besonders deswegen wichtig, weil Entwicklungsteams einen Großteil ihrer Zeit mit dem Lesen und Verstehen von vorhandenem Code verbringen. Erfreulicherweise hat die kognitive Psychologie mehrere Mechanismen identifiziert, mit dem unser Gehirn komplexe Strukturen erfasst. Einer von ihnen liefert eine perfekte Erklärung für Modularisierung: Er heißt Chunking. Auf der Basis von Chunking können wir Modularisierung sehr viel besser beschreiben als durch Entwurfsprinzipien und Heuristiken, die sonst oft als Begründungen herangezogen werden [1]. Zusätzlich liefert uns die kognitive Psychologie zwei weitere Mechanismen: Hierarchisierung und Schemata, die weitere wichtige Hinweise für Modularisierung mitbringen.

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

Chunking ➔ Modularisierung

Damit Menschen in der Menge der Informationen, mit denen sie konfrontiert sind, zurechtkommen, müssen sie auswählen und Teilinformationen zu größeren Einheiten gruppieren. Dieses Bilden von höherwertigen Abstraktionen, die immer weiter zusammengefasst werden, nennt man in der kognitiven Psychologie Chunking (Abb. 1). Dadurch, dass Teilinformationen als höherwertige Wissenseinheiten abgespeichert werden, wird das Kurzzeitgedächtnis entlastet und weitere Informationen können aufgenommen werden.

Abb. 1: Chunking

Als Beispiel soll hier eine Person dienen, die das erste Mal mit einem Telegrafen arbeitet. Sie hört die übertragenen Morsezeichen als kurze und lange Töne und verarbeitet sie am Anfang als getrennte Wissenseinheiten. Nach einiger Zeit wird sie in der Lage sein, die Töne zu Buchstaben – und damit zu neuen Wissenseinheiten – zusammenzufassen, sodass sie schneller verstehen kann, was übermittelt wird. Einige Zeit später werden aus einzelnen Buchstaben Wörter, die wiederum größere Wissenseinheiten darstellen, und schließlich ganze Sätze.

Entwickler:innen und Architekt:innen wenden Chunking automatisch an, wenn sie sich neue Software erschließen. Der Programmtext wird im Detail gelesen, und die gelesenen Zeilen werden zu Wissenseinheiten gruppiert und so behalten. Schritt für Schritt werden die Wissenseinheiten immer weiter zusammengefasst, bis ein Verständnis des Programmtexts und der Strukturen, in die er eingebettet ist, erreicht ist.

Diese Herangehensweise an Programme wird als Bottom-up-Programmverstehen bezeichnet und von Entwicklungsteams in der Regel angewendet, wenn ihnen ein Softwaresystem und sein Anwendungsgebiet unbekannt sind und sie sich das Verständnis erst erarbeiten müssen. Bei Kenntnis des Anwendungsgebiets und des Softwaresystems wird von Entwicklungsteams eher Top-down-Programmverstehen eingesetzt. Top-down-Programmverstehen bedient sich hauptsächlich der beiden strukturbildenden Prozesse Bildung von Hierarchien und Aufbau von Schemata, die in den folgenden Abschnitten eingeführt werden.

Eine andere Form des Chunking kann man bei Expert:innen beobachten. Sie speichern die neuen Wissenseinheiten nicht einzeln im Kurzzeitgedächtnis ab, sondern fassen sie direkt durch Aktivierung bereits gespeicherter Wissenseinheiten zusammen. Wissenseinheiten können allerdings nur aus anderen Wissenseinheiten gebildet werden, die für die Versuchsperson sinnvoll zusammengehören. Bei Experimenten mit Experten eines Wissensgebiets und Anfängern wurden den beiden Gruppen Wortgruppen aus dem Wissensgebiet des Experten präsentiert. Die Experten konnten sich fünfmal so viele Begriffe merken wie die Anfänger. Allerdings nur, wenn die Wortgruppen sinnvoll zusammengehörige Begriffe enthielten.

An Entwickler:innen und Architekt:innen konnten diese Erkenntnisse ebenfalls nachgewiesen werden. Chunking funktioniert auch bei Softwaresystemen nur dann, wenn die Struktur des Softwaresystems sinnvoll zusammenhängende Einheiten darstellt. Programmeinheiten, die beliebige Operationen oder Funktionen zusammenfassen, sodass für die Entwicklungsteams nicht erkennbar ist, warum sie zusammengehören, erleichtern das Chunking nicht. Der entscheidende Punkt dabei ist, dass Chunking nur dann angewendet werden kann, wenn sinnvolle Zusammenhänge zwischen den Chunks existieren.

SIE LIEBEN JAVA?

Den Core-Java-Track entdecken

Module als zusammenhängende Einheiten

Für Modularisierung und modulare Architekturen ist es also essenziell, dass sie aus Bausteinen wie Klassen, Komponenten, Modulen, Schichten bestehen, die in sinnvoll zusammenhängenden Elementen gruppiert sind. In der Informatik gibt es eine Reihe von Entwurfsprinzipien, die diese Forderung nach zusammenhängenden Einheiten einlösen wollen:

  • Information Hiding (Geheimnisprinzip): David Parnas forderte 1972 als Erster, dass ein Modul genau eine Entwurfsentscheidung verbergen soll und die Datenstruktur zu dieser Entwurfsentscheidung in dem Modul gekapselt sein sollte (Kapselung und Lokalität). Parnas gab diesem Grundsatz den Namen Information Hiding [2].

  • Separation of Concerns: Dijkstra schrieb in seinem auch heute noch lesenswerten Artikel mit dem Titel „A Discipline of Programming“ [3], dass verschiedene Teile einer größeren Aufgabe möglichst in verschiedenen Elementen der Lösung repräsentiert werden sollten. Hier geht es also um das Zerlegen von zu großen Wissenseinheiten mit mehreren Aufgaben. In der Refactoring-Bewegung sind solche Einheiten mit zu vielen Verantwortlichkeiten als Code Smell unter dem Namen God Class wieder aufgetaucht.

  • Kohäsion: In den 1970er-Jahren arbeitete Myers seine Ideen über den Entwurf aus und führte das Maß Kohäsion ein, um den Zusammenhalt innerhalb von Modulen zu bewerten [4]. Coad und Yourdon erweiterten das Konzept für die Objektorientierung [5].

  • Responsibility-driven Design (Entwurf nach Zuständigkeit): In die gleiche Richtung wie das Geheimnisprinzip und die Kohäsion zielt Rebecca Wirfs-Brocks Entwurfsheuristik, Klassen nach Zuständigkeiten zu entwerfen: Eine Klasse ist eine Entwurfseinheit, die genau eine Verantwortung erfüllen und damit nur eine Rolle in sich vereinigen sollte [6].

  • Single Responsibility Principle (SRP): Als Erstes legt Robert Martin in seinen SOLID-Prinzipien fest, dass jede Klasse nur eine fest definierte Aufgabe erfüllen soll. In einer Klasse sollten lediglich Funktionen vorhanden sein, die direkt zur Erfüllung dieser Aufgabe beitragen. Effekt dieser Konzentration auf eine Aufgabe ist, dass es nie mehr als einen Grund geben sollte, eine Klasse zu ändern. Dafür ergänzt Robert Martin auf Architekturebene das Common Closure Principle. Klassen sollen in ihren übergeordneten Bausteinen lokal sein, sodass Veränderungen immer alle oder keine Klassen betreffen [7].

All diese Prinzipien wollen Chunking durch den inneren Zusammenhalt der Einheiten fördern. Modularität hat aber noch mehr zu bieten. Ein Modul soll nach Parnas außerdem mit seiner Schnittstelle eine Kapsel für die innere Implementierung bilden.

Treffen Sie unsere Java-Speaker

Module mit modularen Schnittstellen

Durch Schnittstellen kann Chunking erheblich unterstützt werden, wenn die Schnittstellen – welche Überraschung – sinnvolle Einheiten bilden. Die für Chunking benötigte Wissenseinheit kann in der Schnittstelle eines Moduls so gut vorbereitet werden, dass die Entwicklungsteams sich den Chunk nicht mehr durch die Analyse des Inneren des Moduls zusammensammeln müssen.

Eine gute zusammenhängende Schnittstelle entsteht, wenn man die Prinzipien aus dem letzten Abschnitt nicht nur beim Entwurf des Inneren eines Moduls anwendet, sondern auch für seine Schnittstelle [1], [7], [8]:

  • Explizite und kapselnde Schnittstelle: Module sollten ihre Schnittstellen explizit machen, d. h., die Aufgabe des Moduls muss klar erkennbar sein, und von der internen Implementierung wird abstrahiert.

  • Delegierende Schnittstellen und das Law of Demeter: Da Schnittstellen Kapseln sind, sollten die in ihnen angebotenen Dienste so gestaltet sein, dass Delegation möglich wird. Echte Delegation entsteht, wenn die Dienste an einer Schnittstelle Aufgaben komplett übernehmen. Dienste, die dem Aufrufer Interna zurückliefern, an denen der Aufrufer weitere Aufrufe ausführen muss, um zu seinem Ziel zu gelangen, verletzen das Law of Demeter.

  • Explizite Abhängigkeiten: An der Schnittstelle eines Moduls sollte direkt erkennbar sein, mit welchen anderen Modulen es kommuniziert. Erfüllt man diese Forderung, dann wissen Entwicklungsteams, ohne in die Implementierung zu schauen, welche anderen Module sie verstehen oder erzeugen muss, um mit dem Modul zu arbeiten. Dependency Injection passt direkt zu diesem Grundprinzip, denn es führt dazu, dass alle Abhängigkeiten über die Schnittstelle in ein Modul injiziert werden.

All diese Prinzipien haben als Ziel, dass Schnittstellen das Chunking unterstützen. Werden sie eingehalten, so sind Schnittstellen als eine Wissenseinheit schneller zu verarbeiten. Werden nun auch noch die Grundprinzipien der Kopplung beachtet, so haben wir für das Chunking beim Programmverstehen viel gewonnen.

Module mit loser Kopplung

Um ein Modul einer Architektur zu verstehen und ändern zu können, müssen sich Entwicklungsteams einen Überblick über das zu ändernde Modul selbst und seine benachbarten Module verschaffen. Wichtig sind dafür alle Module, mit denen das Modul zusammenarbeitet. Je mehr Abhängigkeiten es von einem Modul zum anderen gibt (Abb. 2), umso schwieriger wird es, die einzelnen Beteiligten mit der begrenzten Kapazität des Kurzzeitgedächtnisses zu analysieren und passende Wissenseinheiten zu bilden. Chunking fällt deutlich leichter, wenn weniger Module und Abhängigkeiten im Spiel sind.

Abb. 2: Stark gekoppelte Klassen (links) oder Packages/Directories (rechts)

Lose Kopplung ist das Prinzip in der Informatik, das an diesem Punkt ansetzt [9], [10], [11]. Kopplung bezeichnet den Grad der Abhängigkeit zwischen den Modulen eines Softwaresystems. Je mehr Abhängigkeiten in einem System existieren, desto stärker ist die Kopplung. Sind die Module eines Systems nach den Prinzipien der letzten beiden Abschnitte zu Einheiten und Schnittstellen entwickelt worden, so sollte das System automatisch aus lose gekoppelten Modulen bestehen. Ein Modul, das eine zusammenhängende Aufgabe erledigt, wird dazu weniger andere Module brauchen als ein Modul, das viele verschiedene Aufgaben durchführt. Ist die Schnittstelle nach dem Law of Demeter delegierend angelegt, dann braucht der Aufrufer nur diese Schnittstelle. Er muss sich nicht von Schnittstelle zu Schnittstelle weiterhangeln, um schließlich durch viel zusätzliche Kopplung seine Aufgabe abzuschließen.

Chunking hat uns bis hierhin geholfen, Modularisierung für das Innere und das Äußere eines Moduls und für seine Beziehung zu betrachten. Spannenderweise spielt auch der nächste kognitive Mechanismus in das Verständnis von Modularisierung hinein.

Modularisierung durch Muster

Der effizienteste kognitive Mechanismus, den Menschen einsetzen, um komplexe Zusammenhänge zu strukturieren, sind sogenannte Schemata. Unter einem Schema werden Konzepte verstanden, die aus einer Kombination von abstraktem und konkretem Wissen bestehen. Ein Schema besteht auf der abstrakten Ebene aus den typischen Eigenschaften der von ihm schematisch abgebildeten Zusammenhänge. Auf der konkreten Ebene beinhaltet ein Schema eine Reihe von Exemplaren, die prototypische Ausprägungen des Schemas darstellen. Jeder von uns hat beispielsweise ein Lehrerschema, das abstrakte Eigenschaften von Lehrer:innen beschreibt und als prototypische Ausprägungen Abbilder unserer eigenen Lehrer:innen umfasst.

Haben wir für einen Zusammenhang in unserem Leben ein Schema, können wir die Fragen und Probleme, mit denen wir uns gerade beschäftigen, sehr viel schneller verarbeiten als ohne Schema. Schauen wir uns ein Beispiel an: Bei einem Experiment wurden Schachmeister:innen und Schachanfänger:innen für ca. fünf Sekunden Spielstellungen auf einem Schachbrett gezeigt. Handelte es sich um eine sinnvolle Aufstellung der Figuren, so waren die Schachmeister:innen in der Lage, die Positionen von mehr als zwanzig Figuren zu rekonstruieren. Sie sahen Muster von ihnen bekannten Aufstellungen und speicherten sie in ihrem Kurzzeitgedächtnis. Die schwächeren Spieler:innen hingegen konnten nur die Position von vier oder fünf Figuren wiedergeben. Die Anfänger:innen mussten sich die Position der Schachfiguren einzeln merken. Wurden die Figuren den Schachexpert:innen und Schachlaien allerdings mit einer zufälligen Verteilung auf dem Schachbrett präsentiert, so waren die Schachmeister:innen nicht mehr im Vorteil. Sie konnten keine Schemata einsetzen und sich so die für sie sinnlose Verteilung der Figuren nicht besser merken.

Die in der Softwareentwicklung vielfältig eingesetzten Entwurfs- und Architekturmuster nutzen die Stärke des menschlichen Gehirns, mit Schemata zu arbeiten. Haben Entwickler:innen und Architekt:innen bereits mit einem Muster gearbeitet und daraus ein Schema gebildet, so können sie Programmtexte und Strukturen schneller erkennen und verstehen, die nach diesen Mustern gestaltet sind. Der Aufbau von Schemata liefert für das Verständnis von komplexen Strukturen also entscheidende Geschwindigkeitsvorteile. Das ist auch der Grund, warum Muster in der Softwareentwicklung bereits vor Jahren Einzug gefunden haben.

In Abbildung 3 sieht man ein anonymisiertes Tafelbild, das ich mit einem Team entwickelt habe, um seine Muster aufzunehmen. Auf der rechten Seite von Abbildung 3 ist der Source Code im Architekturanalysetool Sotograph in diese Musterkategorien eingeteilt und man sieht sehr viele grüne und einige wenige rote Beziehungen. Die roten Beziehungen gehen von unten nach oben gegen die durch die Muster entstehende Schichtung. Die geringe Anzahl der roten Beziehungen ist ein sehr gutes Ergebnis und zeugt davon, dass das Entwicklungsteam seine Muster sehr konsistent einsetzt.

Abb. 3: Muster auf Klassenebene = Mustersprache

Spannend ist außerdem, welchen Anteil des Source Codes man Mustern zuordnen kann und wie viele Muster das System schlussendlich enthält. Lassen sich 80 Prozent oder mehr des Source Codes Mustern zuordnen, dann spreche ich davon, dass dieses System eine Mustersprache hat. Hier hat das Entwicklungsteam eine eigene Sprache erschaffen, um sich die Diskussion über seine Architektur zu erleichtern.

Die Verwendung von Mustern im Source Code ist für eine modulare Architektur besonders wichtig. Wir erinnern uns: Für das Chunking war es entscheidend, dass wir sinnvoll zusammenhängende Einheiten vorfinden, die eine gemeinsame Aufgabe haben. Wie, wenn nicht durch Muster, lassen sich die Aufgaben von Modulen beschreiben? Modularisierung wird durch den umfassenden Einsatz von Mustern vertieft und verbessert, wenn für die jeweiligen Module erkennbar ist, zu welchem Muster sie gehören, und die Muster konsistent eingesetzt werden.

Hierarchisierung ➔ Modularisierung

Der dritte kognitive Mechanismus, die Hierarchisierung, spielt beim Wahrnehmen und Verstehen von komplexen Strukturen und beim Abspeichern von Wissen ebenfalls eine wichtige Rolle. Menschen können Wissen dann gut aufnehmen, es wiedergeben und sich darin zurechtfinden, wenn es in hierarchischen Strukturen vorliegt. Untersuchungen zum Lernen von zusammengehörenden Wortkategorien, zur Organisation von Lernmaterialien, zum Textverstehen, zur Textanalyse und zur Textwiedergabe haben gezeigt, dass Hierarchien vorteilhaft sind. Bei der Reproduktion von Begriffslisten und Texten war die Gedächtnisleistung der Versuchspersonen deutlich höher, wenn ihnen Entscheidungsbäume mit kategorialer Unterordnung angeboten wurden. Lerninhalte wurden von den Versuchspersonen mit Hilfe von hierarchischen Kapitelstrukturen oder Gedankenkarten deutlich schneller gelernt. Lag keine hierarchische Struktur vor, so bemühten sich die Versuchspersonen, den Text selbstständig hierarchisch anzuordnen. Die kognitive Psychologie zieht aus diesen Untersuchungen die Konsequenz, dass hierarchisch geordnete Inhalte für Menschen leichter zu erlernen und zu verarbeiten sind und dass aus einer hierarchischen Struktur effizienter Inhalte abgerufen werden können.

Die Bildung von Hierarchien wird in Programmiersprachen bei den Enthalten-Sein-Beziehungen unterstützt: Klassen sind in Packages oder Directories, Packages/Directories wiederum in Packages/Directories und schließlich in Projekten bzw. Modulen und Build-Artefakten enthalten. Diese Hierarchien passen zu unseren kognitiven Mechanismen. Sind die Hierarchien an die Muster der Architektur angelehnt, so unterstützen sie uns nicht nur durch ihre hierarchische Strukturierung, sondern sogar auch noch durch Architekturmuster.

Schauen wir uns dazu einmal ein schlechtes und ein gutes Beispiel an: Stellen wir uns vor, ein Team hat für sein System festgelegt, dass es aus vier Modulen bestehen soll, die dann wiederum einige Submodule enthalten sollen (Abb. 4).

Abb. 4: Architektur mit vier Modulen

Diese Struktur gibt für das Entwicklungsteam ein Architekturmuster aus vier Modulen auf der obersten Ebene vor, in denen jeweils weitere Module enthalten sind. Stellen wir uns nun weiter vor, dass dieses System in Java implementiert und aufgrund seiner Größe in einem einzigen Eclipse-Projekt organisiert ist. In diesem Fall würde man erwarten, dass dieses Architekturmuster aus vier Modulen mit Submodulen sich im Package-Baum des Systems wiederfinden sollte.

In Abbildung 5 sieht man den anonymisierten Package-Baum eines Java-Systems, für das das Entwicklungsteam genau diese Aussage gemacht hatte: „Vier Module mit Submodulen, das ist unsere Architektur!“. In der Darstellung in Abbildung 5 sieht man Packages und Pfeile. Die Pfeile gehen jeweils vom übergeordneten Package zu seinen Kindern.

Abb. 5: Das geplante Architekturmuster ist schlecht umgesetzt

Tatsächlich findet man die vier Module im Package-Baum. In Abbildung 5 sind sie in den vier Farben markiert, die die Module in Abbildung 4 haben (grün, orange, lila und blau). Allerdings sind zwei der Module über den Package-Baum verteilt und ihre Submodule sind zum Teil sogar unter fremden Ober-Packages einsortiert. Diese Umsetzung im Package-Baum ist nicht konsistent zu dem von der Architektur vorgegebenen Muster. Sie führt bei Entwicklern und Architekten zu Verwirrung. Das Einführen von jeweils einem Package-Root-Knoten für die orange- und die lilafarbene Komponente würde hier Abhilfe schaffen.

Eine bessere Abbildung des Architekturmusters auf den Package-Baum sieht man in Abbildung 6. Bei diesem System ist das Architekturmuster symmetrisch auf den Package-Baum übertragbar. Hier können die Entwickler sich anhand der hierarchischen Struktur schnell zurechtfinden und vom Architekturmuster profitieren.

Abb. 6: Gut umgesetztes Architekturmuster

Wird die Enthalten-Sein-Beziehung richtig eingesetzt, so unterstützt sie unseren kognitiven Mechanismus Hierarchisierung. Für alle anderen Arten von Beziehungen gilt das nicht: Wir können beliebige Klassen und Interfaces in einer Source-Code-Basis per Benutzungs- und/oder per Vererbungsbeziehung miteinander verknüpfen. Dadurch erschaffen wir verflochtene Strukturen (Zyklen), die in keiner Weise hierarchisch sind. Es bedarf einiges an Disziplin und Anstrengung, Benutzungs- und Vererbungsbeziehung hierarchisch zu verwenden. Verfolgt das Entwicklungsteam von Anfang an dieses Ziel, so sind die Ergebnisse in der Regel nahezu zyklenfrei. Ist der Wert von Zyklenfreiheit nicht von Anfang an klar, entstehen Strukturen wie in Abbildung 7.

Abb. 7: Zyklus aus 242 Klassen

Der Wunsch, Zyklenfreiheit zu erreichen, ist aber kein Selbstzweck! Es geht nicht darum, irgendeine technisch strukturelle Idee von „Zyklen müssen vermieden werden“ zu befriedigen. Vielmehr wird damit das Ziel verfolgt, eine modulare Architektur zu entwerfen.

Achtet man bei seinem Entwurf darauf, dass die einzelnen Bausteine modular, also jeweils genau für eine Aufgabe zuständig sind, dann entstehen in der Regel von selbst zyklenfreie Entwürfe und Architekturen. Ein Modul, das Basisfunktionalität zur Verfügung stellt, sollte nie Funktionalität aus den auf ihm aufbauenden Modulen benötigen. Sind die Aufgaben klar verteilt, dann ist offensichtlich, welches Modul welches andere Modul benutzen muss, um seine Aufgabe zu erfüllen. Eine umgekehrte und damit zyklische Beziehung entsteht erst gar nicht.

 

Stay tuned

Regelmäßig News zur Konferenz und der Java-Community erhalten

Zusammenfassung: Regeln für Modularisierung

Mit den drei kognitiven Mechanismen Chunking, Schemata und Hierarchisierung haben wir das Hintergrundwissen bekommen, um Modularisierung in unseren Diskussionen klar und eindeutig zu verwenden. Eine gut modularisierte Architektur besteht aus Modulen, die den Einsatz von Chunking, Hierarchisierung und Schemata erleichtern. Zusammenfassend können wir die folgenden Regeln festlegen: Die Module einer modularen Architektur müssen

  1. In ihrem Inneren ein zusammenhängendes, kohärentes Ganzes bilden, das für genau eine klar definierte Aufgabe zuständig ist (Einheit als Chunk),

  2. Nach außen eine explizite, minimale und delegierende Kapsel bilden (Schnittstelle als Chunk),

  3. Nach einheitlichen Mustern durchgängig gestaltet sein (Musterkonsistenz) und

  4. Mit anderen Modulen minimal, lose und zyklenfrei gekoppelt sein (Kopplung zur Chunk-Trennung und Hierarchisierung).

Sind dem Entwicklungsteam diese Mechanismen und ihre Umsetzung in der Architektur klar, ist eine wichtige Grundlage für Modularisierung gelegt.

 

Links & Literatur

[1] Dieser Artikel ist ein überarbeiteter Auszug aus meinem Buch: Lilienthal, Carola: „Langlebige Softwarearchitekturen. Technische Schulden analysieren, begrenzen und abbauen“; dpunkt.verlag, 2019

[2] Parnas, David Lorge: „On the Criteria to be Used in Decomposing Systems in-to Modules“; in: Communications of the ACM (15/12), 1972

[3] Dijkstra, Edsger Wybe: „A Discipline of Programming“; Prentice Hall, 1976

[4] Myers, Glenford J.: „Composite/Structured Design“; Van Nostrand Reinhold, 1978

[5] Coad, Peter; Yourdon, Edward: „OOD: Objektorientiertes Design“; Prentice Hall, 1994

[6] Wirfs-Brock, Rebecca; McKean, Alan: „Object De-sign: Roles, Responsibilities, and Collaborations“; Pearson Education, 2002

[7] Martin, Robert Cecil: „Agile Software Development, Principles, Patterns, and Practices“; Prentice Hall International, 2013

[8] Bass, Len; Clements, Paul; Kazman, Rick: „Software Architecture in Practice“; Addison-Wesley, 2012

[9] Booch, Grady: „Object-Oriented Analysis and Design with Applications“; Addison Wesley Longman Publishing Co., 2004

[10] Gamma, Erich; Helm, Richard; Johnson, Ralph E.; Vlissides, John: „Design Patterns. Elements of Reusable Object-Oriented Software“; Addison-Wesley, 1994

[11] Züllighoven, Heinz: „Object-Oriented Construction Handbook“; Morgan Kaufmann Publishers, 2005

The post Modularisierung und kognitive Psychologie appeared first on JAX 2022.

]]>
Pattern Matching in Java 17 https://jax.de/blog/pattern-matching-in-java-17/ Mon, 11 Oct 2021 11:48:24 +0000 https://jax.de/?p=84397 Pattern Matching kennt man in erster Linie aus funktionalen Programmiersprachen. Damit lassen sich Daten sehr einfach und effizient auf bestimmte Inhalte prüfen und die relevanten Informationen aus den Datenstrukturen für die weitere Verarbeitung extrahieren. Seit drei Jahren wird nun Pattern Matching Stück für Stück in Java eingeführt. Und auch wenn noch nicht alle Funktionen fertig implementiert sind, lohnt sich schon jetzt ein Blick auf die vorhandenen Möglichkeiten und natürlich der Ausblick auf die zukünftigen Optionen.

The post Pattern Matching in Java 17 appeared first on JAX 2022.

]]>
Es gibt diverse Sprachen auf der Java-Plattform, die bereits Pattern Matching oder ähnliche Ansätze mitbringen. Das sind mit Scala bzw. Clojure natürlich Vertreter der funktionalen Programmierung und mit Groovy eine Sprache, die schon vor 15 Jahren funktionale Ansätze auf die Java-Plattform gebracht hat. Java hat ab der Version 8 mit der Einführung von Lambdas, Streams, Higher Order Functions usw. ebenfalls diesen Weg eingeschlagen. Darum verwundert es nicht, dass nun auch Pattern Matching Einzug hält und die Sprache damit wieder ein Stück attraktiver und relevanter für Ansätze der funktionalen Programmierung wird.

In der Dokumentation von Scala findet sich die folgende, treffende Aussage: „Pattern matching is a mechanism for checking a value against a pattern. A successful match can also deconstruct a value into its constituent parts. It is a more powerful version of the switch statement in Java and it can likewise be used in place of a series of if/else statements.“ [1]

Wie in der Definition angedeutet, kann man in Java mit einem Switch Statement oder verknüpften if/else-Bedingungen das gleiche Ergebnis erzielen. Leider gibt es beim klassischen Switch in Java viele Stolperfallen, und lange if/else-Ketten sind sowieso nicht sonderlich schön anzusehen. Es leidet die Lesbarkeit und durch die vielen Redundanzen erhöht sich außerdem die Fehleranfälligkeit. Java-Code gilt sowieso immer als sehr aufgebläht (Boilerplate-Code) und viele Programmierer haben sich unter anderem deshalb bereits alternativen Sprachen zugewandt. Nichtsdestotrotz ist Java weiterhin eine der meistgenutzten Programmiersprachen und wird auch nach über 25 Jahren noch stetig weiterentwickelt. Der heute in Java geschriebene Quellcode profitiert davon und wird verständlicher sowie wartbarer.

Geschichte des Pattern Matching in Java

Die Pläne zur Einführung von Pattern Matching reichen bereits einige Jahre zurück [2]. Im Gegensatz zu den Big-Bang-Releases der früheren Jahre (bis Java 9), werden solche größeren Sprachänderungen durch das neue halbjährliche Releasemodell nun in kleinen Schritten verteilt auf mehrere Releases ausgeliefert. Los ging es im Frühjahr 2019 (OpenJDK 12) mit der Einführung der Switch Expressions und damit der Runderneuerung des klassischen Switch Statements. Es folgten neue Sprachkonstrukte (Records, Sealed Classes) sowie weitere Optimierungen bestehender Features (Pattern Matching for instanceof). Mit dem OpenJDK 17 ist dann im September 2021 die erste Preview für „Pattern Matching for switch“ (JEP 406) [3] erschienen.

Verschaffen Sie sich den Zugang zur Java-Welt mit unserem kostenlosen Newsletter!

Project Amber

Alle Teile des Pattern Matchings für Java wurden bzw. werden im Inkubatorprojekt Amber [4] entwickelt. Das Ziel von Amber ist die Einführung kleiner Sprachfeatures, die die Produktivität steigern. Es besteht aus einer Vielzahl von JDK Enhancement Proposals (JEP), von denen bereits 18 bis zum JDK 17 ausgeliefert wurden. Die meisten JEPs von Amber stehen direkt oder indirekt mit der Einführung von Pattern Matching in Zusammenhang. Es gibt aber auch davon unabhängige Features wie die Local-Variable Type Inference (var), die Text Blocks (beide bereits ausgeliefert) oder die aktuell gestoppten JEPs zu Enhanced Enums und Lambda Leftovers. Für das Pattern Matching wurden folgende JEPs in den vergangenen Releases umgesetzt:

  • JEP 325: Switch Expressions (Preview) – Java 12

  • JEP 354: Switch Expressions (Second Preview) – Java 13

  • JEP 361: Switch Expressions – Java 14

  • JEP 359: Records (Preview) – Java 14

  • JEP 384: Records (Second Preview) – Java 15

  • JEP 395: Records – Java 16

  • JEP 305: Pattern Matching for instanceof (Preview) – Java 14

  • JEP 375: Pattern Matching for instanceof

  • JEP 394: Pattern Matching for instanceof – Java 16

  • JEP 360: Sealed Classes (Preview) – Java 15

  • JEP 397: Sealed Classes (Second Preview) – Java 16

  • JEP 409: Sealed Classes – Java 17

  • JEP 406: Pattern Matching for switch (Preview) – Java 17

Auffällig ist, dass alle neuen Features mehrere Previewphasen durchlaufen haben. So konnten interessierte Java-Entwickler die neuen Funktionen frühzeitig ausprobieren und schnell Feedback geben – ein großer Vorteil der neuen halbjährlichen Releasefrequenz. Beim noch sehr frischen JEP 406 „Pattern Matching for switch“ startete dieser Prozess im aktuellen LTS-Release JDK 17 und der JEP 405 „Record Patterns and Array Patterns (Preview)“ wird voraussichtlich im OpenJDK 18 dazukommen [5]. Das bedeutet aber auch, dass wir frühestens im September 2024 mit Java 23 das komplette Pattern Matching in einem LTS-Release nutzen können.

Über den Tellerrand geschaut

Bevor wir tiefer in das Pattern Matching für Java einsteigen, wagen wir zunächst einen Blick auf die Umsetzung in Haskell. Haskell ist eine statisch typisierte, rein funktionale Sprache und existiert seit Anfang der 90er Jahre des 20. Jahrhunderts. Sie wurde im akademischen Umfeld entwickelt und diente aufgrund der fortschrittlichen Konzepte und Ansätze beim Entwurf oder der Erweiterung vieler Programmiersprachen als Vorlage. Haskell enthält keine imperativen Sprachkonstrukte und damit auch keine Switch-Anweisung. Patterns werden vielmehr bei der Deklaration von Funktionen angegeben und beim Aufruf wird dann der passende Zweig ausgewählt. Im Gegensatz zum klassischem Switch Statement in Java, wo man nur wenige Datentypen und diese auch nur auf Gleichheit prüfen kann, bietet Haskell sehr viele unterschiedliche und mächtigere Optionen. Einige schöne Beispiele kann man dem kostenfreien Onlinetutorial „Learn You a Haskell for Great Good!“ [6] (auch als Buch verfügbar) entnehmen. Listing 1 zeigt, wie die rekursive Berechnung der Fakultät aussehen könnte. In der ersten Zeile wird die Funktion factorial deklariert, wobei sie mit einem Parameter vom Typ Integral (Zahl) aufgerufen und ein Ergebnis des gleichen Typs zurückgeben wird. Bei der Implementierung gibt es zwei Pfade (analog zu den case-Blöcken bei switch). Erfolgt der Aufruf der Funktion mit 0 als Parameter, wird direkt 1 zurückgeliefert. Bei jedem beliebigen anderen Wert wird dieser als Parameter n bereitgestellt und damit die weitere Berechnung rekursiv aufgerufen.

factorial :: (Integral a) => a -> a
factorial 0 = 1
factorial n = n * factorial (n - 1)

In der funktionalen Programmierung kommt der Verarbeitung von Listen eine besondere Bedeutung zu. In Listing 2 wird in Haskell die Funktion head’ definiert, die als Parameter eine Liste mit Werten eines beliebigen Typs (a) entgegennimmt und dann das erste Element zurückliefert. Der Sonderfall des Aufrufs mit einer leeren Liste (Zeile 2) wird mit einer Fehlermeldung quittiert. In allen anderen Fällen wird das Parameterobjekt (die Liste) dekonstruiert. In diesem konkreten Beispiel wird das erste Element als x zurückgegeben. Der Rest der Liste (könnte auch leer sein) interessiert an dieser Stelle nicht und wird darum mit dem Unterstrich ignoriert.

head' :: [a] -> a
head' [] = error "Can't call head on an empty list, dummy!"
head' (x:_) = x
 
ghci> head' [4,5,6]
4
ghci> head' "Hello"
'H'

Switch Expression

Werfen wir zunächst einen Blick auf die bereits im JDK 12 eingeführten Switch Expressions. Sie sind eine der Grundlagen für das Pattern Matching in Java. Das klassische Switch Statement kann dafür nicht verwendet werden. Es bleibt natürlich aus Abwärtskompatibilitätsgründen weiterhin verfügbar. Switch Statements haben aber einige Schwächen:

  • Sie sind nur auf bestimmte Datentypen anwendbar (einige Primitive bzw. deren Wrapper sowie Enums und Strings).

  • Die Werte können nur auf Gleichheit und nicht mit beliebigen booleschen Ausdrücken (kleiner oder größer als …) geprüft werden.

  • Der Fall-through-Mechanismus kann zu schwer erkennbaren Bugs führen, notwendige break-Anweisungen stören zudem die Lesbarkeit.

  • Der Compiler kann keine Hilfestellung dabei geben, ob alle möglichen Fälle als case-Zweig abgedeckt sind.

  • Werden neue Ausprägungen hinzugefügt, müssen alle betroffenen Switch Statements manuell ermittelt und erweitert werden.

  • Durch unnötigen Boilerplate-Code sind Switches aufwendig zu schreiben und schwer zu lesen.

  • Die case-Zweige besitzen keinen eigenen Gültigkeitsbereich, der gesamte Switch wird als ein Codeblock behandelt. Eine Variable kann daher nicht lokal sein, sondern ist in allen case-Zweigen sichtbar.

  • Es sind keine Nullwerte erlaubt, mögliche Nullreferenzen müssen vor dem Aufruf des Switch Statements manuell geprüft werden.

Mit den Switch Expressions wurden bereits einige dieser Nachteile behoben. Es gibt dabei verschiedene Varianten. Listing 3 zeigt eine mögliche Form, die einerseits mehrere Labels je case-Zweig erlaubt (als Ersatz für den Fall-through) und durch die Pfeil-Notation kompakter geschrieben werden kann. Das Ergebnis eines case-Zweigs wird dann automatisch zurückgegeben und somit kann das Resultat der Switch Expression einer Variablen zugewiesen oder als Rückgabewert verwendet werden. Muss man in einem Zweig mehrere Anweisungen unterbringen, können diese mit geschweiften Klammern in einen Codeblock eingeschlossen werden. Dann liefert die yield-Anweisung das Ergebnis analog zu einem return zurück. yield ist im Übrigen kein reserviertes Schlüsselwort, es hat nur im Kontext der Switch Expression eine besondere Bedeutung. Außerhalb kann man es auch für normale Variablennamen einsetzen.

String developerRating( int numberOfChildren ) {
  return switch (numberOfChildren) {
    case 0 -> "open source contributor";
    case 1, 2 -> "junior";
    case 3 -> "senior";
    default -> {
      if (numberOfChildren < 0) 
        throw new IndexOutOfBoundsException( numberOfChildren );
      yield "manager";
    }
  };
}

Pattern Matching for switch

Mit dem JEP 406 wird der Switch in Java nochmal auf ein höheres Level gehoben. Im Gegensatz zur Prüfung auf Gleichheit mit der begrenzten Anzahl von erlaubten Datentypen, kann man in den case-Blöcken nun auch Type Patterns und in Zukunft (JEP 405) noch ganz andere Muster verwenden. Die Type Patterns sind auch außerhalb eines Switch einsetzbar und wurden im JDK 16 als „Pattern Matching for instanceof“ offizieller Teil des Java-Sprachstandards. Listing 4 zeigt ein Beispiel, in dem sehr kompakt auf leere Strings oder Collections geprüft werden kann. Im Gegensatz zum klassischen instanceof wird hier der Parameter o im Erfolgsfall direkt auf den Typ gecastet und automatisch einer Variablen (s oder c) zugewiesen. Das erspart unnötige Redundanzen und macht den Quellcode zudem kompakter und lesbarer. Außerdem ist er weniger fehleranfällig, da man das Objekt beim Cast nicht aus Versehen in einen ganz anderen Typ umwandeln kann. Ein Fehler, der nicht vom Compiler abgefangen wird und somit erst zu Laufzeit auffallen würde.

boolean isNullOrEmpty( Object o ) {
  return o == null ||
    o instanceof String s && s.isBlank() ||
    o instanceof Collection c && c.isEmpty();
}

Das Type Pattern kann nun auch in den case-Zweigen einer Switch Expression verwendet werden. Es wird automatisch ein instanceof-Check ausgeführt und im Erfolgsfall in den zu prüfenden Typ umgewandelt sowie einer Variablen zugewiesen. Diese Variable (s oder o) kann dann im case-Zweig direkt verwendet werden (Listing 5).

String evaluateTypeWithSwitch( Object o ) {
  return switch(o) {
    case String s -> "String: " + s;
    case Collection c -> "Collection: " + c;
    default -> "Something else: " + o;
  };
}

Neben den Type Patterns wird es in Kürze noch mindestens zwei andere Arten geben. Bei Records und Array Patterns können dazu die Datenstrukturen dekonstruiert und damit in ihre einzelnen Bestandteile zerlegt werden. Denkbar wäre auch das Matchen von regulären Ausdrücken. Außerdem wird man Patterns kombinieren können, um auch komplexe, verschachtelte Datenstrukturen prüfen zu können.

Guarded Patterns und Prüfung auf null

Wenn man auf einen bestimmten Typ prüft, kann man über einen Guard das Suchergebnis noch weiter verfeinern. Die Guarded Patterns bestehen aus dem Primary Pattern, zu dem auch das Type Pattern gehört, gefolgt von der logischen Und-Verknüpfung (&&) und einem booleschen Ausdruck (ConditionalAndExpression). Damit spart man sich zusätzliche if/else-Bedingungen innerhalb des case-Zweigs und erhöht so wieder die Lesbarkeit. Listing 6 zeigt ein Beispiel dazu. Wenn die Variable o einen String referenziert und dieser leer ist, dann wird true zurückgeliefert. Bei jedem anderen String (der nicht leer ist), kommt entsprechend false zurück. Wobei case String s an dieser Stelle sogar unnötig wäre, da dieser Fall vom default-Zweig mit abgedeckt wird. Es soll aber zeigen, dass die Reihenfolge der case-Zweige relevant ist, da allgemeine Patterns dominant sind und darum unterhalb der speziellen stehen müssen. Sonst könnten sie nie aufgerufen werden. Hier hilft allerdings auch der Compiler mit einer Fehlermeldung.

Das klassische Switch Statement und auch die Switch Expression können nicht mit Nullwerten umgehen und werfen im Fall der Fälle einfach eine NullPointerException. Als Aufrufer ist man selbst dafür verantwortlich, vorher einen Nullcheck durchzuführen. Beim „Pattern Matching for switch“ darf es aber einen case-Zweig mit einem null-Label geben, der das default-Verhalten (NullPointerException) quasi neu definieren kann. In Listing 6 wird entsprechend beim Aufruf mit einer Nullreferenz direkt true zurückgeliefert.

boolean isNullOrEmptyWithSwitch( Object o ) {
  return switch(o) {
    case null -> true;
    case String s && s.isBlank() -> true;
    case String s -> false;
    case Collection c && c.isEmpty() -> true;
    default -> false;
  };
}

Vollständigkeit vom Compiler prüfen lassen

Bei einem Switch Statement interessiert es den Compiler nicht, ob durch die vorhandenen Zweige alle Fälle abgedeckt sind. Wenn man aus Versehen einen Block vergisst, wird der Switch ggf. lautlos ignoriert, weil kein passender Zweig gefunden werden konnte. Über den default-Block gibt es immerhin die Möglichkeit, auf solche Situationen zu reagieren. Allerdings auch nur zur Laufzeit, indem beispielsweise eine Exception geworfen wird.

Bei der Switch Expression sieht das schon anders aus. Da sie einen Wert zurückliefert, stellt der Compiler sicher, dass immer mindestens ein Zweig für beliebige Eingaben aufgerufen und somit ein valider Wert zurückgegeben werden kann. Im Fall von Constant Expression (Integer, String, …) brauchen wir damit immer den default-Zweig als Backup. Wenn wir Aufzählungstypen (enum) verwenden, kann der default-Zweig aber immerhin entfallen, wenn auf alle Enum-Werte reagiert wird. Der Compiler kann durch die konstante Anzahl an möglichen Werten die Vollständigkeit sicherstellen und uns somit ein sehr frühes Feedback geben. Und je früher ein Fehler gefunden wird, desto günstiger ist es, ihn zu beheben.

Um unsere Real-World-Probleme in unserem objektorientierten Datenmodell abzubilden, reichen Enums aber nicht immer aus. Um abstrahieren zu können, modellieren wir ebenso Vererbungshierarchien aus Klassen und Interfaces. Dank der im OpenJDK 17 finalisierten Sealed Classes [7] kann man die Ableitungen eines Interface oder eine Superklasse konfigurativ einschränken und erhält somit wieder eine feste Menge an existierenden Implementierungen zu einem bestimmten Typ. Der Compiler kann dadurch beim „Pattern Matching for switch“ genauso auf Vollständigkeit prüfen wie bei den Aufzählungstypen.

Sealed Classes und Interfaces legen dazu im Quellcode fest, welche abgeleiteten Klassen in der Vererbungshierarchie erlaubt sind (Listing 7). Die Subklassen müssen entweder auch wieder sealed, non-sealed oder final sein. In diesem Fall wurden sie als Records implementiert und sind dadurch automatisch final. Weitere Informationen zu Sealed Classes kann man in der Dokumentation des JEP 409 [7] nachlesen.

public sealed interface Tarif permits Privat, Business, Profi {}
 
public record Profi(double preisProMinute) implements Tarif {}
public record Business(double preisProMinute) implements Tarif {}
public record Privat(double preisProMinute) implements Tarif {
  public int getNettoMinuten(int minuten) { return Math.max(minute - 1, 0); }
}

Die Preisberechnung der Telefontarife kann dann in einem Switch erfolgen. Wenn alle drei erlaubten Subklassen verwendet werden, kann der Compiler die Vollständigkeit prüfen und der default-Zweig darf entfallen (Listing 8). Sollte eine neue Tarifart hinzugefügt oder eine bestehende geöffnet werden (non-sealed oder sealed), muss man den Switch wieder entsprechend anpassen, um den Compiler zu befriedigen.

double preis = switch(tarif) {
  case Privat p -> p.getNettoMinuten(minuten) * p.preisProMinute();
  case Business b -> minuten * b.preisProMinute();
  case Profi p -> minuten * p.preisProMinute();
};

Sealed Classes können einfach mit Records integriert werden (Listing 7). Mit dieser Kombination lassen sich in Java algebraische Datentypen nachbilden. Die kommen in zwei Ausprägungen: Produkt- und Summentypen. Unveränderbare POJOs sind Produkttypen. Sie enthalten mehrere Werte, eine Person hat z. B. Vor- und Nachname. Mit Records kann man diese Produkttypen sehr einfach implementieren und spart sich eine Menge Boilerplate-Code.

Mit Java Enums und Sealed Classes lassen sich wiederum Summentypen abbilden. Sie definieren eine feste Anzahl an Aufzählungswerten oder möglichen Subklassen. Das gibt dem Compiler dann die angesprochene Möglichkeit, auf Exhaustiveness (Vollständigkeit) zu prüfen.

Ausblick

Demnächst wird es eine erste Preview zu „Record Patterns & Array Patterns“ geben. Das führt schlussendlich zu folgender Grammatik für Patterns in Java (Listing 9).

Pattern:
PrimaryPattern
GuardedPattern
 
GuardedPattern:
PrimaryPattern && ConditionalAndExpression
 
PrimaryPattern:
TypePattern
RecordPattern
ArrayPattern
( Pattern )

Records sind selbst ein recht neues Sprachfeature. Sie dienen als transparente Datenträger, die ohne viel Aufwand implementiert werden können. Sie kompilieren zu normalen Klassen, sind aber final und es werden einige notwendige Methoden (Konstruktoren, equals/hashCode, …) und die finalen Felder generiert. Bei der Verwendung von Records im Switch wird aber nicht nur der Datentyp geprüft und der Cast durchgeführt. Vielmehr werden die benannten Datenfelder extrahiert, sodass sie direkt im Quellcode weiterverarbeitet werden können (Listing 10).

record Point(int x, int y) {}
 
void printSum(Object o) {
  if (o instanceof Point(int x, int y)) {
    System.out.println(x+y);
  }
}

Arrays sind vergleichbar mit Records. Sie beinhalten aber beliebig viele Elemente nur eines Typs im Gegensatz zu den unterschiedlichen Werten eines Tuples (Record). Und so wird man in Kürze auch Arrays dekonstruieren können, um einzelne Elemente für die weitere Verarbeitung zu extrahieren. Listing 11 zeigt diverse Varianten für Array Patterns. Der Typ des Arrays ist hier immer String[]. In Zeile 1 gibt es nur dann einen Treffer, wenn die Liste genau zwei Werte enthält. Bei Verwendung der Ellipse (“…”) in Zeile 3 werden auch Arrays mit mehr als zwei Elementen gefunden. Da aber nur die ersten beiden Elemente relevant sind, werden alle weiteren einfach ignoriert. Mehrdimensionale Arrays (verschachtelt) werden auch unterstützt und zeigen gleichzeitig eine Kombination von verschiedenen Mustern.

String[] { String s1, String s2 } // genau zwei Elemente
String[] { String s1, String s2, ... } // mind. zwei Elemente
String[][] { { String s1, String s2, ...}, { String s3, String s4, ...}, ...}

Fazit

Switch Expressions sind nicht nur eine platzsparende Alternative zu den klassischen Switch Statements. Durch JEP 406 „Pattern Matching for switch“ können sie nun neben den altbekannten Konstanten auch Type Patterns (instanceof) verarbeiten (aktuell lassen sich die beiden Arten aber noch nicht mischen). Zusätzlich erlauben Guarded Patterns auf kompakte Weise die weitere Einschränkung der Suchkriterien. Dank Sealed Classes kann der Compiler zudem sicherstellen, dass alle möglichen Fälle abgedeckt sind. Wird der versiegelten Klassenhierarchie ein neuer Datentyp hinzugefügt, erhält man durch die Compilerfehler sofort eine Rückmeldung und vergisst somit nirgendwo die Auswertung der neuen Option.

Steter Tropfen höhlt den Stein und so entwickelt sich Java wie ein Uhrwerk von Release zu Release ein Stück weiter. Wir dürfen gespannt sein, wie sich das Pattern Matching in den nächsten Monaten integrieren und die Arbeit mit Java noch angenehmer machen wird.

 

Links & Literatur

[1] https://docs.scala-lang.org/tour/pattern-matching.html

[2] https://cr.openjdk.java.net/~briangoetz/amber/pattern-match.html

[3] https://openjdk.java.net/jeps/406

[4] https://openjdk.java.net/projects/amber/

[5] https://openjdk.java.net/jeps/405

[6] http://learnyouahaskell.com/syntax-in-functions#pattern-matching

[7] https://openjdk.java.net/jeps/409

The post Pattern Matching in Java 17 appeared first on JAX 2022.

]]>
Java 17: Garbage Collection in JDK 17 https://jax.de/blog/java-17-garbage-collection-in-jdk-17/ Mon, 27 Sep 2021 10:06:26 +0000 https://jax.de/?p=84127 Zwischen JDK 11 and JDK 17 hat sich viel getan in Javas GC-Landschaft. Man könnte sogar so weit gehen, zu sagen, dass nach einer Periode gewisser Stagnation seit dem JDK 11 wieder neues Leben und Innovation in die Memory-Management-Szene gekommen sind, die zu beträchtlichen Innovationen geführt haben. Dieser Artikel soll die wichtigsten Änderungen im Bereich Java Garbage Collection zwischen JDK 11 und JDK 17 näher beleuchten.

The post Java 17: Garbage Collection in JDK 17 appeared first on JAX 2022.

]]>
Die für manche Entwickler vermutlich einschneidendste Veränderung dürfte die Entfernung des Concurrent Mark Sweep (CMS) Collector sein. CMS war lange Zeit der einzige GC, der versprach, „concurrent“ zu sein, d. h., neben der Anwendung zu laufen und die berüchtigten GC-Pausen zu minimieren. Er war allerdings auch nicht frei von diversen Problemen. Am hervorstechendsten im negativen Sinne war seine Eigenschaft, den Heap mit der Zeit zu fragmentieren, was irgendwann dazu führt, dass eine Allokation keinen passenden Speicherblock mehr findet, und einen Full GC auslöst – der dann wieder eine längere Stop-the-World-Pause bedeutet. Um das möglichst zu vermeiden, kann man versuchen, verschiedene Parameter zu konfigurieren. Und hier beginnt das nächste Problem: seine relative Komplexität an Einstellungsmöglichkeiten. Um den CMS Collector existiert eine Vielzahl an Flags (mehr als 70). Die Auswirkungen dieser Flags auch nur ansatzweise zu verstehen, inklusive der Interferenzen untereinander, erfordert ein mittleres Curriculum in GC Tuning. Zu den genannten äußeren Problemen kommt, dass die Codebasis von CMS unwahrscheinlich komplex und damit anfällig für Fehler ist, von denen einige sehr aufwendig zu finden und zu beheben waren und viele schlichtweg brach liegen.

All das veranlasste die OpenJDK-Community mit dem JEP 291 den CMS in JDK 9 als veraltet (deprecated) zu markieren. Als sich auch nach mehreren Releases niemand gefunden hat, der den CMS weiter pflegen wollte, wurde er schließlich mit JEP 363 in JDK 14 ganz entfernt. Damit stellt sich für bisherige Nutzer des CMS die Frage, welche Alternativen es gibt. Es bieten sich hierfür (mit Einschränkungen) G1, ZGC (seit JDK 11) und Shenandoah GC (seit JDK 12, inzwischen in JDK 11) an.

Verschaffen Sie sich den Zugang zur Java-Welt mit unserem kostenlosen Newsletter!

G1

Der G1 GC – G1 steht für „Garbage First“ – ist ein Garbage Collector, der teilweise concurrent arbeitet, und dessen Ziel es ist, die GC-Pausen managebar zu machen. Das soll heißen, der Nutzer kann per Flag ein Pause-Target vorgeben und der G1 GC versucht anhand eigener Messungen und Heuristiken dieses Ziel einzuhalten. Wichtig ist dabei zu verstehen, dass diese Pause-Targets vernünftig sein müssen: 200 ms (der Defaultwert) ist noch in der Komfortzone, bei 50 ms wird es schwierig, 10 ms sind nahezu unmöglich. Man muss außerdem verstehen, dass die Varianz durchaus groß sein kann: Falls der Heap in einen ungünstigen Zustand kommt, können Old Generation GCs oder sogar Full GCs getriggert werden, und die Pausen können dann durchaus auch mehrere 100 ms betragen.

Der G1 GC bietet also einen brauchbaren Kompromiss zwischen Performance und Latenzzeiten. Was sind nun die Neuentwicklungen in JDK 17?

  • JEP 344 – Abortable Mixed Collections for G1: Diese Änderung reduziert Fälle, in denen das Pause-Target verfehlt wird, indem es sogenannte Mixed Collections – Young und Old Collection während einer GC Pause – aufteilbar und abbrechbar macht. Damit kann G1 eine Mixed Collection abbrechen, wenn gemessen wird, dass das Pause-Target verfehlt wird, und bald einen zweiten GC Cycle beginnen.

  • JEP 346 – Promptly Return Unused Committed Memory from G1: Vor dieser Änderung hat G1 nur bei Full GCs Speicher an das Betriebssystem zurückgegeben. Da allerdings versucht wird, Full GCs zu vermeiden, kann es sein, dass das sehr selten oder gar nicht passiert. Diese Änderung verbessert das Verhalten, indem G1 auch Speicher zurückgibt, wenn der GC sonst nichts zu tun hat.

  • JEP 345 – NUMA-Aware Memory Allocation for G1: NUMA steht für Non-Uniform Memory Access, und bedeutet, dass Arbeitsspeicherregionen „näher“ oder „entfernter” von CPU Sockets sind. Diese Architektur steht im Gegensatz zu UMA (Uniform Memory Access), bei der auf sämtlichen Arbeitsspeicher gleichberechtigt von CPU Sockets zugegriffen wird. NUMA findet sich vor allem auf größeren Serverkonfigurationen. Diese Änderung verbessert die Zuordnung von GC-Regionen zu NUMA Sockets und führt zu verbesserter Performance auf Systemen, die mit NUMA konfiguriert sind.

Dazu kommen zahlreiche kleinere Verbesserungen am G1 Collector, die seine Performance verbessern und für mehr Stabilität sorgen. Der G1 GC ist der Default-Collector.

Shenandoah GC

Shenandoah schickt sich an, die Lücke zu füllen, die der CMS hinterlassen hat: Er ist ein Garbage Collector, der vollständig concurrent arbeitet, also nebenläufig zur laufenden Anwendung. Das erklärte Ziel des Projekts ist, einen Garbage Collector bereitzustellen, der das Problem der GC-Pausen löst und damit die Latenz der Java VM deutlich verringert. Dabei sollten nicht die Probleme des CMS wiederholt werden: Er kompaktiert den Heap (bzw. einzelne Regionen darin) und vermeidet damit die mittel- und langfristige Fragmentierung, die beim CMS zu Problemen führte. Im Vergleich zum G1 wird auch die Evakuierungsphase nebenläufig zur Anwendung ausgeführt, was bedeutet, dass selbst bei sehr großen Heaps die Pausen kurz bleiben können.

Als OpenJDK-Projekt hat Red Hat 2013 mit der Entwicklung am Shenandoah GC begonnen, in JDK 12 ist eine erste stabile Version ins JDK aufgenommen worden (inzwischen wurde Shenandoah auch nach JDK 11 rückportiert), und es wurden in jedem Release deutliche Verbesserungen vorgenommen:

  • JDK 12 / JEP 189: Einführung des Shenandoah GC als Experimental Feature

  • JDK 13: Ein neues Barrier-Konzept (Load Reference Barriers) wurde eingeführt, das zu besserer Performance führte

  • JDK 14: Concurrent Class Unloading

  • JDK 15: Mit JEP 379 wurde Shenandoah als non-experimental (also Production-ready) eingestuft

  • JDK 16: Concurrent Weak Reference Processing

  • JDK 17: Concurrent Thread-Stack Processing

All diese Verbesserungen bewirken, dass die GC-Pausen deutlich unter 10 ms liegen, meistens sogar unter 1 ms.

Shenandoah GC wird auf allen von OpenJDK unterstützten Betriebssystemen (Linux, Windows, Mac OS X, Solaris) sowie den wichtigsten Architekturen (x86_64, x86_32, ARM64) unterstützt. Er ist Teil der OpenJDK Builds aller Anbieter, mit Ausnahme von Oracle. Shenandoah kann mit dem Kommandozeilen-Flag -XX:+UseShenandoahGC aktiviert werden.

ZGC

Der ZGC wurde der Öffentlichkeit 2017 von Oracle als OpenJDK-Projekt vorgestellt. Wie Shenandoah GC verfolgt er das Ziel, die GC-Pausen zu minimieren, indem alle GC-Phasen nebenläufig zur Java-Anwendung ausgeführt werden. Mit JEP 333 wurde ZGC in JDK 11 aufgenommen. Diese Version war voll funktionsfähig und stabil. Ähnlich wie Shenandoah GC wurden in folgenden JDK-Versionen wesentliche Verbesserungen implementiert:

  • JDK 13 / JEP 351: ZGC: Uncommit Unused Memory (Experimental)

  • JDK 14 / JEP 364: ZGC on macOS (Experimental)

  • JDK 14 / JEP 365: ZGC on Windows (Experimental)

  • JDK 15 / JEP 377: ZGC: A Scalable Low-Latency Garbage Collector (Production)

  • JDK 16 / JEP 376: ZGC: Concurrent Thread-Stack Processing

Angesichts der Tatsache, dass ZGC und Shenandoah die gleichen Ziele haben, nämlich die GC-Pausen zu minimieren, lohnt es sich, einen kurzen Blick auf die Unterschiede zu werfen. ZGC verfolgt im Vergleich zu Shenandoah einen anderen Ansatz bei der Implementierung: ZGC verwendet sogenannte „colored pointers“, um den GC-Zustand von Objekten zu markieren, sowie eine Heap-externe Tabelle, um Forwarding-Information zu speichern, während Shenandoah Forwarding-Information im Objektheader speichert (sog. Brooks Pointers) und den GC-Zustand extern in Marking-Bitmap- und anderen Strukturen verwaltet. Für den Nutzer wirkt sich dieser Unterschied hauptsächlich in der Unterstützung von Compressed References aus (-XX:+UseCompressedOops). Compressed References erlauben es der Java VM, Referenzen von einem Objekt zu einem anderen in 32 Bit darzustellen, anstatt der üblichen 64 Bit, solange der Java Heap kleiner als 32 GB ist (oder mehr, wenn man größeres Object Alignment akzeptiert – aber das führt hier zu weit). Das bedeutet, dass Objekte mit vielen Referenzen oder große Objektarrays deutlich weniger Arbeitsspeicher in Anspruch nehmen. Erreicht wird das durch smarte Komprimierung von Referenzen auf 32 Bit. Da ZGC allerdings Extrabits in Referenzen benötigt, können diese nicht mehr auf 32 Bit komprimiert werden, d. h., ZGC kann Compressed References nicht unterstützen. Allgemeiner gesprochen bedeutet das, dass ZGC bei Heap-Größen unter 32 GB etwas im Nachteil ist, was Performanz und Speicherverbrauch betrifft.

Der andere relevante Unterschied zwischen ZGC und Shenandoah liegt in der Unterstützung seitens der JVM-Anbieter: Oracle hat sich bisher geweigert, Shenandoah in seinen Builds einzubauen, alle anderen Anbieter (Red Hat, Amazon Corretto, SAP SAPMachine, Microsoft, Azul Zulu etc.) bieten Shenandoah an. ZGC wird von allen JVM-Anbietern in ihren Builds bereitgestellt. ZGC wird mit dem Kommandozeilen-Flag -XX:+UseZGC aktiviert.

Serial GC

Serial GC ist das Urgestein unter den Garbage Collectors in OpenJDK. Er ist im Wesentlichen ein klassischer single-threaded, generational, mark-compact GC, der vollständig die Anwendung blockiert, während der Speicher aufgeräumt wird. Die Tatsache, dass er nur mit einem Thread arbeitet, macht ihn für viele größere Workloads ungeeignet. Durch die Entwicklung hin zu Microservices und Containern hat er allerdings in letzter Zeit ein kleines Revival erfahren. In solchen Anwendungen, bei denen man eher kleinere Workloads hat, die wenig Speicher benötigen und bei denen Antwortzeiten und Latenz keine große Rolle spielen, kann der Serial GC die beste Wahl sein – für diese Anwendungsbereiche ist er definitiv am besten optimiert.

Abgesehen von internen Umstrukturierungen durch die Entfernung des CMS hat der Serial GC keine nennenswerten Weiterentwicklungen erfahren. Das ist vielleicht auch nicht notwendig, da er innerhalb seiner Grenzen sehr ausgereift ist. Der Serial GC kann mit dem Flag –XX:+UseSerialGC aktiviert werden.

Parallel GC

Ein weiteres Urgestein ist der Parallel GC. Im Wesentlichen ist der Parallel GC eine Weiterentwicklung der Algorithmen des Serial GC, um die Garbage Collection mit mehreren Threads parallel (aber immer noch nicht nebenläufig zur Anwendung) arbeiten lassen zu können. Das macht ihn zum Collector der Wahl, wenn es um reine Performance geht und Antwortzeiten und Latenz keine Rolle spielen, z. B. bei Batchprozessen. Die Möglichkeit, mehrere GC Threads zu verwenden, macht ihn auch geeignet für größere Workloads. Wie schon der Serial GC, hat der Parallel GC keine nennenswerten Weiterentwicklungen erfahren, abgesehen von internen Umstrukturierungen. Aktiviert wird er mit dem Flag -XX:+UseParallelGC.

Verschaffen Sie sich den Zugang zur Java-Welt mit unserem kostenlosen Newsletter!

Epsilon GC

Nicht unerwähnt bleiben soll der relativ neue experimentelle Epsilon GC. Epsilon nennt sich auch „no-op“ GC, einfach deswegen, weil er nichts macht. Sobald der Java Heap voll ist, steigt die JVM mit einem OutOfMemoryError aus. Das mag nach einem seltsamen Ansatz klingen, hat aber gewisse Anwendungsbereiche:

  1. Anwendungen, die beim Start einmal alle ihre Datenstrukturen erstellen und im weiteren Verlauf keine neuen mehr benötigen

  2. Anwendungen, bei denen es relativ egal ist, wenn die Anwendung aussteigt, zum Beispiel weil ein Container sie einfach schnell neustartet (FaaS, SaaS etc.)

  3. Testing, zum Beispiel um verschiedene GCs in Performancetests zu vergleichen – der Epsilon GC stellt da sozusagen die Baseline dar.

Der Epsilon GC hat Vorteile: Dadurch, dass er nichts macht, benötigt er auch keine Barriers bei Heap-Zugriffen (wie alle anderen GCs) und kann daher den Benutzercode optimal kompilieren und maximale Performance bieten. Falls man Epsilon verwenden möchte, dann kann man ihn mit den Kommandozeilenflags -XX:+UnlockExperimentalVMOptions -XX:+UseEpsilonGC aktivieren.

The post Java 17: Garbage Collection in JDK 17 appeared first on JAX 2022.

]]>
Iterierst du noch, oder streamst du schon? https://jax.de/blog/iterierst-du-noch-oder-streamst-du-schon/ Tue, 07 Sep 2021 07:24:11 +0000 https://jax.de/?p=84028 Bei einer Diskussion mit Kollegen kam die Frage auf, worin sich Streams und Iteratoren in Java eigentlich unterscheiden. Man sollte denken, dass beide Ansätze dazu gedacht sind, Dinge wiederholt auszuführen, und dass Streams nur eine komfortablere Art und Weise sind, den Algorithmus auszudrücken. Tatsächlich sind sich die beiden APIs in manchen Dingen ähnlich, in anderer Hinsicht unterscheiden sie sich jedoch.

The post Iterierst du noch, oder streamst du schon? appeared first on JAX 2022.

]]>
Ziel dieses Artikels ist es, die beiden Ansätze zu vergleichen, zum einen mit Blick auf die Ergonomie (oder die „Schönheit“, wenn man es ehrlich formuliert) eines Beispiels, zum anderen auf funktionale Unterschiede bei der Parallelisierung und Mutabilität.

Als Diskussionsgrundlage verwenden wir die Implementierung einer paginierten Datenbankabfrage sowohl via Stream als auch via Iterator, damit wir die grundlegenden Unterschiede sehen können. Bei Paginierung werden die Ergebnisse nicht auf einmal aus der Datenbank geholt, sondern „Stück für Stück“. Wir kennen dieses Vorgehen aus der Google-Suche, auch wenn ich normalerweise alle Ergebnisse nach Seite eins ignoriere.

Paginierung hat den Vorteil, dass große Ergebnismengen verarbeitet werden können, ohne die Daten komplett in den Arbeitsspeicher zu laden. Nach dem Verarbeiten eines Teilstücks können die verarbeiteten Daten verworfen und das nächste Teilstück kann verarbeitet werden. Als Beispiel sei folgende Tabelle angegeben, bei der wir die Summe der Werte bestimmen wollen (und das zum Wohle des Beispiels nicht in der Datenbank tun). Stellvertretend soll dies für kompliziertere Operationen stehen, die nicht einfach in der Datenbank ausgeführt werden können. Tabelle 1 zeigt ein paar Zeilen Datenbank für unsere Demozwecke. Wie durch die Punkte impliziert, ist die eigentliche Datenbank natürlich deutlich größer.

ID Wert
1 15
2 24
3 45
4 38

Tabelle 1: Beispieldatenbank

Unpaginiert würden wir die gesamte Datenbank abfragen und die Werte addieren. Das würde bedeuten, dass wir die komplette lange Liste im Hauptspeicher halten müssen. Bei einer paginierten Abfrage würden wir immer z. B. zehn Werte abfragen, summieren und dann die nächsten zehn Werte abfragen und so weiter. Bei einer paginierten Vorgehensweise müssen wir also nie alle Daten vorhalten und können auch Datenmengen verarbeiten, die nicht in den Hauptspeicher passen. Natürlich geht mit diesem Vorgehen auch ein gewisser Overhead einher, weil ja wiederholt Abfragen an die Datenbank geschickt werden und somit mehr Zeit für Roundtrips zwischen Server und Datenbank verbraucht wird.

In SQL können wir mit den LIMIT– und OFFSET-Parametern [1] paginieren. Wir fragen also erst die ersten zehn Werte ab (LIMIT 10 OFFSET 0), dann die nächsten zehn (LIMIT 10 OFFSET 10) und so weiter. Nachfolgend die Query für unsere Beispieldatenbank:

SELECT wert FROM t1 ORDER BY id OFFSET 0 LIMIT 10;

Wichtig ist hier, dass die Abfrage geordnet ist, da sonst die Reihenfolge der Ergebnisse nicht definiert ist und wir ohne konsistente Reihenfolge keine korrekte Paginierung erhalten.

Es gibt bessere Wege zur Paginierung, diese sind jedoch komplizierter zu implementieren, und stärker vom verwendeten Datenbanksystem abhängig: Eine Paginierung via Cursor [2] oder Keyset Pagination [3] wird meistens vorzuziehen sein, die Implementierung würde jedoch den Rahmen dieses Artikels sprengen. Das generelle Schema der Iteration bleibt jedoch erhalten und die Implementierung einer paginierten Abfrage wäre ähnlich.

Verschaffen Sie sich den Zugang zur Java-Welt mit unserem kostenlosen Newsletter!

Implementierung der Paginierung

Das Ziel unserer Implementierung ist es, eine Query mit großer Ergebnismenge so auszuführen, dass der Speicherbedarf der Lösung konstant ist, d. h. zu keiner Zeit mehr als x Ergebnisse vorgehalten werden, egal wie groß die gesamte Ergebnismenge ist. Der Benutzer sollte von dieser Eigenschaft nichts merken und sollte mit dem Ergebnis-Stream bzw. Iterator umgehen können wie mit einem „normalen“ Stream oder Iterator. Natürlich kann man das von Hand implementieren, aber Java gibt einem mächtige APIs an die Hand, damit man das nicht tun muss. Also ab in den Code!

Paginierung mit Streams

Zuerst betrachten wir die Streams-Implementierung, in der natürlich völlig unvoreingenommenen Ansicht des Autors die elegantere und kürzere Lösung (Listing 1).

List<Result> runQuery(int offset) {...}; // Dummyimplementierung
 
Stream.iterate(0, (skip) -> skip + 10)
  .map(
    (offset) ->
    runQuery(offset))
  .takeWhile((result) -> result.size() > 0)
  .flatMap(Collection::stream)
  .mapToInt((result) -> (int)result.get("wert"))
  .sum();

Was tun wir? Zuerst erzeugen wir in Zeile 1 einen unendlichen Stream [4] von Zahlen, die mögliche Offsets angeben. Das tun wir, indem wir die Funktion x + 10 iterativ anwenden. Das erste Ergebnis ist 0 (das erste Argument von iterate), wir addieren 10 drauf, das Ergebnis ist 10, wir addieren 10, das Ergebnis ist 20, und so weiter. Was machen wir nun mit diesen Offsets?

Wir mappen unsere Query über die Offsets und geben die Ergebnisse zurück. Das bedeutet, dass wir unsere Query mit jedem Offset einmal ausführen – natürlich nicht sofort, sonst hätten wir eine unendliche Schleife. takeWhile() macht unseren Stream jetzt verwendbar: Der Integer-Stream ist unendlich lang, und der gemappte Stream ist natürlich auch unendlich lang. Wir unterbrechen die Unendlichkeit jetzt mit takeWhile(), das so lange Elemente aus dem Stream entnimmt, wie es die Bedingung sagt. Unsere Bedingung ist „Query hat mehr als 0 Ergebnisse“. Wenn eine Query keine Ergebnisse mehr hat, sind im Moment keine Daten verfügbar, die wir zurückgeben können. Wir können also aufhören, unseren Stream zu lesen, sobald das erste Mal eine leere Ergebnisliste zurückkommt. Wir haben jetzt eine Liste von Ergebnislisten – diese machen wir mit flatMap() zu einer Liste von Ergebnissen, operieren mit mapToInt() unseren Wert aus dem Ergebnis und summieren ihn mit sum().

Paginierung mit Iteratoren

Listing 2 zeigt zum Vergleich die Paginierung mittels Iterator-API. Was tun wir mit diesem vielen Code?

List<Result> runQuery(int offset) {...}; // Dummyimplementierung
 
var iterator = new Iterator<Result>() {
  private Iterator<Result> subIter = runQuery(0).iterator();
  private int offset = 0;
  @Override
  public boolean hasNext() {
    if (subIter.hasNext())
      return true;
    offset += 10;
    subIter = runQuery(offset).iterator();
    return subIter.hasNext();
  }
  @Override
  public Integer next() {
    return subIter.next();
  }
};
 
var sum = 0;
while (iterator.hasNext())
  sum += iterator.next().getValue("wert");

Wir erzeugen einen neuen Iterator, der unsere Query paginiert. Voraussetzung (wie beim Streams-Beispiel oben) ist natürlich, dass die Query einen Offset-Parameter unterstützt und eine Liste an Ergebnissen zurückliefert. Wir bauen uns dann einen neuen Iterator, der die Query paginiert ausführt. Iteratoren [5] benötigen zwei Methoden, hasNext() und next()hasNext() gibt genau dann true zurück, wenn der Iterator noch Elemente hat, next() gibt ein Element zurück, wenn es noch eins gibt, ansonsten wirft es eine NoSuchElementException. Wir implementieren hasNext() jetzt einfach über einen unterliegenden Iterator auf unserer Queryliste. Wenn diese Liste noch Ergebnisse enthält, können wir einfach auf diesem Iterator next() aufrufen. Wenn die Liste keine Ergebnisse mehr enthält, müssen wir uns eine neue Liste holen. Wir geben dann hasNext() der neuen Liste zurück. Wenn die neue Liste leer ist, ist unsere Iteration zu Ende, wenn noch Elemente enthalten sind, können wir weitermachen. Die next()-Implementierung ist entsprechend einfach, weil hasNext() sich um den unterliegenden Iterator kümmert und nur true zurückgibt, wenn dieser weiter iterierbar ist.

Ergonomie

An diesem Beispiel kann man meiner Meinung nach die ästhetischen Unterschiede zwischen Iteratoren und Streams sehen: Der Stream-Code ist sehr viel flüssiger und erlaubt es mir, über die Daten und Operationen nachzudenken. Beim Iteratorenbeispiel verschwindet die eigentliche Logik hinter einem Wald aus Boilerplate, anonymen Klassen, while-Schleifen und anderem. Ergonomie also: Streams: 1, Iteratoren 0!

Man muss natürlich anmerken, dass das Beispiel klein und recht gut auf Streams zugeschnitten ist. Mit externen Bibliotheken, wie z. B. Googles Guava [6], kann man auch Iteratoren besser handhabbar machen, sodass das Beispiel dann fast gleich ausschaut.

Ein anderer Gesichtspunkt ist die Kontrolle über die Evaluation: Streams sind lazy, d. h., die „intermediate“-Operationen eines Streams werden erst dann ausgeführt, wenn eine „terminale“ Operation auf dem Stream aufgerufen wird. Wir haben das im Beispiel oben gesehen, bei dem die Map über den Stream erst aufgerufen wird, wenn sum() ausgeführt wird. Bei Iteratoren kann man meistens davon ausgehen, dass „teurer“ Code im next()-Aufruf ausgeführt wird. Bei Streams weiß man das nicht so genau, und man sollte sich genau überlegen, wann man den Stream konsumiert. Meine persönliche Meinung ist, dass ich Streams für einfache Transformationen bevorzuge, wenn funktionale Unterschiede keine Rolle spielen.

Es gibt natürlich auch noch andere Unterschiede zwischen Streams und Iteratoren. Über diese lässt sich zwar nicht so trefflich diskutieren wie über die Codeschönheit, relevant sind sie jedoch allemal. Wir gehen deshalb auch noch auf die Unterschiede bei Mutabilität und Parallelisierung von Streams und Iteratoren ein.

Parallelisierung

Im Gegensatz zu Iteratoren sind Streams einfach parallelisierbar. Um einen Iterator zu parallelisieren, muss man sich selbst mit Thread Pools und Nebenläufigkeit herumschlagen. Bei Streams kann ich statt stream() einfach parallelStream() aufrufen und erhalte eine parallelisierbare Variante meiner Transformation.

Hierbei muss man allerdings gut darauf achten, welche Operationen man auf dem Stream ausführt und welche Reihenfolge der Stream-Elemente man erwartet. Ein Stream kann geordnet oder ungeordnet sein, je nach Quelle des Streams. So gibt List.of(1, 2, 3, 4).stream().forEachOrdered(System.out::println) stets 1, 2, 3, 4 aus, Set.of(1, 2, 3, 4).stream().forEachOrdered(System.out::println) gibt die Elemente in einer zufälligen Reihenfolge aus. Parallelismus ändert die Sortierung des Streams nicht, solange ich nicht unordered() aufrufe. Es ändert jedoch die Reihenfolge, in der Operationen angewendet werden.

ForEach ist hier ein Sonderfall, da es bei parallelen Streams explizit die Sortierung des Streams ignoriert, egal ob ordered oder unordered. List.of(1, 2, 3).stream().parallel().forEach(System.out::println) kann also die Zahlen in einer beliebigen Reihenfolge ausgeben.

Ich muss also darauf achten, dass die Lambdas, die ich in meiner Pipeline verwende, den Anforderungen des API gerecht werden. So produziert zum Beispiel der Code in Listing 3 auf parallelen und nichtparallelen Streams unterschiedliche Ergebnisse.

List.of(1, 2, 3).stream().reduce(0, (x, y) -> x - y)
== (((0 - 1) - 2) - 3)
== -6;
List.of(1, 2, 3).parallelStream().reduce(0, (x, y) -> x - y)
== (0 - (1 - (2 - 3)))
== -2;
Verschaffen Sie sich den Zugang zur Java-Welt mit unserem kostenlosen Newsletter!

Beide Ergebnisse sind Zufall: reduce gibt keine Reihenfolge vor, in der Operationen ausgeführt werden, und hier kann man diesen Unterschied sehen. In Listing 3 kommt das davon, dass 0 keine Identität von  ist (0 – x ≠ x) und dass – nicht assoziativ ist ((x – y) – z ≠ x – (y – z)). Beide Bedingungen sind in der Dokumentation von reduce() angegeben [7]. Wenn man diese Seitenbedingungen nicht erfüllt, kann es bei der Parallelisierung zu interessanten Ergebnissen kommen, es lohnt sich also stets, die Anforderungen von (Terminal-)Operationen zu kennen. Bei Iteratoren hat man dieses Problem nicht, muss sich jedoch selbst um die Feinheiten der Parallelisierung kümmern, was meiner Meinung nach viel größere Sorgfalt voraussetzt.

Das Streams-API ist inhärent für Parallelität designt, weshalb man bei Nichtnutzung von Parallelitätsfeatures vereinzelt auf Eigenschaften stößt, die sich nicht unbedingt erschließen. So kann man z. B. nicht direkt einen Stream aus einem Iterator erzeugen, sondern muss erst aus dem Iterator einen Spliterator [8] machen, der dann mittels StreamSupport.stream [9] zu einem Stream wird. Man kann die Parallelität eines Streams zwar mit isParallel() abfragen, diese Eigenschaften des Streams sind jedoch nicht im Typsystem hinterlegt, weshalb alle Operationen des Streams potenziell parallel ausgeführt werden können müssen.

Neben Parallelität ist der Umgang mit veränderlichen Daten ein anderer wichtiger Unterschied zwischen Streams und Iteratoren.

Mutabilität

Im Gegensatz zu Streams kann man über den Iterator die unterliegende Quelle während der Iteration strukturell verändern, indem man Elemente entfernt. Der (sinnfreie) Code in Listing 4 ist also kein Problem.

var list = new ArrayList<>(List.of(1, 2, 3));
var iter = list.iterator();
while (iter.hasNext()) {
  System.out.println(iter.next());
  iter.remove();
}
System.out.println(list.size()); // 0

Nach der Iteration ist die Liste leer, und der Iterator ist durchgelaufen. Das ist ein Sonderfall, den das Iterator-API anbietet. Modifikationen von list (z. B. über add) lösen weiterhin eine ConcurrentModificationException aus.

Das Problem kann jedoch auch bei sinnvollem Code auftreten: Listing 5 versucht, diesen Umstand zu zeigen. Bei der Stream-Variante wird im Hintergrund eine zweite Liste erzeugt, um das Ergebnis aufzubewahren. Am Ende der Schleife ist diese Liste halb so groß wie die Originalliste. Ein ausreichend schlauer Compiler kann das zwar unter Umständen wegoptimieren, darauf verlassen würde ich mich jedoch nicht.

Die Iterator-Variante hingegen benötigt maximal so viel Platz wie die Originalliste, da Elemente sich immer nur in einer der beiden Listen langeliste oder result befinden. Der Stream-Verteidiger wird natürlich argumentieren, dass langeliste selbst in einem Stream vorgehalten werden sollte, in diesem Beispiel ist die Liste allerdings vom API vorgegeben, ein Fall, der in echtem Code häufig auftreten wird.

var langeliste = new ArrayList<Integer>(...);
langeliste = langeliste.stream().filter((x) -> x % 2 == 0).collect(Collectors.toList());
 
var langeliste = new ArrayList<Integer>(…);
var result = new ArrayList<Integer>();
var iter = langeliste.iterator();
while (iter.hasNext()) {
  var value = iter.next();
  iter.remove();
  if (value % 2 == 0)
    result.add(value);
}

Wenn also Performanceoptimierungen fällig sind, lohnt es sich unter Umständen, Iteratoren zu benutzen, da man hier die Kontrolle über die Evaluation hat und auch Modifikationen möglich sind. Natürlich nur, wenn man über veränderliche Listen iteriert.

Fazit

Vielleicht hat man es schon aus dem Text herausgelesen: Ich persönlich mag das Streams-API sehr, weil es mir Aufgaben wie „Tausche bei einer Map Keys und Values“ sehr einfach macht. Man gibt natürlich Kontrolle auf, aber bei einzeiligen Transformationen sollte das selten ein Problem sein.

Das andere Ziel des Streams-API ist es natürlich, performante Datenverarbeitung im großen Stil zu ermöglichen. Hier ist mir mangels Erfahrung noch nicht klar, ob die in diesem Artikel beschriebenen Einschränkungen von Streams relevanter werden. Meine Vermutung ist, dass bei Beachtung einiger Seitenbedingungen das Streams-API genauso schön ist wie bei kleinen Problemen, belegen kann ich das jedoch noch nicht.

Links & Literatur

[1] https://www.postgresql.org/docs/current/queries-limit.html

[2] https://www.postgresql.org/docs/9.2/plpgsql-cursors.html

[3] https://blog.jooq.org/2013/11/18/faster-sql-pagination-with-keysets-continued/

[4] https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html

[5] https://docs.oracle.com/javase/8/docs/api/java/util/Iterator.html

[6] https://github.com/google/guava

[7] https://docs.oracle.com/javase/8/docs/api/java/util/stream/Stream.html#reduce-T-java.util.function.BinaryOperator-

[8] https://docs.oracle.com/javase/8/docs/api/java/util/stream/StreamSupport.html#stream-java.util.Spliterator-boolean-

[9] https://docs.oracle.com/javase/8/docs/api/java/util/stream/StreamSupport.html#stream-java.util.Spliterator-boolean-

The post Iterierst du noch, oder streamst du schon? appeared first on JAX 2022.

]]>
Einfacher als gedacht https://jax.de/blog/einfacher-als-gedacht/ Tue, 24 Aug 2021 07:06:56 +0000 https://jax.de/?p=83973 In der Regel bestehen Microservices-Projekte aus mehreren einzelnen Services, die als getrennte Deployment-Einheiten separat betrieben werden und sich dabei gegenseitig aufrufen. Für die Security des Gesamtsystems ergeben sich hieraus mehrere Konsequenzen. Zum einen muss jeder einzelne Microservice für sich gewisse Securityrichtlinien beachten. Doch das allein reicht noch nicht aus, da auch die Kommunikation mit den anderen Services abgesichert werden muss.

The post Einfacher als gedacht appeared first on JAX 2022.

]]>
Erst durch beide Maßnahmen erreicht das Geflecht an Services einen Securitystandard, der bei einem monolithischen System sehr viel einfacher zu erreichen ist. Der administrative Umgang mit den Zertifikaten für die Transport Layer Security (TLS) kann bei einem verteilten System einen recht erheblichen Aufwand verursachen.

Allein schon durch die hohe Anzahl an eigenständigen Services erhöht sich die Wahrscheinlichkeit einer Sicherheitslücke. Sobald neue sogenannte Common Vulnerabilities and Exposures (CVEs) veröffentlicht werden, müssen diese unter Umständen in mehreren Services zeitnah ausgebessert werden. Erst wenn alle Microservices anschließend neu deployt wurden, gilt dieses Sicherheitsrisiko als behoben. Ein möglicher Angreifer hat im Grunde die Qual der Wahl, welchen der vielen Services er als Erstes zu kompromittieren versuchen soll. Sollte der Angriffsversuch auf den ersten Microservice nicht zum Erfolg führen, hat er noch genügend andere Opfer, über die er in das Geflecht der Microservices eindringen kann. Werden die Services über eine ungesicherte Verbindung aufgerufen, so ergeben sich für den Hacker noch mehr Möglichkeiten, in das Gesamtsystem einzubrechen.

Zero Trust

Werden die zuvor genannten Securityprobleme bei Microservices konsequent zu Ende gedacht, kommt man im Grunde zu dem Ergebnis, dass nur ein Zero-Trust-Ansatz einen ausreichenden Sicherheitsschutz bieten kann.

Zero Trust besagt, dass im Grunde keinem (Micro-)Service vertraut werden darf, sogar dann nicht, wenn er sich in einer sogenannten Trusted Zone befindet. Jeder Request zwischen den Services muss authentifiziert (AuthN) und autorisiert (AuthZ) werden und mittels TLS abgesichert sein. Als Authentifikationsmerkmal wird oft ein JSON Web Token (JWT) verwendet, das bei jedem Request mitgeschickt werden muss und somit den Aufrufer identifiziert. Dieses JWT wird entweder End-to-End verwendet, d. h., dass es während der gesamten Aufrufkette nicht ausgetauscht wird, oder man generiert sich mit einem TokenExchangeService für jeden einzelnen Request ein eigenes neues Token. Zu guter Letzt soll die Absicherung nicht nur auf der HTTP- oder gRPC-Ebene stattfinden (OSI Layer 7 Application), sondern auch in den darunter liegenden Netzwerkschichten Transport und Network (OSI Layer 4 und 3). Damit wird dem OWASP-Securityprinzip [1] Defense in Depth genüge getan.

Nicht jeder sicherheitsverantwortliche Mitarbeiter will diesen finalen Schritt gehen, da klar erkennbar ist, dass die Umsetzung von Zero Trust nicht gerade trivial ist. In der Gegenüberstellung Aufwand gegen Nutzen wird dabei oft der (falsche) Schluss gezogen, dass für Zero Trust der Aufwand viel zu hoch wäre, obwohl einem die innere Securitystimme sagt, dass Zero Trust der richtige Ansatz ist. Mittlerweile gibt es jedoch Systeme, die den Aufwand für Zero Trust sehr stark minimieren, womit der Nutzen die Oberhand gewinnt.

Verschaffen Sie sich den Zugang zur Java-Welt mit unserem kostenlosen Newsletter!

Zero Trust bei Microservices

Als Konsequenz der vorherigen Überlegungen müssen für Zero Trust bei Microservices mehrere Funktionalitäten umgesetzt werden. Jeder Request muss Informationen über den Aufrufer enthalten. Am einfachsten gelingt das mit einem JWT, das im HTTP-Header Authorization als Typ Bearer mitgeschickt wird. Der aufgerufene Service validiert dieses JWT und führt dann die notwendigen Berechtigungsprüfungen durch. Bei Verwendung eines Service-Mesh-Tools kann diese Prüfung auch von einem Sidecar übernommen oder ergänzt werden (dazu mehr in den folgenden Abschnitten). Zur Absicherung der Kommunikation mit TLS sind SSL-Zertifikate notwendig, die die Eigenschaft haben, dass sie nach einem gewissen Zeitintervall ungültig werden und somit ausgetauscht werden müssen. Das kann wegen der hohen Anzahl an Microservices nicht manuell erfolgen. Für diese administrative Aufgabe bieten Service-Mesh-Tools eine passende Automatisierung an, wodurch der Aufwand für die Ops-Kollegen gegen Null geht.

Um jetzt noch dem OWASP-Securityprinzip Defense in Depth gerecht zu werden, sollten die Kommunikationsverbindungen zwischen den Microservices mit Firewallregeln berechtigt oder unterbunden werden. Somit erfolgt eine weitere Absicherung der Kommunikation auf TCP/IP-Ebene. Kubernetes sieht dafür das Konzept der Network Policies vor. Auch hierzu mehr in den kommenden Abschnitten.

Typischer Ausgangspunkt

Die meisten Microservices-Projekte, die auf Kubernetes betrieben werden, starten mit der Ausgangssituation in Abbildung 1.

Abb. 1: Low Secure Deployment

Für die Ingress-Kommunikation stellen die Cloud-Betreiber passende Komponenten zur Verfügung oder beschreiben mit Tutorials, wie diese Komponenten einfach installiert werden können. Der Kommunikationspfad aus dem Internet geht meist über eine Firewall und wird mit einem LoadBalancer in Richtung Kubernetes-Cluster geroutet. Dafür stellen die Cloud-Provider oft fertige Lösungen zur Verfügung, die auch sehr einfach auf TLS umgestellt werden können.

Jetzt beginnt der Teil, bei dem man eigenverantwortlich weitere Maßnahmen zur Sicherheit umsetzen muss. Ein Ingress-Controller innerhalb Kubernetes kümmert sich dann um die Weiterleitung des Requests innerhalb des Clusters. Dieser wird typischerweise als SSL-Endpunkt mit einem firmeneigenen Zertifikat ausgestattet. Als Folge davon werden die Requests nach der SSL-Terminierung ohne TLS an die jeweiligen Microservices weitergeleitet, d. h., die Kommunikation innerhalb des Clusters erfolgt komplett ohne Absicherung. Manche der Microservices (oder doch schon alle?) besitzen eine Securityprogrammierung, die das empfangene JWT validiert und anschließend mit den enthaltenen Claim-Werten die Berechtigungsprüfung durchführt. Zur Validierung des JWT müssen (sporadisch) Requests zum Identity Provider (IDP), der das JWT ausgestellt hat, abgeschickt werden. Das erfolgt in der Regel mit TLS, da der IDP nur einen HTTPS-Zugang anbietet. Die interne Aufwand-Nutzen-Analyse hat ergeben, dass mit den vorhandenen Mitteln bei vertretbarem Aufwand ein gewisser Grad an Security erreicht worden ist. Das Projektteam ist sich bewusst, dass dieses Setting keinem Zero-Trust-Ansatz entspricht, glaubt aber, dass der Aufwand für Zero Trust viel zu hoch ist. Mangels besseren Wissens gibt man sich mit diesem (geringen) Level an Security zufrieden.

Service Mesh

Ein sehr viel höheres Securitylevel kann man mit Service-Mesh-Tools wie Istio oder Linkerd (u. v. m.) erreichen. Diese Tools bieten hierfür entsprechende Funktionalitäten an, die zum Beispiel das Zertifikatsmanagement automatisieren. Am Beispiel von Istio sollen diese Features genauer betrachtet werden.

Mutual TLS

Jeder Service, der Bestandteil eines Service Mesh wird, bekommt ein sogenanntes Sidecar, das die eingehende und ausgehende Kommunikation zum Service steuert und überwacht. Gesteuert wird das Verhalten des Sidecar über eine zentrale Steuerungskomponente, die dem Sidecar die passenden Informationen und Anweisungen übermittelt. Beim Start des Pods, der aus dem Service und dem Sidecar besteht, holt sich das Sidecar ein individuelles SSL-Zertifikat von der zentralen Steuereinheit ab. Damit ist das Sidecar in der Lage, eine Mutual-TLS-Verbindung (mTLS) zu etablieren. Nur der Request vom Sidecar zum Service, also die Kommunikation innerhalb des Pods, erfolgt dann ohne TLS. Nach einem vordefinierten Zeitintervall (bei Istio ist der Default 24 h), lässt sich das Sidecar automatisch ein neues Zertifikat vom Steuerungsservice ausstellen. Dieses neue Zertifikat wird dann für die nächsten 24 Stunden für die mTLS-Verbindung verwendet. Mit der in Listing 1 gezeigten Istio-Regel wird eine mTLS-Kommunikation für das gesamte Service Mesh verpflichtend.

apiVersion: security.istio.io/v1beta1
kind: PeerAuthentication
metadata:
  name: default
  namespace: istio-system
spec:
  mtls:
    mode: STRICT

Für eine Übergangslösung, in der noch nicht alle Services in den Service Mesh integriert wurden, gibt es einen speziellen Modus: PERMISSIVE. Damit werden Services innerhalb des Service Mesh mit mTLS angesprochen und Services, die außerhalb des Service Mesh laufen, werden weiterhin ohne TLS aufgerufen. Auch das erfolgt automatisch und wird vom jeweiligen Sidecar, das den Aufruf initiiert, entsprechend ausgeführt.

Der Vorteil für die Security liegt hier auf der Hand. Die Kommunikation innerhalb des Service Mesh erfolgt über mTLS, und das Zertifikatsmanagement läuft in kurzen Zeitintervallen vollautomatisch ab. Darüber hinaus erfolgt das gesamte mTLS-Handling ohne einen Eingriff in den Code des Service, läuft also aus Sicht des Service völlig transparent ab. Diese Absicherung auf dem Application Layer des Netzwerkstacks kann dann ohne Probleme mit Absicherungen auf Layer 3 und 4 kombiniert werden (siehe kommende Abschnitte).

Ingress Gateway

Istio bietet auch ein sogenanntes Ingress Gateway an, das den Eintrittspunkt in den Kubernetes-Cluster und somit in das Service Mesh regelt. Es kann damit den Ingress Controller aus der vorherigen Systemlandschaft (Abb. 1) vollständig ersetzen.

Man könnte Istio auch ohne Ingress Gateway betreiben, würde dann aber eine Menge an Funktionalitäten verlieren. Der Vorteil des Ingress Gateway liegt darin, dass dort schon die gesamten Istio-Regeln greifen. Somit sind alle Regeln für Trafficrouting, Security, Releasing usw. anwendbar. Auch die Kommunikation vom Ingress Gateway zum ersten Service ist bereits mit mTLS abgesichert. Im Falle eines alternativen Ingress Controllers wäre dieser Request noch ohne SSL-Absicherung. Die Bezeichnung Gateway (anstatt Controller) wurde von Istio ganz bewusst gewählt, da mit dem existierenden Funktionsumfang das Ingress Gateway auch als sogenanntes API Gateway betrieben werden kann.

Für den gesicherten Eintritt in das Cluster kann das Ingress Gateway mit dem entsprechenden Firmenzertifikat konfiguriert werden, ganz analog zum Vorgehen bei einem Ingress Controller. Die Istio-Regel in Listing 2 definiert die Funktionsweise des Ingress Gateways.

apiVersion: networking.istio.io/v1alpha3
  kind: Gateway
  metadata:
    name: mygateway
  spec:
    selector:
      istio: ingressgateway
    servers:
    - port:
        number: 443
        name: https
        protocol: HTTPS
      tls:
        mode: SIMPLE
        credentialName: mytls-credential
      hosts:
      - myapp.mycompany.de
Verschaffen Sie sich den Zugang zur Java-Welt mit unserem kostenlosen Newsletter!

 

Es wird ein SSL-Port (443) für alle Requests auf den Hostnamen myapp.mycompany.de geöffnet und das zugehörige SSL-Zertifikat wird aus dem Kubernetes Secret mytls-credential ausgelesen. Die Erstellung des SSL-Secrets erfolgt mit der Kubernetes-Regel in Listing 3.

apiVersion: v1
   kind: Secret
   metadata:
     name: mytls-credential
   type: kubernetes.io/tls
   data:
     tls.crt: |
           XYZ...
     tls.key: |
           ABc...

Damit ist auch hier der Eingang in den Cluster mit TLS abgesichert und die weiterführende Kommunikation erfolgt mit dem mTLS-Setting von Istio.

Network Policy

Nachdem die HTTP-Ebene mit SSL abgesichert ist, sollten nun noch weitere Netzwerkschichten (OSI Layer 3 und 4) abgesichert werden. Um in Kubernetes so etwas wie Firewalls zu etablieren, gibt es das Konzept der Network Policy. Diese Policies definieren die Netzwerkverbindungen zwischen den Pods, wobei die Einhaltung der Regeln von einem zuvor installierten Netzwerk-Plug-in durchgesetzt werden. Network Policies ohne ein solches Netzwerk-Plug-in haben keinen Effekt. Kubernetes bietet eine große Auswahl an Plug-ins [2] an, die man in einem Kubernetes-Cluster installieren kann.

Als Best Practice gilt es, eine sogenannte Deny-All-Regel zu definieren. Damit wird im gesamten Cluster die Netzwerkkommunikation zwischen den Pods unterbunden. Die Deny-All-Ingress-Regel verbietet jede eingehende Kommunikation auf Pods im zugehörigen Namespace (Listing 4).

apiVersion: networking.k8s.io/v1
  kind: NetworkPolicy
  metadata:
    name: default-deny-ingress
    namespace: my-namespace
  spec:
    podSelector: {}
    policyTypes:
    - Ingress

Nachdem diese Regel aktiviert wurde, kann man nun gezielt die einzelnen Ingress-Verbindungen freischalten. Die Regel in Listing 5 gibt beispielsweise den Ingress-Traffic auf den Pod mit dem Label app=myapp frei, aber nur wenn der Request vom Ingress Gateway kommt.

apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
  name: access-myapp
  namespace: my-namespace
spec:
  podSelector:
    matchLabels:
      app: myapp
  ingress:
  - from:
    - podSelector:
        matchLabels:
          istio: ingressgateway

Für jede weitere zulässige Verbindung muss die Regel entsprechend erweitert oder es müssen zusätzliche Regeln definiert werden. Durch die Etablierung der zuvor genannten Regeln hat sich das Anfangs-Deployment (Abb. 1) verändert, wie in Abbildung 2 gezeigt wird.

Abb. 2: Medium Secure Deployment

Die SSL-Terminierung wird nun vom Istio Ingress Gateway ausgeführt. Jeder weitergeleitete Request wird mit mTLS abgesichert. Zusätzlich definiert eine Network Policy je Pod den gewollten Ingress Request. Alle ungewollten Requests werden vom Network-Plug-in unterbunden. Damit ist schon mal ein großer Schritt in Richtung Zero Trust umgesetzt. Was jetzt noch fehlt, sind Berechtigungsprüfungen, die die Zulässigkeit der Aufrufe noch weiter eingrenzen.

Authentication (AuthN) und Authorization (AuthZ)

Für AuthN und AuthZ bietet Istio eine Menge an Regeln, mit denen man sehr granular steuern kann, welche Aufrufe berechtigt sind und welche nicht. Diese Regeln werden von den Sidecars und vom Ingress Gateway beachtet, wodurch der gesamte definierte Regelsatz überall im Service Mesh angewandt wird. Auch hiervon merkt die jeweilige Applikation nichts, da dies transparent von den jeweiligen Sidecars übernommen wird.

Die Authentifizierung erfolgt auf Basis eines JSON Web Tokens, das von Istio überprüft wird. Dazu werden die notwendigen JWT-Validierungen ausgeführt, wobei auf den ausstellenden Identity Provider (IDP) zugegriffen wird. Nach erfolgreicher Prüfung gilt der Request innerhalb des gesamten Service Mesh als authentifiziert. Am besten geschieht das im Ingress Gateway, womit die Prüfung gleich beim Eintritt in das Service Mesh bzw. Cluster ausgeführt wird.

Mit der Regel in Listing 6 wird Istio angewiesen, das empfangene JWT gegen den IDP mit dem URL unter [3] zu validieren.

apiVersion: security.istio.io/v1beta1
kind: RequestAuthentication
metadata:
  name: ingress-idp
  namespace: istio-system
spec:
selector:
    matchLabels:
      istio: ingressgateway
  jwtRules:
  - issuer: "my-issuer"
    jwksUri: https://idp.mycompany.de/.well-known/jwks.json

Wie aus der Regel ersichtlich wird, sind die Issuer als Array zu definieren, d. h. es können auch mehrere unterschiedliche IDPs angegeben werden.

Damit gilt der Request zwar als authentifiziert, es finden aber noch keine Berechtigungsprüfungen statt. Diese müssen separat mit einem anderen Regeltyp angegeben werden. Ebenso wie bei der Network Policy gibt es auch hier eine Best Practice, mit der alle Zugriffe innerhalb des Service Mesh als nicht berechtigt deklariert werden. Dies kann mit der Regel in Listing 7 festgelegt werden.

apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
  name: allow-nothing
  namespace: istio-system
spec:
  {}

Jetzt kann wie zuvor bei der Network Policy jeder einzelne Zugriff auf einen genau spezifizierten Pod sehr feingranular geregelt werden. Innerhalb der Regel kann in den Abschnitten from, to und when definiert werden, woher der Request kommen muss, welche HTTP-Methoden und Endpunkte aufgerufen werden sollen und welche Authentifizierungsinhalte (Claims im JWT) enthalten sein müssen. Erst wenn alle diese Kriterien zutreffen, wird der Zugriff erlaubt (action: allow), wie in Listing 8 gezeigt wird.

apiVersion: security.istio.io/v1beta1
kind: AuthorizationPolicy
metadata:
name: my-app
  namespace: my-namespace
spec:
  selector:
    matchLabels:
      app: my-app
  action: ALLOW
  rules:
  - from:
    - source:
        principals: ["cluster.local/ns/ns-xyz/sa/my-partner-app"]
- source:
        namespaces: ["ns-abc", "ns-def"]
    to:
    - operation:
        methods: ["GET"]
        paths: ["/info*"]
    - operation:
        methods: ["POST"]
        paths: ["/data"]
    when:
    - key: request.auth.claims[iss]
      values: ["https://idp.my-company.de"]

Neben der Möglichkeit, einen Request mit der action: allow zu berechtigen, gibt es noch die Möglichkeit, gewisse Request zu verbieten (action: deny) oder mit action: custom eigene Berechtigungsprüfungen in den Service Mesh zu integrieren. Damit ist es möglich schon vorhandene Berechtigungssysteme, wie sie in vielen Unternehmen existieren, weiterhin zu nutzen.

Verschaffen Sie sich den Zugang zur Java-Welt mit unserem kostenlosen Newsletter!

 

Finales System

Nach Anwendung der gesamten Regeln ergibt sich die Zero-Trust-Infrastruktur in Abbildung 3.

Abb. 3: Secure Deployment

Neben der mit TLS abgesicherten Kommunikation sind jetzt noch Berechtigungsprüfungen hinzugekommen, die im Grunde in jedem der Sidecars ausgewertet werden. Als Basis für die Authentifizierung dient das im HTTP-Header mitgeschickte JWT. Um diesen finalen Zustand zu erreichen, sind die in Tabelle 1 gezeigten Regeln notwendig.

Funktion Regeln
TLS Terminierung Gateway-Regel und Kubernetes Secret
mTLS Network Policy (Deny Ingress) und jeweils eine Network Policy pro Pod
Network Segmentation Request Authentication
Authentication Authorization Policy (Allow Nothing) und jeweils eine Authorization Policy pro Pod
Authorization Regeln

Tabelle 1: Übersicht Regeln

Insgesamt also nur sechs Basisregeln und pro Pod noch zwei weitere Regeln zur Zugriffssteuerung. Mit dieser geringen Anzahl an Regeln sollte nun die zuvor aufgestellte Aufwand- Nutzen-Analyse für eine Zero-Trust-Infrastruktur zu einem ganz anderen Ergebnis führen.

Fazit

Zugegeben, der Aufwand, ein Service-Mesh-Tool im Projekt zu etablieren, ist nicht gerade gering. Aber neben den ganzen Securityaspekten bieten diese Tools noch sehr viel mehr an Funktionalität, die einem beim Betrieb von Microservices einen sehr guten Dienst erweisen können. Rein aus dem Gesichtspunkt der Security wäre der Aufwand für ein Service-Mesh-Tool wohl relativ hoch, aber im Zusammenwirken mit den anderen Service-Mesh-Funktionalitäten wie Trafficrouting, Resilience und Releasing ergibt sich durchaus eine positive Bilanz in der Aufwand-Nutzen-Analyse. Außerdem wäre ein automatisiertes Zertifikatsmanagement auch nicht gerade trivial und bei einer selbst implementierten Lösung vielleicht auch nicht ganz fehlerfrei. Im Bereich der Security sollte man sich aber keine Fehler erlauben.

Die Kombination mit der Network Policy kann ohne Einflüsse auf Istio parallel etabliert werden, womit einem Defense-in-Depth-Ansatz entsprochen wird. Die Berechtigungsprüfungen können sehr fein gesteuert werden und lassen im Grunde keine Wünsche offen. Hier ist allerdings Vorsicht geboten, da durch die umfangreichen Möglichkeiten Situationen entstehen können, die sehr komplex und damit nur noch schwer verständlich sind. „Keep it simple, stupid“ (KISS) sollte hier als Handlungsoption immer wieder in Betracht gezogen werden.

Um auch bei der Berechtigungsprüfung einen Defense-in-Depth-Ansatz zu etablieren, sollte natürlich noch in den jeweiligen Applikationen eine Berechtigungsprüfung stattfinden. Im Bereich des Auditing wird von Istio derzeit nur Stackdriver unterstützt. Hier wäre eine größere Auswahl an Auditsystemen wünschenswert. Doch was noch nicht ist, kann ja noch werden.

Insgesamt lässt sich mit ein paar Kubernetes- bzw. Istio-Regeln eine Zero-Trust-Infrastruktur etablieren, die wohl bei jedem Securityaudit standhalten wird.

Links & Literatur

[1] https://github.com/OWASP/DevGuide/blob/master/02-Design/01-Principles%20of%20Security%20Engineering.md

[2] https://kubernetes.io/docs/concepts/cluster-administration/addons/

[3] https://idp.mycompany.de/.well-known/jwks.json

The post Einfacher als gedacht appeared first on JAX 2022.

]]>
Spring Native Hands-on https://jax.de/blog/spring-native-hands-on/ Mon, 05 Jul 2021 07:18:10 +0000 https://jax.de/?p=83728 Mit dem neuen Projekt Spring Native können Spring-Boot-Anwendungen von der GraalVM-Native-Image-Technologie Gebrauch machen und auch für existierende Spring-Boot-Anwendungen Start-up-Zeiten im Millisekundenbereich erzielen. Der Artikel zeigt, wie das funktioniert, wie weit Spring Native schon ist, und wie man die Technologie für eigene Spring-Boot-Anwendungen einsetzen kann.

The post Spring Native Hands-on appeared first on JAX 2022.

]]>
Die Vorteile der GraalVM-Native-Image-Technologie klingen verlockend: Start-up-Zeiten im Millisekundenbereich und ein deutlich reduzierter Verbrauch an Ressourcen (vor allem Speicher) – wer möchte das nicht?

Jedoch kommt diese Technologie mit einer Reihe von Einschränkungen daher. Reflection funktioniert beispielsweise in einem Native Image nur, wenn der Compiler darüber informiert wird, für welche Elemente (Klassen, Methoden, Attribute) er die Reflection-Informationen zur Compile-Zeit erzeugen und im Binary hinterlegen muss. Ähnliches gilt für Proxys, zusätzliche Ressourcen, JNI-Aufrufe und Dynamic Class Loading. Andere Techniken, wie zum Beispiel invokedynamic, funktionieren in einem Native Image grundsätzlich nicht.

Insofern kann es eine erhebliche Herausforderung sein, eine existierende Java-Anwendung in ein Native Image zu kompilieren. Zum einen muss der Code der eigenen Anwendung frei von nicht unterstützten Techniken sein, und zum anderen müssen passende Konfigurationsdateien erstellt werden, um beispielsweise Reflection zu ermöglichen. Gleiches gilt natürlich auch für alle von der eigenen Anwendung genutzten Libraries.

Was ist mit Spring-Boot-Anwendungen?

Auch für Spring-Boot-Anwendungen gilt: Sie lassen sich mit der GraalVM-Native-Image-Technologie in native Anwendungen kompilieren. Allerdings verwendet das Spring Framework viele der eben genannten Technologien relativ ausgiebig, sodass es mitunter mühsam werden kann, die nötigen Konfigurationen für den Compiler manuell zu erstellen. Grundsätzlich ist das aber möglich.

Was ist Spring Native?

Das Spring-Native-Projekt [1] ermöglicht es Entwicklern, Spring-Boot-Anwendungen mit der GraalVM-Native-Image-Technologie in Executable Binaries zu kompilieren, ohne dass die nötigen Konfigurationsdateien manuell erstellt werden müssen oder die Anwendung speziell angepasst werden muss. Im Idealfall lassen sich also bestehende Spring-Boot-Anwendungen ausschließlich durch wenige zusätzliche Build-Instruktionen zu Native Executables kompilieren (Kasten: „In drei einfachen Schritten zur fertigen Anwendung“).

In drei einfachen Schritten zur fertigen Anwendung


  • Projekt auf https://start.spring.io erzeugen (Spring Web | Spring Native) und auspacken.

  • ./mvnw spring-boot:build-image (Build ausführen, Native Image wird kompiliert, Container-Image wird erzeugt, benötigt nur Docker)

  • docker run –rm -p 8080:8080 demo:0.0.1-SNAPSHOT (Beispielanwendung starten)

Ob es Sinn ergibt, jede Spring-Boot-Anwendung zu einem Native Executable zu kompilieren, anstatt die Anwendung in einer JVM laufen zu lassen, sei einmal dahingestellt. Diese Entscheidung hat weniger mit Spring Boot selbst zu tun als vielmehr mit dem Einsatzkontext der Anwendung.

Erste Schritte mit Spring Native

Wie beginnt man neue Spring-Boot-Projekte? Natürlich auf https://start.spring.io (bzw. den entsprechenden Wizards in der eigenen Lieblings-IDE).

<dependency>
  <groupId>org.springframework.experimental</groupId>
  <artifactId>spring-native</artifactId>
  <version>0.9.2</version>
</dependency>
<plugin>
  <groupId>org.springframework.experimental</groupId>
  <artifactId>spring-aot-maven-plugin</artifactId>
  <version>0.9.2</version>
  <executions>
    <execution>
      <id>test-generate</id>
      <goals>
        <goal>test-generate</goal>
      </goals>
    </execution>
    <execution>
      <id>generate</id>
      <goals>
        <goal>generate</goal>
      </goals>
    </execution>
  </executions>
</plugin>
<plugin>
  <groupId>org.springframework.boot</groupId>
  <artifactId>spring-boot-maven-plugin</artifactId>
  <configuration>
    <image>
      <builder>paketobuildpacks/builder:tiny</builder>
      <env>
        <BP_NATIVE_IMAGE>true</BP_NATIVE_IMAGE>
      </env>
    </image>
  </configuration>
</plugin>

Als Beispiel wähle ich hier die Starter Spring Web, Spring Boot Actuator und eben Spring Native aus. Das generierte Projekt hat dann drei verschiedene Komponenten in der pom.xml-Datei, die speziell für Spring Native hinzugefügt wurden:

  1. eine zusätzliche Dependency (Listing 1)

  2. ein Build-Plug-in, das zusätzliche Informationen zur Build-Zeit erzeugt (Listing 2)

  3. ein Build-Plug-in, um ein Container-Image zu erzeugen (Listing 3)

Verschaffen Sie sich den Zugang zur Java-Welt mit unserem kostenlosen Newsletter!

Konfigurationen automatisch erzeugen

Die zusätzliche Dependency spring-native (aus Listing 1) beinhaltet vor allem die Spring-spezifische Erweiterung für den GraalVM-Native-Image-Compiler. Diese Erweiterung wird automatisch vom GraalVM-Native-Image-Compiler als Teil des Native-Image-Build-Prozesses ausgeführt.

Das neuartige Spring-AOT-Plug-in für den Build erzeugt die für das Native Image nötigen Konfigurationen automatisch während des Build-Vorgangs. Inhaltlich analysiert diese Build-Erweiterung die zu kompilierende Anwendung auf verwendete Spring-Komponenten und -Annotationen. Je nachdem, welche Spring-Bibliotheken und -Annotationen in der Anwendung verwendet werden, erzeugt Spring Native die passenden Konfigurationsdateien für den GraalVM-Native-Image-Compiler, sodass diese nicht manuell erstellt werden müssen.

Darüber hinaus kann die Spring-AOT-Erweiterung auch mit vielen Schaltern konfiguriert werden, um das Native Image noch genauer auf die eigenen Bedürfnisse zuzuschneiden. Beispielsweise lassen sich diverse Features von Spring komplett ausschalten und somit der dafür benötigte Code komplett aus dem Native Image entfernen.

Der etwas in die Jahre gekommene Support für Spring-XML-Config-Dateien ist ein gutes Beispiel dafür. Verwendet die Anwendung überhaupt keine Spring-XML-Config-Dateien, kann mit dieser Option der komplette XML-Support von Spring inkl. der dazu benötigten Dependencies gar nicht erst in das Native Image hineinkompiliert werden.

Die Spring-AOT-Erweiterung erlaubt es darüber hinaus, dem Native-Image-Support eigene sogenannte Hints mitzugeben. Diese „Hinweise“ geben dem Spring-Native-Support genaue Informationen darüber mit, welche Zusatzinformationen (beispielsweise zu Reflection) benötigt werden – sollten diese nicht automatisch identifiziert werden können.

Ein Beispiel dafür sind eigene Klassen, auf die zum Beispiel eine Library per Reflection zugreift, um sie in JSON zu transformieren.

Container-Images mit Native Executables

Spring Boot bringt schon seit einigen Versionen ein Maven-Build-Plug-in mit, welches automatisch ein Container-Image für die gebaute Spring-Anwendung erzeugt. Dieses Maven-Build-Plug-in nutzt im Hintergrund die Paketo Buildpacks [2], um aus kompilierten Spring-Boot-Anwendungen fertige Container-Images zu erzeugen.

Dieses Maven-Build-Plug-in (spring-boot-maven-plugin) kann so konfiguriert werden, dass vollautomatisch der GraalVM-Native-Image-Compiler verwendet und ein Native Executable erzeugt wird, welches dann in das Container-Image gelegt wird (anstatt eines JREs und den JAR-Dateien der Dependencys und der Anwendung selbst) – siehe Listing 3.

Ein großer Vorteil dieser Buildpack-basierten Methode ist, dass auf der lokalen Maschine kein passendes GraalVM SDK und keine Native-Image-Erweiterung installiert werden muss. Es reicht aus, die entsprechende Konfiguration (Listing 3) in die pom.xml-Datei zu integrieren und den Build auszuführen:

./mvnw spring-boot:build-image

Das Buildpack bringt das nötige GraalVM SDK automatisch mit. Das Resultat ist ein relativ kleines Container-Image. Es enthält weder ein vollständiges JRE noch die kompletten JAR-Dateien, sondern hauptsächlich das Binary der Anwendung.

Die eigentliche Größe des Binarys und dessen Speicherverbrauch im Betrieb hängt stark davon ab, wie gut und exakt zugeschnitten der Native-Image-Compiler konfiguriert wird. Je mehr Reflection-Informationen man beispielsweise konfiguriert, desto größer wird auch das Binary und desto mehr Speicher verbraucht es. Es kann sich also durchaus lohnen, möglichst wenig und möglichst genaue Reflection-Informationen zu konfigurieren, anstatt pauschal einfach alles.

Das gleiche gilt auch für die Erreichbarkeit von Code. Je genauer der Native-Image-Compiler analysieren kann, welcher Code nicht gebraucht wird, desto mehr Code wird er bei der Kompilierung des Binarys entfernen und desto weniger Ressourcen wird das Binary im Betrieb verbrauchen.

Sobald der Build das Container-Image mit dem Native Binary erzeugt hat, können wir den Container per Docker starten:

docker run --rm -p 8080:8080 rest-service:0.0.1-SNAPSHOT

Im Logoutput werden wir sehen: Die Spring-Boot-Anwendung startet innerhalb des Containers in wenigen Millisekunden.

Native Images lokal erzeugen

Ein Native Executable für eine Spring-Boot-Anwendung lässt sich auch ohne Buildpacks erzeugen. Wie im Artikel über die Native-Image-Technologie beschrieben, benötigt man dazu ein GraalVM SDK mit installierter Native Image Extension.

Anschließend lässt sich das GraalVM-Maven-Plug-in dem Build hinzufügen und passend für den Native-Image-Compiler konfigurieren (Listing 4). Zusätzlich sollte man in diesem Profil das Standardverhalten des Spring-Boot-Maven-Plug-ins leicht verändern (Listing 5), um einen Konflikt mit dem Repackaged JAR des Standard-Spring-Boot-Maven-Plug-ins zu vermeiden.

<profiles>
  <profile>
    <id>native-image</id>
    <build>
      <plugins>
        <plugin>
          <groupId>org.graalvm.nativeimage</groupId>
          <artifactId>native-image-maven-plugin</artifactId>
          <version>21.0.0.2</version>
          <configuration>
            <!-- The native image build needs to know the entry point to your application -->
            <mainClass>com.example.restservice.RestServiceApplication</mainClass>
          </configuration>
          <executions>
            <execution>
              <goals>
                <goal>native-image</goal>
              </goals>
              <phase>package</phase>
            </execution>
          </executions>
        </plugin>
      </plugins>
    </build>
  </profile>
</profiles>
<plugins>
  <!-- ... -->
  <plugin>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-maven-plugin</artifactId>
    <configuration>
      <classifier>exec</classifier>
    </configuration>
  </plugin>
</plugins>

Auch für das lokal erzeugte Native Image muss die bereits erwähnte Spring Native Dependency ergänzt werden (Listing 1) sowie die Spring-AOT-Erweiterung (Listing 2). Diese beiden Erweiterungen im Build sind also für beide Varianten (Buildpack und lokaler Build) wichtig und sinnvoll.

Der Build wird dann (im hier beschriebenen Beispiel) ausgeführt mit:

./mvnw -Pnative-image package

Daraufhin wird im target-Directory das Native Executable abgelegt, das direkt ausgeführt werden kann:

./target/demo

Dieser lokale Native-Image-Compile-Schritt läuft direkt auf der eigenen Maschine ab und verwendet das native-image-Kommando des lokal installierten GraalVM-SDKs. Führt man diesen Build also beispielsweise auf einer Windows-Maschine aus, wird ein Windows Binary erzeugt. Das ist ein bedeutender Unterschied zur Buildpack-basierten Native-Image-Kompilierung. Das Buildpack erzeugt ein Linux-basiertes Container-Image, in dem das Native Image erzeugt wird und per Docker-Runtime ausgeführt werden kann.

Die Roadmap

Die nächsten Schritte für das Spring-Native-Projekt sind zum einen, stetig weiter den Ressourcenverbrauch über die unterschiedlichsten Projekte und Bibliotheken zu reduzieren. Aktuell lassen sich zwar schon recht viele Spring-Boot-Starter-Module mit Spring Native verwenden, aber nicht alle sind schon komplett auf Speicherverbrauch und Performance optimiert. Hier liegt noch einige Arbeit vor dem Spring-Team.

Darüber hinaus arbeitet eine Reihe von Projekten daran, möglichst viel von Spring Native zu unterstützen und automatisch zur Build-Zeit zu erzeugen. Auch hier sind viele Verbesserungen zu erwarten.

Nicht zuletzt werden mit den nächsten Releases auch kontinuierlich mehr Spring-Boot-Starter-Module und deren Dependencys unterstützt werden. Die aktuelle Liste der unterstützten Module kann man in der Dokumentation einsehen [3].

Fazit

Spring Native kann für Spring-Boot-Entwickler zu einem echten Gamechanger werden. Mit Spring Native werden Entwickler von Spring-Boot-Anwendungen in die Lage versetzt, alle Vorteile der GraalVM-Native-Image-Technologie zu nutzen, ohne die Spring-Boot-Anwendungen speziell dafür zu modifizieren oder gar auf ein anderes Framework zu portieren. Existierende und bereits seit Jahren in der Entwicklung und im Einsatz befindliche Spring-Boot-Anwendungen können mit Spring Native von der neuen GraalVM-Native-Image-Technologie profitieren – und so unter Umständen erhebliche Ressourcen einsparen.

Ohne Frage, das Spring-Native-Projekt steht noch ziemlich am Anfang. Es lassen sich noch nicht alle Spring Boot Starter damit nutzen und auch von den unterstützten Projekten sind noch nicht alle komplett für diesen Einsatz optimiert. Aber die Arbeit an dem Projekt geht mit großen Schritten voran und das Ziel ist extrem vielversprechend.

Links & Literatur

[1] Spring Native: https://github.com/spring-projects-experimental/spring-native

[2] Paketo Buildpacks und Spring Boot: https://spring.io/blog/2021/01/04/ymnnalft-easy-docker-image-creation-with-the-spring-boot-maven-plugin-and-buildpacks + https://docs.spring.io/spring-boot/docs/current/reference/htmlsingle/#boot-features-container-images-buildpacks

[3] https://docs.spring.io/spring-native/docs/current/reference/htmlsingle/#support-spring-boot

The post Spring Native Hands-on appeared first on JAX 2022.

]]>
Kotlin ‒ das bessere Java? https://jax.de/blog/kotlin-das-bessere-java/ Mon, 07 Jun 2021 10:36:31 +0000 https://jax.de/?p=83667 Kotlin ist eine Programmiersprache, die im Februar 2016 in Version 1.0 das Licht der Welt erblickt hat. Im Jahr 2019 hat Google Kotlin zum „First-class Citizen“ der Android-Entwicklung erklärt und ihr damit zum Durchbruch verholfen. Doch was macht eigentlich Kotlin besonders und wieso lohnt sich ein Blick darauf auch für alte Java-Hasen, die nichts mit Android zu tun haben? Diese und weitere Fragen behandle ich in diesem Artikel.

The post Kotlin ‒ das bessere Java? appeared first on JAX 2022.

]]>
Eines gleich vorweg: Ich bin kein Kotlin-Fanboy und finde Java als Programmiersprache sehr gelungen. Meine tägliche Arbeit dreht sich vor allem um die Backend-Entwicklung auf der JVM, hauptsächlich in Java. Allerdings bin ich der Meinung, dass man auch öfter mal über den Tellerrand blicken und sich mit neueren Sprachen auf der JVM beschäftigen darf. Dieser Artikel geht davon aus, dass Sie bereits Java „sprechen“, und zeigt interessante Features von Kotlin aus dieser Sicht. Ich konzentriere mich dabei auf die meiner Meinung nach wichtigsten Features von Kotlin: Funktionen, Variablen, null safety, objektorientierte Programmierung, funktionale Programmierung und Interoperabilität mit Java.

Kotlin wird als Open-Source-Software unter der Apache-2.0-Lizenz von der Firma JetBrains entwickelt. Das ist die Firma, die auch die bekannte IDE IntelliJ IDEA vertreibt und entsprechend gut ist dort auch die Unterstützung für Kotlin. Es gibt aber auch Plug-ins für Eclipse und NetBeans. Kotlin wird bereits seit 2010 entwickelt, allerdings erlangte es erst 2016 mit Version 1.0 einige Bekanntheit. Der endgültige Durchbruch, zumindest in der Android-Entwicklung, kam 2019, als Google auf der Google I/O die Android-Entwicklung „Kotlin-first“ deklariert hat.

Kotlin ist eine moderne, statisch typisierte Programmiersprache, die Konzepte aus der objektorientierten und der funktionalen Programmierung vereint. Ein besonderes Augenmerk liegt auf der Kompatibilität mit Java-Code – dies ist auch eines der Differenzierungsmerkmale von Kotlin gegenüber Scala. Entwickler können in Kotlin problemlos Java-Bibliotheken verwenden und müssen dabei auch nicht Datentypen, wie z. B. Listen, konvertieren. Ebenso ist es möglich, aus Java heraus Kotlin-Code zu verwenden. Der Kotlin-Compiler erzeugt Bytecode für die JVM (Java Virtual Machine) – d. h. Programme, die in Kotlin geschrieben sind, können mit einer handelsüblichen Java-Installation ausgeführt werden. Es wird nur eine JVM in mindestens Version 1.6 benötigt.

Kotlin selbst vermarktet sich im Vergleich zu Java als „concise“, also ohne viel so genannten Boilerplate-Code. Kotlin-Code ist um ca. 40 Prozent kürzer als vergleichbarer Java-Code und damit ausdrucksstärker [1]. Außerdem hat Kotlin ein verbessertes Typsystem, das vor allem darauf abzielt, NullPointerExceptions zu vermeiden. Aber schauen wir uns diese Versprechen doch einmal genauer an.

Hello World, in Kotlin

Wir beginnen klassisch mit „Hello World“:

package wjax
 
fun main(args: Array<String>) {
  print("Hello world")
}

Genau wie Java ist Kotlin-Code in Packages verwaltet. Was in obigem Beispiel sofort auffällt: Es ist nicht notwendig, eine Klasse zu erstellen, um die main-Funktion zu definieren. Funktionen werden mit dem Keyword fun deklariert (kurz für „function“). Bei den Parametern wird zuerst der Name des Parameters (args), danach dessen Typ (Array<String>) angegeben. Es ist also genau umgekehrt zu Java. Arrays werden als generische Typen ausgedrückt und nicht, wie in Java, mit eckigen Klammern hinter dem Typ (z. B. String[]).

Im Funktions-Body benötigt man hinter den Statements keine Semikola, sie sind in Kotlin optional. In der Funktion findet sich nun ein Funktionsaufruf von print(…) ohne Objektinstanz. Es ist in Kotlin also möglich, Funktionen ohne zugehörige Objektinstanz aufzurufen. In der Standardbibliothek gibt es mehrere Funktionen, die keiner Klasse angehören. print(…) ist hier eine Abkürzung für System.out.print(…), das einem vermutlich bekannt vorkommt. Führt man diesen Code aus, erscheint auf der Konsole wie erwartet „Hello World“.

Typinferenz und Funktionen

In Listing 1 werden Variablen mit dem Keyword val deklariert. Den Typ der Variablen kann Kotlin automatisch per Typinferenz bestimmen (in diesem Fall Int, das Kotlin-Äquivalent zu Javas int). Wem das nicht gefällt, der kann den Typ getrennt mit einem Doppelpunkt explizit angeben: val operand1: Int = 1. Variablen, die mit val definiert werden, sind automatisch unveränderlich (immutable), ähnlich wie final in Java. Eine weitere Zuweisung würde einen Compilerfehler auslösen. Das ist ein wichtiges Konzept in Kotlin: Man sollte so viel wie möglich unveränderlich machen.

fun main(args: Array<String>) {
  val operand1 = 1
  val operand2 = 2
  val sum = operand1 + operand2
 
  print("$operand1 + $operand2 = $sum")
}

Die Typinferenz funktioniert auch, wenn mehrere Variablen zusammen verwendet werden – im Beispiel bestimmt der Ergebnistyp des +-Operators den Typ der Variablen sum (Int).

Im print(…)-Statement habe ich ein weiteres Feature von Kotlin verwendet, die sogenannte Stringinterpolation, die die Variablen operand1, operand2 und sum in einen String einbettet. Wenn das Programm ausgeführt wird, erscheint auf der Konsole 1 + 2 = 3.

private fun sum(operand1: Int, operand2: Int = 0): Int = operand1 + operand2
 
fun main(args: Array<String>) {
  val result1 = sum(1, 2)
  println("1 + 2 = $result1")
 
  val result2 = sum(1)
  println("1 + 0 = $result2")
}

In der ersten Zeile in Listing 2 ist eine Funktion namens sum deklariert. Der Modifier private gibt an, dass die Funktion nur in derselben Datei verwendet werden kann. Außerdem ist der Rückgabetyp der Funktion mit angegeben, hier Int. Den Rückgabetyp gibt man mit einem Doppelpunkt nach der Parameterdeklaration an. Im Beispiel ist auch die Kurzschreibweise für Funktionen verwendet, die auf geschweifte Klammern verzichtet und direkt mit = hinter der Signatur eingeleitet wird. Diese Schreibweise kann für alle Funktionen verwendet werden, die nur aus einer Zeile Code bestehen. Der Parameter operand2 ist außerdem standardmäßig auf 0 gesetzt, d. h., dass der Aufrufer ihn nicht explizit angeben muss. Optionale Parameter sind sicherlich ein Feature, das sich viele Java-Entwickler schon immer gewünscht haben.

In der main-Funktion sieht man den Aufruf von sum zuerst mit explizit gesetzten Argumenten 1 und 2. Der zweite Aufruf der Funktion sum lässt das zweite Argument weg, sodass der Standardwert des Parameters (0) verwendet wird. Wenn dieser Code ausgeführt wird, zeigt die Konsole

1 + 2 = 3
1 + 0 = 1

Null Safety

Das in meinen Augen beste Feature von Kotlin ist das verbesserte null-Handling. Ein oft gesehener Fehler bei (Java-)Programmen ist die NullPointerException. Sie tritt auf, wenn eine Variable null enthält, der Entwickler den Fall aber nicht behandelt hat und trotzdem auf die Variable zugreift. Kotlin verhindert dies durch eine Erweiterung des Typsystems: nullable Variablen haben einen anderen Typ als Variablen, die nicht null sein können. An einem Beispiel wird das anschaulicher.

fun formatName(firstName: String, midName: String?, lastName: String): String {
  var result = firstName.toLowerCase()
  result += midName.toLowerCase()
  result += lastName.toLowerCase()
 
  return result
}

In Listing 3 ist eine Funktion deklariert, die drei Parameter annimmt: firstName vom Typ String, midName vom Typ String? und lastName vom Typ String. Der Typ String? ist hier kein Tippfehler, sondern Kotlins Syntax für einen nullable String. Endet ein Typ mit einem Fragezeichen, bedeutet das, dass null als Wert erlaubt ist. Steht kein Fragezeichen am Typ, ist null auch kein valider Wert für diesen Typ.

Die Variable result wurde mit dem Keyword var deklariert – das bedeutet, dass die Variable änderbar (mutable, das Gegenteil von immutable) ist.

Listing 3 enthält außerdem einen Fehler, den Kotlin erkennt und so eine NullPointerException verhindert. Wenn der midName null ist, verursacht der Aufruf der toLowerCase()-Funktion eine NullPointerException. Da Kotlin weiß, dass midName null sein kann (durch den Typ String?), wird der Fehler zur Compilezeit erkannt und die Kompilierung abgebrochen.

Um den Code dennoch kompilieren zu können, ist die in Listing 4 enthaltene Änderung notwendig.

fun formatName(firstName: String, midName: String?, lastName: String): String {
  var result = firstName.toLowerCase()
  if (midName != null) {
    result += midName.toLowerCase()
  }
  result += lastName.toLowerCase()
 
  return result
}

Durch das if wird Kotlin nun „bewiesen“, dass der Entwickler den null-Fall behandelt hat, und der Aufruf der Funktion toLowerCase() ist innerhalb des if möglich. Im Typsystem hat die Variable midName in dem if-Block den Typ von String? (nullable) auf String (nicht nullable) geändert. Dieses Konzept bezeichnet Kotlin als Smart Casts, und man findet es auch an anderen Stellen, z. B. bei Typecasts. Der Aufruf der Funktion kann nun so stattfinden:

val name = formatName("Moritz", null, "Kammerer")
val name2 = formatName("Moritz", "Matthias", "Kammerer")
val name3 = formatName(null, "Matthias", "Kammerer")

Zeile 1 setzt den midName auf null, was durch den Typ String? erlaubt ist. Zeile 2 setzt den midName auf Matthias. Zeile 3 kompiliert nicht, da der Typ des ersten Parameters String ist – und null ist bei String nicht als Wert erlaubt.

Das null-Handling von Kotlin ist dabei nicht nur bei Referenztypen erlaubt, sondern auch bei primitiven Typen wie int, boolean etc. Kotlin unterscheidet im Gegensatz zu Java nicht zwischen primitiven und Referenztypen – so ist es auch möglich, auf int, boolean etc. Funktionen aufzurufen (Listing 5).

val count = 1
val enabled = true
val percentage = 0.2
 
println(count.toString())
println(enabled.toString())
println(percentage.toString())

Die Typen der Variablen im Beispiel sind übrigens Int, Boolean und Double – es gibt keine kleingeschriebenen Varianten wie int, boolean oder double wie in Java. Der Compiler kümmert sich um das Autoboxing, sollte es nötig sein.

Sehen wir uns nun an, was Kotlin in Sachen Objektorientierung zu bieten hat.

Objektorientierte Programmierung in Kotlin

Listing 6 definiert eine neue Klasse namens Square. Diese Klasse hat eine Property length vom Typ Int, die immutable ist. Als Property bezeichnet man dabei die Kombination von Feld und dessen Zugriffsfunktionen. Kotlin generiert hier automatisch eine Getter-Funktion sowie einen Konstruktorparameter für length. Würde man statt val ein var verwenden, würde Kotlin noch eine Setter-Funktion generieren. Es ist zudem noch die Funktion area() definiert, die die Property length verwendet, um den Flächeninhalt auszurechnen.

class Square(val length: Int) {
  fun area(): Int = length * length
}

Interessanterweise ist diese Klasse automatisch public – ein fehlender Modifier bedeutet also public, nicht wie bei Java package protected. Außerdem wird die Klasse automatisch als nicht vererbbar markiert (final in Java). Wenn man dies nicht möchte, kann man das Keyword open verwenden. Ein Aufrufer kann diese Klasse nun so verwenden:

val square = Square(4)
 
val length = square.length
val area = square.area()
 
println("Length: $length, Area: $area")

Zeile 1 erstellt eine neue Instanz der Klasse Square mit length = 4. Kotlin hat kein new-Keyword, was ich persönlich etwas schade finde – ich habe des Öfteren schon Java-Code nach new durchsucht, um Objektinstanziierungen zu finden. Klar, hier würde auch die IDE helfen, aber bei Code-Reviews auf z. B. GitHub oder GitLab hat man diese ja nicht unbedingt.

Auf Properties greift man einfach mittels des Punkt-Operators zu, analog zum Feldzugriff in Java. Unter der Haube wird allerdings die Getter-Funktion aufgerufen. Aufrufe von Funktionen sehen genau aus wie in Java.

Listing 7 zeigt eine Vererbungshierarchie: Die Klasse Square erbt von der abstrakten Klasse Shape, die eine abstrakte Funktion namens area() definiert, die Square implementiert.

abstract class Shape {
  abstract fun area(): Int
}
 
class Square(val length: Int): Shape() {
  override fun area(): Int = length * length
}

In Kotlin gibt es kein extends-Keyword, man verwendet den Doppelpunkt. Außerdem ist override ein Keyword, nicht wie in Java eine Annotation. Es ist zudem möglich, mehrere public-Klassen in einer Datei zu definieren und diese Datei beliebig zu benennen. Das kann in manchen Situationen Vorteile haben, könnte aber auch zu Nachteilen wie erschwerter Navigation im Code führen. Die Kotlin Coding Conventions haben dazu eine Empfehlung [2]: Wenn die Klassen semantisch zusammengehören und die Datei nicht zu viele Zeilen hat, ist es okay, die Klassen in eine Datei zu schreiben.

Interessanterweise kennt Kotlin das Keyword static nicht. Es können also keine statischen Funktionen definiert werden. An deren Stelle treten die beiden Konzepte Singletons und Companion Objects. Ein Singleton, in Kotlin object genannt, ist eine Klasse, die nicht instanziiert werden kann und von der immer genau eine Instanz existiert:

object FixedSquare: Shape() {
  override fun area(): Int = 25
}

Singletons werden mit dem Keyword object definiert und Aufrufer müssen (und können) keine Instanzen davon erzeugen. Der Funktionsaufruf sieht aus wie ein Aufruf einer statischen Funktion in Java:

val area = FixedSquare.area()

Will man nun eine Klasse mit einer „statischen“ Funktion ausstatten, sieht das so aus:

class Square(val length: Int): Shape() {
  override fun area(): Int = length * length
 
  companion object {
    fun create() : Square = Square(5)
  }
}

Die statischen Funktionen der Klasse, in diesem Fall nur create(), befinden sich in einem Companion Object und können in einer Weise aufgerufen werden, die wie static in Java aussieht:

val area = Square.create()

Ich persönlich finde die Lösung mit dem Companion Object etwas seltsam, und ich bin damit auch nicht allein [3]. Die Designer begründen die Entscheidung damit, dass es Top-Level-Funktionen gibt und man damit kaum mehr statische Funktionen benötigt. Außerdem ist ein Companion Object ein vollwertiges Objekt, kann also auch z. B. Interfaces implementieren und in einer Referenz gespeichert werden.

Was ich hingegen hervorragend gelöst finde, ist das Verhalten von if, try etc. Diese Kontrollstrukturen sind in Java Statements, keine Expressions. Der Unterschied: Ein Statement ist ein Stück Code, das keinen Rückgabetyp hat, während eine Expression einen Wert liefert. Wie oft haben wir in Java schon Code dieser Art geschrieben:

boolean green = true;
String hex;
if (green) {
  hex = "#00FF00";
} else {
  hex = "#FF0000";
}

Das Problem hierbei ist, dass in Java ein if keinen Wert zurückgeben kann. Java löst dieses Problem speziell für if mit dem ternären Operator ? (z. B. String hex = green ? “#00FF00” : “#FF0000”). In Kotlin braucht es diesen Operator nicht (Listing 8).

val green = true
val hex = if (green) {
  "#00FF00"
} else {
  "#FF0000"
}
 
// Oder kürzer:
val hex = if (green) "#00FF00" else "#FF0000"

Das Konzept ist dabei nicht auf if beschränkt, sondern funktioniert z. B. auch mit try-catch (Listing 9).

val input = "house"
 
val result = try {
  input.toInt()
} catch (e: NumberFormatException) {
  -1
}

Hier wird auf dem String input die Funktion toInt() aufgerufen. Diese Funktion wirft, falls input kein gültiger Integer ist, eine NumberFormatException. Die Exception wird mit dem umschließenden try-catch gefangen, das im Fehlerfall -1 zurückgibt. Weil try-catch eine Expression ist, also einen Wert zurückgibt, wird der Wert (entweder der Integer-Wert des Strings oder -1 im Fehlerfall) der Variablen result zugewiesen. Dieses Feature habe ich in Java schon sehr oft vermisst, und tatsächlich brachte Java 12 so etwas Ähnliches auch mit den Switch-Expressions [4]. Apropos Exceptions: Kotlin hat, im Gegensatz zu Java, keine Checked Exceptions.

In Listing 9 wird die Funktion toInt() auf einem String aufgerufen. Diese Funktion existiert im JDK nicht auf der Klasse String. Woher kommt sie also? Die Lösung des Geheimnisses heißt Extension Functions.

Extension Functions

In Kotlin kann man beliebige Typen, auch wenn sie nicht im eigenen Code definiert sind, durch Funktionen erweitern.

private fun String.isPalindrome(): Boolean {
  return this.reversed() == this
}

In obigem Beispiel wird auf dem Typ String die Funktion isPalindrome() definiert. Diese Funktion gibt true zurück, falls der String ein Palindrom ist. Ein Palindrom ist ein Wort, das man sowohl von vorne als auch von hinten lesen kann, z. B. „anna“ oder „otto“.

Der String, der geprüft werden soll, ist in der Referenz this zu finden. Anders als in Java lassen sich in Kotlin Strings (und alle anderen Objekte) mit dem ==-Operator vergleichen. In Kotlin ruft der ==-Operator equals() auf und vergleicht nicht wie in Java die Referenzen der Objekte. Sollte man den Referenzvergleich in Kotlin brauchen, so existiert der ===-Operator. Die Funktion in obigem Beispiel ist in der längeren Schreibweise geschrieben, mit geschweiften Klammern und einem expliziten return. Da die Funktion nur aus einer Zeile besteht, könnte man aber auch die Kurzschreibweise verwenden. Man kann die Extension Function nun folgendermaßen aufrufen:

import wjax.isPalindrome
 
val palindrom = "anna".isPalindrome()
println(palindrom)

Es sieht so aus, als ob String eine weitere Funktion namens isPalindrome() besitzt. Extension Functions sind ein praktisches Werkzeug, um vorgegebene Typen um weitere Funktionen zu erweitern und damit den Code lesbarer zu machen. Übrigens: Der Aufrufer muss die Extension Function explizit über ein import-Statement importieren, damit er sie nutzen kann.

Functional Programming

Funktionale Konzepte kamen in Java 8 mit den Streams. Kotlin erlaubt es ebenfalls, einen funktionalen Programmierstil zu verwenden (Listing 10).

val names = listOf("Moritz", "Sebastian", "Stephan")
val namesWithS = names
  .map { it.toLowerCase() }
  .filter { it.startsWith("s") }
namesWithS.forEach { name -> println(name) }

Die erste Zeile von Listing 10 erstellt eine neue Liste mit drei Strings, Kotlin inferiert dabei den Typ der Liste als List<String>.

Die map-Funktion transformiert jedes Element in einen String mit Kleinbuchstaben, Lambdas gibt man dabei mit geschweiften Klammern an. Erhält das Lambda nur einen Parameter (in diesem Beispiel der String, der transformiert wird), ist der Name des Parameters nicht notwendig. Möchte man in diesem Fall auf den Parameter zugreifen, kann man den Variablennamen it verwenden.

Nach der map-Funktion selektiert die filter-Funktion nur die Strings, die mit einem S anfangen. Das Resultat dieser ganzen Pipeline wird in der Variablen namesWithS gespeichert. Der Typ dieser Variable ist ebenfalls List<String>. Im Gegensatz zu Java arbeiten die funktionalen Operatoren direkt auf Listen, Sets etc. und nicht auf Streams, die dann zu Listen, Sets etc. umgewandelt werden müssen. Wünscht man dasselbe Verhalten wie Java Streams, vor allem die Lazy Evaluation, dann stehen Sequences bereit.

In der letzten Zeile ist ein Lambda mit einem expliziten Parameternamen name definiert, und die Namen werden auf der Konsole ausgegeben:

sebastian
stephan

Auch weitere funktionale Konstrukte wie zip, reduce und foldLeft lassen sich mit Kotlin über vordefinierte Funktionen verwenden (Listing 11).

val firstNames = listOf("Moritz", "Sebastian", "Stephan")
val lastNames = listOf("Kammerer", "Weber", "Schmidt")
 
firstNames
  .zip(lastNames)
  .forEach { pair -> println(pair.first + " " + pair.second) }

Listing 11 erzeugt mittels der zip-Funktion aus zwei Listen (Typ List<String>) eine Liste vom Typ List<Pair<String, String>>. Kotlin bringt also schon ein paar generische Containertypen wie Pair, Triple etc. mit. Möchte man statt pair.first und pair.second lieber fachlich sprechende Namen verwenden, ist das auch möglich, aber etwas mehr Arbeit:

data class Person(
  val firstName: String,
  val lastName: String
)

In diesem Beispiel verwenden wir ein weiteres tolles Feature von Kotlin, die Data Classes. Diese Klassen erzeugen automatisch Getter (und ggf. Setter), hashCode()-, equals()- und toString()-Funktionen. Auch eine sehr praktische copy()-Funktion wird erzeugt, mit der man eine Kopie der Data Class erstellt und dabei auch einzelne Properties ändern kann. Data Classes sind vergleichbar mit den Records, die in Java 14 Einzug gehalten haben [5]. Eine Data Class kann nun folgendermaßen verwendet werden:

firstNames
  .zip(lastNames)
  .map { Person(it.first, it.second) }
  .forEach { person -> println(person.firstName + " " + person.lastName) }

Die map-Funktion transformiert das Pair<String, String> in eine Person-Instanz, und in der forEach-Funktion kann man nun die fachlich richtigen Namen verwenden. Data Classes sind nicht nur beim funktionalen Programmieren hilfreich, ich z. B. verwende sie sehr oft für DTOs (Data Transfer Objects), Klassen, die nur aus Feldern und Zugriffsfunktionen bestehen.

Die Kotlin-Designer beheben übrigens auch ein Versäumnis der Java-Entwickler: Listen, Maps, Sets etc. sind in Kotlin standardmäßig unveränderlich, haben also keine add-, remove-, set– etc. Funktionen. Trotzdem zeigt auch hier Kotlin seinen pragmatischen Kern, denn es gibt auch veränderbare Listen in Form von MutableList, MutableSet, MutableMap etc. Diese veränderbaren Varianten erben von ihrem unveränderlichen Pendant.

Interoperabilität mit Java

Ein wichtiger Punkt bei Kotlin ist die Zusammenarbeit mit bestehendem Java-Code. Kotlin verzichtet z. B. auf die Einführung eines eigenen Collection Frameworks, sodass Java-Funktionen, die Listen erwarten, auch mit den „Kotlin-Listen“ aufgerufen werden können. Generell ist der Aufruf von Java-Code aus Kotlin kein Problem. An manchen Stellen wendet der Compiler auch etwas Magie an. So sind z. B. die Getter und Setter von Java-Klassen in Kotlin als Properties verwendbar:

public class Person {
  private String firstName;
  private String lastName;
  private LocalDate birthday;
  // Konstruktor, Getter und Setter
}

Diese in Java geschriebene Klasse lässt sich einfach in Kotlin verwenden:

val person = Person("Moritz", "Kammerer", LocalDate.of(1986, 1, 2))
 
println(
  person.firstName + " " + person.lastName + " wurde am " +
  person.birthday + " geboren"
)

Die getFirstName()-Funktion in Java ist nun als firstName Property in Kotlin verfügbar. Es werden sowohl Typen aus der Java-Bibliothek, wie hier im Beispiel LocalDate, unterstützt als auch eigens geschriebener Code in Java. Es ist sogar möglich, Kotlin und Java in einem Projekt zu mischen. Ich finde dieses Feature toll, weil man damit auch bestehende Projekte nach und nach zu Kotlin konvertieren kann (falls man dies möchte) und keine Big-Bang-Migration braucht. Man könnte auch, um Kotlin etwas kennenzulernen, den Produktivcode weiter in Java schreiben, den Testcode aber in Kotlin. IntelliJ bietet auch einen automatischen Java-zu-Kotlin-Konverter an. Dieser erzeugt zwar nicht optimalen Kotlin-Code, ist aber durchaus brauchbar.

Hochinteressant ist auch das null-Handling, wenn man Kotlin- und Java-Code mischt. Kotlin deutet dabei die diversen Nullability-Annotationen (@Nullable, @NotNull etc.) von Java. Wenn diese Annotationen allerdings fehlen, dann versagt die null Safety von Kotlin. In diesem Fall führt Kotlin für Java-Code sogenannte Platform Types ein, z. B. String!. Das Ausrufezeichen bedeutet, dass Kotlin nicht erkennen kann, ob der Typ null sein kann oder nicht. Der Kotlin-Programmierer kann bei Variablen dieses Typs dann so tun, als seien sie nicht nullable, muss aber mit NullPointerExceptions zur Laufzeit rechnen. Ein Beispiel macht dies klarer:

public class Person {
  private String firstName;
  private String middleName; // Nullable!
  private String lastName;
  // Konstruktor, Getter und Setter
}

In dieser in Java definierten Klasse ist middleName per fachlicher Definition nullable. Verwendet man diese Klasse in Kotlin, sucht der Kotlin-Compiler zuerst nach Nullability-Annotationen. Da diese Klasse keine solche Annotationen besitzt, ist der Typ von firstName, middleName und lastName String!, d. h., dass diese Variablen null sein könnten oder auch nicht. Beim Verwenden dieser Java-Klasse in Kotlin sind dann wieder NullPointerExceptions möglich:

val person = Person("Moritz", null, "Kammerer")
println(person.middleName.toLowerCase())

Das obige Listing ruft auf der Variablen middleName die Funktion toLowerCase() auf. Dieser Code kompiliert und lässt sich auch ausführen, führt aber zur Laufzeit zu einer NullPointerException:

java.lang.NullPointerException: person.middleName must not be null

Immerhin hat die NullPointerException eine sinnvolle Fehlermeldung und zeigt genau an, was null war. Auch bei den Platform Types zeigt sich wieder die pragmatische Natur von Kotlin: Die Sprachdesigner hätten ja auch alle Typen, deren Nullability nicht bekannt ist, als nullable annehmen können. Dies hätte aber, wenn man Java-Code in Kotlin verwenden möchte, zu jeder Menge (vermutlich unnötigen) null-Checks geführt und damit die Interoperabilität mit Java um einiges erschwert. Die jetzige Lösung empfinde ich als gut gewählten Trade-off. Viele Java-Bibliotheken und Frameworks setzen auch schon die Nullability-Annotationen ein, so z. B. Spring. Wenn man mit diesen Bibliotheken in Kotlin arbeitet, merkt man nicht, dass sie in Java geschrieben sind – auch die null Safety ist wieder gegeben.

Fazit

Ich finde, dass Kotlin eine sehr gelungene Programmiersprache ist. Als Java-Entwickler fühlt man sich sofort heimisch. Viele Probleme sind pragmatisch gelöst. Kotlin nimmt sich dabei vieler kleinerer „Probleme“ an, die Java hat. In aller Fairness muss man aber sagen, dass Java für diese „Probleme“ entweder gute Workarounds hat (z. B. Codegenerierung über die IDE oder Lombok) oder die Java-Entwickler bereits Lösungen dafür geschaffen haben (Switch Expression, Multiline Strings, Records, NullPointerExceptions).

Ein großer Pluspunkt von Kotlin, der in Java so nicht zu finden ist, ist das null-Handling. Meiner Meinung nach hat Kotlin dafür eine perfekte Lösung geschaffen – zumindest solange man sich in purem Kotlin-Code bewegt. Wenn man Java-Interoperabilität benötigt, um z. B. eigenen Java-Code aufzurufen oder eine der vielen tollen Bibliotheken aus dem Java-Umfeld (wie Spring Boot) zu verwenden, funktioniert das auch gut – man muss aber bei den Platform Types aufpassen.

Als Backend-Entwickler habe ich mehrere Projekte mit Spring Boot und Kotlin umgesetzt, und es hat viel Spaß gemacht – Kotlin ist also keineswegs nur auf Android-Entwicklung beschränkt. Mit Kotlin fühlt man sich mindestens so produktiv wie in Java, und die Standardbibliothek hat viele nützliche kleine Helferlein. Zugegeben, der Kotlin-Compiler ist langsamer als der von Java, aber die Features machen das wieder wett. Aus Sicht des Software-Engineerings macht die Sprache auch vieles richtig: hervorragende IDE-Unterstützung und das Verwenden der Java-üblichen Buildtools Maven und Gradle. Die Unterstützung von statischen Codeanalysetools wie SonarQube lässt allerdings noch zu wünschen übrig.

Kotlin hat noch ein paar mehr tolle Features zu bieten, die ich aus Platzmangel hier nicht mehr beschrieben habe. Wer jetzt Lust auf mehr bekommen hat, dem empfehle ich die Kotlin Koans [6]. Mit diesen kann man anhand von kleinen vorgegebenen Problemstellungen Kotlin lernen, und das, ohne den Browser verlassen zu müssen.

Und um die Frage der Artikelheadline zu beantworten: Java ist und bleibt eine tolle Programmiersprache, die gerade in der letzten Zeit ordentlich den Feature-Hahn aufgedreht hat. Kotlin ist allerdings auch nicht zu verachten und gefällt durch gut durchdachte und an Problemen der echten Welt orientierten Features. Also: Kotlin oder Java? Die Antwort lautet wie immer beim Software-Engineering: „It depends“.

Links & Literatur

[1] https://kotlinlang.org/docs/faq.html

[2] https://kotlinlang.org/docs/coding-conventions.html

[3] https://discuss.kotlinlang.org/t/what-is-the-advantage-of-companion-object-vs-static-keyword/4034

[4] https://openjdk.java.net/jeps/361

[5] https://openjdk.java.net/jeps/359

[6] https://play.kotlinlang.org/koans/overview

The post Kotlin ‒ das bessere Java? appeared first on JAX 2022.

]]>