The post Flexibel, kollaborativ, modern: Diana Montalions Vision für Systemarchitektur auf der W-JAX appeared first on JAX.
]]>The post Flexibel, kollaborativ, modern: Diana Montalions Vision für Systemarchitektur auf der W-JAX appeared first on JAX.
]]>The post JavaFX-Animationen auf dem Prüfstand: Node oder Canvas? appeared first on JAX.
]]>Ich habe schon mehrmals gelesen, dass ein Canvas für diese Art von Anwendungsfall viel effizienter sein kann, und das hat mich dazu veranlasst, eine Testanwendung mit „Bouncing Balls“ (Abb. 1) zu erstellen, die es einfach macht, Nodes und Canvas zu vergleichen.
Abb. 1: Meine Testanwendung
In JavaFX sind sowohl Nodes als auch Canvas Teil des Scene Graphs, aber sie haben unterschiedliche Use Cases. Die Wahl zwischen den beiden hängt oft von den spezifischen Anforderungen Ihrer Anwendung ab. Sie verwenden Nodes für statische Inhalte wie Eingabeformulare, Datentabellen, Dashboards mit Diagrammen … Das ist in der Regel bequemer und effizienter. Das Canvas bietet Ihnen mehr Flexibilität, wenn Sie dynamische oder benutzerdefinierte Inhalte erstellen müssen.
javafx.scene.Node ist die Basisklasse und alle visuellen JavaFX-Komponenten erweitern sie. Das geht mehrere „Schichten“ tief. Zum Beispiel Button > ButtonBase > Labeled > Control > Region > Parent > Node.
Zusammengefasst:
Das sind einige typische Komponenten, die von Node abgeleitet sind:
Label label = new Label("Hello World!");
Button button = new Button("Click Me!");
javafx.scene.canvas erweitert ebenfalls Node, fügt aber spezielle Funktionen hinzu. Sie können Ihren eigenen Inhalt auf dem Canvas zeichnen, indem Sie eine Reihe von Grafikbefehlen verwenden, die von einem GraphicsContext bereitgestellt werden.
Zusammengefasst:
In diesem Beispiel wird ein Rechteck gezeichnet:
Canvas canvas = new Canvas(400, 300); GraphicsContext gc = canvas.getGraphicsContext2D(); gc.setFill(Color.BLUE); gc.fillRect(50, 50, 100, 70);
Die Demoanwendung kann im GitHub Gist unter [2] gefunden werden. Sie enthält Code, um eine Menge sich bewegender Kreise zu erzeugen – sowohl als Nodes als auch gezeichnet auf einem Canvas. Der Wert am Anfang des Codes definiert, welcher Ansatz verwendet wird:
private static int TYPE_OF_TEST = 1; // 1 = Nodes, 2 = Canvas
Wenn Sie Nodes verwenden, wird dem Bildschirm ein Bereich hinzugefügt, in dem Bälle eingefügt werden. Bei jedem Ball handelt es sich um einen Circle Node mit einer Bewegungsmethode (Listing 1).
class BallNode extends Circle { private final Color randomColor = Color.color(Math.random(), Math.random(), Math.random()); private final int size = r.nextInt(1, 10); private double dx = r.nextInt(1, 5); private double dy = r.nextInt(1, 5); public BallNode() { this.setRadius(size / 2); this.setFill(randomColor); relocate(r.nextInt(380), r.nextInt(620)); } public void move() { if (hitRightOrLeftEdge()) { dx *= -1; // Ball hit right or left } if (hitTopOrBottom()) { dy *= -1; // Ball hit top or bottom } setLayoutX(getLayoutX() + dx); setLayoutY(getLayoutY() + dy); } ... }
Wenn Sie das Canvas verwenden, ist jeder Ball ein Datenobjekt, und alle Bälle werden bei jedem Tick auf das Canvas gezeichnet (Listing 2).
class BallDrawing { private final Color fill = Color.color(Math.random(), Math.random(), Math.random()); private final int size = r.nextInt(1, 10); private double x = r.nextInt(APP_WIDTH); private double y = r.nextInt(APP_HEIGHT - TOP_OFFSET); private double dx = r.nextInt(1, 5); private double dy = r.nextInt(1, 5); public void move() { if (hitRightOrLeftEdge()) { dx *= -1; // Ball hit right or left } if (hitTopOrBottom()) { dy *= -1; // Ball hit top or bottom } x += dx; y += dy; } ... }
Die Anwendung verwendet eine Timeline, um alle fünf Millisekunden weitere Objekte hinzuzufügen und sie zu verschieben (Listing 3).
Timeline timeline = new Timeline(new KeyFrame(Duration.millis(5), t -> onTick())); timeline.setCycleCount(Timeline.INDEFINITE); timeline.play(); private void onTick() { if (TYPE_OF_TEST == 1) { // Add ball nodes to the pane for (var i = 0; i < ADD_BALLS_PER_TICK; i++) { paneBalls.getChildren().add(new BallNode()); } // Move all the balls in the pane for (Node ballNode : paneBalls.getChildren()) { ((BallNode) ballNode).move(); } } else if (TYPE_OF_TEST == 2) { // Add balls to the list of balls to be drawn for (var i = 0; i < ADD_BALLS_PER_TICK; i++) { ballDrawings.add(new BallDrawing()); } // Clear the canvas (remove all the previously balls that were drawn) context.clearRect(0.0, 0.0, canvas.getWidth(), canvas.getHeight()); // Move all the balls in the list, and draw them on the Canvas for (BallDrawing ballDrawing : ballDrawings) { ballDrawing.move(); context.setFill(ballDrawing.getFill()); context.fillOval(ballDrawing.getX(), ballDrawing.getY(), ballDrawing.getSize(), ballDrawing.getSize()); } } }
Zum Ausführen der Anwendung habe ich folgenden Ansatz gewählt:
Natürlich hängt die Leistung vom System ab, auf dem Sie die Anwendung ausführen. Wie Sie im Video unter [6] und in Abbildung 2 sehen können, habe ich es sowohl auf einem Apple Mac Studio als auch auf einem Raspberry Pi 5 ausgeführt. Das Ergebnis ist konsistent, da man ungefähr zehnmal mehr Objekte zum Canvas verglichen mit der Anzahl der Nodes hinzufügen kann, bevor die Framerate einbricht. Das ist kein „wissenschaftliches Ergebnis“, aber es vermittelt einen guten Eindruck davon, was mit Canvas erreicht werden kann.
Abb. 2: Das laufende Experiment
Eine große Anzahl visueller Komponenten in einer typischen JavaFX-Benutzeroberfläche würde eine schlecht gestaltete Anwendung darstellen. Stellen Sie sich ein langes Registrierungsformular mit Hunderten von Eingabefeldern und Beschriftungen vor … Das würde Ihre Benutzer in den Wahnsinn treiben. Aber in anderen Fällen, in denen Sie eine komplexe Animation oder eine fortgeschrittene Benutzerschnittstellenkomponente erzeugen wollen, ist die Möglichkeit, auf dem Canvas zu zeichnen, ein idealer Ansatz.
[1] https://gluonhq.com/products/javafx/
[2] https://gist.github.com/FDelporte/c74cdf59ecd9ef1b14df86e08faa0c56
[3] https://www.azul.com/downloads/?package=jdk-fx#zulu
[6] https://www.youtube.com/watch?v=nJGRW5xP_AE
[7] https://leanpub.com/gettingstartedwithjavaontheraspberrypi/
[8] https://www.elektor.com/getting-started-with-java-on-the-raspberry-pi
The post JavaFX-Animationen auf dem Prüfstand: Node oder Canvas? appeared first on JAX.
]]>The post Erweiterte Streams in Java 23: Was JEP 473 Entwicklern bietet appeared first on JAX.
]]>Regelmäßig News zur Konferenz und der Java-Community erhalten
Mit JEP 473 wird eine Erweiterung des Stream API zur Unterstützung benutzerdefinierter Intermediate Operations umgesetzt. Darunter versteht man Verarbeitungsschritte wie das Filtern und Transformieren, die sich zu komplexeren Aktionen verbinden lassen. Bisher gab es zwar diverse vordefinierte Intermediate Operations, eine Erweiterungsmöglichkeit war allerdings nicht vorgesehen. Eine solche ist jedoch wünschenswert, um Aufgaben realisieren zu können, die zuvor nicht ohne Weiteres oder nur mit Tricks und eher umständlich umzusetzen waren.
Nehmen wir an, wir wollten alle Duplikate aus einem Stream herausfiltern und für ein Kriterium angeben. Um es einfach nachvollziehbar zu halten, betrachten wir einen Stream von Strings und als Kriterium deren Länge.
Hypothetisch wäre das wie folgt mit einer Intermediate Operation in Form einer fiktiven Methode distinctBy() bezüglich der Länge umsetzbar, indem man String::length als Kriterium definiert:
var result = Stream.of("Tim", "Tom", "Jim", "Mike"). distinctBy(String::length). // hypothetisch toList(); // result ==> [Tim, Mike]
Bitte beachten Sie, dass ich mich bei einigen Beispielen von jenen aus dem Original-JEP [1] inspirieren lassen und diese angepasst oder erweitert habe.
Schauen wir uns einmal an, wie sich Duplikate bezüglich der Stringlänge mit den bisherigen Möglichkeiten des Stream API vermeiden lassen (Listing 1) – den dazu ebenfalls benötigten Record DistinctByLength stelle ich weiter unter vor.
jshell> var result = Stream.of("Tim", "Tom", "Jim", "Mike"). ...> map(DistinctByLength::new). // #1 ...> distinct(). // #2 ...> map(DistinctByLength::str). // #3 ...> toList(); result ==> [Tim, Mike]
Schauen wir uns einmal an, wie sich Duplikate bezüglich der Stringlänge mit den bisherigen Möglichkeiten des Stream API vermeiden lassen (Listing 1) – den dazu ebenfalls benötigten Record DistinctByLength stelle ich weiter unter vor.
record DistinctByLength(String str) { @Override public boolean equals(Object obj) { return obj instanceof DistinctByLength(String other) && str.length() == other.length(); } @Override public int hashCode() { return str == null ? 0 : Integer.hashCode(str.length()); } }
Dieser Record ist lediglich ein Wrapper um einen String und besitzt dazu ein Attribut str sowie die korrespondierende Zugriffsmethode. Damit wir den Record für unseren Zweck verwenden können, müssen wir die Methoden equals() und hashCode() auf die Stringlänge ausgerichtet überschreiben. In der Implementierung von equals() verwenden wir das Pattern Matching bei instanceof in Kombination mit Record Patterns, wodurch sich der Sourcecode sehr kompakt halten lässt.
Ein weiteres Beispiel für den Bedarf an selbst definierten Intermediate Operations ist die Gruppierung der Daten eines Streams in Abschnitte fixer Größe. Zur Demonstration sollen jeweils vier Zahlen zu einer Einheit zusammengefasst, also gruppiert werden. Für unser Beispiel sollen nur die ersten drei Gruppen ins Ergebnis aufgenommen werden. Auch hier wird wieder der an den JEP angelehnte Sourcecode mit einer fiktiven Methode windowFixed() gezeigt (Listing 3).
record DistinctByLength(String str) { @Override public boolean equals(Object obj) { return obj instanceof DistinctByLength(String other) && str.length() == other.length(); } @Override public int hashCode() { return str == null ? 0 : Integer.hashCode(str.length()); } }
Im Lauf der Jahre ist aus der Java-Community einiges an Vorschlägen und Wünschen für Intermediate Operations als Ergänzung für das Stream API eingebracht worden. Oftmals sind diese in ganz spezifischen Kontexten sinnvoll. Hätte man sie alle ins JDK integriert, hätte dies das API allerdings ziemlich aufgebläht und den Einstieg in das (ohnehin schon umfangreiche) Stream API (weiter) erschwert. Um aber dennoch die Flexibilität benutzerdefinierter Intermediate Operations zu ermöglichen, wird ein ähnlicher Ansatz wie bei den Terminal Operations und dem Extension Point in Form der Methode collect(Collector) und des Interface java.util.stream.Collector verfolgt. Durch diese Kombination lassen sich Terminal Operations bei Bedarf individuell ergänzen.
Um flexibel neue Intermediate Operations bereitstellen zu können, offeriert das Stream API nun eine Methode gather(Gatherer) in Kombination mit dem Interface
java.util.stream.Gatherer. Wollten wir die zuvor besprochene distinctBy()-Funktionalität selbst realisieren, so könnten wir dazu einen eigenen Gatherer implementieren – das würde jedoch den Rahmen dieser Einführung sprengen.
Praktischerweise sind zur Umsetzung einiger Vorschläge und Wünsche aus der Java-Community nach spezifischen Intermediate Operations bereits ein paar Gatherer in das JDK aufgenommen worden. Sie sind in der Utility-Klasse java.util.stream.Gatherers definiert. Zum Nachvollziehen der Beispiele ist folgender Import nötig:
jshell> import java.util.stream.*
Um einen Stream in kleinere Bestandteile fixer Größe ohne Überlappung zu unterteilen, dient windowFixed() aus dem JDK. Greifen wir das zweite Beispiel aus der Einführung auf und schauen uns an, wie einfach es sich jetzt mit JDK-Basisfunktionalität realisieren lässt.
Nachfolgend wird per iterate() ein unendlicher Stream von Zahlen erzeugt und durch Aufruf von windowFixed(4) jeweils in Teilbereiche der Größe vier untergliedert. Mit limit(3) wird die Anzahl an Teilbereichen auf drei begrenzt und diese werden durch Aufruf von toList() in Form einer Liste als Ergebnis bereitgestellt (Listing 4).
jshell> var result = Stream.iterate(0, i -> i + 1). ...> gather(Gatherers.windowFixed(4)). ...> limit(3). ...> toList() resultNew ==> [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11]]
Beim Unterteilen in Bereiche fester Größe gibt es einen Spezialfall zu beachten: Enthält ein Datenbestand nicht genügend Elemente, um die gewünschte Teilbereichsgröße zu füllen, enthält der letzte Teilbereich weniger Elemente. Als Beispiel dient ein Stream mit einem durch Aufruf von of() erzeugten fixen Datenbestand der Werte 0 bis einschließlich 6. Dieser wird mit windowFixed(3) in Teilbereiche der Größe drei unterteilt, wodurch der letzte Teilbereich nur ein Element enthält, nämlich die Zahl 6 (Listing 5).
jshell> var result = Stream.of(0, 1, 2, 3, 4, 5, 6). ...> gather(Gatherers.windowFixed(3)). ...> toList() result ==> [[0, 1, 2], [3, 4, 5], [6]]
Neben dem Unterteilen in jeweils unabhängige Teilbereiche kann auch eine Untergliederung mit Überlagerungen von Interesse sein. Um einen Stream in kleinere Bestandteile fixer Größe mit Überlappung zu unterteilen, dient die Methode windowSliding() aus dem JDK.
Wieder wird per iterate() ein unendlicher Stream von Zahlen erzeugt und durch Aufruf von windowSliding(4) jeweils in Teilbereiche der Größe vier untergliedert, allerdings mit einer Überlappung bzw. Verschiebung um ein Element. Mit limit(3) wird die Anzahl an Teilbereichen auf drei begrenzt. Wie zuvor werden diese durch Aufruf von toList() in Form einer Liste als Ergebnis bereitgestellt (Listing 6).
jshell> var result = Stream.iterate(0, i -> i + 1). ...> gather(Gatherers.windowSliding(4)). ...> limit(3). ...> toList() result ==> [[0, 1, 2, 3], [1, 2, 3, 4], [2, 3, 4, 5]]
Betrachten wir die Auswirkungen auf das zuvor als Spezialfall aufgeführte Beispiel eines Streams wieder mit den Werten 0 bis inklusive 6. Statt mit windowFixed() wird hier windowSliding() genutzt. Dadurch wird der Datenbestand in sich überlappende Teilbereiche untergliedert. Dementsprechend tritt hier die Situation eines unvollständigen letzten Teilbereichs nicht auf, sondern es werden fünf Teilbereiche mit je drei Elementen erzeugt (Listing 7).
jshell> var result = Stream.of(0, 1, 2, 3, 4, 5, 6). ...> gather(Gatherers.windowSliding(3)). ...> toList() result ==> [[0, 1, 2], [1, 2, 3], [2, 3, 4], [3, 4, 5], [4, 5, 6]]
Bei dieser Art von Operation kann der zuvor behandelte Spezialfall eines Datenbestands mit einer nicht ausreichenden Menge an Elementen normalerweise nicht auftreten. Das ist lediglich dann möglich, wenn die Länge der Eingabe kleiner als die Window-Größe ist – in dem Fall besteht das Ergebnis aus der gesamten Eingabe, wie es nachfolgend für einen Datenbestand von drei Werten und eine Window-Größe von fünf zu sehen ist. Das Ergebnis ist eine Liste, die wiederum eine Liste mit drei Elementen enthält (Listing 8).
jshell> var resultNew = Stream.of(1, 2, 3). ...> gather(Gatherers.windowSliding(5)). ...> toList() resultNew ==> [[1, 2, 3]]
Dazu, nämlich die Werte eines Streams miteinander zu verknüpfen, dient die Methode fold(). Sie arbeitet ähnlich wie die Terminal Operation reduce(), die ein Ergebnis aus einer Folge von Elementen erzeugt, indem wiederholt eine Operation zur Kombination, beispielsweise + oder * für Zahlen, auf die Elemente angewendet wird. Dazu gibt man einen Startwert und eine Berechnungsvorschrift an. Diese Letztere fest, wie das bisherige Ergebnis mit dem aktuellen Element verknüpft wird.
Regelmäßig News zur Konferenz und der Java-Community erhalten
Nutzen wir dieses Wissen als Ausgangsbasis für ein Beispiel mit fold(). Damit lässt sich die Summe der Werte mit 0 als Startwert und einer Addition als Berechnungsvorschrift wie in Listing 9 berechnen.
jshell> var crossSum = Stream.of(1, 2, 3, 4, 5, 6, 7). ...> gather(Gatherers.fold(() -> 0L, ...> (result, number) -> result + number)). ...> findFirst() crossSum ==> Optional[28]
Bedenken Sie, dass gather() einen Stream als Ergebnis zurückgibt. Hier ist das ein einelementiger Stream. Um daraus einen Wert auszulesen, dient der Aufruf von findFirst(), der ein Optional<T> liefert, weil theoretisch der Stream auch leer sein könnte.
Als Berechnungsvorschrift können wir alternativ etwa eine Multiplikation mit einem Startwert von 1 nutzen und so für die spezielle Wertefolge von 1 bis 7 die Fakultät berechnen (Listing 10). Ganz allgemein handelt es sich um eine Multiplikation der gegebenen Zahlen (Listing 11).
jshell> var result = Stream.of(1, 2, 3, 4, 5, 6, 7). ...> gather(Gatherers.fold(() -> 1L, ...> (result, number) -> result * number)). ...> findFirst() result ==> Optional[5040]
jshell> var result = Stream.of(10, 20, 30, 40, 50). ...> gather(Gatherers.fold(() -> 1L, ...> (result, number) -> result * number)). ...> findFirst() result ==> Optional[12000000]
Was passiert, wenn wir zur Kombination der Werte auch solche Aktionen ausführen wollen, die nicht für die Typen der Werte, hier int, definiert sind? Als Beispiel wird ein Zahlenwert in einen String umgewandelt und dieser gemäß dem Zahlenwert durch Aufruf der Methode repeat() der Klasse String wiederholt (Listing 12).
jshell> var repeatedNumbers = Stream.of(1, 2, 3, 4, 5, 6, 7). ...> gather(Gatherers.fold(() -> "", ...> (result, number) -> result + ...> ("" + number).repeat(number))). ...> toList() repeatedNumbers ==> [1223334444555556666667777777]
Nur der Vollständigkeit halber seien hier die vorherigen Berechnungen als Varianten mit reduce() gezeigt. Weil reduce() eine Terminal Operation ist, lässt sie keine weitere Verarbeitung im Stream mehr zu – zudem funktionieren die Aktionen nur auf den Typen der Werte, womit sich das zuletzt gezeigte Beispiel nicht umsetzen lässt (Listing 13). Genau wie bei fold() werden bei reduce() ein Startwert und eine Berechnungsvorschrift angegeben. Daraus entsteht dann ein Ergebniswert.
jshell> var sum = Stream.of(1, 2, 3, 4, 5, 6, 7). ...> reduce(0, (result, number) -> result + number) sum ==> 28 jshell> var result = Stream.of(1, 2, 3, 4, 5, 6, 7). ...> reduce(1, (result, number) -> result * number) result ==> 5040 jshell> var result = Stream.of(10, 20, 30, 40, 50). ...> reduce(1, (result, number) -> result * number) result ==> 12000000
Sollen alle Elemente eines Streams zu neuen Kombinationen zusammengeführt werden, sodass jeweils immer ein Element dazukommt, kommt scan() zum Einsatz. Die Methode arbeitet ähnlich wie fold(), das die Werte zu einem Ergebnis kombiniert. Bei scan() wird dagegen für jedes weitere Element ein neues Ergebnis produziert.
Zunächst nutzen wir dies für die Ermittlung von Summen (Listing 14). Danach kombinieren wir Texte statt Ziffern nur durch Abwandlung des Startwerts, für den wir hier einen Leerstring nutzen, wodurch das + zu einer Stringkonkatenation wird (Listing 15).
jshell> var crossSums = Stream.of(1, 2, 3, 4, 5, 6, 7). ...> gather(Gatherers.scan(() -> 0, ...> (result, number) -> result + number)). ...> toList() crossSums ==> [1, 3, 6, 10, 15, 21, 28]
jshell> var crossSums = Stream.of(1, 2, 3, 4, 5, 6, 7). ...> gather(Gatherers.scan(() -> 0, ...> (result, number) -> result + number)). ...> toList() crossSums ==> [1, 3, 6, 10, 15, 21, 28]
Man könnte auch eine n-malige Wiederholung realisieren – dabei wird schön der Unterschied zu fold() deutlich (Listing 16).
jshell> var repeatedNumbers = Stream.of(1, 2, 3, 4, 5, 6, 7). ...> gather(Gatherers.scan(() -> "", ...> (result, number) -> result + ...> ("" + number).repeat(number))). ...> toList() repeatedNumbers ==> [1, 122, 122333, 1223334444, 122333444455555, 122 ... 3334444555556666667777777]
In diesem Artikel haben wir uns mit den Stream Gatherers als Preview-Feature beschäftigt. Zunächst habe ich erläutert, warum diese Neuerung für uns Entwickler nützlich und hilfreich ist. Danach wurden diverse bereits im JDK vordefinierte Stream Gatherers anhand von kleinen Anwendungsbeispielen vorgestellt. Insbesondere wurden auch Randfälle und Besonderheiten beleuchtet. Dadurch sollten Sie einen guten ersten Überblick gewonnen haben und fit genug sein, um eigene Experimente zu starten.
Neben den Stream Gatherers enthält Java 23 viele weitere Neuerungen, die die Programmiersprache voranbringen und attraktiver machen. Diese Modernisierung sollte dazu beitragen, dass Java weiterhin konkurrenzfähig bleibt und sich in modernen Anwendungsbereichen behauptet und insbesondere auch zu anderen derzeit populären Sprachen wie Python oder Kotlin aufschließt. Auch für komplette Newbies wird die Einstiegshürde gesenkt: Dank JEP 477 (Implicitly Declared Classes and Instance Main Methods (Third Preview)) lassen sich kleinere Java-Programme viel schneller und mit deutlich weniger Zeilen sowie für Anfänger schwierigen Begrifflichkeiten erstellen.
In diesem Sinne: Happy Coding mit dem brandaktuellen Java 23!
Regelmäßig News zur Konferenz und der Java-Community erhalten
The post Erweiterte Streams in Java 23: Was JEP 473 Entwicklern bietet appeared first on JAX.
]]>The post Java Core 2024: Ein umfassender Überblick appeared first on JAX.
]]>Regelmäßig News zur Konferenz und der Java-Community erhalten
Oracles Entscheidung, die Auslieferung neuer Java-Releases zu beschleunigen, wirkt sich auf das gesamte Ökosystem aus. Java 21 war aufgrund der Inklusion diverser Preview-Technologien ein besonders interessantes Release, das rasch angenommen wurde. Genauer: Nach der Auslieferung wurden binnen sechs Monaten 1,4 Prozent der überwachten Applikationen umgestellt – die Vorgängervariante Java 17 erreichte in der entsprechenden Zeitspanne nur eine Umstellung von 0,37 Prozent.
Relevant ist auch, dass Nicht-LTS-Versionen in den Ergebnissen von New Relic eine untergeordnete Rolle spielen: Weniger als 2 Prozent der untersuchten Programme nutzen sie produktiv.
Mindestens ebenso relevant ist die Frage, welches JDK beziehungsweise welche Runtime zur Ausführung verwendet wird – Oracle hat in der Vergangenheit durch eigenwillige lizenzpolitische Entscheidungen einiges an Goodwill verspielt, was sich in den Zahlen widerspiegelt (Abb. 1).
Abb. 1: Das Wachstum anderer Anbieter erfolgt fast ausschließlich auf Oracles Kosten (Bildquelle: [1])
Der Höhenflug von Amazon war dabei von kurzer Dauer, mittlerweile liegt der Großteil des Wachstums im Bereich des von der Eclipse Foundation verwalteten Adoptium. Azul Systems ist mehr oder weniger konstant, auch Red Hat und BellSoft erfreuen sich einer loyalen Nutzerschaft. SAP und Ubuntu konnten ihre (minimalen) Mindshare-Anteile indes nicht wirklich halten.
Neue Designparadigmen wie Serverless und Microservices wurden von der Java-Community schnellstmöglich umgesetzt bzw. angenommen. Das geht mit interessanten Veränderungen hinsichtlich des Ressourcenbedarfs der individuellen Komponenten eines Systems einher.
Neue Designparadigmen wie Serverless und Microservices wurden von der Java-Community schnellstmöglich umgesetzt bzw. angenommen. Das geht mit interessanten Veränderungen hinsichtlich des Ressourcenbedarfs der individuellen Komponenten eines Systems einher.
Kennzahl | 2023 [%] |
---|---|
1 bis 4 | 57,7 |
5 bis 8 | 25 |
9 bis 16 | 8,2 |
17 bis 32 | 4,1 |
33 bis 64 | 3,0 |
Mehr als 64 | 2,0 |
Tabelle 1: Verfügbare logische Prozessoren nach Java-Anwendungen im Jahr 2023
Im Bereich der JVM-Speicherzuweisungen setzt sich dieser Trend dagegen nicht fort. Sehr kleine VMs sind nach wie vor sehr beliebt, während sehr große Speicherbereiche eine (eher geringe) Schrumpfung zeigen. Der Gutteil der Systeme kommt indes mit weniger als 2 GB aus, was die im Markt vorherrschende Meinung von Java als Speicherfresser relativiert.
Insbesondere im Embedded-Bereich wird Java wegen der durch den Garbage Collector (GC) systemimmanenten „Denkpausen“ kritisiert. Die diversen JVM-Anbieter begegnen diesem Problem seit einiger Zeit durch neuartige GC-Algorithmen, die insbesondere auf mehrkernigen Systemen die Minderung der Probleme ermöglichen (Abb. 2). Zu berücksichtigen ist dabei, dass die verschiedenen VMs unterschiedliche Standardeinstellungen mitbringen – die Umstellung von Java 11 auf G1 und der damit einhergehende Zuwachs an darauf basierenden Systemen belegt, dass viele Installationen nach dem Prinzip „defaults are fine“ agieren.
Abb. 2: Garbage Collectors, die von Java-LTS-Versionen genutzt werden (Bildquelle: [1])
Der Rückgang im Bereich des Klassikers Serial ist im Zusammenhang mit der oben besprochenen Änderung an der Konstruktion der Systeme interessant – er hält das Gesamtsystem an, ist aber auf ressourcenbeschränkten Systemen am effizientesten.
Im Fall von G1 gilt aufgrund des Aufräumens kleiner Speicherbereiche, dass das System insbesondere auf nebenläufigen Maschinen für weniger Ärger sorgt. Der Garbage Collector kann seine Stärken vor allem dann ausspielen, wenn die JVM mehr als 4 GB Speicher zugewiesen hat. Die feinere Unterteilung macht die Aufräumprozesse effizienter.
Im Fall von G1 gilt aufgrund des Aufräumens kleiner Speicherbereiche, dass das System insbesondere auf nebenläufigen Maschinen für weniger Ärger sorgt. Der Garbage Collector kann seine Stärken vor allem dann ausspielen, wenn die JVM mehr als 4 GB Speicher zugewiesen hat. Die feinere Unterteilung macht die Aufräumprozesse effizienter.
Die Verfügbarkeit von Modularisierungssystemen wie Maven oder die in Gradle integrierte Artefaktverwaltung animieren Entwickler dazu, Komponenten aus dem Ökosystem zur Erfüllung der anstehenden Aufgaben heranzuziehen.
Die erste in diesem Zusammenhang wichtige Frage betrifft die Art der Datenspeicherung: Schon aus dem Enterprise-Fokus folgt, dass Java-Applikationen häufig mit Datenbankservern aller Sorten interagieren. Die Herkunft von Java aus dem Sun- bzw. Oracle-Umfeld spiegelt sich in einer klaren Marktdominanz der Oracle Database wider, die in fast 20 Prozent der von New Relic überwachten Java-Applikationen zum Einsatz kommt (Tabelle 2).
Datenbank | Nutzerschaft [%] |
---|---|
Oracle Database | 17,3 |
PostgreSQL | 14,4 |
MySQL | 12,5 |
MongoDB | 7,4 |
DynamoDB | 4,9 |
SQL Server | 4,4 |
Cassandra | 2,7 |
Elasticsearch | 2,5 |
MariaDB | 1,4 |
Redshift | 0,3 |
Tabelle 2: Die beliebtesten Datenbankserver [1]
Dabei dominieren klassische, auf SQL basierende Datenbanken: Werden die Anteile der drei Bestplatzierten addiert, erhält man einen Gesamtwert von 44,2 Prozent. An vierter Stelle folgt MongoDB; Java-Datenbanken wie die Graphdatenbank Neu4J sind überhaupt nicht auf den Rangplätzen anzutreffen.
Ein weiteres Thema betrifft den Verbreitungsgrad der Kryptographienutzung: Aus den von New Relic erhobenen Zahlen lässt sich ableiten, dass 41 Prozent der überwachten Applikationen auf die ein oder andere Weise auf eine Kryptographiebibliothek zurückgreifen. Das muss aber nicht unbedingt durch eine explizite Willensäußerung des Entwicklerteams bedingt sein, es ist genauso gut vorstellbar, dass die Bibliothek als Dependency einer anderen Bibliothek Eingang in die Build-Artefaktliste findet.
Jedenfalls zeigt die Verteilung der verwendeten Bibliotheken nur wenig Überraschendes (Abb. 3). Der erste Platz geht an den Klassiker Bouncy Castle, während die in diversen Spring-Frameworks inkludierte Spring Security den zweiten Platz einnimmt.
Abb. 3: Meistgenutzte Verschlüsselungsbibliotheken für Java-Anwendungen (Bildquelle: [1])
In seinem Report weist New Relic darauf hin, dass man ein baldiges deutliches Wachstum von Amazon Corretto erwartet. Ursache dafür ist demnach erstens die Vereinheitlichung der Software-Supply-Chain und zweitens die im Allgemeinen sehr gute Performance der diversen von Amazon implementierten Algorithmen.
Eine weitere im Report gestellte Frage betrifft die Art, wie Java-Applikationen Logging-Informationen sammeln. SLF4J wird dabei von 83 Prozent der Entwickler benutzt und damit ein Framework, das wie in Abbildung 4 schematisch dargestellt als Abstraktionsschicht zwischen der Applikation und dem jeweiligen Logging-Framework fungiert und zu einer Steigerung der Flexibilität beiträgt.
Abb. 4: SLF4J abstrahiert zwischen Applikationscode und dem jeweiligen Logging-Framework
Neben diesem genutzten Shortcut gilt, dass sich Log4j nach wie vor als absoluter Platzhirsch im Bereich der Logging-Frameworks etabliert hat: In 76,4 Prozent der von New Relic überwachten Applikationen findet sich eine Abhängigkeit auf diesen Universallogger. An zweiter Stelle steht JBoss (Abb. 5).
Abb. 5: Die beliebtesten Logging-Frameworks (Bildquelle: [1])
Dass die Anzahl der im Entwicklerfragedienst Stack Overflow zu bestimmten Technologien sichtbaren Interaktionen eine gute Benchmark für die Popularität der jeweiligen Technologie darstellt, soll in den folgenden Schritten als gegeben angenommen werden. Im Hause NewRelic bietet man mit GenAI seit einiger Zeit etwas Vergleichbares an, das auf Java-Entwickler fokussiert ist. Abbildung 6 zeigt, wie sich die Anfragen an diese KI über die verschiedenen Kategorien verteilen. Unter Learning versteht man dabei im Hause New Relic dabei nicht Machine Learning. Vielmehr handelt es sich um Fragen, die man auch als „How to“-Questions bezeichnen würde.
Abb. 6: Entwicklerfragen an die New-Relic-KI nach Themen (Bildquelle: [1])
Die von New Relic erhobenen Informationen geben Entwicklern und Nutzern einen Überblick über den Zustand der Java-Entwicklung als Ganzes. Die rasche Annahme neuer Technologien zeigt, dass Sorgen um das Ableben der Java-Entwicklung gelinde gesagt vollkommen übertrieben sind.
Regelmäßig News zur Konferenz und der Java-Community erhalten
[2] https://openjdk.org/jeps/376
The post Java Core 2024: Ein umfassender Überblick appeared first on JAX.
]]>The post Exactly Once in verteilten Systemen: Realität oder Utopie? appeared first on JAX.
]]>Ein Producer erstellt dabei Nachrichten, wie in Abbildung 1 gezeigt, während ein Consumer sie verarbeitet.
Abb.1: Einfacher Nachrichtenaustausch
Der Message Broker ist häufig ein zustandsbehaftetes System – eine Art Datenbank – und vermittelt Nachrichten zwischen Producer und Consumer. Als zustandsbehaftetes System hat ein Message Broker die Aufgabe, Nachrichten vorzuhalten und abrufbar zu machen. Ein Producer schreibt also Nachrichten in den Broker, während ein Consumer sie zu einer beliebigen Zeit lesen kann. Exactly once, also einmaliges Ausliefern, bedeutet, dass der Producer genau eine Nachricht produziert und der Consumer diese genau einmal verarbeitet. Also ganz einfach, oder?
Regelmäßig News zur Konferenz und der Java-Community erhalten
Da eine Kommunikation zwischen den Systemen über eine Netzwerkebene stattfindet, ist nicht gewährleistet, dass die Systeme über den gleichen Wissensstand verfügen. Es muss also einen Rückkanal geben, der einzelne Operationen bestätigt, um einen Zustand zu teilen. Nur so wird sichergestellt, dass Nachrichten, die erstellt wurden, auch korrekt angekommen und verarbeitet worden sind. Aktualisiert sieht der Fluss also eher so aus, wie es Abbildung 2 zeigt.
Abb. 2: Durch Bestätigungen wird der Zustand zwischen Systemen geteilt
Erstellt ein Producer eine Nachricht, die vom Consumer gelesen werden soll, benötigt dieser eine Bestätigung (Abb. 2, Schritt 2). Nur dadurch weiß der Producer, dass die Nachricht korrekt im Broker persistiert vorliegt und er sie nicht erneut übertragen muss.
Der Consumer wiederum liest die Nachricht und verarbeitet sie. Ist die Verarbeitung fehlerfrei abgeschlossen, bestätigt der Consumer das. Der Broker muss die Nachricht deshalb nicht noch einmal ausliefern.
Bei einer verteilten Kommunikation über ein Netzwerk kann es leider immer passieren, dass diverse Kommunikationskanäle abbrechen oder Fehler entstehen – und zwar an vielen Stellen: beim Erstellen, vor dem Konsumieren und danach. Genau diese Eigenschaft macht es so schwer oder gar unmöglich, eine Exactly-once-Semantik zu erreichen.
Angenommen, ein Producer produziert eine Nachricht (Abb. 3). Um sicherzustellen, dass diese auch vom Broker gespeichert wurde, wartet der Producer auf eine Bestätigung. Bleibt sie aus, ist nicht garantiert, dass der Broker diese Nachricht wirklich ausliefern wird.
Abb. 3: Der Producer erhält keine Bestätigung und sendet die Nachricht erneut
Der Producer muss diese Nachricht folglich erneut ausliefern. Genau das ist in Schritt 2 der Abbildung 3 auch geschehen, weshalb der Producer in Schritt 3 eine weitere Nachricht mit demselben Inhalt sendet. Da jetzt zwei Nachrichten vorliegen, verarbeitet der Consumer in den Schritt 4 und 5 beide Nachrichten – wohl eher nicht „exactly once“. Die Nachricht wird durch den Retry-Mechanismus „at least once“ – mindestens einmal, nicht genau einmal – übertragen. Denn wie im Bild zu erkennen ist, überträgt der Producer dieselbe Nachricht zweimal, um sicherzustellen, dass sie mindestens einmal vom Broker bestätigt wurde. Nur so ist sichergestellt, dass die Nachricht nicht verloren geht.
Natürlich kann die Bestätigung auch ignoriert werden. Schritt 2 kann also ausbleiben. Ein Retry-System würde folglich fehlen. Der Producer überträgt also eine Nachricht, ohne auf eine Bestätigung des Brokers zu warten. Kann der Broker die Nachricht selbst nicht verarbeiten oder wegspeichern, hat er keine Möglichkeit, das Fehlschlagen oder eine erfolgreiche Operation zu quittieren. Die Nachricht würde „at most once“ – maximal einmal oder eben keinmal – übertragen werden. Exactly once ist also grundsätzlich ein Problem verteilter Anwendungen, die mittels Bestätigungen funktionieren.
Leider ist das noch nicht das Ende der Fahnenstange, wenn die Nachricht Ende zu Ende, also vom Producer bis zum Consumer, betrachtet wird. Denn es existiert in einem solchen System zusätzlich ein Consumer, der die Nachrichten wiederum einmalig verarbeiten muss. Selbst wenn garantiert wird, dass der Producer eine Nachricht einmalig erzeugt, ist ein einmaliges Verarbeiten nicht garantiert.
Abb. 4: Ein Consumer verarbeitet die Nachricht und versucht diese danach zu bestätigen
Es kann passieren, dass der Consumer wie in Abbildung 4 gezeigt die Nachricht in Schritt 3 liest und in Schritt 4 korrekt verarbeitet. In Schritt 5 geht die Bestätigung verloren. Das führt dazu, dass die Nachricht mehrmals, aber mindestens einmal – at least once – verarbeitet wird.
Abb. 5: Ein Consumer bestätigt die Nachricht vor dem Verarbeiten
Es ist natürlich umgekehrt auch möglich, die Nachricht vor dem Verarbeiten zu bestätigen. Der Consumer lädt also die Nachricht und bestätigt sie direkt. Erst dann wird in Schritt 5 von Abbildung 5 die Bearbeitung der Nachricht erfolgen. Schlägt jetzt die Bearbeitung fehl, ist die Nachricht in Schritt 4 bereits bestätigt worden und wird nicht erneut eingelesen. Die Nachricht wurde wieder maximal einmal oder keinmal – at most once – verarbeitet.
Wie also zu erkennen, ist es leicht, At-most-once- und At-least-once–Semantiken in den verschiedenen Konstellationen sowohl auf Producer- als auch auf der Consumer-Seite herzustellen. Exactly once ist aber aufgrund der verteilten Systematik ein schwieriges Problem – oder gar unmöglich?
Für eine Möglichkeit, eine Exactly-once-Semantik zu erreichen, muss die Verarbeitung der Nachrichten einer Applikation eine bestimmte Eigenschaft unterstützen: Idempotenz. Idempotenz bedeutet, dass eine Operation, egal wie oft sie verarbeitet wird, immer dasselbe Ergebnis zur Folge hat. Ein Beispiel dieses Prinzips könnte das Setzen einer Variablen im Programmcode sein. Hier gibt es etwa die Möglichkeit, dies über Setter oder eben relative Mutationen zu implementieren.
Zum Beispiel setAge oder incrementAge. Die Operation person.setAge(14); kann beliebig oft nacheinander ausgeführt werden, das Ergebnis bleibt immer dasselbe, nämlich 14. Hingegen wäre person.incrementAge(1) nicht idempotent. Wird diese Methode unterschiedlich oft hintereinander ausgeführt, gibt es verschiedene Ergebnisse, nämlich nach jeder Ausführung ein Jahr mehr. Genau diese Eigenschaft der Idempotenz ist der Schlüssel, um eine Exactly-once-Semantik zu etablieren.
Angewandt auf die Systeme von zuvor bedeutet das, dass eine At-least-once-Semantik mit der Eigenschaft der Idempotenz zu einer Exactly-once-Verarbeitung führen kann. Wie eine At-least-once-Semantik umgesetzt werden kann, zeigt das zuvor beschriebene Bestätigungssystem. Was fehlt, ist also ein System von Idempotenz in der Verarbeitung. Aber wie kann eine Verarbeitung von Nachrichten idempotent gemacht werden?
Um das zu erreichen, muss der Consumer die Möglichkeit haben, einen lokalen, synchronisierten Zustand zu erhalten. Um den Zustand einer Nachricht zu erhalten, muss diese eindeutig identifizierbar sein. Nur so werden das Aufsuchen und eine Deduplizierung der Nachricht ermöglicht.
Abb. 6: Eine idempotente Verarbeitung
Anders als zuvor speichert der Consumer mit jedem Aufruf in Schritt 4 der Abbildung 6 die Nachricht zunächst in einer lokalen Zustandshaltung. An dieser Stelle kann, sofern die Nachricht bereits lokal vorhanden ist, ein erneutes Speichern vernachlässigt werden. In Schritt 5 wird die Nachricht bestätigt. Schlägt die Bestätigung fehl und wird die Nachricht folglich erneut übertragen, ist das kein Problem, da in Schritt 4 das erneute Speichern der Nachricht verhindert werden kann. An dieser Stelle lebt also die Idempotenz. Beim Bearbeiten kann der Consumer nun selbst entscheiden, ob eine Verarbeitung notwendig ist, z. B. indem zu einer Nachricht ein Status eingeführt und dieser in Schritt 6 lokal abgefragt wird. Steht dieser bereits auf Processed, muss nichts getan werden. Umgekehrt muss eine verarbeitete Nachricht den Status korrekt aktualisieren.
Regelmäßig News zur Konferenz und der Java-Community erhalten
Verteilte Systeme haben ein grundsätzliches Problem, eine Exactly-once-Semantik herzustellen. Es kann auf Infrastrukturebene entweder zwischen at least once oder at most once gewählt werden. Erst durch die Eigenschaft der Idempotenz kann auf dem Applikationslevel sichergestellt werden, dass Nachrichten genau einmal von Ende zu Ende verarbeitet werden.
Natürlich ist das nicht kostenlos. Es bedeutet, dass die Applikation selbst eine Verwaltung von Nachrichten übernehmen muss und deren Zustand verwaltet – wirklich exactly once ist das natürlich auch nicht, es kommt diesem durch die Eigenschaft der Idempotenz jedoch im Ergebnis sehr nahe.
The post Exactly Once in verteilten Systemen: Realität oder Utopie? appeared first on JAX.
]]>The post Java 22 … and beyond | JAX 2024 Keynote appeared first on JAX.
]]>Die Verbesserungen gehen aber weit über den Release-Zyklus hinaus. Bahnbrechende Innovationen wie Project Loom (leichtgewichtige virtuelle Threads) und ZGC (Low-Pause Garbage Collection) haben die Java-Entwicklung nachhaltig verändert.
Das Projekt Panama verbessert die Entwickler:innen-Erfahrung durch eine sicherere und leistungsfähigere Methode zur Interaktion mit nativen Codebibliotheken. Das Projekt Amber führt datenorientierte Programmierfunktionen wie Records und Pattern Matching ein und macht Java ausdrucksstärker.
Die Zukunft ist vielversprechend, aber Goetz räumt auch ein, dass es Herausforderungen gibt. Library-Upgrades können durch Abhängigkeiten von älteren Funktionen behindert werden. Um diese Lücke zu schließen, schlägt er einen schnelleren Release-Zyklus für das Library-Ökosystem vor, um mit der Innovation von Java Schritt zu halten.
Diese Keynote ist vollgepackt mit Erkenntnissen für Java-Entwickler:innen. Sehen Sie sich die Aufzeichnung an, um mehr über diese Fortschritte zu erfahren und zu sehen, was Java als Nächstes erwartet!
Regelmäßig News zur Konferenz und der Java-Community erhalten
The post Java 22 … and beyond | JAX 2024 Keynote appeared first on JAX.
]]>The post Java Module und JPMS: Endlich bereit für den Einsatz? appeared first on JAX.
]]>Abb. 1: Namespace Pollution
Lösungen für diese Probleme sowie eine Alternative für den mit Java 17 abgekündigten Security Manager [1] bieten Java-Module. Der Einsatz von Modulen ist eine grundlegende Entscheidung, da das Java-Modul-System nur ganz oder gar nicht verwendet werden kann. Wenn man es nutzt, kommen neben den Vorteilen auch grundsätzliche Veränderungen im Verhalten dazu. Im Open-Source-Projekt m-m-m [2] nutze ich das JPMS extensiv und mit großer Begeisterung. Ein weiteres großes Java-Open-Source-Projekt, das JPMS konsequent nutzt, ist Helidon [3]. Insgesamt wurde JPMS aber bisher vom Java Ecosystem nur sehr zögerlich angenommen.
Regelmäßig News zur Konferenz und der Java-Community erhalten
Dieser Artikel versteht sich als eine logische Fortsetzung von [4] und will Mut machen, sich mit dem JPMS auseinanderzusetzen. Er geht auf Details und Best Practices ein, die über die typischen Hello-World-Tutorials zu Java-Modulen im Web hinausgehen.
Begriffe wie Modul oder auch Komponente werden in der IT nicht präzise definiert und daher in verschiedenen Kontexten unterschiedlich verwendet, was zu großer Verwirrung führen kann. Relativ gut kann man sich darauf einigen, dass es sich dabei um geschlossene Funktionseinheiten mit definiertem API handelt und dadurch eine bessere Kapselung erreicht wird. In Java ist ein Modul eine Einheit, wie man es von einer JAR-Datei oder einem Maven-Modul kennt, die über einen spezifischen Moduldeskriptor verfügt (oder für die als Automatic Module ein Moduldeskriptor dynamisch erzeugt wird).
Dieser Deskriptor wird im Source Code in der Datei module-info.java im Default-Package definiert. Es handelt sich hierbei um ein völlig anderes Konstrukt als Class, Interface, Enum, Annotation oder Record. Beim Build wird aus dem Modul eine JAR-Datei, die im Wurzelverzeichnis eine module-info.class-Datei enthält. Der Modul-Deskriptor definiert eine ganze Reihe wichtiger Eigenschaften des Moduls:
Der Name des Moduls dient als globaler, weltweit eindeutiger Identifier, um das Modul zu referenzieren. Er folgt den gleichen Syntaxregeln wie ein Package, ist davon aber prinzipiell völlig unabhängig. Nutzer von Maven oder Gradle können es sich als Kombination von Group-ID und Artifact-ID vorstellen. Es hat sich als Best Practice herausgestellt, Package- und Modulnamen sowie Artifact-ID in Einklang zu bringen. Das bedeutet, dass sich der Modulname als Namensraum bzw. Präfix in den Packages, die im Modul enthalten sind, wiederfindet. Diese Konvention hat auch den Vorteil, dass Entwickler, die ein Modul verwenden möchten, einfach zwischen Modulnamen und Packages hin und her mappen können, ohne in einer externen Dokumentation nachschauen zu müssen. Eine Klasse aus einem bekannten Package zu verwenden, heißt zwar noch nicht, dass der Modulname mit dem Package-Namen übereinstimmt, aber wenn der Modulname ein Präfix davon ist, kann er in modernen IDEs mit Autovervollständigung gezielt gefunden werden.
Zudem sind alle Java-Entwickler bereits mit den Best Practices für Packages samt den Namensräumen als „umgedrehten Domainnamen“ vertraut. Zur Veranschaulichung stellen wir uns als konkretes Beispiel vor, dass wir für awesome-restaurant.com arbeiten und gerade das Backend für Onlinereservierungen und -bestellungen bauen. Der Java-Code verwendet für Bestellungen bereits Packages wie com.awesome_restaurant.online.order.domain oder com.awesome_restaurant.online.order.repository. Da ein Minuszeichen in Package-Namen nicht zulässig ist, wurde es beim Mapping der Domain auf Java-Packages durch einen Unterstrich ersetzt (man kann es natürlich auch weglassen oder durch einen Punkt ersetzen).
Möchte ich nun das Backend als Java-Modul bereitstellen, könnte meine module-info.java-Datei so aussehen:
module com.awesome_restaurant.online.order { requires jakarta.persistence; // ... exports com.awesome_restaurant.online.order.domain; // ... }
Abhängigkeiten werden in der module-info.java-Datei durch das Schlüsselwort requires eingeleitet. Am Ende folgt der Name des angeforderten Moduls. Im obigen Beispiel ist das jakarta.persistence, der neue Modulname der JPA aus Jakarta EE.
Optional kann requires noch einer der beiden folgenden Modifier folgen:
Damit kommen wir zur ersten starken Einschränkung bei der Verwendung des JPMS: In meinem Modul sehe ich fremden Code nur dann, wenn er aus einem Modul kommt, das als Abhängigkeit per requires importiert wird (inklusive transitiver Abhängigkeiten). Das betrifft sogar Klassen aus dem JDK – bis auf solche, die aus dem Modul java.base stammen. Das ist super, weil es das eingangs erwähnte Problem der Namespace Pollution löst.
Gleichzeitig bedeutet es aber auch, dass ich nur Code importieren und auf ihn zugreifen kann, wenn er selbst wiederum als Java-Modul bereitsteht. Und damit beginnt schon ein großes Dilemma: Die coole Open-Source-Bibliothek, die ich gerne nutzen möchte, ist kein Modul? Dann kann ich sie in meinem Modul erst mal nicht verwenden.
An dieser Stelle möchten wir die verschiedenen Arten von Java-Modulen differenzieren:
Die Spielregeln beim JPMS sind vom Prinzip her das Gegenteil von dem, was man als Java-Entwickler mit den normalen Classpaths gewohnt ist: Im normalen Java ist grundsätzlich alles sichtbar und erlaubt, außer, wir schränken es mit Schlüsselwörtern, wie z. B. private ein. Packages selbst sind immer public. Beim JPMS ist mein eigener Code zunächst nur innerhalb meines Moduls sichtbar. Will ich das ändern, muss ich Packages explizit exportieren. Hierzu verwenden wir in der module-info.java-Datei das Schlüsselwort exports, gefolgt von dem Package, das wir veröffentlichen möchten. Das geht nur für Packages, die im Modul auch existieren und nicht leer sind. Ich muss also jedes einzelne Package, das ich exportieren möchte, explizit auflisten.
Eine Syntax, die alle Packages automatisch exportiert, ist nicht vorgesehen. Das ist auch sinnvoll, denn so wird verhindert, dass ein nachträglich hinzugefügtes Package aus Versehen exportiert und damit öffentlich wird. Es geht hierbei um das Geheimnisprinzip, das der erfahrene Java-Entwickler aus der Softwarearchitektur kennt. Nur beim Automatic Module werden automatisch alle Packages exportiert und damit das gesamte Verhalten von JARs im Classpath für Module simuliert.
Die Restriktionen gehen aber noch weiter: Ich kann keinen Code in ein Package platzieren, in das bereits ein anderes Modul Code platziert – auch wenn ich als Autor dieses anderen Moduls alles unter meiner Kontrolle habe und sogar dann nicht, wenn das Package nicht exportiert ist. Dieses sogenannte Split-Package-Konstrukt wird in konventionellem Java manchmal eingesetzt, um Sichtbarkeiten bewusst zu hintergehen: Ich lege meine eigene Klasse ins Package org.hibernate, um an eine Methode von Hibernate zu kommen, die protected oder default als Sichtbarkeit hat. Und wenn dieser Hack nicht reicht, hole ich den Holzhammer raus und nutze Reflection und mache mit der Methode setAccessible(true) alles zugänglich. Um die Katze vollständig aus dem Sack zu lassen: Reflection geht in JPMS per Default auch nicht mehr, außer, sie wird explizit erlaubt.
Als ich mich zum ersten Mal mit dem JPMS auseinandergesetzt habe, war spätestens das der Moment, wo ich geschluckt und das Thema erstmal beiseite gewischt habe. Das ist vermutlich auch der Grund, warum viele Artikel im Web so negativ über das JPMS berichten und vom Einsatz abraten. Das ist aber sehr schade, denn meines Erachtens gründet diese Reaktion ausschließlich in der Macht der Gewohnheit.
IT ist ständig im Wandel und Patterns, die vor zehn oder zwanzig Jahren unser Denken bestimmt haben, sind heute überholt und wurden durch neue ersetzt. Aus Sicht von IT-Security und Zero Trust ist das Design des JPMS genial für einen Neustart. Sind nicht gerade Reflection und Serialisierung die Wurzel aller Übel der gravierenden CVEs in der Java-Welt? Und ist nicht die Verwendung von (Deep) Reflection zur Laufzeit spätestens seit dem Erscheinen von GraalVM, Quarkus und Co. ein Konstrukt des vergangenen Jahrtausends? Mit JEP 411 wurde der Security Manager beerdigt und man sollte sich die Frage stellen, ob man nicht auf einem sinkenden Schiff segelt, wenn man sich nicht mit dem JPMS auseinandersetzt.
Wer sich in diesem Sinne auf das JPMS einlassen kann, der wird sich nach einer gewissen Eingewöhnungszeit fragen, warum Java nicht von Anfang an so konzipiert wurde. Mit dem konkreten Ausprobieren kommt auch die Erfahrung, um die Vor- und Nachteile abwägen zu können und zu wissen, in welchem Vorhaben der Einsatz sinnvoll ist und wo eher nicht. Der Vollständigkeit halber sei noch erwähnt, dass das Schlüsselwort open vor module im Moduldeskriptor das ganze Modul per Reflection freigibt oder das Schlüsselwort opens dies nur für ein explizites Package erlaubt (analog zu exports). Im Allgemeinen rate ich von diesem Konstrukt jedoch ab.
Regelmäßig News zur Konferenz und der Java-Community erhalten
In meiner Zeit vor dem JPMS verwendete ich in meinen Packages Segmente wie api oder impl, um das auszudrücken, was ich ohne JPMS nicht ausdrücken konnte. Mit externen Tools wie SonarQube konnte ich dann ungewollte Zugriffe aus einem api-Package auf ein impl-Package aufdecken. Inzwischen kann ich auf künstliche Segmente wie api verzichten und über meinen Moduldeskriptor festlegen, welches Package zum API gehört und daher exportiert wird. Die Implementierungen können immer noch in impl-Sub-Packages liegen, wenn man es explizit machen möchte. Da diese für andere Module unsichtbar sind, können die Packages und sämtliche enthaltenen Klassen nach Belieben umbenannt werden, ohne Gefahr zu laufen, dass ein Nutzer des Moduls beim nächsten Release Compilerfehler erhält. Das Beste ist, dass alle diese internen Klassen und ihre Methoden nach Belieben public sein dürfen und der Spagat zwischen flexibler interner Nutzung und Verhinderung ungewollter externer Nutzung beendet ist.
Was aber, wenn mein Projekt aus vielen Modulen besteht und ich bestimmte Zugriffe für eigene Module erlauben und für fremde Module verbieten will? Kein Problem! Im Moduldeskriptor kann ich dazu beim exports-Statement nach dem Package und dem Schlüsselwort to einen oder mehrere Modulnamen angeben, auf die der Zugriff eingeschränkt wird. In unserem Beispiel könnte das so aussehen:
exports com.awesome_restaurant.online.order.repository to com.awesome_restaurant.online.order.app, com.awesome_restaurant.online.reservation.app;
Wichtig zum Verständnis ist, dass nach exports ein Package kommt und nach to Modulnamen. Wildcards, um gleich einen ganzen Namensraum zu erlauben (z. B. com.awesome_restaurant.*), werden dabei leider nicht unterstützt.
In Java gibt es seit der Version 1.6 den ServiceLoader, um Services dynamisch anzufordern, die sich über textuelle Dateien unter META-INF/services konfigurieren lassen. Das Konstrukt hat keine übermäßige Verbreitung gefunden und ist vielen Java-Entwicklern unbekannt. In Frameworks wie Spring oder Quarkus hat man mit Dependency Injection bereits mächtigere Werkzeuge für dieses Problem.
Mit dem JPMS erfährt der ServiceLoader aber ein Facelifting und damit eine Wiedergeburt: Im Moduldeskriptor können angebotene und genutzte Services Refactoring-stabil angegeben werden. Damit können Module Implementierungen einer Schnittstelle (oder abstrakten Klasse) anfordern, und beliebige andere Module können dazu Implementierungen bereitstellen. Dieses Konstrukt ermöglicht es, mit purem Java, unabhängig von irgendwelchen Frameworks (und Reflection Magic), dynamische Services umzusetzen. Egal ob mit ServiceLoader oder Frameworks wie Spring haben sich für mich zwei Muster herauskristallisiert:
In beiden Fällen füge ich im Modul, das auf den Service zugreifen möchte, ein uses-Statement in den Modul-Deskriptor ein und gebe dabei das Interface des Service an:
module org.search.engine { uses org.search.engine.extractor.TextExtractor; exports org.search.engine; exports org.search.engine.extractor; // ... }
Um im Code an Implementierungen des Service TextExtractor zu gelangen, erzeuge ich einen passenden ServiceLoader und iteriere über die verfügbaren Implementierungen:
ServiceLoader<TextExtractor> serviceLoader = ServiceLoader.load(TextExtractor.class); for (TextExtractor extractor : serviceLoader) { register(extractor); }
Dabei ist es wichtig, dass der Aufruf von ServiceLoader.load im Code des Moduls stattfindet, in dem das uses-Statement definiert ist. Wer eine generische Hilfsklasse in ein anderes Modul auslagert, die den ServiceLoader anhand eines übergebenen Class-Objekts lädt, wird das schmerzlich bemerken. Die Hilfsklasse habe ich in meinem Projekt trotzdem gebaut, muss aber den ServiceLoader außerhalb im verantwortlichen Modul erzeugen und ihn dann an die Hilfsklasse übergeben. Nutzen entsteht aber erst dann, wenn für den Service auch mindestens eine Implementierung bereitgestellt wird.
In einem anderen Modul implementieren wir dazu besagten PdfTextExtractor und stellen diese Implementierung über ein provides-Statement im Moduldeskriptor zur Verfügung:
module com.company.search.extractor.pdf { provides org.search.engine.extractor.TextExtractor with com.company.search.extractor.pdf.PdfTextExtractor; }
Eine Anwendung, die das Ganze im Zusammenspiel verwenden möchte, importiert die Suchmaschine samt der gewünschten Plug-in-Module:
module com.awesome_restaurant.online.app { requires org.search.engine; requires com.company.search.extractor.pdf; requires com.enterprise.search.extractor.xps; // ... }
Brauche ich den XPS-Support nicht mehr, entferne ich einfach das entsprechende requires-Statement. Will ich ein anderes Format unterstützen, lade ich das passende Plug-in über eine weitere requires-Anweisung (wobei das requires-Statement optional ist, solange sich das Modul mit dem Plug-in im Modulpfad befindet).
Hier noch einige Tipps aus dem praktischen Umgang damit:
Java bietet mit ResourceBundle und MessageFormat die Grundlagen für die Internationalisierung einer Anwendung, also für die Unterstützung mehrerer Sprachen z. B. bei Texten für den Endnutzer. Das Laden eines solchen ResourceBundle aus properties-Dateien (z. B. UiMessages_de.properties) lädt jedoch eine Ressource per ClassLoader, was wie ein Reflection-Zugriff gewertet wird. Daher gibt es Probleme, wenn man diese properties-Dateien in einem Modul verpackt und dann generischen Code aus einem anderen Modul diese als ResourceBundle laden will. Solche Aspekte sind in Java nicht klar dokumentiert und es hat mich initial etwas Zeit gekostet, das vollständig zu verstehen. Um nicht das komplette Modul mit dem Schlüsselwort open für Reflection freizugeben, ist die pragmatischste Lösung, Bundles zur Internationalisierung in separaten JAR-Dateien ohne Moduldeskriptor auszuliefern. Sind sie im Class-/Modulpfad, so landen sie im Unnamed Module und können ohne Probleme geladen werden.
Als ich die ersten Versuche mit Java-Modulen gemacht habe, war die Unterstützung in gängigen Entwicklungsumgebungen katastrophal bis nicht vorhanden. Inzwischen hat sich das deutlich stabilisiert und auch Code Completion und Refactoring werden unterstützt. Ich kann sowohl in IntelliJ als auch in Eclipse komplexe Projekte mit vielen Java-Modulen zuverlässig entwickeln. In Eclipse gibt es noch diverse Bugs bei module-info.java-Dateien mit dem Code-Formatter und insbesondere mit Import-Statements. Ich verzichte daher einfach auf Imports in diesen Dateien und verwende voll qualifizierte Typen. Auch bei IntelliJ gibt es Kinderkrankheiten, wie z. B. IllegalAccessError, weil irgendein Teil von JUnit im Unnamed Module gelandet ist.
Im einfachen Fall schreibt man seine JUnit-Tests ganz normal wie in Projekten ohne Java-Module. Will man aber im Test ein zusätzliches Testmodul haben, einen Service zum Testen hinzufügen oder Ähnliches, gibt es Probleme. Eigentlich würde man gern unter src/test/java eine zusätzliche module-info.java hinterlegen können, die dann für den Test verwendet wird. Genau das wird auch in der Maven-Dokumentation als Lösung propagiert – wird aber von IDEs gar nicht unterstützt [5]. Als Workaround erstelle ich dann zusätzliche Module, die nur zum Testen dienen, und schließe diese vom Deployment aus, sodass sie nicht im Release veröffentlicht werden.
Im nächsten Spring-Boot- oder Quarkus-Projekt würde ich persönlich JPMS trotzdem noch nicht nutzen. Diese Frameworks basieren maßgeblich auf Reflection, und mit Java-Modulen schaffe ich mir hier viele Probleme, ohne großen Nutzen zu stiften. Inzwischen ist es immerhin technisch möglich, was schon mal als großer Fortschritt zu werten ist. Wenn ich ein Backend mit JPMS ausprobieren will, kann ich das mit Helidon tun, das besser für JPMS geeignet ist, jedoch im Vergleich zu Spring Boot und Quarkus eher ein Schattendasein fristet. Micronaut kann mit dem JPMS aktuell gar nicht verwendet werden [6].
Schreibe ich hingegen eine Java-App ohne große Frameworks, sollte ich das JPMS ausprobieren. Entwickle ich eine Bibliothek, die möglichst viele Nutzer erreichen soll, ist die Unterstützung des JPMS Pflicht. Andernfalls schließe ich diverse potenzielle Nutzer von vornherein aus.
Im Jahr 2018 konnte man das JPMS zwar schon vollständig nutzen, kämpfte aber gegen Windmühlen. In der heutigen Zeit hat sich das JPMS so weit etabliert, dass die Unterstützung sämtlicher Tools ausgereift und die der wichtigen Java-Bibliotheken gegeben ist. Es ist also an der Zeit, sich intensiver mit dem Thema auseinanderzusetzen und eigene Experimente und Erfahrungen zu machen. Für Entwickler von Bibliotheken ist es heutzutage Pflicht, Moduldeskriptoren (mindestens als Automatic-Module-Name) mitzuliefern.
Wenn sich Java weiter weg von Deep Reflection zur Laufzeit hin zu AoT und CWA entwickelt und auch die Frameworks sich entsprechend weiterentwickeln, könnte die Verwendung des JPMS in der Zukunft zumindest für Green-Field-Projekte zum Standard werden.
[1] https://openjdk.org/jeps/411
[3] https://github.com/helidon-io/helidon
[4] Langer, Angelika; Kreft, Klaus: „Eine Anwendung in vielen Teilen. Project Jigsaw aka Java Module System“; in: Java Magazin 10.2017, https://entwickler.de/reader/reading/java-magazin/10.2017/1976daae3f43cab4336cd7ad
[5] https://github.com/eclipse-jdt/eclipse.jdt.core/issues/1465
[6] https://github.com/micronaut-projects/micronaut-core/issues/6395
The post Java Module und JPMS: Endlich bereit für den Einsatz? appeared first on JAX.
]]>The post Resiliente Kafka Consumer bauen appeared first on JAX.
]]>Bei der Bewertung der Strategien haben wir die möglichen Fehlerszenarien nach zwei Dimensionen unterschieden, der Fehlerquelle und der Fehlerhäufigkeit.
Fehlerquelle:
Fehlerhäufigkeit:
Bei der schlussendlichen Bewertung der Strategien haben sich die Vor- und Nachteile dann oft als unabhängig von Fehlerquelle oder Fehlerhäufigkeit herausgestellt.
Regelmäßig News zur Konferenz und der Java-Community erhalten
Um die verschiedenen Strategien der Fehlerbehandlung zu verstehen, ist es notwendig, die grundlegenden Konzepte von Kafka zu kennen. Diese erläutern wir hier kurz. Für Details verweisen wir z. B. auf den Artikel „Kafka 101: Massive Datenströme mit Apache Kafka“ [2].
Kafka bezeichnet sich als Event-Streaming-Plattform, die als Ersatz für traditionelle Message-Broker benutzt werden kann. Der Message-Broker dient dazu, asynchrone Nachrichten in Topics zu veröffentlichen. Nach einer konfigurierbaren Zeit, der Retention Time, löscht der Broker die Nachrichten.
Der Producer veröffentlicht Nachrichten mit einem Schlüssel in einem Kafka Topic. Der Broker teilt ein Topic in mehrere Partitionen auf und speichert eine Nachricht in genau einer Partition des Topics. Dabei berücksichtigt der Broker den Schlüssel der Nachricht und schreibt alle Nachrichten mit demselben Schlüssel auch in dieselbe Partition. Je Partition garantiert der Broker, dass die Reihenfolge der Nachrichten erhalten bleibt. Der Producer muss die Schlüssel der Nachrichten also so wählen, dass alle Nachrichten, deren Reihenfolge untereinander erhalten werden muss, denselben Schlüssel haben.
Die an den Nachrichten interessierten Consumer registrieren sich für das Topic und können so die Nachrichten vom Broker lesen und verarbeiten. Dabei geben die Consumer die Consumer Group an, zu der sie gehören. Der Broker stellt dann sicher, dass jede Partition genau einem Consumer einer Consumer Group zugewiesen ist.
In unserem Projekt nutzen wir Kafka (Kasten: „Kafka-Grundlagen“), um Änderungen an den Daten, die ein System verantwortet, als Events dieses Systems zu veröffentlichen. Im Domain-Driven Design spricht man hier von den Zustandsänderungen eines Aggregats [3]. Wir erzeugen und verarbeiten die Events im Publish-Subscribe-Modus. Da die Retention Time mit fünf Tagen als endlich vorgegeben ist, enthalten die Events nicht nur die geänderten Daten, sondern immer den kompletten neuen Zustand des Aggregats.
Bezüglich der Performance haben wir recht entspannte Anforderungen: Aus fachlicher Sicht ist es ausreichend, wenn die Events vor Ablauf der Retention Time verarbeitet werden. Dabei ist es jedoch wichtig, dass die Reihenfolge der Events mit demselben Schlüssel erhalten bleibt und kein Event übersprungen oder anderweitig nicht vollständig verarbeitet wird. Events mit demselben Schlüssel nennen wir abhängig.
Nach Mathias Verraes [4] gibt es zwei schwierige Probleme in verteilten Systemen. Die Einhaltung der Reihenfolge ist das erste. Dieses Problem haben wir gelöst, indem die Nachrichten, deren Reihenfolge untereinander erhalten werden muss, denselben Schlüssel bekommen. Das zweite schwere Problem ist die Exactly-once Delivery. Wir können es umgehen, indem wir unsere Consumer idempotent implementieren.
Im Folgenden illustrieren wir jede betrachtete Strategie mit einem Codebeispiel. Diese nutzen die MicroProfiles Reactive Messaging und Fault Tolerance. Als Implementierung für beide MicroProfiles benutzen wir die für Quarkus üblichen Bibliotheken von SmallRye. Die Codebeispiele sind auch auf GitHub [5] zu finden.
Bei der Nutzung der Codebeispiele sind zwei Aspekte zu berücksichtigen: die Commit Strategy [6] und die Failure Strategy [7]. Als Commit Strategy haben wir latest gewählt, da beim Default throttled der Timeout throttled.unprocessed-record-max-age.ms [8] während länger dauernder Retries einer fehlerhaften Nachricht erreicht werden kann. Als Failure Strategy haben wir eine eigene Strategie implementiert, die davon ausgeht, dass der Code, der die Nachricht empfängt, die Fehler direkt behandelt und daher keine separate Failure Strategy mehr notwendig ist (Listing 1).
public class CustomKafkaFailureHandler implements KafkaFailureHandler { @ApplicationScoped @Identifier("custom") public static class Factory implements KafkaFailureHandler.Factory { @Override public KafkaFailureHandler create( KafkaConnectorIncomingConfiguration config, Vertx vertx, KafkaConsumer<?, ?> consumer, BiConsumer<Throwable, Boolean> reportFailure) { return new CustomKafkaFailureHandler(); } } @Override public <K, V> Uni<Void> handle(IncomingKafkaRecord<K, V> record, Throwable reason, Metadata metadata) { return Uni.createFrom() .<Void>failure(reason) .emitOn(record::runOnMessageContext); }
Der erste Halt auf unserer Reise führt uns zu einer äußerst einfach umzusetzenden Strategie: Consumer herunterfahren. Hierbei stoppt der Consumer die Verarbeitung und fährt den Prozess, in dem er läuft, herunter. Das macht er in dem Codebeispiel (Listing 2), indem er System.exit() aufruft. Alternativ könnte er z. B. in einer Kubernetes-Umgebung die Liveness-Probe [9] fehlschlagen lassen und so den Containermanager dazu veranlassen, den Prozess herunterzufahren. Diese Strategie, eingeschränkt auf den Kafka-Client des betroffenen Topics, bietet auch SmallRye Reactive Messaging an und nennt sie fail [10], [11].
@Incoming("aggregate-in") public CompletionStage<Void> consume(Message<String> record) { LOGGER.trace("Aggregate received: {}", record.getPayload()); try { controller.process(record.getPayload()); return record.ack(); } catch (Exception e) { LOGGER.error("Oops, something went terribly wrong with " + record.getPayload(), e); System.exit(1); return null; // never reached } }
Um diese Strategie universell einsetzen zu können, ist es hilfreich, einen Containermanager zu haben, der den heruntergefahrenen Prozess neu startet. So bekommen wir den Retry in Form eines Restart Loops [7] geschenkt.
Der Weg zur Korrektur ist je nach Fehlerursache unterschiedlich: Bei einem Fehler im Producer sendet dieser die korrigierte Nachricht sowie alle abhängigen Nachrichten erneut. Der Consumer muss dann die Offsets der fehlerhaften Nachricht und aller ursprünglichen abhängigen Nachrichten überspringen.
Bei einem Fehler im Consumer ist lediglich der Bugfix zu deployen, dann läuft der Consumer einfach weiter. Bei einem Fehler in der Infrastruktur ist nichts zu tun, der Consumer läuft einfach weiter, sobald der Fehler behoben wurde.
Vorteile:
Nachteile:
Die nächste Station unserer Reise ist ein Klassiker der Fehlerbehandlung von asynchronen Nachrichten: die Dead Letter Queue (DLQ). Im Zusammenhang mit Kafka wird diese Strategie oft auch als Dead Letter Topic bezeichnet, bei der Umsetzung mittels Datenbank auch als Dead Letter Table. Tritt ein Fehler beim Verarbeiten einer Nachricht im Consumer auf, sortiert der Consumer die betroffene Nachricht in die namensgebende Warteschlage für „tote“ Nachrichten aus. Anschließend macht er mit der Verarbeitung der nächsten Nachricht weiter (Listing 3). Als Alternative zu einer eigens implementierten Fehlerbehandlung bietet SmallRye die Dead Letter Queue auch als Failure Strategy an [10], [12].
@Incoming("aggregate-in") public CompletionStage<Void> consume(Message<String> record) { LOGGER.trace("Aggregate received: {}", record.getPayload()); try { controller.process(record.getPayload()); } catch (Exception e) { LOGGER.error("Oops, something went terribly wrong with " + record.getPayload(), e); deadLetterEmitter.send(record); } return record.ack(); }
Der Weg zur Korrektur ist wieder von der Fehlerursache abhängig. Bei einer semantisch oder syntaktisch fehlerhaften Nachricht schickt der Producer eine korrigierte Nachricht. Die fehlerhafte Nachricht in der DLQ muss daraufhin übersprungen werden. Bei einem Fehler des Consumers muss der Bugfix deployt werden, anschließend müssen die Nachrichten der DLQ wieder eingelesen werden. Auch nach Behebung eines Infrastrukturproblems muss die DLQ wieder eingelesen werden.
Vorteile:
Nachteile:
Mit Blick auf unsere Problemstellung hat die einfache Dead Letter Queue ein entscheidendes Problem: Sie kann die Reihenfolge von Nachrichten nicht erhalten. Unsere Reise führt deswegen tiefer in die Welt der DLQs. Die Idee: Eine erweiterte Dead Letter Queue nimmt nicht nur Nachrichten auf, die bei der Verarbeitung Probleme machen, sondern auch alle von ihr abhängigen Nachrichten. Dazu muss lediglich der Schlüssel der fehlerhaften Nachricht gespeichert werden. Anschließend können alle abhängigen Nachrichten durch den gleichen Schlüssel identifiziert und ebenfalls in die DLQ geleitet werden (Listing 4).
@Incoming("aggregate-in") public CompletionStage<Void> consume(KafkaRecord<String, String> record) { LOGGER.trace("Aggregate received: {}", record.getPayload()); try { if(controller.shouldSkip(record.getKey())) { LOGGER.warn("Record skipped: " + record); deadLetterEmitter.send(record); } else { controller.process(record.getPayload()); } } catch (Exception e) { LOGGER.error("Oops, something went terribly wrong with " + record.getPayload(), e); controller.addKeyToSkip(record.getKey()); deadLetterEmitter.send(record); } return record.ack(); }
Der Weg zur Korrektur ist zunächst identisch mit dem der einfachen DLQ. Im Detail wird es jedoch kompliziert. Um die Reihenfolge beim Wiedereinlesen der DLQ weiterhin zu erhalten, muss ihr Einlesen mit dem Einlesen des regulären Topics koordiniert werden. Benutzt man ein Kafka Topic als DLQ, stellen sich weitere Probleme, wie die Verteilung von Schlüsseln in zwei Topics auf unterschiedliche Consumer und die Vermischung von korrigierten und nicht korrigierten Nachrichten in der DLQ. Die Realisierung als Dead Letter Table in der Datenbank löst manche dieser Probleme, verursacht aber auch neue. Wegen dieser steigenden Komplexität haben wir zu diesem Zeitpunkt ein Zwischenfazit gezogen, um nach weiteren Alternativen zu suchen.
Vorteile:
Nachteile:
Die Dead Letter Queue ist eine naheliegende Lösung, wenn man von Fehlern ausgeht, die einzelne Nachrichten betreffen. Je mehr Nachrichten betroffen sind – egal, ob durch die Reihenfolge oder die gemeinsame Fehlerursache –, desto komplexer und aufwendiger wird die Lösung. Man kann sich dem Problem einer allgemeinen Fehlerbehandlung aber auch von einem anderen Szenario her nähern: dem Ausfall der Infrastruktur. Dabei spielt es keine Rolle, ob dieser Fehler den Ausfall einer notwendigen Datenbank, des Kafka-Clusters oder der Netzwerkverbindung betrifft. Entscheidend ist: Der Fehler betrifft vermutlich alle Nachrichten. Er lässt sich also nicht durch eine Quarantäne einzelner Nachrichten begrenzen. Die klassische Lösung für diesen Fall kennt jeder von automatischen Telefonansagen: „Bitte versuchen Sie es später noch einmal!“
Der erste Teil dieser Taktik besteht im Pausieren der Arbeit. Beim kompletten Herunterfahren des Consumers provozieren wir jedoch ein kostspieliges Rebalancing durch den Broker (siehe oben). Man kann aber auch das Konsumieren pausieren, solange man nicht den gesamten Consumer anhält. Das verhindert, dass der Broker die Pause als Ausfall des Consumers wertet und ein Rebalancing einleitet.
Der zweite Teil der Taktik ist das Wiederholen (engl. Retry) der Aktion, bei der ein Fehler aufgetreten ist. In unserem Fall bedeutet das auf oberster Abstraktionsebene immer das Konsumieren einer Nachricht. Wenn man außerdem die fehlerhafte Nachricht nicht committet, wird sie der Consumer nach der Pause einfach erneut einlesen und versuchen, sie zu verarbeiten.
Listing 5 zeigt, wie man beide Schritte mit der Retry-Annotation des MicroProfile Fault Tolerance umsetzt. Hiermit pausiert der Consumer für alle Partitionen, die ihm der Broker zugewiesen hat. Alternativ kann man dem Broker auch das Pausieren [13] einer einzelnen Partition direkt mitteilen und später die Verarbeitung mittels resume [14] wieder aufnehmen.
@Incoming("aggregate-in") @Retry(delay = 2000L, jitter = 400L, maxRetries = -1, maxDuration = 0) @ExponentialBackoff(maxDelay = 2, maxDelayUnit = ChronoUnit.HOURS) public CompletionStage<Void> consume(Message<String> record) { LOGGER.trace("Aggregate received: {}", record.getPayload()); try { controller.process(record.getPayload()); return record.ack(); } catch (Exception e) { LOGGER.error("Oops, something went terribly wrong with " + record.getPayload(), e); return record.nack€; } }
Der Weg zur Korrektur ist wie immer abhängig von der tatsächlichen Fehlerursache. Da Infrastrukturprobleme meist externe Probleme sind, muss das Entwicklerteam bei der Lösung der Fehlerursache oft gar nicht aktiv werden. Tritt ein Infrastrukturproblem auf, können alle Consumer in einer Consumer Group unabhängig voneinander die Arbeit einstellen und in den Wiederholungsmodus wechseln. Sobald das Problem gelöst ist, sorgt das automatische Wiederholen für den Wiederanlauf aller Consumer.
Falls der Fehler beim Producer liegt, muss dieser wie bei allen anderen Strategien auch eine korrigierte Nachricht sowie eventuell weitere abhängige Nachrichten erneut schicken. Der Consumer muss alle vom Fehler betroffenen Nachrichten konsumieren und ignorieren. Bis dahin werden je nach gewählter Umsetzungsvariante entweder alle Nachrichten blockiert, die in der Partition mit der fehlerhaften Nachricht landen oder die in den Partitionen landen, die dem Consumer zugewiesen sind. Für abhängige Nachrichten wird die Partition quasi zu einer erweiterten Dead Letter Queue, die beim Wiedereinlesen allerdings keine Synchronisation benötigt. Ob und wie viele unabhängige Nachrichten vom Pausieren betroffen sind, hängt vom Verhältnis der Anzahl der Kafka-Schlüssel zur Anzahl der Partitionen und ggf. auch von der Anzahl der Consumer ab.
Falls der Fehler im Consumer liegt, blockieren die Partitionen, die von dem Fehler aktuell betroffen sind. Wie stark das Pausieren den Durchsatz bremst, hängt von der Schwere des Fehlers, möglichen Zusammenhängen mit den Kafka-Schlüsseln sowie vom Zufall ab. Sobald ein Bugfix deployt wurde, laufen die pausierten Partitionen durch das automatische Wiederholen ohne operativen Eingriff selbstständig wieder an.
Vorteile:
Nachteile:
Zunächst dachten wir nach dem produktiven Einsatz unseres Systems einige Wochen lang, wir könnten es uns auf der Insel „Pausieren und Wiederholen“ gemütlich machen. Dann mussten wir jedoch einen Abstecher zu einer weiteren Strategie unternehmen, die wir ursprünglich als absurd angesehen und daher direkt aussortiert hatten. Mittlerweile haben wir sie jedoch für spezielle Fälle zu schätzen gelernt. Diese Fälle zeichnen sich dadurch aus, dass ein Sachbearbeiter im ursprünglichen System Daten manuell korrigieren muss. Das verringerte den Durchsatz bei der Strategie „Pausieren und Wiederholen“ so deutlich, dass wir die Nachricht nun mittels einfachem Loggen behandeln. Dabei haben wir sichergestellt, dass nur einzelne Nachrichten so behandelt werden, und setzen diese Strategie somit nicht als Catch-all-Strategie ein.
Diese Strategie loggt die fehlerhafte Nachricht und bzw. oder ihre Metadaten, zählt gegebenenfalls eine Metrik hoch und verarbeitet dann die nächste Nachricht (Listing 6). Auch diese Strategie bietet SmallRye an und nennt sie ignore [10], [15].
@Incoming("aggregate-in") public CompletionStage<Void> consume(Message<String> record) { LOGGER.trace("Aggregate received: {}", record.getPayload()); try { controller.process(record.getPayload()); } catch (Exception e) { LOGGER.error("Oops, something went terribly wrong with " + record.getPayload(), e); } return record.ack(); }
Der Weg zur Korrektur ist hier unabhängig von der Fehlerursache manuell: Die Logmeldung oder die erhöhte Metrik stößt einen manuellen Prozess an, der die Fehlerursache beheben und dann die geloggten und damit nicht verarbeiteten Nachrichten reproduzieren muss. Aufgrund des hohen manuellen Aufwands sollte diese Strategie nur für Fehlerursachen angewandt werden, die einzelne Nachrichten betreffen. Insbesondere scheidet sie damit für Infrastrukturprobleme aus.
In unserem Projekt setzen wir diese Strategie für Nachrichten ein, die der Producer fehlerhaft erstellt hat. Nachdem der Fehler im Producer behoben wurde, lässt der manuelle Prozess die Nachricht und alle davon abhängigen Nachrichten erneut erzeugen.
Vorteil:
Nachteile:
Unsere Reise durch die Untiefen der asynchronen Fehlerbehandlung zeigt, dass je nach konkreter Problemstellung verschiedene Optionen infrage kommen, die auf den ersten Blick absurd erscheinen. Ganz prinzipiell kann man die von uns gefundenen Optionen nach der Granularität des Eingriffs und der Komplexität ihrer Umsetzung sortieren (Abb. 1).
Abb. 1: Strategien im Überblick
Einfach loggen bedeutet de facto keinen Eingriff und ermöglicht so einen hohen Durchsatz. Zudem ist es bestechend simpel. Der Preis ist das Risiko des Verlusts der korrekten Reihenfolge und kompletter Nachrichten. Die klassische Dead Letter Queue verhindert den Datenverlust durch einen minimalen Eingriff, kann aber die Reihenfolge auch nicht sicherstellen. Sie ist dafür auch etwas komplexer, als nur zu loggen. Die erweiterte Dead Letter Queue greift exakt so viel ein wie notwendig, um Datenverlust zu verhindern und die Reihenfolge zu erhalten. Dafür muss man sich aber um eine Reihe von Detailproblemen kümmern. Diese Option ist deshalb die komplexeste. Bei Pausieren und Wiederholen werden u. U. auch unabhängige Nachrichten angehalten, allerdings selten das gesamte Topic. Datenverlust und Reihenfolge sind kein Thema. Die Komplexität ist moderat und beschränkt sich größtenteils auf das Wissen über die Konfiguration von Kafka selbst. Die einfachste Option, die Datenverlust vermeidet und die Reihenfolge einhält, ist das komplette Herunterfahren des Consumers. Das bedeutet aber in den meisten Fällen auch den niedrigsten Durchsatz während eines Fehlers.
Selbstverständlich kann man all diese Lösungen durch weitere kleinere Lösungen ergänzen. Auch wenn wir auf der Suche nach einer Catch-all-Strategie waren: Oft ist es besser, Probleme dort zu lösen, wo sie auftreten. Das heißt, dass z. B. eine Datenbankausnahme auch einfach das Wiederholen des Datenbankaufrufs auslösen kann, statt die Verarbeitung der Nachricht sofort abzubrechen. Für bestimmte fachliche Spezialfälle mögen sogar einfaches Loggen und eine manuelle Lösung durch den Fachbereich akzeptabel sein.
Für unser Projekt haben wir uns am Ende für Pausieren und Wiederholen entschieden. Die erweiterte Dead Letter Queue wurde uns zu komplex und wir waren uns nicht sicher, ob wir trotz dieser Komplexität nicht immer noch manuell in die Produktion würden eingreifen müssen. Pausieren und Wiederholen waren dagegen vergleichsweise simpel zu verstehen und dabei nicht zu restriktiv. Vor allem aber faszinierte uns, dass wir damit effektiv manuelle Eingriffe in der Produktion vermeiden konnten. Das automatische Wiederholen bedeutete letztlich den Beginn einer geradezu magischen neuen Eigenschaft unseres Systems: der Selbstheilung.
[1] https://6sense.com/tech/queueing-messaging-and-background-processing/apache-kafka-market-share
[2] https://entwickler.de/software-architektur/kafka-101/
[3] https://www.dddcommunity.org/library/vernon_2011/
[4] https://twitter.com/mathiasverraes/status/632260618599403520
[5] https://github.com/HilmarTuneke/kafka-fehlerbehandlung
[7] https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#restart-policy
[11] https://quarkus.io/blog/kafka-failure-strategy/#the-fail-fast-strategy
[12] https://quarkus.io/blog/kafka-failure-strategy/#the-dead-letter-topic-strategy
[15] https://quarkus.io/blog/kafka-failure-strategy/#the-ignore-strategy
The post Resiliente Kafka Consumer bauen appeared first on JAX.
]]>The post Showdown der funktionalen Programmierung auf der JVM appeared first on JAX.
]]>Ein wichtiger pragmatischer Aspekt funktionaler Programmierung (FP) ist die Verwendung unveränderlicher (immutable) Daten. Diese kann eine Programmiersprache mehr oder weniger gut unterstützen. Dazu gehört auch die Möglichkeit, Funktionen ohne die Verwendung von Zuweisungen zu schreiben. Schließlich zählt die Unterstützung von höherstehenden Abstraktionen wie Monaden zur Standardausrüstung funktionaler Sprachen.
Das mit den unveränderlichen Daten klingt eigentlich ganz einfach: Wir verzichten auf Zuweisungen, fertig. Damit das praktikabel wird, gehört allerdings doch mehr dazu. Wenn eine Klasse unveränderliche Objekte beschreibt, ist es meist sinnvoll, einen Konstruktor zu definieren, der für jedes Attribut der Klasse ein Argument akzeptiert, außerdem equals– und hashCode()-Methoden, die ausschließlich über die Gleichheit beziehungsweise die Hashfunktion der Attribute definiert sind, Ähnliches gilt für die toString()-Methode. In Java hat die IDE, die Konstruktor und Methoden automatisch generieren kann, diesen Job traditionell erledigt. Danach liegt aber eine Menge Code herum, der nur das Offensichtliche tut und Übersicht sowie Wartung behindert.
Regelmäßig News zur Konferenz und der Java-Community erhalten
Glücklicherweise hat Kotlin sogenannte Data Classes, in denen dieser Code automatisch vom Compiler generiert wird. Das ist so eine Klasse:
data class Rectangle(val length: Double, val width: Double)
Das data vor class sorgt für die automatische Generierung von Konstruktor und Methoden, val sorgt dafür, dass die entsprechenden Attribute nicht verändert werden können. (Weil das so praktisch ist, hat Java längst mit Records nachgezogen.)
Bei funktionalen Sprachen sind Pendants zur Java Collections Library dabei, die allerdings ebenfalls ohne Veränderungen auskommen. Bei Kotlin sind scheinbar unveränderliche Collections dabei. So hängen wir zum Beispiel eine Liste an eine andere Liste:
val list1 = listOf(1,2,3) val list2 = listOf(4,5,6) val list3 = list1 + list2
Hier ist list3 eine neue Liste mit den gleichen Elementen wie list1 (die unverändert bleibt) und den Elementen von list2 zusätzlich dahinter. Hinter den Kulissen tut aber die gute alte Java ArrayList ihren Dienst: Das bedeutet, dass die Operation + list2 ein neues Array für list3 anlegt und alle Elemente sowohl von list1 als auch von list2 nach list3 kopiert – und das ist teuer. Aus diesem Grund verwenden die Collection Libraries funktionaler Sprachen sogenannte funktionale Datenstrukturen, die nicht bei jedem Update alles kopieren müssen. Insbesondere ist eine Liste in der funktionalen Programmierung eine konkrete Datenstruktur, die bei allen (anderen) funktionalen Sprachen fest eingebaut ist. Wir können sie aber in Kotlin nachbauen:
sealed interface List<out A> object Empty : List<Nothing> data class Cons<A>(val first: A, val rest: List<A>) : List<A>
Eine Liste ist also entweder Empty (die leere Liste) oder eine sogenannte Cons-Liste, bestehend aus erstem Element und rest-Liste (es handelt sich also um eine unveränderliche, einfach verkettete Liste). FP-Pendants zu den ersten beiden Listen von oben können wir so konstruieren:
val list1 = Cons(1, Cons(2, Cons(3, Empty))) val list2 = Cons(4, Cons(5, Cons(6, Empty)))
Das ist etwas umständlich, verglichen mit listOf, dieses Manko könnten wir aber mit einer einfachen varargs-Funktion beheben. Die plus-Methode auf den eingebauten Kotlin-Listen können wir für die FP-Listen ebenfalls implementieren:
fun <A> List<A>.append(other: List<A>): List<A> = when (this) { is Empty -> other is Cons -> Cons(this.first, this.rest.append(other)) }
Das Beispiel von oben funktioniert mit FP-Listen so:
val list3 = list1.append(list2)
Die append-Methode wirft ein paar Fragen auf: Warum ist sie eine „Extension Method“? Könnte das bei der Rekursion Probleme machen? Dazu später mehr, hier geht es ja erst einmal um unveränderliche Daten: Und tatsächlich verändert append gar nichts, kopiert die Elemente aus list1, hängt aber list2 an, ohne sie zu kopieren. Hinterher teilen sich also list3 und list2 dieselben Cons-Objekte. Weil Listen unveränderlich sind, ist das unproblematisch: Das Programm kann so tun, als wären list2 und list3 völlig unabhängig voneinander. Die append-Methode verbraucht nur so viel Laufzeit und Speicher, wie list1 lang ist – die Länge von list2 ist völlig irrelevant.
Listen sind für viele Situationen geeignet, aber natürlich nicht für alle – wenn list1 lang ist, braucht append auch mehr Zeit und viel Speicherplatz. Entsprechend haben die Collection Libraries funktionaler Sprachen in der Regel auch noch ausgefeiltere Datenstrukturen auf der Basis von Bäumen beziehungsweise Trees im Gepäck, bei denen die meisten Operationen quasi konstant laufen.
Solche funktionalen Datenstrukturen gibt es auch für Java und Kotlin in Form der Vavr-Library [1], die neben FP-Listen auch Vektoren mitbringt. Für Vavr gibt es auch das Add-on vavr-kotlin [2], das die Vavr Collections in Kotlin noch etwas idiomatischer zugänglich macht. Zwischenfazit: Funktionale Datenstrukturen sind bei Kotlin nicht standardmäßig dabei, können aber in Form von Vavr nachgeladen werden. Tolle Sache.
Kommen wir zur Unterstützung für Funktionen als Objekte: Da macht Kotlin gegenüber Java einen riesigen Sprung durch zwei Neuerungen:
Was das bringt, zeigt die Implementierung des FP-Klassikers map, den eine Funktion (die als Objekt übergeben wird) auf alle Elemente einer Liste anwendet und dann eine Liste der Ergebnisse zurückliefert:
fun <A, B> List<A>.map(f: (A) -> B): List<B> = when (this) { is Empty -> Empty is Cons -> Cons(f(this.first), this.rest.map(f)) }
Super-Sache:
val list4 = list3.map { x -> x + 1 }
Das geht natürlich auch in Java, das Pendant sieht fast identisch aus:
var list4 = list3.map(x -> x + 1);
Allerdings funktioniert das in Java nur so richtig gut, wenn der Lambdaausdruck unmittelbar im Methodenaufruf steht. In dem Moment, in dem wir ihn herausziehen, sieht die Sache anders aus. In Kotlin ist das kein Problem:
val inc = { x: Int -> x + 1 } val list4 = list3.map(inc)
Wir müssen bei inc den Typ von x angeben, weil noch andere Typen die Operation + 1 (Double, Long) unterstützen. Den Typ der Funktion inferiert Kotlin nun als (Int) -> Int. In Java müssen wir uns allerdings beim Typ von inc für ein Interface entscheiden – am Naheliegendsten ist IntUnaryOperator:
IntUnaryOperator inc = x -> x + 1;
Allerdings können wir den nicht an map übergeben – es kommt zu einer Fehlermeldung: „‘map(Function<Integer,B>)’ cannot be applied to ‘(IntUnaryOperator)’“. Das liegt daran, dass map ein Argument des Typs Function<A, B> akzeptiert – wir mussten uns ja für ein spezifisches Interface entscheiden. Leider passen Function und IntUnaryOperator nicht zueinander, weil Function auf Referenztypen besteht, während IntUnaryOperator auf den Value Type int festgelegt ist. Die Konsequenz ist, dass zum Beispiel das Stream-Interface in Java neben map auch mapToInt, mapToDouble und mapToLong anbieten muss, die jeweils ein IntStream, DoubleStream und LongStream produzieren. Das ist umständlich und macht fortgeschrittene Anwendungen von Funktionen höherer Ordnung in Java unpraktikabel – in Kotlin kein Problem, ein weiterer Pluspunkt.
Zu erstklassiger Unterstützung von Funktionen gehört auch, dass wir in der Lage sein sollten, Funktionen ohne Verwendung von Zuweisungen zu schreiben. Dafür sind append und map gute Beispiele – rein funktional programmiert. Sie haben aber beide denselben Haken: Für lange Listen funktionieren sie nicht, weil sie einen Stack-Overflow verursachen. Das liegt an einigen Eigenheiten der JVM: Sie muss sich merken, wie es nach dem rekursiven Aufruf weitergeht (mit Cons) und verwendet dafür einen speziellen Speicherbereich, den Stack. Dieser Stack ist in seiner Größe beschränkt und viel kleiner als der Hauptspeicher, der eigentlich zur Verfügung stünde. Außerdem ist die Größe beim Start der JVM festgelegt und später nicht mehr zu ändern. Funktionale Sprachen mit eigener Runtime haben dieses Problem meist nicht, weil sie flexiblere Datenstrukturen anstatt eines Stacks mit fester Größe verwenden.
fun <A> List<A>.append(other: List<A>): List<A> { var list = this.reverse() var acc = other while (list is Cons) { acc = Cons(list.first, acc) list = list.rest } return acc }
In Java wäre der einzige Weg, das Problem zu vermeiden, eine Schleife – und Schleifen benötigen Zuweisungen, um von einem Durchlauf zum nächsten zu kommunizieren. In Kotlin sieht das so aus wie in Listing 1. Die neue append-Funktion benutzt zwei Schleifenvariablen: die Eingabeliste und das Zwischenergebnis, den Akkumulator. Die Eingabeliste wird am Anfang umgedreht, weil Listen von hinten nach vorn konstruiert werden.
Das neue append funktioniert, ist aber aus funktionaler Sicht schlecht – insbesondere, weil while-Schleifen fehleranfällig sind. Werden zum Beispiel die beiden Zuweisungen an acc und list vertauscht, funktioniert die Methode nicht mehr. Diese Methode können wir auch ohne Zuweisungen mit einer Hilfsfunktion schreiben, die den Akkumulator als Parameter akzeptiert (Listing 2).
fun <A> List<A>.append(other: List<A>): List<A> = appendHelper(this.reverse(), other) fun <A> appendHelper(list: List<A>, acc: List<A>): List<A> = when (list) { is Empty -> acc is Cons -> appendHelper(list.rest, Cons(list.first, acc)) }
Diese Version kommt ohne Zuweisungen aus und benutzt einen rekursiven Aufruf statt der while-Schleife. (Die Hilfsfunktion könnten wir auch lokal innerhalb von append definieren – ein weiterer Punkt für FP-Support in Kotlin.) Anders als der rekursive Aufruf der ursprünglichen append-Version ist dieser hier ein sogenannter Tail Call – also ein Aufruf, der am Ende der Funktion steht.
Solche Tail Calls brauchen eigentlich keinen Platz auf dem Stack – entsprechend bestünde bei appendHelper auch keine Gefahr, dass der Stack überläuft. „Eigentlich“ deshalb, weil die JVM leider auch für Tail Calls völlig unnötig Stackplatz verbraucht. (Das ist ein seit mindestens 1996 bekannter Bug, und FP-Fans fiebern mit jedem neuen JVM-Release darauf, dass endlich ein Fix dabei ist.) Wir können aber den Kotlin-Compiler überreden, den rekursiven Tail Call intern in eine Schleife zu kompilieren, indem wir vor die Funktionsdefinition das Wort tailrec schreiben. Das geht leider nur bei statischen Selbstaufrufen (insbesondere also nicht bei Aufrufen von Methoden, die spät gebunden werden), aber immerhin, mehr ist auf der JVM auch nicht drin. Zwischenfazit: Funktionen sind in Kotlin tatsächlich so erstklassig, wie es auf der JVM geht. Die Richtung stimmt also auch hier.
Kommen wir nochmal zu den Typen: Wir haben ja schon gesehen, dass Kotlin mit Funktionstypen eine wesentliche Anforderung funktionaler Programmierung erfüllt. Leider hat das Typsystem aber auch einige Tücken. Um die zu verstehen, werfen wir einen Blick darauf, wie die Definition von Listen in der funktionalen Programmiersprache Haskell aussehen würde (wenn Listen da nicht schon eingebaut wären):
data List a = Empty | Cons a (List a)
Die Struktur ist ähnlich wie die Kotlin-Definition, aber zwei Unterschiede fallen auf: In Kotlin steht beim Typparameter A noch ein out davor. Außerdem steht beim Empty-Objekt Nothing als Typparameter von List.
Das kommt daher, dass Kotlin versucht, sowohl OO als auch FP zu unterstützen: OO braucht Klassenhierarchien, was auf der Typebene zu Subtyping führt. Deshalb ist es dort notwendig, dass bei Empty ein Typparameter für List angegeben wird – und da der Typparameter A für den Typ der Elemente der Liste steht, die leere Liste aber keine Elemente hat, müssen wir einen Typ angeben, der zu allen anderen passt. In Kotlin ist das eben Nothing, ein „magischer“ Typ ohne Elemente, der als Subtyp aller anderen Typen fungiert (das Gegenteil von Any sozusagen.) Außerdem ist out notwendig, damit eine Liste aus Elementen bestehen kann, die unterschiedliche Typen haben – solange sie einen Supertyp gemeinsam haben. Falls das kompliziert klingt: ist es. Wir können uns der Sache annähern, indem wir out entfernen. Sofort kommt eine Fehlermeldung, zum Beispiel bei:
val list1 = Cons(1, Cons(2, Cons(3, Empty)))
Abgekürzt lautet diese so: „Type mismatch: inferred type is Int but Nothing was expected“. Das liegt daran, dass Empty den Typ List<Empty> hat, wir aber für die Gesamtliste den Typ List<Int> brauchen. Das out sorgt jetzt dafür, dass List<Empty> ein Subtyp von List<Int> ist und damit alles wieder passt – ein Fall von sogenannter Kovarianz. (Wenn wir in geschrieben hätten, wäre Kontravarianz angesagt und es würde andere Fehlermeldungen geben.)
Das Wort out steht dafür, dass die Methoden von List andere Listen nur als „Output“ produzieren dürfen. Das bedeutet insbesondere, dass wir append nicht als „normale“ Methode innerhalb von List implementieren können, weil sie die zweite Liste als Eingabe akzeptiert. Diese Einschränkung gilt nicht für Extension Methods, weshalb wir append als eine solche definiert haben.
Die Situation ist aber immerhin besser als in Java, das erfordert, die Varianzannotationen an die Methoden zu schreiben. Die Java-Stream-Version von append, genannt concat (ebenfalls als statische Methode realisiert), hat zum Beispiel diese Signatur:
static <T> Stream<T> concat(Stream<? extends T> a, Stream<? extends T> b)
Die Kombination von OO und FP führt also zu einigen Komplikationen, auch wenn Kotlin die Situation besser meistert als Java.
Kotlin hat sich sichtlich von Scala inspirieren lassen, was die FP-Unterstützung betrifft, aber auch auf einige Aspekte von Scala verzichtet – in der Hoffnung, die aus Scala bekannte Komplexität einzudämmen. Dem sind leider auch nützliche Features des Scala-Typsystems zum Opfer gefallen.
Eine wesentliche Einschränkung gegenüber Scala betrifft sogenannte Higher-kinded Types, die es in Scala gibt, in Kotlin leider nicht. Zur Erläuterung dieser Typen schauen wir uns eine Kotlin-Version des Optional-Typs aus Java an. Er ist nützlich, wenn eine Funktion manchmal ein Ergebnis liefert, manchmal aber auch nicht. Entsprechend gibt es zwei Fälle Some und None, ähnlich wie bei Listen:
sealed interface Option<out A> object None : Option<Nothing> data class Some<A>(val value: A) : Option<A>
Wir können für Option eine praktische map-Methode definieren:
fun <A, B> Option<A>.map(f: (A) -> B): Option<B> = when (this) { is None -> None is Some -> Some(f(this.value)) }
Diese Verwandtschaft mit der map-Methode von List können wir sehen, wenn wir beide Signaturen untereinanderschreiben:
fun <A, B> List <A>.map(f: (A) -> B): List <B> fun <A, B> Option<A>.map(f: (A) -> B): Option<B>
Typen wie List und Option, die eine map-Operation unterstützen, heißen in der funktionalen Programmierung Funktor und spielen dort eine wichtige Rolle. Deshalb liegt es nahe, ein Interface Functor zu definieren, in dem die map-Methode steht. Das könnte so aussehen:
interface Functor<F> { fun <A, B> map(f: (A) -> B): F<B> }
Mit dieser Definition wäre Functor ein Higher-kinded Type: Ein Typkonstruktor (also ein Typ mit Generics), der einen anderen Typkonstruktor als Parameter akzeptiert. Das ist dann aber leider doch ein Schritt zu viel für Kotlin, der Compiler quittiert: „Type arguments are not allowed for type parameters“. Es gibt zwar einen Trick, um Higher-kinded Types zu simulieren, nachzulesen zum Beispiel in dem wunderbaren Buch „Functional Programming in Kotlin“ [3]. Er ist aber so umständlich, dass er in der Praxis kaum anwendbar ist.
Regelmäßig News zur Konferenz und der Java-Community erhalten
Beim Typsystem ist in Kotlin also noch Luft nach oben. Dass es geht, zeigt Scala, das Higher-kinded Types unterstützt und für das es entsprechend Libraries mit höherstehenden Konzepten wie Functor gibt, die in Kotlin einfach nicht praktikabel sind. Fairerweise muss man aber zugegeben, dass Higher-kinded Types auch von manchen „richtigen“ funktionalen Sprachen wie F# nicht unterstützt werden.
Wo funktionale Programmierung ist, sind Monaden oft nicht weit: Sie sind dort fürs Grobe zuständig und erlauben die Verknüpfung von Funktionen in sequenziellen Abläufen mit Effekten. Effekte sind Dinge, die neben der reinen Berechnung stattfinden – typischerweise Interaktionen mit der Außenwelt. Effekte können zum Beispiel benutzt werden, um in hexagonaler Architektur zwischen Ports und Adaptern zu vermitteln. Für eine komplette Einführung in Monaden fehlt uns hier der Platz, darum skizzieren wir nur, wie sie in Kotlin funktionieren.
Die Idee ist ganz einfach: Ein sequenzieller Prozess wird durch ein Objekt repräsentiert. Für die Objekte erstellen wir einen speziellen Typ (das ist die Monade), in dem genau die Operationen enthalten sind, die in dem Prozess zulässig sind. Zum Beispiel enthält die kleine Demo-Library kotlin-free-monads [4] ein Interface TtyM, um Prozesse abzubilden, die eine textuelle Ausgabe generiereren:
sealed interface TtyM<out A> { fun <B> bind(next: (A) -> TtyM<B>): TtyM<B> }
Die bind-Operation (manchmal auch flatMap genannt) macht zusammen mit einer sogenannten pure-Operation TtyM zu einer Monade. Der Typparameter A ist für das Ergebnis des Prozesses zuständig. Wenn wir die TtyM-Monade benutzen, verwenden wir also nicht mehr println, sondern konstruieren stattdessen ein Write-Objekt, dessen Klasse TtyM implementiert.
Diese Vorgehensweise hat den Vorteil, dass wir das TtyM-Objekt in unterschiedlichen Kontexten ausführen können. Im normalen Betrieb macht das folgende Funktion, die für jedes Write einfach println ausführt:
fun <A> run(tty: TtyM<A>): A
Für automatisierte Tests wollen wir aber wissen, welcher Text ausgegeben wurde, und dafür gibt es eine Funktion, die die Zeilen des Outputs in eine Liste schreibt:
fun <A> output(tty: TtyM<A>, output: MutableList<String>): A
Das ist praktisch, wenn wir schon ein TtyM-Objekt haben. Aber dessen Konstruktion mit Konstruktor- und Methodenaufrufen ist normalerweise ziemlich umständlich, mit vielen geschachtelten Lambdaausdrücken und Klammern. Aus diesem Grund haben die meisten funktionalen Sprachen eine spezielle, deutlich kompaktere Syntax für monadische Programme. Kotlin bringt eine solche Syntax zwar nicht direkt mit, wir können sie aber mit Hilfe von suspend-Funktionen selbst bauen, sodass ein TtyM-Programm zum Beispiel so gebaut werden kann:
TtyDsl.effect { write("Mike") write("Sperber") pure(42) }
Das Lambdaargument von effect ist eine suspend-Funktion. Das suspend sorgt dafür, dass der Compiler die Funktion in eine Coroutine transformiert. Die wiederum erlaubt der effect-Funktion, eine monadische Form der Funktion zu extrahieren. Wer sich für die Details interessiert, sei an das Kotlin-free-monads-Projekt [4] verwiesen.
Auch hier also ein Pluspunkt für Kotlin, zumindest gegenüber Java. Die Verwendung von suspend-Funktionen für monadische Syntax ist aber gegenüber funktionalen Sprachen deutlich aufwendiger und auch mit einigen Einschränkungen verbunden, sodass immer noch Luft nach oben bleibt.
Mit zwei Fragen sind wir in diesen Artikel gestartet:
Die Antwort auf die erste Frage ist ein entschiedenes Ja: Kotlin unterstützt die einfache Definition unveränderlicher Daten, kennt Typen für Funktionen, eliminiert die lästige Unterscheidung zwischen Referenztypen und Value Types, kompiliert rekursive Tail Calls korrekt, vereinfacht gegenüber Java den Umgang mit Generics und erlaubt die Definition monadischer Syntax. Das ist mehr als genug, um funktionale Programmierung gewinnbringend zu nutzen, mächtige Abstraktionen zu definieren und architektonisch von der Entkopplung durch Unveränderlichkeit zu profitieren. Wer mehr darüber wissen möchte, dem sei das oben erwähnte Buch [3] ans Herz gelegt. Eine grundsätzliche Einführung in funktionale Programmierung gibt es in meinem eigenen Buch „Schreibe Dein Programm!“ [5].
Die zweite Frage ist schwieriger zu beantworten: Auf der JVM gibt es zwei (weitere) funktionale Sprachen, nämlich Clojure und Scala. Beide sind großartig – während aber Clojure in vielerlei Hinsicht fundamental anders tickt als Kotlin und Java (kein Typsystem, Lisp-Syntax), ist Scala die Verlängerung der Linie von Java zu Kotlin.
Scala unterstützt funktionale Programmierung besser als Kotlin: Es gibt Higher-kinded Types, die Monaden funktionieren in Scala deutlich einfacher und außerdem erleichtern sogenannte Implicits es, automatisch die richtigen Implementierungen für Interfaces zu finden. Des Weiteren existiert um Scala herum ein umfangreiches Ökosystem funktionaler Libraries und Frameworks.
Die gegenüber Scala verringerte Komplexität von Kotlin fällt in der Praxis kaum ins Gewicht. Viele der typischen Schwierigkeiten in der täglichen Arbeit gibt es in Kotlin auch. Die IDE-Unterstützung ist für beide ähnlich gut. Wer also funktionale Programmierung mag und die Wahl hat, wird wahrscheinlich mit Scala glücklicher.
[1] Vavr, funktionale Library für Java: https://github.com/vavr-io/vavr
[2] Kotlin-Wrapper für Vavr: https://github.com/vavr-io/vavr-kotlin
[3] Vermeulen, Marco; Bjarnason, Rúnar; Chiusano, Paul: „Functional Programming in Kotlin“; Manning, 2021
[4] Monaden-Library für Kotlin: https://github.com/active-group/kotlin-free-monad
[5] Sperber, Michael; Klaeren, Herbert: „Schreibe Dein Programm!“; Tübingen University Press, 2023
The post Showdown der funktionalen Programmierung auf der JVM appeared first on JAX.
]]>The post Sichere Web-Apps für alle appeared first on JAX.
]]>Sicherlich ist der erste und wichtigste Schritt, sämtliche Variablen, die durch Formularfelder befüllt werden, auf eine gültige Eingabe hin zu prüfen. Ein gängiges Mittel ist die Validierung über reguläre Ausdrücke, die sicherstellen, dass nur gültige Zeichen und ein korrektes Format zugelassen werden. Möchte man aber beispielsweise formatierte und umfangreiche Artikel ermöglichen, wie es bei einem Content-Management-System der Fall ist, haben die gängigen Schutzmaßnahmen durchaus ihre Schwächen. Denn das Prinzip, möglichst die Eingabe zu beschränken und gefährliche Zeichen herauszufiltern, hat bei formatiertem Text seine Grenzen.
Wollen wir beispielsweise ein Zitat in Anführungszeichen setzen, können wir die beiden Zeichen ‘ und “ nicht einfach aus dem Text entfernen, da diese ja Bestandteil des Inhalts sind. In der Vergangenheit nutzte man bei dieser Problematik einen zusätzlichen Mechanismus, um Schutz vor Cyberangriffen sicherzustellen. Hierfür werden sämtliche kritische Zeichen wie spitze Klammern, Anführungszeichen und Backslashes über ihre HTML Escapes ersetzt. Damit gelten diese als problematisch klassifizierten Sonderzeichen nicht mehr als Quellcode, da aus > ein &gr; wird. Daher sind die soeben beschriebenen Maßnahmen besonders wirkmächtig und bieten bereits einen effektiven Schutz.
Die Schutzwirkung lässt sich allerdings über den Mechanismus CSP erheblich verbessern, da dieser weit über das Prüfen von Nutzereingaben hinausgeht. Sämtliche in einer Webapplikation geladene Ressourcen wie Bilder, CSS und JavaScript lassen sich dank dieser Technologie steuern.
Die Content Security Policy ist ein Projekt des W3C und greift als Bestandteil des Browsers – es sind keine zusätzlichen Plug-ins oder Ähnliches zu installieren. Für aktuelle Browser gilt CSP Level 3. Auf der entsprechenden Webseite des W3C [2] finden Sie eine vollständige Übersicht aller Versionen einschließlich der unterstützenden Webbrowser.
In Umgebungen, die erhöhte Sicherheitsanforderungen erfordern, beispielsweise Onlinebanking, kann die Browserversion des Clients abgefragt werden und bei erheblich veralteten Varianten des Browsers somit der Zugriff verweigert werden.
String userAgent = request.getHeader("user-agent"); String browserName = ""; String browserVer = ""; if(userAgent.contains("Chrome")){ String substring=userAgent.substring(userAgent.indexOf("Chrome")).split(" ")[0]; browserName=substring.split("/")[0]; browserVer=substring.split("/")[1]; } else if(userAgent.contains("Firefox")) { String substring=userAgent.substring(userAgent.indexOf("Firefox")).split(" ")[0]; browserName=substring.split("/")[0]; browserVer=substring.split("/")[1]; }
Regelmäßig News zur Konferenz und der Java-Community erhalten
Listing 1 zeigt die Möglichkeit, über den request die Identifikation des Browsers auszulesen. Anschließend demonstrieren die beiden Beispiele, wie die Versionsnummer aus dem Text für Chrome und Firefox extrahiert wird. Bei dieser Strategie sollte man sich aber bewusst sein, dass es für Schwindler recht einfache Möglichkeiten gibt, die Nutzung eines anderen Browsers vorzugaukeln.
Damit wird uns noch einmal vor Augen geführt, dass Web Application Security als ganzheitliches Konzept zu verstehen ist, in dem mehrere Maßnahmen als Verbund wirken – denn einzelne Techniken lassen sich durchaus umgehen. Schauen wir uns daher nun den Wirkmechanismus von CSP im Detail an.
Beginnen wir mit einer reinen HTML-Ausgabe, die CSS, Google Fonts, CDN JavaScript, ein YouTube iFrame und Grafiken enthält. Listing 2 dient uns als Ausgangspunkt, ohne den Einsatz von CSP.
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8" /> <title>Content Security Policy Demo</title> <link rel="stylesheet" href="style.css" /> <link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap" rel="stylesheet" /> <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css" integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2" crossorigin="anonymous" /> </head> <body> <main> <div class="box"> <div id="vue"></div> </div> <div class="box"> <div class="embed"> <iframe width="100%" height="500px" frameborder="0" src="https://www.youtube.com/embed/3nK6rcAbuzo" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture" allowfullscreen></iframe> </div> </div> <div class="box"> <div class="grid"> <img src="https://images.unsplash.com/photo-1535089780340-34cc939f9996?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=80" /> <img src="https://images.unsplash.com/photo-1587081917197-95be3a2d2103?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=80" /> <img src="https://images.unsplash.com/photo-1502248103506-76afc15f5c45?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=500&q=80" /> </div> </div> </main> <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.min.js"></script> <script nonce="rAnd0m"> new Vue({ el: '#vue', render(createElement) { return createElement('h1', 'Hello World!'); }, }); </script> </body> </html>
Abb. 1: HTML ohne CSP
Wie wir in Abbildung 1 sehen können, handelt es sich hierbei um eine einfache HTML-Seite, die verschiedenen Content lädt. Dazu gehört zum Beispiel auch, das JavaScript-Framework Bootstrap extern über das Content Delivery Network (CDN) jsDelivr zu laden. Wer mit dem Thema CDN noch nicht vertraut ist, findet dazu einen sehr informativen Artikel auf Wikipedia [3]. Stellen wir uns nun einmal ein realistisches Szenario aus der Praxis vor.
Es könnte nun sein, dass die von uns verwendete Bootstrap-Bibliothek eine bekannte Sicherheitslücke für XSS-Angriffe enthält. Der Content des CDN von jsDelivr ist durchaus vertrauenswürdig. Ein Angreifer könnte nun aber versuchen, von einem Webspace eine eigens präparierte JavaScript-Datei über die Bootstrap-Sicherheitslücke einzuschleusen. Ich möchte an dieser Stelle natürlich kein Hackertutorial an die Hand geben und erklären, wie ein solcher Angriff ausgeführt werden kann; ebensowenig möchte ich Ideen verbreiten, welcher Unfug sich über XSS anstellen lässt. Mir geht es darum, eine zuverlässige Methode vorzustellen, die den Angriff wirkungsvoll verhindern kann. Also richten wir unser Augenmerk nun auf die Prävention von XSS.
Mittels CSP werden nun Meta-Header-Attribute gesetzt, mit denen bestimmt werden kann, was zuverlässige Quellen sind. Das erlaubt es jedem Contenttyp (Ressource, also Bild, CSS und so weiter), explizite, vertrauenswürdige Quellen (Domains) zuzuweisen. Damit lassen sich JavaScript-Dateien so einschränken, dass sie nur vom eigenen Server geladen werden und beispielsweise von CDN jsDelivr. Findet ein Angreifer nun einen Weg, eine XSS-Attacke auszuführen, bei der er versucht, von einer anderen Domain als von den erlaubten Domains Schadcode einzuschleusen, blockiert CSP das Skript von der nicht freigegebenen Domain.
Dazu ein einfaches Beispiel: Wenn wir im <head> in Listing 2 gleich nach dem meta-Tag für die Seitencodierung die Zeile <meta http-equiv=”Content-Security-Policy” content=”default-src ‘self'” /> einfügen und die Datei ausführen, bekommen wir eine Ausgabe wie in Abbildung 2.
Abb. 2: Blocked Content
Der Teil content=”default-src ‘self'” sorgt dafür, dass sämtliche Inhalte, die nicht von der eigenen Domäne stammen, über CSP im Browser blockiert werden. Wollen wir zusätzlich für JavaScript das jsDelivr CDN zulassen, müssen wir den meta-Tag wie in folgt formulieren:
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'nonce-rAnd0m' https://cdn.jsdelivr.net; />
Auf der offiziellen Webseite für Content Security Policy [4] finden Sie eine vollständige Auflistung sämtlicher Attribute und eine umfangreiche Liste an Beispielen. So bewirkt der Ausdruck nonce-rAnd0m dass Inline-Skripte geladen werden dürfen. Dazu muss das zugehörige JavaScript mit dem Attribut [script nonce=”rAnd0m”/] gekennzeichnet werden. Damit unser Beispiel aus Listing 2 vollständig funktioniert, benötigen wir für den CSP-meta-Tag folgenden Eintrag (Listing 3).
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'nonce-rAnd0m' https://cdn.jsdelivr.net; img-src https://images.unsplash.com; style-src 'self' https://cdn.jsdelivr.net; frame-src https://www.youtube.com; font-src https://fonts.googleapis.com;" />
Für Java-Webapplikationen können die Headerinformationen zu den Beschränkungen der CSP über den response hinzugefügt werden. Die Zeile response.addHeader (“Content-Security-Policy”, “default-src ‘self'”); bewirkt wie zu Beginn, dass sämtliche Inhalte, die nicht von der eigene Domäne stammen, blockiert werden.
Nun ist das applikationsweite manuelle Hinzufügen von CSP-Regeln für jede eigene Seite recht mühselig und zudem noch sehr fehleranfällig. Eine zentrale Lösung für den Einsatz von Java-Servern ist die Verwendung von Servlet-Filtern (Listing 4).
public class CSPFilter implements Filter { public static final String POLICY = "default-src 'self'"; @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { if (response instanceof HttpServletResponse) { ((HttpServletResponse)response).setHeader("Content-Security-Policy", CSPFilter.POLICY); } } @Override public void init(FilterConfig filterConfig) throws ServletException { } @Override public void destroy() { } }
Um den Filter dann zum Beispiel im Apache Tomcat zu aktivieren, wird noch ein kleiner Eintrag in der web.xml benötigt (Listing 5).
<filter> <filter-name>CSPFilter</filter-name> <filter-class>com.content-security-policy.filters.CSPFilter</filter-class> </filter> <filter-mapping> <filter-name>CSPFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>
Sicher könnte man für sämtliche Applikationen in einem vorgeschalteten Proxyserver wie dem Apache-2-HTTP-Server oder NGNIX alle CSP-Regeln zentral verwalten. Davon ist allerdings aus zwei Gründen abzuraten: Zum einen verlagert sich so die Verantwortlichkeit von der Entwicklung hin zum Betrieb, was zu erhöhtem Kommunikationsaufwand und einem Flaschenhals in der Entwicklung führt. Zum anderen werden die so entstehenden Regeln sehr komplex und entsprechend schwieriger zu lesen beziehungsweise zu warten. Der hier vorgeschlagene Servlet-Filter ist daher eine gute Option, um die Sicherheitsregeln applikationsweit zentral für exakt die entwickelte Anwendung zu verwalten. Zudem passt das auch hervorragend in ein agiles DevOps-Konzept, denn die notwendige Konfiguration ist Bestandteil des Source Codes und über die Versionsverwaltung unter Konfigurationsmanagement zugänglich.
Wie ich in diesem Artikel zeigen konnte, ist das Thema Web-Application-Security nicht immer gleich ein Fall für Spezialisten. Persönlich empfinde ich es als eine große Errungenschaft der letzten Jahre, wie das Härten von Webanwendungen kontinuierlich einfacher wird. CSP halte ich auf diesem Weg für einen Schritt in die richtige Richtung. Natürlich gilt es weiterhin, stets ein Auge auf aktuelle Entwicklungen zu haben, denn die bösen Buben und Mädels ruhen sich nicht auf ihren Lorbeeren aus und lassen sich ständig neue Gemeinheiten einfallen. Der gerade populär gewordene breite Einsatz von künstlichen neuronalen Netzen wie ChatGPT lässt im Moment nur grob erahnen, was uns künftig noch an Cyberangriffen aus den Tiefen des weltweiten Netzes erwarten wird.
[1] https://owasp.org/www-community/attacks/xss/
[2] https://www.w3.org/TR/CSP/
[3] https://de.wikipedia.org/wiki/Content_Delivery_Network
[4] https://content-security-policy.com
The post Sichere Web-Apps für alle appeared first on JAX.
]]>