JAX https://jax.de/ Java, Architecture & Software Innovation Wed, 02 Oct 2024 12:40:25 0000 de-DE hourly 1 https://wordpress.org/?v=6.5.2 Erweiterte Streams in Java 23: Was JEP 473 Entwicklern bietet https://jax.de/blog/java-23-stream-gatherers-jep-473-neuerungen/ Mon, 02 Sep 2024 12:30:52 0000 https://jax.de/?p=90169 Java 23 bringt eine Vielzahl von Verbesserungen in der Syntax und bei den APIs. Dieser Artikel fokussiert sich auf „JEP 473 – Stream Gatherers (Second Preview)“ als interessante Neuerung bei Streams. JEP 473 folgt auf JEP 461 aus Java 22, in dem Stream Gatherers bereits als Erweiterung der Stream-Verarbeitung vorgestellt wurden, die die Definition eigener Intermediate Operations erlauben. Schauen wir uns an, was uns Neues erwartet.

The post Erweiterte Streams in Java 23: Was JEP 473 Entwicklern bietet appeared first on JAX.

]]>
Die in Java 8 LTS eingeführten Streams waren von Anfang an recht mächtig. In den folgenden Java-Versionen wurden verschiedene Erweiterungen im Bereich der Terminal Operations hinzugefügt. Erinnern wir uns: Terminal Operations dienen dazu, die Berechnungen eines Streams abzuschließen und den Stream beispielsweise in eine Collection oder einen Ergebniswert zu überführen.

Stay tuned

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.

Einführung

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.

Abhilfe mit den bisherigen Möglichkeiten

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.

SIE LIEBEN JAVA?

Den Core-Java-Track entdecken

 

Beispiel Gruppierung

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());
  }
}

Neu: Interface Gatherer und die Methode gather()

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.

Ausgewählte Gatherer

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.*

windowFixed

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]]

windowSliding

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]]

fold

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.

Stay tuned

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]

Aktionen für abweichende Typen

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]

Varianten mir reduce()

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

scan

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]

Fazit

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!

Stay tuned

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.

]]>
Java Core 2024: Ein umfassender Überblick https://jax.de/blog/java-core-2024-ueberblick/ Thu, 04 Jul 2024 12:56:46 0000 https://jax.de/?p=89923 Wer populäre Technologien einsetzt, bekommt im Problemfall schneller Hilfe und hat auch bei der Entwicklerrekrutierung weniger Probleme. Die aktuelle Ausgabe des von dem SaaS-Anbieter New Relic veröffentlichten Status-quo-Reports zeigt, wie es um das Java-Ökosystem steht.

The post Java Core 2024: Ein umfassender Überblick appeared first on JAX.

]]>
Der New-Relic-Report [1] speist sich überwiegend aus Daten, die das Unternehmen aus den hauseigenen Observability-Systemen erhält. Wir stellen die Ergebnisse vor und gehen kurz auf die Besonderheiten verschiedener GC-Algorithmen ein. Bei der Bewertung der Daten ist zu beachten, dass die Informationen auf im Einsatz befindlichen Systemen beruhen – Prototypen und nur auf der Workstation eines Entwicklers lebende Programme werden nur selten mit New Relic verbunden.

Stay tuned

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

 

Schnellere Releases

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.

Ressourcen und ihr Management

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.

SIE LIEBEN JAVA?

Den Core-Java-Track entdecken

 

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.

Frameworks am Puls der Zeit

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])

Stack Overflow als Pulsmesser

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])

Fazit

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.

Stay tuned

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

 


Links & Literatur

[1] https://newrelic.com/sites/default/files/2024-05/new-relic-state-of-the-java-ecosystem-report-2024-05-21.pdf

[2] https://openjdk.org/jeps/376

The post Java Core 2024: Ein umfassender Überblick appeared first on JAX.

]]>
Exactly Once in verteilten Systemen: Realität oder Utopie? https://jax.de/blog/exactly-once-idempotenz-java/ Mon, 10 Jun 2024 13:24:45 0000 https://jax.de/?p=89825 Sind verteilte Systeme im Einsatz, wie es beispielsweise bei Microservices der Fall ist, ist eine verteilte Datenverarbeitung – häufig asynchron über Message Queues – an der Tagesordnung. Werden Nachrichten ausgetauscht, sollen diese häufig genau einmal verarbeitet werden – gar nicht so einfach, wie sich herausstellt.

The post Exactly Once in verteilten Systemen: Realität oder Utopie? appeared first on JAX.

]]>
Bei verteilten Systemen wird eine asynchrone Kommunikation häufig über einen Message Broker abgedeckt. Dadurch soll eine Entkopplung zwischen zwei Diensten erreicht werden, die unter Umständen separat skaliert werden können. Eine Kommunikation über einen Message Broker ist inhärent immer mindestens zweigeteilt, nämlich in Producer und Consumer.

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?

Stay tuned

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

 

Es kann so einfach sein

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.

Immer Ärger mit der Kommunikation

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.

ALL ABOUT MICROSERVICES

Microservices-Track entdecken

 

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-onceSemantiken 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?

SIE LIEBEN JAVA?

Den Core-Java-Track entdecken

 

Lösungen müssen her

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.

Stay tuned

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

 

Fazit

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.

]]>
Java 22 … and beyond | JAX 2024 Keynote https://jax.de/blog/java-release-cycle-beschleunigt-neue-features-java-22/ Tue, 07 May 2024 11:20:44 0000 https://jax.de/?p=89728 Brian Goetz, Java Language Architect bei Oracle, hielt am 24. April auf der JAX eine fesselnde Keynote. Er hob die jüngsten Fortschritte von Java hervor und ging dabei über die Funktionen von Java 22 hinaus. Was hält die Zukunft für Java-Entwickler:innen bereit?

The post Java 22 … and beyond | JAX 2024 Keynote appeared first on JAX.

]]>
Das neue Release-Modell für Java-Versionen ist laut Goetz ein klarer Vorteil für Entwickler:innen. Es ermöglicht einen schnelleren Zugang zu den neuesten Features und Sicherheitsupdates und sorgt so für moderne und sichere Anwendungen.

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.

SIE LIEBEN JAVA?

Den Core-Java-Track entdecken

 

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!

Stay tuned

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

 

The post Java 22 … and beyond | JAX 2024 Keynote appeared first on JAX.

]]>
Java Module und JPMS: Endlich bereit für den Einsatz? https://jax.de/blog/java-module-jpms-einfuehrung-vorteile-herausforderungen-best-practices/ Mon, 08 Apr 2024 07:41:42 0000 https://jax.de/?p=89614 Wer im Web nach Java-Modulen oder dem Java Platform Module System (JPMS) sucht, stößt vor allem auf Kritik. Ist das JPMS also eher Flop als top? Auch wenn Module nur sehr langsam im Java Ecosystem angenommen werden, gibt es Projekte, die sie mit großem Erfolg einsetzen. Wir schauen die Vor- und Nachteile an, erfahren, mit welchen Best Practices man ein großes Projekt mit JPMS zum Erfolg führen kann, betrachten, welche Probleme es gibt, wann der Einsatz von JPMS sinnvoll ist, und zeigen, was Entwickler von (Open-Source-)Bibliotheken auch dann beachten sollten, wenn sie kein Interesse an Java-Modulen haben.

The post Java Module und JPMS: Endlich bereit für den Einsatz? appeared first on JAX.

]]>
Jeder Java-Entwickler kennt das: Die Sichtbarkeiten public, protected, default und private sind nicht immer hilfreich. Wer hat sich nicht schon gewünscht, dass etwas nur für das eigene Package sowie für Sub-Packages sichtbar wäre? Noch schlimmer ist das Problem der Namespace Pollution: In einem großen Projekt sammeln sich unzählige Bibliotheken im Classpath, und die Auto Completion für generische Begriffe wie List, Entity, Metadata oder StringUtil liefert alle möglichen und unmöglichen Treffer und im Worst Case den richtigen erst ganz am Ende der Liste (Abb. 1).

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.

Stay tuned

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.

Java-Module

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).

Der Moduldeskriptor

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:

  • Name des Moduls
  • Abhängigkeiten von anderen Modulen
  • Liste der exportieren Packages
  • Angebotene sowie verwendete Services
  • Reflection-Zugriffe

Modulname und Packages

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 von anderen Modulen

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:

  • transitiv: Damit wird die Abhängigkeit transitiv, d. h. Module, die mein Modul anfordern, erhalten diese Abhängigkeit automatisch ebenfalls. Andernfalls ist das nicht so und der Code der Abhängigkeit ist für sie zunächst unsichtbar. Anders als bei Maven oder Gradle betrifft diese Eigenschaft nur die Sichtbarkeit und ändert nichts daran, dass die Abhängigkeit zur Laufzeit gebraucht wird. Somit wird beim JPMS sehr genau zwischen transitiven und nichttransitiven Abhängigkeiten unterschieden: Ich deklariere Abhängigkeiten von einem anderen Modul nur dann als transitiv, wenn es für die Verwendung meines Moduls (via API) auch für den Aufrufer sichtbar sein muss oder etwas völlig Querschnittliches ist, wie z. B. das SLF4J API zum Logging. Tauchen z. B. Typen aus diesem Modul in meinem eigenen API auf, dann deklariere ich die Abhängigkeit als transitiv. Andernfalls erhalte ich von meiner IDE eine Warnung. Abhängigkeiten, die ich nur intern für meine Implementierungen benötige, deklariere ich nicht transitiv und vermeide damit das Namespace-Pollution-Problem.
  • static: Damit wird eine Abhängigkeit optional, d. h., das angeforderte Modul ist zur Laufzeit nicht erforderlich und das JPMS wirft keinen Fehler, wenn es fehlt. Natürlich muss ich meinen Code so schreiben, dass er damit umgehen kann. Am Schlüsselwort static sieht man mal wieder, dass der Java-Compiler auf reservierte Schlüsselwörter fokussiert ist. Hier wurde also offensichtlich ein bereits in Java reserviertes Schlüsselwort missbraucht, was für diesen Kontext nicht gerade selbsterklärend ist. Es wäre wesentlich intuitiver gewesen, hier einfach optional statt static zu schreiben.

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:

  • Systemmodule werden vom JRE bzw. JDK bereitgestellt. Mit dem Projekt Jigsaw (auf Deutsch Stichsäge) hat Oracle die Mammutaufgabe gemeistert, das JDK als historisch gewachsenen Codehaufen sauber in Java-Module mit definierten Abhängigkeiten zu zerschneiden. Das beschleunigt den Start der JVM. Module wie java.desktop (Swing) oder java.rmi mit all ihren Klassen müssen nicht geladen werden, wenn man sie nicht braucht.
  • Custom- oder Application-Module sind Module des Java Ecosystem, die explizit über einen Moduldeskriptor (module-info.class) verfügen, aber nicht zum JDK gehören. Auf ihnen liegt der Fokus dieses Artikels.
  • Automatic Module sind ein Behelfsmittel für das angesprochene Problem, eine Bibliothek aus einem Modul zu nutzen, die selbst keinen Moduldeskriptor hat. Entwickler von (Open-Source-)Bibliotheken für Java, die keine Lust haben, Moduldeskriptoren für ihre JARs zu pflegen, sollten unbedingt in ihrer Manifest-Datei einen Modulnamen über die Property Automatic-Module-Name festlegen. Damit kann das JAR über diesen Modulnamen importiert werden und alle Packages sind automatisch exportiert. Ist auch das nicht der Fall, bleibt als letzter Notanker nur die Möglichkeit, die Bibliothek nach bestimmten Regeln über den Kernbestandteil des Dateinamens der JAR-Datei anzusprechen. Das mag zum Ausprobieren einen Versuch wert sein – ich rate jedoch davon ab, es als finale Lösung für ein Release zu verwenden. Stattdessen sollte man die Autoren der Bibliothek per Feature-Request-Ticket überzeugen, einen Automatic-Module-Name festzulegen oder man verzichtet einfach auf den Einsatz der Bibliothek und sucht eine Alternative.
  • Unnamed Module: In Java gibt es genau ein Unnamed Module, in dem automatisch alles landet, was kein Modul ist. Andere Module können aber nicht auf Code daraus zugreifen.

Neue Spielregeln für Packages und Reflection

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.

Stay tuned

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.

Services 2.0

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:

  • Singleton: Ich möchte zu einem Interface genau eine Instanz haben. Woher die Implementierung kommt und wie diese zusammengebaut wird, ist für die Nutzung abstrahiert und wird vom Framework oder eben von Java geregelt. Ein Beispiel wäre ein Interface ConfigurationService oder der berüchtigte EntityManager.
  • Plug-ins: Ich möchte etwas flexibel erweiterbar gestalten und biete selbst ein Interface an, zu dem es auch zur Laufzeit viele unterschiedliche Implementierungen parallel geben darf, die wir Plug-ins nennen. Sie erweitern den Funktionsumfang und dazu können unterschiedliche Parteien in Harmonie beitragen. Als Beispiel stellen wir uns vor, dass wir eine Suchmaschine programmieren, und bieten als Plug-in-Interface TextExtractor an, das zu einem bestimmten Dateiformat den reinen Text extrahieren kann. Ein Modul stellt eine Implementierung PdfTextExtractor bereit, ein anderes XpsTextExtractor usw. Keins dieser Module exportiert irgendetwas, und die nutzende Anwendung importiert diese Module, ohne direkten Zugriff auf deren Klassen zu erhalten.

 

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:

  • Findet der ServiceLoader zur Laufzeit für den angeforderten Service gar keine oder „zu viele“ Implementierung(en), so muss ich mich selbst um die Fehlerbehandlung kümmern.
  • Für einen Singleton Service möchte ich eine Exception, wenn keine Implementierung gefunden wurde. Gegebenenfalls möchte ich aber auch eine Default- bzw. Fallback-Implementierung mitliefern, die dann verwendet wird.
  • Finde ich für einen Singleton Service mehrere Implementierungen, will ich typischerweise auch eine Exception. Doch der ServiceLoader und das Java-Modul bieten nicht viel Flexibilität (wie z. B. DI-Frameworks aus Spring oder Quarkus): Eine Möglichkeit, einen Service durch ein Modul wieder zu entfernen oder zu ersetzen, gibt es nicht. Mit extrem feingranularen Modulschnitten kann man das alles irgendwie lösen, aber das überfordert schnell die Nutzer meiner Module. Daher habe ich mir angewöhnt, Implementierungen aus meinem eigenen Package Namespace bei Singleton Services zu ignorieren, falls es auch eine Implementierung aus einem externen Package gibt. Damit erlaube ich externen Nutzern meiner Bibliotheken, meine Implementierung durch ihre eigene zu ersetzen, und erhöhe die Flexibilität.

Internationalisierung

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.

SIE LIEBEN JAVA?

Den Core-Java-Track entdecken

 

IDE-Unterstützung

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.

Tests

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.

Entscheidung für oder gegen JPMS

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.

Fazit und Ausblick

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.


Links & Literatur

[1] https://openjdk.org/jeps/411

[2] https://m-m-m.github.io

[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.

]]>
Resiliente Kafka Consumer bauen https://jax.de/blog/kafka-fehlerbehandlung-guide/ Mon, 04 Mar 2024 08:55:02 0000 https://jax.de/?p=89539 Asynchrone Kommunikation ist der Königsweg für den Datenaustausch zwischen lose gekoppelten Systemen. Laut einer Erhebung von 6sense [1] ist der Marktführer für den Austausch asynchroner Nachrichten Apache Kafka. Wir beschreiben in diesem Artikel unseren Weg als agiles Team zu einer einfachen Catch-all-Fehlerbehandlung für Nachrichten, die wir via Kafka empfangen. Und so viel sei vorab verraten: Am Ende unserer Reise finden wir sogar die richtige Strategie zur Selbstheilung.

The post Resiliente Kafka Consumer bauen appeared first on JAX.

]]>
Wir berichten hier über die Erfahrungen, die wir in einem gemeinsamen Projekt im öffentlichen Sektor gesammelt haben. Dort haben wir verschiedene Strategien für die Fehlerbehandlung bei der Verarbeitung asynchroner Nachrichten in unseren Consumern erarbeitet und verglichen. Neben der Minimierung unserer Betriebsaufwände mussten wir dabei wegen notorisch enger Zeitpläne auch unseren Entwicklungsaufwand minimieren. Deswegen haben wir zunächst nach einer Strategie gesucht, die in einem ersten Schritt als einzige „Catch all“-Strategie für alle Fehlerszenarien anwendbar ist: eine „Minimum Viable“-Fehlerstrategie.

Bei der Bewertung der Strategien haben wir die möglichen Fehlerszenarien nach zwei Dimensionen unterschieden, der Fehlerquelle und der Fehlerhäufigkeit.

Fehlerquelle:

  • Der Producer erzeugt Nachrichten, die nicht dem Schnittstellenvertrag entsprechen, entweder syntaktisch oder semantisch.
  • Der Consumer ist nicht in der Lage, alle Nachrichten, die dem Schnittstellenvertrag entsprechen, zu verarbeiten, z. B., wirft er unerwartete Ausnahmen.
  • Die Infrastruktur arbeitet nicht erwartungsgemäß, z. B. fällt die Verbindung des Consumers zu seiner Datenbank aus.

Fehlerhäufigkeit:

  • Der Fehler tritt nur selten und vereinzelt auf, z. B. als sporadischer Edge Case.
  • Der Fehler ist ein generelles und länger andauerndes Problem, das viele Nachrichten betrifft.

Bei der schlussendlichen Bewertung der Strategien haben sich die Vor- und Nachteile dann oft als unabhängig von Fehlerquelle oder Fehlerhäufigkeit herausgestellt.

Stay tuned

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

 

Kafka-Grundlagen

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].

Broker

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.

Producer

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.

Consumer

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.

Problembeschreibung

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.

Codebeispiele

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);
  }

Consumer herunterfahren

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:

  • Die Strategie erhält die Reihenfolge.
  • Der manuelle Aufwand für die Fehlerbehebung ist beim Team des Consumers unabhängig von der Anzahl fehlerhafter Nachrichten.

Nachteile:

  • Der Microservice blockiert auch fehlerfreie Nachrichten.
  • Die Strategie führt in überwachten Umgebungen wie Kubernetes zu einer Restart-Schleife. Die wiederum führt je nach Timeout, Restart Backoff, Restart Cutoff und Start-up-Zeit dazu, dass der Broker die Partitionen auf die restlichen Consumer umverteilt, sodass am Ende jeder Consumer versuchen wird, die fehlerhafte Nachricht zu verarbeiten, und dann herunterfährt. Das wiederum verursacht vermutlich einen erhöhten Ressourcenverbrauch. Durch das Herunterfahren weiterer Consumer werden auch immer mehr Nachrichten in anderen Partitionen blockiert.
  • Die Strategie erfordert in nicht überwachten Umgebungen einen Mechanismus, um den Consumer nach der Fehlerbehebung wieder zu starten.
  • Die Strategie erfordert einen zusätzlichen Mechanismus zum Überspringen von fehlerhaften und ggf. auch abhängigen Nachrichten.
  • Bei Fehlern in der Nachricht müssen alle abhängigen Nachrichten vom Producer nochmals geschickt werden.

Dead Letter Queue

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:

  • Blockiert nur Nachrichten, bei denen ein Fehler auftritt, d. h. keine Verzögerung nicht betroffener Nachrichten und maximaler Durchsatz.
  • Gute Skalierung auch bei vielen fehlerhaften Nachrichten.
  • Falls der Fehler nicht in der Nachricht lag, kann die DLQ nach dem Bugfix erneut eingelesen werden.
  • Der manuelle Aufwand für die Fehlerbehebung ist beim Team des Consumers unabhängig von der Anzahl fehlerhafter Nachrichten.
  • Bei einer Implementierung der DLQ mittels Datenbank kann man die Retention Time von Kafka umgehen, wodurch man mehr Zeit für die Fehlerbehebung hat.

Nachteile:

  • Erfordert einen separaten (u. U. manuellen) Mechanismus zum Einlesen der DLQ nach dem Bugfix.
  • Wirft die Reihenfolge der Nachrichten durcheinander.
  • DLQ muss eine Kapazität wie das ursprüngliche Topic haben, falls der Fehler einen Großteil der Nachrichten betrifft.
  • Die Strategie erfordert einen zusätzlichen Mechanismus zum Überspringen von fehlerhaften und ggf. auch abhängigen Nachrichten.
  • Bei Fehlern in der Nachricht müssen alle abhängigen Nachrichten vom Producer nochmals geschickt werden.

DLQ Advanced

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:

  • Die Strategie erhält die Reihenfolge.
  • Blockiert nur fehlerhafte und davon abhängige Nachrichten, d. h. keine Verzögerung nicht betroffener Nachrichten und maximaler Durchsatz.
  • Bei einer Implementierung der DLQ mittels Datenbank kann man die Retention Time von Kafka umgehen, womit man mehr Zeit für die Fehlerbehebung hat.

Nachteile:

  • Erfordert einen zusätzlichen Mechanismus zum Überspringen von fehlerhaften und ggf. auch abhängigen Nachrichten.
  • Erfordert komplexen (u. U. manuellen) Mechanismus zum Einlesen und Synchronisieren der DLQ nach dem Bugfix mit dem ursprünglichen Topic.
  • Die DLQ muss eine Kapazität wie das ursprüngliche Topic haben, falls der Fehler einen Großteil der Nachrichten betrifft.
  • Der manuelle Aufwand für die Fehlerbehebung ist beim Team des Consumers unabhängig von der Anzahl fehlerhafter Nachrichten.

Pausieren und Wiederholen

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:

  • Pausiert nur betroffene Partitionen eines Consumers bzw. nur einen Consumer. Die Granularität hängt von der Anzahl der Consumer und der Anzahl der Partitionen ab.
  • Die Umsetzungsvariante mit der Retry-Annotation ist einfacher als das Pausieren einzelner Partitionen.
  • Verhindert Ressourcenverschwendung durch unnötiges Rebalancing.
  • Bei notwendigem Rebalancing (z. B. Consumer stürzt ab, neue Consumer kommen zwecks Skalierung hinzu) wird der neue Consumer automatisch die fehlerhafte Partition wieder pausieren.
  • Das System macht automatisch weiter, nachdem das Problem behoben ist, z. B. durch Deployment eines Bugfixes. Auch bei sporadischen Problemen, die sich selbst nach kurzer Zeit lösen, macht das System automatisch weiter. Das Team profitiert dabei vom Prinzip der Selbstheilung.
  • Der manuelle Aufwand für die Fehlerbehebung ist beim Team des Consumers unabhängig von der Anzahl fehlerhafter Nachrichten.

Nachteile:

  • Falls es mehr Kafka-Schlüssel als Partitionen gibt (was der Normalfall sein dürfte), werden auch Schlüssel angehalten, die sich zufällig in derselben Partition befinden, aber nicht betroffen sind.
  • Bei der Umsetzungsvariante mit der Retry-Annotation werden auch Nachrichten in Partitionen angehalten, die nicht die fehlerhafte Nachricht enthalten, falls es weniger Consumer als Partitionen gibt.
  • Erfordert zusätzlichen Mechanismus zum Überspringen von fehlerhaften und ggf. auch abhängigen Nachrichten.
  • Bei Fehlern in der Nachricht müssen alle abhängigen Nachrichten vom Producer nochmals geschickt werden.

Einfach loggen

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:

  • Überspringt nur Nachrichten, bei denen ein Fehler auftritt, und vermeidet damit eine Verzögerung nicht betroffener Nachrichten. Die Strategie maximiert also den Durchsatz.

Nachteile:

  • Wirft die Reihenfolge der Nachrichten durcheinander.
  • Verliert Nachrichten, falls die Fehlerursache im Consumer oder in der Infrastruktur liegt.

Fazit

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.


Links & Literatur

[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

[6] https://smallrye.io/smallrye-reactive-messaging/4.16.0/kafka/receiving-kafka-records/#acknowledgement

[7] https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#restart-policy

[8] https://smallrye.io/smallrye-reactive-messaging/4.16.0/kafka/receiving-kafka-records/#configuration-reference

[9] https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/

[10] https://smallrye.io/smallrye-reactive-messaging/4.16.0/kafka/receiving-kafka-records/#failure-management

[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

[13] https://kafka.apache.org/37/javadoc/org/apache/kafka/clients/consumer/KafkaConsumer.html#pause(java.util.Collection)

[14] https://kafka.apache.org/37/javadoc/org/apache/kafka/clients/consumer/KafkaConsumer.html#resume(java.util.Collection)

[15] https://quarkus.io/blog/kafka-failure-strategy/#the-ignore-strategy

The post Resiliente Kafka Consumer bauen appeared first on JAX.

]]>
Showdown der funktionalen Programmierung auf der JVM https://jax.de/blog/kotlin-vs-scala-funktionale-programmierung-jvm/ Mon, 19 Feb 2024 10:08:20 0000 https://jax.de/?p=89461 Kotlin ist eine Weiterentwicklung von Java mit zusätzlichen Features und Vereinfachungen. Die Ideen dafür kamen überwiegend aus der funktionalen Programmierung. Kein Wunder, bietet diese doch gegenüber OOP viele Vorteile, wie architektonische Entkopplung durch unveränderliche Daten und bessere Domänenmodelle durch mächtigere Abstraktionen. Aber reichen Kotlins Innovationen aus, um es zu einer waschechten funktionalen Programmiersprache zu machen?

The post Showdown der funktionalen Programmierung auf der JVM appeared first on JAX.

]]>
Der Begriff „funktionale Programmiersprache“ gründete sich ursprünglich darauf, dass die Sprache Funktionen „erstklassig“, also als normale Objekte behandelt und erlaubt, sie zur Laufzeit zu erzeugen. Bei statisch typisierten Sprachen (und dazu gehört auch Kotlin) bedeutet der Begriff außerdem, dass es das Typsystem erlaubt, Funktionen zu beschreiben, und die erstklassige Manipulation von Funktionen zumindest nicht behindert.

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.

Unveränderliche Daten

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.

Stay tuned

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.

SIE LIEBEN JAVA?

Den Core-Java-Track entdecken

 

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.

Erstklassige Funktionen

Kommen wir zur Unterstützung für Funktionen als Objekte: Da macht Kotlin gegenüber Java einen riesigen Sprung durch zwei Neuerungen:

  • Das Kotlin-Typsystem kennt Funktionstypen, die man sich in Java umständlich als Single-Method-Interface selbst definieren muss.
  • Kotlin macht effektiv keinen Unterschied zwischen Value Types und Reference Types.

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.

Schleifen und Endrekursion

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.

Typen

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.

Higher-kinded Types

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.

Stay tuned

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.

Monaden

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.

Fazit

Mit zwei Fragen sind wir in diesen Artikel gestartet:

  1. Ist Kotlin funktional genug, dass es sich in lohnt, darin funktional zu programmieren?
  2. Ist Kotlin die richtige Sprache, wenn man auf der JVM funktional programmieren möchte?

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.


Links & Literatur

[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.

]]>
Sichere Web-Apps für alle https://jax.de/blog/sichere-web-apps-content-security-policy-csp-gegen-cross-site-scripting-xss-tipps-tricks/ Fri, 05 Jan 2024 11:53:43 0000 https://jax.de/?p=89358 Das Einschleusen von Schadcode über eine fremde Domäne lässt sich mit einer Content Security Policy (CSP) erheblich einschränken. Erfahren Sie, wie Sie Ihre Webapplikation gegen Cross-site-Scripting-(XSS-)Attacken härten können.

The post Sichere Web-Apps für alle appeared first on JAX.

]]>
Formulare in Webapplikationen sind für so manche Hackerattacke ein geeignetes Einfallstor. Ganz gleich, ob es ein Angriff auf die Datenbank mit einer SQL Injection ist, oder ob schädliches JavaScript per XSS [1] nachgeladen und zur Ausführung gebracht wird.

Gefahr durch Eingaben

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];
}

Stay tuned

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.

Schutz durch CSP

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.

MEHR PERFORMANCE GEFÄLLIG?

Performance & Security-Track entdecken

 

Zentrale Regelung über Servlet-Filter

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.

 

Fazit

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.


Links & Literatur

[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.

]]>
Quarkus: Next Level Jakarta EE https://jax.de/blog/quarkus-next-level-jakarta-ee/ Mon, 11 Dec 2023 12:13:34 0000 https://jax.de/?p=89304 Jakarta EE (aka Java EE) ist eine weit verbreitete Basis für Java-Enterprise-Anwendungen. Für viele ist die Plattform fest mit dem Begriff „Application Server“ verbunden, einem vermeintlich schwergewichtigen Ding, auf dem große Anwendungen laufen. Das scheint so gar nicht in die aktuelle Zeit zu passen, in der statt weniger Großanwendungen nun viele kleinere betrieben werden und separate Serverinstallationen eher unerwünscht sind. Das bedeutet aber mitnichten das Ende von Jakarta EE.

The post Quarkus: Next Level Jakarta EE appeared first on JAX.

]]>
Vielfach hört man die Meinung, dass moderne Anwendungen oder Microservices mit Jakarta EE nicht machbar seien und man daher seine Codebasis auf Spring Boot migrieren müsse. Wir werden im Folgenden sehen, dass das so nicht stimmt. Um Missverständnissen vorzubeugen: Es geht nicht darum, ob Spring Boot oder Jakarta EE das beste Enterprise-Framework ist – so etwas gibt es ohnehin nicht. Es geht vielmehr darum zu zeigen, dass man bei größtenteils unveränderter Codebasis moderne, schnelle und kleine (oder auch große) Anwendungen erstellen kann und dass das sogar richtig Spaß macht – pardon: eine gute „Developer Experience“ hat.

Programmiermodell

Werfen wir zunächst einen Blick darauf, wie wir Anwendungen mit Jakarta EE schreiben. Im Listing 1 ist ein beispielhafter Ausschnitt einer Web-Service-Klasse zu sehen. Wir arbeiten in JEE stark deklarativ, das heißt wir geben dem Code mit Annotationen wie @ApplicationScoped Eigenschaften mit, statt sie selbst zu implementieren. Die Verknüpfung der verschiedenen Komponenten untereinander programmieren wir ebenso nicht aus, sondern lassen benötigte Services per Dependency Injection mit @Inject anliefern.

Möglich wird diese Vorgehensweise dadurch, dass in einer JEE-Plattform standardisierte Subsysteme zur Verfügung stehen, die die jeweilige technische Implementierung enthalten und dabei die angeführten Annotationen als Steuerparameter konsumieren. So übernimmt z. B. RESTEasy als eine der sogenannten kompatiblen Implementierungen für RESTful Web Services die Aufgabe, Fachdaten wie Person-Objekte in HTTP-Requests zu packen und dabei die Annotationen @Path, @GET und @Produces für die Konfiguration von Zugriffspfaden, Methoden und Serialisierungsformen zu berücksichtigen. Für uns Entwickelnde bedeutet das, dass wir uns im Wesentlichen auf unsere Fachlogik konzentrieren können und uns die notwendige Technik ohne großen Aufwand zur Verfügung gestellt wird.

@ApplicationScoped
@Path("persons")
public class PersonResource {

  @Inject
  PersonRepository personRepository;

  @GET
  @Produces(MediaType.APPLICATION_JSON)
  public List<Person> get() {
    return this.personRepository.findAll();

Stay tuned

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

 

Das klassische Laufzeitmodell

Davon unabhängig ist die Art und Weise, wie Anwendungen betrieben werden. Die klassische Weise nutzt einen Application Server wie WildFly, Open Liberty oder Payara. Die Anwendung wird von unserem Build-Tool – z. B. Maven – in ein Deploy-File paketiert, heute meist ein .war-File. Da es nur die Anwendung, einige wenige Konfigurationsdateien und eventuelle Third-Party Libraries enthält, ist das .war-File vergleichsweise klein. Es wird dann in den Server geladen – deployt –, der die Implementierung der Standardsysteme enthält und dadurch deutlich größer ist.

Diese Vorgehensweise wird heutzutage als schwergewichtig empfunden, was aber kaum an der Größe oder der Startgeschwindigkeit des Servers liegt – einige Hundert MB Installationsgröße schrecken uns kaum ab und die oben erwähnten Server starten alle in Sekunden. Es liegt vielmehr daran, dass ein Server zunächst installiert und konfiguriert werden muss, bevor man mit der Anwendung um die Ecke kommen kann. Es kommt erschwerend dazu, dass wir die Möglichkeit, mehrere Anwendungen auf einem Server zu deployen, meist aus organisatorischen Gründen gar nicht nutzen. Vielmehr betreiben wir häufig nur eine Anwendung pro Server, um so die Abhängigkeiten untereinander und zu Server- und Java-Versionen zu minimieren. Ein Server für jede Anwendung ist dann schon ein „schweres Gewicht“.

Viele setzen nun Jakarta EE mit Application Server gleich. Da letzterer schwergewichtig ist, ist Jakarta EE eben auch schwergewichtig. Das wäre mathematisch korrekt, wenn die Einstiegsannahme stimmen würde. Das ist aber nicht so: Wie wir im Folgenden sehen werden, können wir JEE-Anwendungen auch ohne Application Server betreiben.

Migration auf Quarkus

Wenn wir schon nur eine Anwendung pro Server haben, können wir das Ganze auch zu einer kompakten Einheit verschmelzen. Das Modell, den Server in die Anwendung zu integrieren, ist durch Spring Boot im großen Stil bekannt geworden. In seinem Schatten haben sich auch im JEE-Bereich ähnlich ausgerichtete Frameworks entwickelt, die aber nie annähernd die gleiche Popularität erlangt haben. Das ändert sich gerade mit Quarkus. Das Open-Source-Framework hat ähnliche Features wie Spring Boot, aber mit nahezu unveränderten JEE-Programmquellen. Zudem werden durch diverse Optimierungen Ressourcenverbrauch und Startzeiten erheblich reduziert und der Komfort während der Entwicklung deutlich verbessert.

Aber dazu später mehr – schauen wir uns zunächst an, welche Änderungen an einer bestehenden Jakarta-EE-Anwendung zu machen sind, um sie auf Quarkus zu migrieren.

In [1] finden Sie eine Anwendung in zwei Varianten: Im Verzeichnis std-jee liegt der Quellcode einer Standard-Jakarta-EE-Anwendung, die einen zwar kleinen, aber kompletten Stack aus RESTful Web Service, Injection-Container und Jakarta Persistence enthält. Als kleines Schmankerl ist sogar ein mittels Jakarta Faces gebautes Web-UI enthalten – sicher heute kein Mainstream, aber in vielen Altanwendungen durchaus enthalten. Details zur Anwendung können Sie aus der Projektdatei README.adoc entnehmen.

Im Verzeichnis quarkus finden Sie die gleiche Anwendung auf Basis von Quarkus. Schauen wir mal, welche Änderungen für die Migration von std-jee zu quarkus nötig waren. Die größte Änderung ergibt sich in der Projektdefinition für Maven: In der pom.xml wird jetzt statt des .war-Files ein .jar-File gebaut und wir fordern einzelne sogenannte Extensions für die zu nutzenden Basisdienste an – hier REST- und Injection-Container, Hibernate und PostgreSQL-Treiber (Listing 2).

<dependencies>
  <dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-resteasy-reactive-jsonb</artifactId>
  </dependency>
  <dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-arc</artifactId>
  </dependency>
  <dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-hibernate-orm</artifactId>
  </dependency>
  <dependency>
    <groupId>io.quarkus</groupId>
    <artifactId>quarkus-jdbc-postgresql</artifactId>
  </dependency>

Weiterhin wird das quarkus-maven-plugin in den Build-Zyklus eingebaut. Es übernimmt die Aufgabe, die gebaute Anwendung in target/quarkus-app mit den notwendigen Bibliotheken zu sammeln und darüber hinaus einige Optimierungen zur Build-Zeit durchzuführen – dazu später mehr. Natürlich müssen Sie die pom.xml nicht selbst schreiben. Für den Bootstrap von Projekten gibt es Kommandozeilentools oder Erweiterungen Ihrer Lieblings-IDE. Details finden Sie unter [2]. Möchten Sie sich lieber das Projekt auf einer Webseite zusammenklicken? Dann schauen Sie mal auf [3].

Konzeptionell anders ist auch die Konfiguration der Anwendung. Bei einer klassischen JEE-Anwendung werden Logging-Einstellungen, DB-Verbindungen etc. auf dem Server konfiguriert. Das geschieht nun in der Projektdatei application.properties (Listing 3).

quarkus.log.level=WARN
quarkus.log.category."io.quarkus".level=INFO
quarkus.log.category."de.gedoplan.showcase".level=DEBUG

%prod.quarkus.datasource.jdbc.url=jdbc:postgresql://${db.host:localhost}:5432/showcase
%prod.quarkus.datasource.username=showcase
%prod.quarkus.datasource.password=showcase

Das Präfix %prod. deutet auf ein sogenanntes Configuration Profile hin. Die so benannten Propertys gelten nur im Produktionmodus, das heißt wenn die Anwendung ganz normal gestartet wird. Welche anderen Configuration Profiles es gibt und warum dann auch eine DB-Verbindung verfügbar ist, sage ich Ihnen später. Sie finden .properties-Dateien doof und möchten lieber .yaml einsetzen? Geht auch!

Die Java-Klassen der Anwendung bleiben im Wesentlichen unverändert. Es sind allerdings ein paar Vereinfachungen möglich und sinnvoll, die später beschreibe.

NEUES AUS DER JAVA-ENTERPRISE-WELT

Serverside Java-Track entdecken

 

Bau und Start

Mit mvn package wird die Anwendung gebaut. Das oben erwähnte Plug-in erzeugt das Verzeichnis target/quarkus-app, in dem ein .jar-File zum Start und diverse Unterverzeichnisse mit den Anwendungsklassen und Bibliotheken liegen.

Durch java -jar target/quarkus-app/quarkus-run.jar wird die Anwendung gestartet (Listing 4). Wenn Sie das ausprobieren wollen, müssen Sie die PostgreSQL-DB zuvor gestartet haben.

java -jar target/quarkus-app/quarkus-run.jar
__  ____  __  _____   ___  __ ____  ______
 --/ __ \/ / / / _ | / _ \/ //_/ / / / __/
 -/ /_/ / /_/ / __ |/ , _/ ,< / /_/ /\ \
--\___\_\____/_/ |_/_/|_/_/|_|\____/___/
2023-10-27 14:07:57,406 INFO  [io.quarkus] (main) quarkus 1.0-SNAPSHOT on JVM (powered by Quarkus 3.4.1) started in 2.954s. Listening on: http://0.0.0.0:8080
2023-10-27 14:07:57,416 INFO  [io.quarkus] (main) Profile prod activated.
2023-10-27 14:07:57,417 INFO  [io.quarkus] (main) Installed features: [agroal, awt, cdi, hibernate-orm, itext, jdbc-postgresql, myfaces, narayana-jta, poi, primefaces, resteasy-reactive, resteasy-reactive-jsonb, servlet, smallrye-context-propagation, vertx, websockets, websockets-client] 

Vereinfachungen

Wenngleich der Code nahezu unverändert übernommen werden kann, gibt es doch ein paar Dinge, die gegenüber dem Standard kürzer oder einfacher sind. Für Jakarta Persistence (aka JPA) ist es zwar möglich, weiterhin einen Deployment Descriptor zu verwenden. Die Definition von Parametern für die DB-Verbindung und anderer Features des Persistenzproviders ist allerdings mit den oben gezeigten Properties viel kürzer, sodass Quarkus-Anwendungen i. A. keine persistence.xml mehr enthalten. Weiterhin enthält die Persistence Extension von Quarkus bereits einen Producer für einen Standard-Entity-Manager, sodass die Klasse EntityManagerProducer aus der Standardanwendung in der Quarkus-Anwendung entfällt.

Der Injection-Container ArC ist eine Implementierung von CDI Lite. Gegenüber CDI Full, das auf Standard-JEE-Servern angeboten wird, gibt es Einschränkungen, aber auch Erweiterungen [4]. So gibt es beispielsweise Erleichterungen für die Konstruktoren einer CDI Bean. Wenn Sie, wie viele unserer Mitstreitenden, statt der im Listing 1 genutzten Field Injection mit einem Konstruktor arbeiten wollen, müssen Sie im Standard

  • einen passenden Konstruktor mit @Inject annotieren
  • und einen Konstruktor ohne Parameter mindestens in protected-Sichtbarkeit ergänzen.

Für Quarkus dürfen Sie den No-argument Constructor weglassen. Wenn dann der andere Konstruktor der einzige ist, kann auch @Inject darauf entfallen (Listing 5).

@ApplicationScoped
@Path("persons")
public class PersonResource {

  private PersonRepository personRepository;

  // not necessary for Quarkus
  // @Inject
  public PersonResource(PersonRepository personRepository) {
    this.personRepository = personRepository;
  }

  // not necessary for Quarkus
  // protected PersonResource() {
  // }

Die REST-Klassen bleiben komplett unverändert. Eine Besonderheit stellt die sogenannte Application Class dar – im Beispielprojekt RestApplication. Sie dient häufig nur dazu, den Einstiegspfad für das REST API mit Hilfe der Annotation @ApplicationPath festzulegen. In Quarkus-Anwendungen kann die Klasse entfallen, wenn dieser Pfad / ist.

Hat die zu migrierende Anwendung Webressourcen wie .html-Files, .css-Files etc. im Quellordner src/main/webapp, bleiben diese wiederum unverändert, müssen aber an anderer Stelle abgelegt werden, da es den erwähnten Ordner nur für .war-Files gibt. Der Deployment Descriptor web.xml – soweit überhaupt vorhanden – gehört nun in src/main/resources/META-INF. Der restliche Inhalt von src/main/webapp kommt in src/main/resources/META-INF/resources.

Für die Nutzung von Jakarta Faces gibt es eine Quarkus Extension im Apache-Projekt myfaces. Und im sogenannten Quarkiverse, einer Sammlung von weiteren Quarkus Extensions, gibt es eine Extension für Primefaces. Beide werden im Beispielprojekt verwendet, sollen hier aber nicht weiter erläutert werden, um den Rahmen des Artikels nicht zu sprengen – vielleicht nur so viel: Auch das Faces-UI der Standardanwendung konnte im Wesentlichen unverändert für Quarkus übernommen werden.

 

Next Level

Das bisher Gezeigte ist schon mal sehr schön: Jakarta-EE-Anwendungen können also mit weitgehend unverändertem Code nach Quarkus migriert werden, was den JEE-Kundigen das weite Feld der Microservices und Cloud-Anwendungen eröffnet, ohne substanziell umlernen zu müssen. Dieser Punkt kann eigentlich nicht deutlich genug gesagt werden, bedeutet er doch nicht weniger, als dass wir unsere umfangreichen Kenntnisse und Erfahrungen mit Jakarta EE weiter nutzen können.

Aber wie war das denn nun mit dem Komfort in der Softwareentwicklung, der „Developer Experience“? Da bietet Quarkus uns tatsächlich einiges! Ich picke mir mal drei Dinge heraus:

Developer Mode

Quarkus bietet einen sogenannten Developer Mode, der mit mvn quarkus:dev gestartet wird. In diesem Modus überwacht die laufende Anwendung die Programmquellen. Sollten sich zum Zeitpunkt eines externen Requests (z. B. REST Call) Änderungen ergeben haben, erfolgt ein Hot Reload der Anwendung. Dadurch kann bei laufender Anwendung weiterentwickelt werden. Im Vergleich zum häufigen Redeployment einer klassischen Serveranwendung ist das wirklich eine große Erleichterung – wobei zur Ehrenrettung der Application Server gesagt werden muss, dass diese mittlerweile vergleichbare Modi anbieten.

Dev Services

Ist Ihnen aufgefallen, dass es im Developer Mode für unsere Beispielanwendung keine DB-Verbindungsparameter gibt? Die sind ja nur im Configuration Profile prod eingetragen, während der Dev Mode das Profil dev nutzt. Wieso funktioniert die Anwendung trotzdem und welche DB nutzt sie? Des Rätsels Lösung ist ein sogenannter Dev Service: Sind im Dev Mode keine Verbindungsparameter konfiguriert, wird eine passende Instanz als Container gestartet. Sie kennen dieses Verfahren vermutlich als Testcontainers aus dem Testumfeld. Quarkus bietet Dev Services für die meisten Datenbank-Extensions an, aber auch für Message Broker, Identity Manager etc. Durch Dev Services wird die Einstiegshürde für die Entwicklung von Anwendungsteilen mit Zugriff auf externe Subsysteme deutlich verringert: Ich muss nicht zuerst eine lokale Installation von PostgreSQL, Kafka oder Keycloak vornehmen, sondern kann direkt losentwickeln.

Test-Support

Werfen wir schließlich einen Blick auf (Integrations-)Tests. Die sind im Standardumfeld nicht gerade einfach zu schreiben, muss doch der Lifecycle der zu testenden Anwendung gesteuert werden: Server starten, Anwendung deployen, Tests laufen lassen, Anwendung undeployen, Server stoppen. Tools wie Arquillian [5] können das, sind aber durch die Vielfalt der Serverprodukte und Testverfahren vergleichsweise komplex. Man könnte auch sagen: So richtig Spaß macht’s nicht.

Für Quarkus-Anwendungen kann man stattdessen einfache JUnit-Testklassen schreiben und sie mit @QuarkusTest annotieren, wodurch aus dem Unit-Test ein Integrationstest wird. Die zu testende Anwendung erhält ihre Konfiguration aus dem Profil test mit eigenen Ports, Dev Services etc. Wenn man z. B. einen REST Endpoint testen möchte, ist das mit dem als Extension verfügbaren REST Assured möglich (Listing 6), das bereits auf die im Test genutzte Portnummer autokonfiguriert ist. Andere Clientbibliotheken sind aber natürlich auch verwendbar. Die Testklasse darf auch Injektionsziele enthalten, so dass neben Blackbox- auch Whitebox-Tests möglich sind (Listing 7).

@QuarkusTest
public class PersonResourceTest {

  @Test
  void testGetAverageAge() {
    given()
      .when().get("/api/persons/avgAge")
      .then()
      .statusCode(200)
      .body(is(Double.toString(PersonRepositoryMock.getAvgAge())));
  }
}
@QuarkusTest
public class PersonServiceTest {

  @Inject
  PersonService personService;

  @Test
  void testGetAverageAge() {
    assertEquals(PersonRepositoryMock.getAvgAge(), this.personService.getAverageAge(), 0.1);
  }
}

Ein besonderes Goodie stellt aus meiner Sicht das sogenannte Continuous Testing dar, das im Developer Mode aktiviert werden kann. Dann laufen während der Entwicklung stets die Tests, die durch die aktuellen Codeänderungen berührt sind. Ein Blick auf die Shell, in der die Anwendung läuft, offenbart also ständig: „grün: alles OK“ und „rot: ups!“. Schneller geht ein Testfeedback kaum.

Augmentation

Eines bin ich Ihnen noch schuldig: Es war die Rede von Optimierung zur Build-Zeit – worum handelt es sich dabei? Wenn eine klassische JEE-Anwendung deployt wird, läuft zunächst eine Initialisierungsphase, in der insbesondere der CDI-Container die Anwendung nach Beans, Scopes, Producern, Observern u. Ä. scannt, Proxies generiert, Zuordnungen zu Injection Points vornimmt etc. Das ist durchaus zeitaufwendig – und es basiert im Wesentlichen auf Informationen, die schon zur Entwicklungszeit bekannt sind. Das Maven-Plug-in von Quarkus führt diese Aufgabe bereits zur Build-Zeit durch. Die Anwendung wird um Code angereichert (daher der Name Augmentation), der zur Laufzeit anstelle der oben angesprochenen Initialisierung ausgeführt wird. Im Ergebnis startet die Anwendung deutlich schneller.

Stay tuned

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

 

Fazit und Ausblick

Mit Quarkus steht uns ein Serverframework zur Verfügung, das es ähnlich wie Spring Boot ermöglicht, Serveranwendungen aus Bausteinen aufzubauen. Bestehender Jakarta-EE-Code – und wichtiger vielleicht sogar, vorhandenes JEE-Wissen – kann weiterverwendet werden. Der Komfort bei der Softwareentwicklung steigt durch das angebotene Tooling. Alles ist leichtgewichtiger, geht schnell von der Hand, macht (wieder) Spaß.

Ich konnte in diesem Artikel Quarkus natürlich nicht erschöpfend behandeln. Es gibt mittlerweile ein großes, ständig wachsendes Ökosystem und Quarkus-Anwendungen lassen sich unter bestimmten Umständen auch nativ mit Startzeiten im Millisekunden-Bereich ausführen. Genug Futter für weitere Artikel also – stay tuned!


Links & Literatur

[1] https://github.com/GEDOPLAN/next-lvl-jee

[2] https://quarkus.io/guides/tooling

[3] https://code.quarkus.io

[4] https://quarkus.io/guides/cdi-reference

[5] http://arquillian.org

[6] https://gedoplan.de

The post Quarkus: Next Level Jakarta EE appeared first on JAX.

]]>
Java 21 und die Helden von heute https://jax.de/blog/java21-und-die-helden-von-heute/ Wed, 15 Nov 2023 09:43:45 0000 https://jax.de/?p=89073 In Java 21 sind viele neue Features enthalten. Doch neben den hippen, großartigen Features für Parallelität und funktionaler Programmierung gibt es auch einige eher unscheinbare, die einen großen Einfluss haben werden: Wir nennen sie Hidden Heroes.

The post Java 21 und die Helden von heute appeared first on JAX.

]]>
In diesem Artikel werden „Generational ZGC“ (JEP 439), „Key Encapsulation Mechanism API“ (JEP 452) und „Code Snippets in Java Doc“ (JEP 413) vorgestellt. Beim ersten JEP handelt es sich um eine kleine Anpassung an den neuem Z Garbage Collector, der einen riesigen Performance Boost auf jeder Größe von Heap verspricht. Beim zweiten geht es um ein neues API mit dem Schlüssel für eine besonders sichere symmetrische Verschlüsselung, die mit Public/Private-Key-Methoden übertragen werden kann, und im letzten um die Einbindung von Codebeispielen in der Java-API-Dokumentation.

JEP 439: Generational ZGC

Eine Instanz auf dem Heap kann entfernt werden, wenn sie nicht mehr benötigt wird. In hardwarenahen Sprachen sind die Entwicklerinnen und Entwickler dafür zuständig, den korrekten, sicheren Zeitpunkt zu bestimmen. Der sichere Zeitpunkt ist der, wenn keine andere Instanz mehr eine Referenz auf die Instanz gespeichert hat. Durch Call-by-Reference-Aufrufe von Methoden können sich diese Referenzen sehr weit im System verbreiten und die Analyse wird komplex. Die manuelle Bestimmung des Zeitpunkts der sicheren Entfernung ist sehr fehleranfällig und deswegen setzt die JVM, wie viele moderne Ökosysteme, auf das Konzept des Garbage Collector, kurz GC. Ein Garbage Collector überwacht dabei automatisiert den Speicher und entfernt Instanzen nach dem sicheren Zeitpunkt.

Stay tuned

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

 

Hidden Heroes

Hidden Heroes leitet der Autor von dem Begriff „Hidden Champions“ ab, der von Hermann Simon geprägt wurde. Hidden Champions bezeichnen kleine Firmen, die Weltmarktführer sind und wenig Wahrnehmung in der Öffentlichkeit haben. Analog dazu sind Hidden Heroes Java-Features, die große Auswirkungen auf das JDK-Environment haben, aber gefühlt zu wenig Aufmerksamkeit in der Community bekommen.

In der Java Virtual Machine gibt es seit längerem mehrere verschieden implementierte Garbage Collectors mit diversen Schwerpunkten. Sie alle haben die GC-Pause gemein, in der die Anwendung pausiert werden muss:

  • Serial GC ist der einfachste GC. Er benutzt nur einen Thread und pausiert die Applikation, während er läuft. Er ist für Einzelkernsysteme und nicht für Hardware mit mehreren Kernen geeignet, da kein Nutzen aus mehreren Rechenkernen gezogen werden kann.
  • Parallel GC[1] verhält sich wie Serial GC, verwendet aber mehrere Kerne und ist damit eine bessere Alternative für Anwendungen auf Mehrkernhardware. Wie auch bei Serial GC ist die Länge der GC-Pause primär abhängig von der Größe des Heap-Speichers.
  • G1 GC heißt eigentlich Garbage First GC [2] und wendet Partitionen auf dem Heap an. Die Partitionen werden entsprechend dem freien Speicher aufsteigend priorisiert und analysiert. G1 GC analysiert und entfernt Instanzen, bis die konfigurierte fixe Länge der GC-Pause verstrichen ist. Es werden oft nicht alle Partitionen komplett bearbeitet, denn das Ziel von G1 ist eine möglichst feste Pausenzeit. Zudem eignet er sich gut für Maschinen mit vielen Prozessoren und großen Heaps.

Relativ neu ist der Z Garbage Collector [3], der in Java 15 eingeführt wurde, kurz ZGC genannt. Der ZGC führt die arbeitsintensive Analyse des Heap parallel zur Ausführung der Anwendung in eigenen Threads durch. Dadurch muss die Anwendung nur sehr kurz unterbrochen werden, um die Threads zu synchronisieren. Die GC-Pause ist dabei 1 ms lang und unabhängig von der analysierten Heapgröße. Realisiert wird die Parallelität mit Hilfe von Colored Pointer und Load Barriers. Colored Pointer sind eine Dekoration eines Pointers, einer Speicheradresse mit Metainformationen über den Status der Instanz. Load Barriers werden für Zugriffe auf Referenzen eingebaut. Sie werten Colored Pointer aus und führen potenziell notwendige Weiterleitungen durch, bevor die Anwendung auf die Instanz zugreift, um Adressänderungen zu verschleiern. Der hochoptimierte Algorithmus von ZGC wird im Artikel „Deep-dive of ZGC’s Architecture“ [4] auf Dev.java ausführlich beschrieben und ist in der Lage, sehr kleine und sehr große Heaps bis 16 TB effizient zu bearbeiten. Aktiviert werden kann ZGC durch den Kommandozeilenparameter -XX: UseZGC. Bei der ersten Verwendung von ZGC wird empfohlen, auch GC-Logging (-Xlog:gc) zu aktivieren, um das Finetuning der Konfiguration zu ermöglichen. Neben der Anzahl der von ZGC verwendeten Threads (-XX:ConcGCThreads) und einigen Linux-spezifischen Parametern kann auch das Zurückgeben von Speicher an das Betriebssystem aktiviert werden (-XX: ZUncommit).

Mit JEP 439: Generational ZGC [5] wird ZGC um die Partitionierung des Heap in Generationen, also je einen Bereich für „jung“ und „alt“, erweitert. Der Bereich für die jungen Instanzen unterteilt sich noch in „Eden“ und „Survivor“. Neu erzeugte Instanzen werden in der Regel in Eden erzeugt und, wenn sie den ersten Durchlauf des GC „überleben“, nach Survivor kopiert. Nachdem sie eine feste Anzahl GC-Läufe in Survivor „überlebt“ haben, werden Instanzen in den Bereich für alte Instanzen kopiert. Die Partitionierung ermöglicht die Anwendung der Weak Generational Hypothesis [6], die im Kern aussagt: „Junge Instanzen haben die Tendenz, jung zu sterben“. Als Konsequenz kann unter Instanzen im Bereich „jung“ und besonders unter denen im Bereich Eden vermutet werden, dass sie nicht mehr referenziert werden und zu entfernen sind. Im Bereich „alt“ sind wahrscheinlich kaum zu entfernende Instanzen enthalten und diese werden nicht so häufig analysiert. Durch die getrennte Behandlung verringert sich der durchschnittliche Aufwand der Analyse und der GC-Prozess wird effizienter. Aktiviert werden kann diese Partitionierung mit dem Parameter -XX: ZGenerational. Zukünftig soll ZGC nur noch den Ansatz mit Generationen verwenden, dann ist der Parameter nicht mehr notwendig.

Mit seiner effizienten Behandlung von riesigen Heaps, der geringen GC-Pause und dem Fokus auf das Entfernen von jungen Instanzen ist Genrational ZGC optimiert für datenintensive Anwendungen, die eine kurze Antwortzeit erfordern. Damit kann Generational ZGC eine gute Wahl für moderne datengetriebene Systeme im Enterprise-Umfeld sein. Durch die hohe Komplexität und viel notwendiger Theorie in diesem Themenfeld fehlt es Generational ZGC ein wenig an Aufmerksamkeit, deswegen ist Generational ZGC ein Hidden Hero des Java-Ökosystems.

Stay tuned

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

 

JEP 452: Key Encapsulation Mechanism API

In der elektronischen Kommunikation wird an vielen Stellen auf Verschlüsselung gesetzt. Viele der heute gängigen Verfahren gelten als nicht sicher für ein Zeitalter mit Quantencomputern. An einem solchen Quantencomputer könnte zum Beispiel Mallroy forschen – und vor kurzen hatte er einen großen Durchbruch. Alice und Bob wollen deswegen eine Überraschungsparty für ihren Freund Mallroy organisieren. Damit Mallroy unwissend bleibt, wollen Alice und Bob nur über sichere, verschlüsselte Kanäle miteinander kommunizieren. Sie wollen zahlreiche Nachrichten austauschen und Mallroy könnte schon den Quantencomputer zur Verfügung haben, deswegen kommt nur ein effizientes und hochsicheres Verfahren in Frage.

Bei ihrer Recherche erfahren sie, dass es symmetrische und asymmetrische Verfahren gibt. Bei einem asymmetrischen Verschlüsselungsverfahren werden zwei verschiedene Schlüssel zum Ver- und Entschlüsseln verwendet. Der Schlüssel zum Verschlüsseln kann gefahrenlos übermittelt werden, leider sind diese Algorithmen in der Regel nicht hochperformant und auch nicht besonders sicher. Bei symmetrischen Verfahren wird derselbe Schlüssel zum Ver- und Entschlüsseln verwendet, sie punkten bei Effizienz und Sicherheit ganz klar. Leider benötigen beide Seiten das Wissen über den verwendeten Schlüssel und dieser kann, da geheim, nicht trivial versendet werden.

Im 2001 von Cramer und Shoup veröffentlichten Artikel „Design and Analysis of Practical Public-Key Encryption Schemes Secure against Adaptive Chosen Ciphertext Attack“ [7] wird in § 7.1 der Mechanismus „Key Encapsulation“ beschrieben, mit dem es möglich ist, Schlüssel für symmetrische Verschlüsselungsverfahren unter Zuhilfenahme asymmetrischer Verschlüsselung sicher zu übertragen. Dieses unter der Abkürzung KEM bekannte Verfahren wird unter anderem vom BSI und der NIST in ihren Post-Quanten-Kryptografie-Konzepten als Basisbaustein betrachtet [8], [9]. In Abbildung 1 ist der KEM-Ablauf zwischen Alice und Bob dargestellt. Zuerst generiert Alice ein Schlüsselpaar aus öffentlichem und privatem Schlüssel. Der öffentliche Schlüssel wird an Bob übertragen. An dieser Stelle ist eine Transportverschlüsselung zwischen Alice und Bob wichtig, aber nicht Gegenstand des Verfahrens. Bob erzeugt einen zufälligen Schlüssel für das später verwendete symmetrische Verschlüsselungsverfahren und verschlüsselt diesen mit dem öffentlichen Schlüssel von Alice. Dieses als „Encupsulated“ bekannte Datenpaket wird an Alice zurückübertragen, und nur sie kann es mit ihrem privaten Schlüssel entschlüsseln. Von nun an können Alice und Bob ihre Nachrichten mit einer symmetrischen Verschlüsselung austauschen, das Schlüsselpaar wird nicht mehr gebraucht.

Abb. 1: Bob und Alice tauschen den Schlüssel für eine symmetrische Verschlüsselung (gelb) mit KEM aus

Da die Implementierung sicherheitsrelevanter Features und insbesondere von Verschlüsselungen ein hochkomplexes Feld ist, gleichzeitig aber die Verschlüsselung eine grundlegende Anforderung moderner Systeme ist, wird mit JEP 452 [10] ein API zur Durchführung eines KEM-Prozesses in Java 21 eingeführt. Das API folgt dem von der ISO 18033-2 definierten und oben beschriebenen Prozess. Dabei bilden drei Bausteine das Fundament für eine sichere Verschlüsselung. Der javax.crypto.KEM.Encapsulator verwendet den öffentlichen Schlüssel und den erzeugten symmetrischen Schlüssel, um ein Encapsulated zu erzeugen. Das Encapsulated wird durch eine Instance von javax.crypto.KEM.Encapsulated repräsentiert und kann direkt als Bytearray von Bob übertragen werden. Auf Alices Seite wird die Klasse javax.crypto.KEM.Decapsulator verwendet, um mit dem passenden privaten Schlüssel den symmetrischen Schlüssel zu erlangen.

Im nun folgenden Beispiel bilden die Methoden sendToBob/sendToAlice und retrieveFromBob/ retrieveFromAlice die jeweilig sendenden beziehungsweise empfangenden Enden der unsicheren Kommunikationskanäle zwischen Alice und Bob ab. Listing 1 zeigt, wie das Set-up des KEM-Prozesses bei Alice mit dem KeyPairGenerator implementiert wird. Die Methode KeyPairGenerator#getInstance(String) erzeugt einen Schlüsselpaarerzeuger für das RSA-Verfahren. Über ein Service Provider Interface ist es möglich, hier weitere Algorithmen zu hinterlegen. Mit dem Aufruf von KeyPairGenerator#generateKeyPair() wird ein Paar aus öffentlichem und privatem Schlüssel erzeugt. Dieses Paar wird bei Alice hinterlegt und der öffentliche Schlüssel an Bob versandt.

var keyGen = KeyPairGenerator.getInstance("RSA");
var pair = keyGen.generateKeyPair();

sendToBob(pair.getPublic());

In Listing 2 wird die erste Phase der Key Exchanges auf der Seite von Bob gezeigt. Nachdem der öffentliche Schlüssel von Alice empfangen wurde, wird mir KEM#getInstance(String) eine für RSA konfigurierte Implementierung gewählt. Neue Implementierungen können auch hier durch ein SPI bereitgestellt werden. Auf der konkreten KEM-RSA-Instanz wird mit der Methode KEM#newEncapsulator(java.security.PublicKey) eine Encapsulator-Instanz für RSA-KEM erzeugt. Der zufällig erzeugte Schlüssel kann und sollte über Encapsulated#key() abgerufen und bei Bob hinterlegt werden (hier in der Variable secret). Mit diesem Schlüssel wird die spätere symmetrische Verschlüsselung durchgeführt. Auf das zu versendende Encapsulate kann über die Methode Encapsulated#encapsulate() zugegriffen werden und es wird von Bob an Alice versendet.

var publicKey = retrieveFromAlice();

var encapsulator = KEM.getInstance("RSA-KEM").newEncapsulator(publicKey);
var secret = encapsulate.key();
var encapsulate = encapsulator.encapsulate();

sendToAlice(encapsulate.encapsulation());

In Listing 3 wird die zweite Phase der Key Encapsulation bei Alice gezeigt. Nachdem das Encapsulate von Bob empfangen und der private Schlüssel aus dem Schlüsselpaar des Set-ups entnommen wurde, kann mit dem Entpacken des symmetrischen Schlüssels begonnen werden. Zuerst wird wieder mit der Methode KEM#getInstance(String) die konkrete Implementierung von KEM für RSA-KEM geladen. Mit dem Aufruf KEM# newDecapsulator(PrivateKey) wird unter Zuhilfenahme des privaten Schlüssels der symmetrische Schlüssel entpackt.

byte[] encapsulate = retrieveFromBob();
var privateKey = pair.getPrivate();

var decapsulator = KEM.getInstance("RSA-KEM").newDecapsulator(privateKey);
var secret = decapsulator.decapsulate(encapsulate);

Von diesem Moment an ist das verwendete öffentliche und private Schlüsselpaar nicht mehr notwendig und kann gelöscht werden. Die Kommunikation kann nun sicher und effizient mit einem symmetrischen Verschlüsselungsverfahren durchgeführt werden. Mit diesem sehr neuen Feature in Java 21 rüstet die OpenJDK-Community das Ökosystem Java für das Post-Quantum-Zeitalter auf. Die Verwendung von Java in einer Welt voller Quantencomputer ist nun möglich und aus diesem Grund sollte dieses Feature mehr Aufmerksamkeit bekommen. Es ist auf jeden Fall ein Hidden Hero von Java 21.

JEP 413: Code Snippets in Java API Documentation

An Schnittstellen stellen Entwickler und Entwicklerinnen ganz eigene Anforderungen. Oft sind Benutzbarkeit und verständliche Dokumentation die wichtigsten Aspekte. Aus diesem Grund ist Javadoc als Dokumentationswerkzeug für Source Code bereits seit Java 1 Teil des JDK. Mit Javadoc können Kommentare mit beschreibenden Tags angereichert werden und in eine navigierbare und durchsuchbare Schnittstellendokumentation überführt werden. Neben Beschreibungen der Parameter und des Verhaltens ist es sinnvoll, die beabsichtigte Verwendung zu dokumentieren, um die Hürden der Einarbeitung zu verringern. Es haben sich einige unterschiedliche Ansätze etabliert:

  1. Separate Tutorials wie die Referenzdokumentation von Spring [11] oder Extensions Guides von Quarkus [12]. Hier liegt der Fokus eher auf der Verwendung des Frameworks und weniger auf dem API.
  2. Bei Code Snippets als HTML mit <pre>{@code } </pre> liegt das API im Fokus. Das Snippet wird leider nicht ansehnlich formatiert und muss bei jeder Änderung an der Schnittstelle bedacht werden. Ein Beispiel ist java.util.stream.Stream [13], [14].
  3. Unit Tests als Dokumentation zu betrachten, ist ein guter Ansatz, vorausgesetzt die Tests sind gut und die verwendenden Entwickler und Entwicklerinnen haben Zugriff auf den Code. Bedauerlicherweise fehlt bei diesem Ansatz oft die Verknüpfung zwischen Code und Tests.

Mit JEP 413: Code Snippets in der Java-API-Dokumentation [15] hält Java seit Version 18 eine Möglichkeit bereit, Source-Code-Auszüge mit Syntax-Highlighting und Testbarkeit zu vereinen, um Entwicklerinnen und Entwicklern das Einbinden von guten Beispielen in der API-Dokumentation zu ermöglichen. Um ein Snippet in einem Javadoc-Kommentar einzubinden, wird der neue {@snippet: …  }-Tag verwendet.

/**
 * Berechnung der MwSt für einen Privatkunden beim Einkauf in Höhe von 1055
 * {@snippet :
 *   var kunde = new Privatkunde("Merlin", "");
 *   var wert = 1055d;
 *   // ...
 *   var mwst = MwStRechner.PlainOOP.calculateMwSt(kunde, wert);
 * }
 */

In Listing 4 wird in einem Inline Snippet die erwartete Interaktion mit dem API des MwStRechner aus dem Data-Oriented-Programming-Beispielprojekt des Autors [16] dargestellt. In der generierten Dokumentation ist der Bereich ab der neuen Zeile hinter dem Doppelpunkt bis zur letzten Zeile vor der schließenden Klammer als formatierter Source Code mit Syntax-Highlighting enthalten. Bei Inline Snippets gibt es zwei Randbedingungen:

  • Ein mehrzeiliger Kommentar mit /* */ ist nicht erlaubt.
  • Für jede geöffnete Klammer muss auch eine schließende enthalten sein.

Ohne diese Randbedingungen ist es dem Generator nicht möglich, die Passage korrekt zu konvertieren. Zusätzlich muss die syntaktische Korrektheit manuell geprüft und Schnittstellenänderungen beachtet werden. Für ein externes Snippet gelten diese Rahmenbedingungen allesamt nicht. Bei einem externen Snippet wird der Inhalt nicht im selben Kommentar angegeben, sondern aus einer vorhandenen Java-Datei entnommen.

/**
 * Berechnung der MwSt für einen Privatkunden beim Einkauf in Höhe von 1055
 * {@snippet file="SwitchExpressionsSnippets.java" region="example"}
 */

// Datei: snippet-files/Snippets.java
class Snippets {
  public void snippet01() {
    void snippet01() {
    // @start region="example"
    // @replace region="replace" regex='(= new.*;)|(= [0-9]*d;)' replacement="= ..."
    // @highlight region="highlight" regex="\bMwStRechner\b"
    var kunde = new Privatkunde("Merlin", "[email protected]"); // @replace regex="var" replacement="Kunde"
    var wert = 1055d; // @replace regex="var" replacement="double"
    /* .. */
    var mwst = MwStRechner.PlainOOP.calculateMwSt(kunde, wert); // @link substring="PlainOOP.calculateMwSt" target="MwStRechner.PlainOOP#calculateMwSt"
    // @end @end @end
  }
}

In Listing 5 wird ein externes Snippet verwendet, das auf eine Datei Snippets.java (auch Listing 5) im Ordner snippet-files verweist. Dieser Ordner liegt direkt neben der Datei, in der das Snippet eingebunden wird und kann mit Hilfe der Konfiguration -snippet-path überschrieben werden. Den Pfad auf den Ordner mit dem Test zu konfigurieren, erscheint dem Autor als guter Standard. Dadurch ist es möglich, die geschriebenen Tests als Beispiele wiederzuverwenden. In Listing 2 ist zudem eine Region definiert. Dadurch werden nur bestimmte Bereiche der referenzierten Java-Datei verwendet und die Datei kann so Beispiele für verschiedene Anwendungsfälle enthalten.

Stay tuned

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

 

Neben Bereichen sind in dem Snippet in Listing 5 noch weitere Anpassungen durchgeführt worden. Mit den @replace Tags werden per regulärem Ausdruck zuerst alle Initialisierungen durch ersetzt, da sie nicht direkt zum Beispiel beitragen. Das Schlüsselwort var wird in den entsprechenden Zeilen durch den Datentyp ersetzt. Hierbei wird keine Region angeben, also die Ersetzung nur auf diese Zeile angewandt. Mit dem Tag @highlight wird jedes Vorkommen von MwStRechner hervorgehoben und mit @link ein Link zur Methode MwStRechner.PlainOOP#calculateMwSt erstellt. In Abbildung 2 wird das Ergebnis der Javadoc-Generierung des Listings 5 gezeigt.

Abb. 2: Aus Listing 5 generierte Dokumentation mit Ersetzungen, Links und Highlights

Durch die Möglichkeit zur Verknüpfung zwischen Source Code und API-Dokumentation kann gewährleistet werden, dass auch Listings in Javadoc aktuell und von hoher Qualität sind. Die Dokumentation von APIs gewinnt dadurch an Qualität, Aktualität und einem hohen Grad an Verständlichkeit. Werden alle Tags in einem Snippet verwendet, wird das Lesen des zugrundeliegenden Codes erschwert. Das betrifft vor allem die Entwicklerinnen und Entwickler von Frameworks und Tools. Von ihrem Mehraufwand werden aber alle profitieren, deswegen sind Code Snippets in Java API Documentation auf jeden Fall ein Hidden Hero im Java-21-Ökosystem.

Zusammenfassung

In diesem Artikel wurden drei der vielen Hidden Heroes im JDK-Ökosystem abseits von Virtual Threads und Pattern Matching gezeigt. Es gibt noch einige mehr, wie beispielsweise Class Data Sharing und den Simple Web Server, aber das ist Material für einen weiteren Beitrag auf Konferenzen, User Groups oder für das Selbststudium.


Links & Literatur

[1] https://docs.oracle.com/en/java/javase/20/gctuning/parallel-collector1.html

[2] https://docs.oracle.com/en/java/javase/20/gctuning/garbage-first-garbage-collector-tuning.html

[3] https://docs.oracle.com/en/java/javase/20/gctuning/z-garbage-collector.html

[4] https://dev.java/learn/jvm/tool/garbage-collection/zgc-deepdive/

[5] https://openjdk.org/jeps/439

[6] https://docs.oracle.com/en/java/javase/17/gctuning/garbage-collector-implementation.html

[7] Design and Analysis of Practical Public-Key Encryption Schemes Secure against Adaptive Chosen Ciphertext Attack, Crammer und Shoup 2001, https://eprint.iacr.org/2001/108.pdf

[8] https://www.bsi.bund.de/DE/Themen/Unternehmen-und-Organisationen/Informationen-und-Empfehlungen/Quantentechnologien-und-Post-Quanten-Kryptografie/Post-Quanten-Kryptografie/post-quanten-kryptografie_node.html

[9] https://csrc.nist.gov/News/2022/pqc-candidates-to-be-standardized-and-round-4

[10] https://openjdk.org/jeps/452

[11] https://docs.spring.io/spring-data/jpa/docs/current/reference/html/

[12] https://quarkus.io/guides/resteasy-reactive

[13] https://docs.oracle.com/en/java/javase/11/docs/api/java.base/java/util/stream/Stream.html

[14] https://github.com/openjdk/jdk/blob/4de3a6be9e60b9676f2199cd18eadb54a9d6e3fe/src/java.base/share/classes/java/util/stream/Stream.java#L52-L57

[15] https://openjdk.org/jeps/413

[16] https://github.com/MBoegers/DataOrientedJava

The post Java 21 und die Helden von heute appeared first on JAX.

]]>