Java - JAX https://jax.de/tag/java/ Java, Architecture & Software Innovation Fri, 18 Oct 2024 13:30:25 +0000 de-DE hourly 1 https://wordpress.org/?v=6.5.2 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.

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

]]>
Neues in Java 21: JEP 430 und 431 https://jax.de/blog/neues-in-java-21-jep-430-und-431/ Wed, 04 Oct 2023 11:55:16 +0000 https://jax.de/?p=89017 Java 21 bringt viele Verbesserungen in Syntax und APIs. In diesem Beitrag stehen die beiden Neuerungen JEP 430: „String Templates (Preview)“ sowie JEP 431: „Sequenced Collections“ im Fokus. Durch diese Funktionalitäten wird Java komfortabler und angenehmer in der Handhabung und besser für die Zukunft gerüstet. String Templates adressieren das Aufbereiten textueller Informationen, die aus fixen und variablen Bestandteilen bestehen, und Sequenced Collections vereinfachen den Zugriff und die Verwaltung von Daten am Anfang und Ende einer Collection.

The post Neues in Java 21: JEP 430 und 431 appeared first on JAX.

]]>
Regelmäßig müssen Strings aus fixen und variablen Textbausteinen zusammengesetzt werden. Um das zu tun, gibt es verschiedene Varianten, angefangen bei der einfachen Konkatenation mit + bis hin zur formatierten Aufbereitung. Die mit Java 21 neu eingeführten String Templates (JEP 430) ergänzen die bisherigen Varianten um eine elegante Möglichkeit, Ausdrücke angeben zu können, die zur Laufzeit ausgewertet und passend in den String integriert werden. Allerdings sind die String Templates noch ein Preview-Feature und müssen beim Kompilieren und Ausführen explizit aktiviert werden.

Bisherige Vorgehensweise

Schauen wir kurz zurück. Um formatierte Ausgaben in Java zu erzeugen, verwenden viele Entwickler die folgenden Varianten, um aus Variablen und Textbausteinen zu kombinieren:

String result = "Calculation: " + x + " plus " + y + " equals " + (x + y);
System.out.println(result);

und

String resultSB = new StringBuilder().append("Calculation: ").append(x).append(" plus ").append(y).append(" equals ").append(x + y).toString();
System.out.println(resultSB);

Die Stringkonkatenation mit + ist leicht verständlich und oft auch durchaus lesbar. Die Lesbarkeit nimmt jedoch bei einer zunehmenden Anzahl von Verknüpfungen ab. Noch dramatischer ist der Effekt beim Einsatz eines StringBuffers oder StringBuilders und dessen Methode append(): Je mehr Elemente zu verknüpfen sind, desto unleserlicher und schwieriger nachvollziehbar wird das Konstrukt.

Stay tuned

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

 

Weitere, in der Praxis seltener anzutreffende Möglichkeiten sind die Methoden format() und formatted() aus der Klasse String. Beide nutzen die gleichen Platzhalter, variieren aber ein wenig in der Handhabung, wobei meine Präferenz bei formatted() liegt.

var resultF1 = String.format("Calculation: %d plus %d equals %d", x, y, x + y);
System.out.println(resultF1);

var resultF2 = "Calculation: %d plus %d equals %d".formatted(x, y, x + y);
System.out.println(resultF2);

Darüber hinaus existiert noch die Klasse java.text.MessageFormat. Von ihr werden zunächst per Konstruktoraufruf mit fixem Text sowie integrierten Platzhaltern Instanzen erzeugt und danach mit format() befüllt – sowohl die Platzhalter als auch die Angabe der Werte unterscheiden sich von den beiden vorherigen Varianten:

var msgFormat = new MessageFormat("Calculation: {0} plus {1} equals {2}");
System.out.println(msgFormat.format(new Object[] { x, y, x + y }));

Möglichkeiten anderer Programmiersprachen

Viele Programmiersprachen unterstützten alternativ zur Stringkonkatenation die sogenannte Stringinterpolation oder auch formatierte Strings. Dabei kommt ein String zum Einsatz, in den an verschiedenen Positionen speziell gekennzeichnete Platzhalter integriert sind, die dann zur Laufzeit durch die entsprechenden Werte ersetzt werden. Das gilt beispielsweise für die f-Strings in Python. Etwas Ähnliches bieten Kotlin, Swift und C# durch unterschiedliche Notationen mit Platzhaltern:

  • Python: “Calculation: {x} + {y} = {x + y}”
  • Kotlin: “Calculation: $x + $y = ${x + y}”
  • Swift: “Calculation: \(x) + \(y) = \(x + y)”
  • C#: $”Calculation: {x} + {y}= {x + y}”

In allen Fällen werden die Platzhalter im String durch die entsprechenden Werte der Variablen, insbesondere auch von Berechnungen, ersetzt.

Die Alternative: String Templates (Preview)

Mit JEP 430 werden String Templates eingeführt, die wir uns nun anschauen wollen. Bilden wir das einführende Beispiel damit nach – aber bitte bedenken Sie, dass es sich um ein Preview-Feature handelt, das extra aktiviert werden muss, etwa wie folgt für die JShell:

$ jshell --enable-preview
|  Willkommen bei JShell - Version 21
|  Geben Sie für eine Einführung Folgendes ein: /help intro

Lernen wir zunächst die grundlegende Syntax kennen. Dabei muss dem String Template das Kürzel STR vorangestellt werden. Es aktiviert einen sogenannten Template Processor, der dann die Platzhalter im Format \{expression} ersetzt, im einfachsten Fall einfache Variablennamen durch deren Wert (Listing 1).

jshell> int x = 47
x ==> 47

jshell> int y = 11
y ==> 11

jshell> System.out.println(STR."Calculation: \{x} + \{y} = \{x + y}")
Calculation: 47 + 11 = 58

Bei der Angabe der Ausdrücke ist man nicht auf einfache mathematische Operationen beschränkt, sondern es lassen sich beliebige Java-Aufrufe einfügen, also auch Methoden (Listing 2).

jshell> int x = 7
x ==> 7

jshell> int y = 2
y ==> 2

jshell> STR."Berechnung: \{x} mal \{y} = \{Math.multiplyExact(x, y)}"
$3 ==> "Berechnung: 7 mal 2 = 14"

SIE LIEBEN JAVA?

Den Core-Java-Track entdecken

 

Praxistipp

Bei der Einführung gab es diverse Diskussionen über das Format der Ausdrücke. Weil viele Third-Party-Bibliotheken etwa $, # oder {} dafür nutzen, hat man sich für ein Format entschieden, das nur innerhalb von String Templates gültig ist. Interessanterweise ist das STR per Definition public static final und automatisch in jeder Java-Datei bereits vorhanden.

Praktischerweise kann man String Templates auch in Kombination mit Textblöcken nutzen (Listing 3).

jshell> int statusCode = 201
statusCode ==> 201

jshell> var msg = "CREATED"
msg ==> "CREATED"

jshell> String json = STR."""
  ...>     {
  ...>       "statusCode": \{statusCode},
  ...>       "msg": "\{msg}"
  ...>     }""";
  ...> 

json ==> "{\n  \"statusCode\": 201,\n  \"msg\": \"CREATED\"\n}"

Tatsächlich gefällt mir in dem Zusammenhang die Methode formatted() ziemlich gut, und ich würde sie String Templates sogar vorziehen:

jshell> String json = STR."""
  ...> {
  ...>     "statusCode": %d,
  ...>     "msg": "%s"
  ...> }""".formatted(statusCode, msg);
json ==> "{\n    \"statusCode\": 201,\n    \"msg\": \"CREATED\"\n}"

String Templates lassen sich auch zur Aufbereitung einer simplen HTML-Seite nutzen (Listing 4). Nach dem Ersetzen entsteht das HTML in Listing 5.

String title = "My First Web Page";
String text = "My Hobbies:";
var hobbies = List.of("Cycling", "Hiking", "Shopping");
String html = STR."""
<html>
  <head><title>\{title}</title></head>
  <body>
    <p>\{text}</p>
    <ul>
      <li>\{hobbies.get(0)}</li>
      <li>\{hobbies.get(1)}</li>
      <li>\{hobbies.get(2)}</li>
    </ul>
  </body>
</html>""";
<html>
  <head><title>My First Web Page</title></head>
  <body>
    <p>My Hobbies:</p>
    <ul>
      <li>Cycling</li>
      <li>Hiking</li>
      <li>Shopping</li>
    </ul>
  </body>
</html>

Speichert man das Ganze als Datei und öffnet diese in einem Browser, so erhält man in etwa die Darstellung in Abbildung 1.

Abb. 1: Simple HTML-Seite mit String Templates

Besonderheiten

Interessanterweise ist es möglich, auch komplexere Aktionen in einem Platzhalter auszuführen. Als eher einfaches Beispiel haben wir bereits einen Methodenaufruf gesehen. Es sind aber auch Aktionen wie die in Listing 6 möglich, also ein Postinkrement, der ?-Operator oder Zugriffe auf das Date and Time API – in diesem Muster lassen sich dann einfache Anführungszeichen ohne Escaping nutzen.

int index = 0;
var modified = STR."\{index++}, \{index++}, \{index++}, \{index++}";
System.out.println(modified);

String filePath = "tmp.dat";
File file = new File(filePath);
  String old = "The file " + filePath + " " + file.exists() ? "does" : "does not" + " exist");
 String msg = STR. "."The file \{filePath} \{file.exists() ? 
                  "does" : "does not"} exist";

String currentTime = STR."Current time: \{DateTimeFormatter.ofPattern("HH:mm").format(LocalTime.now())}";
System.out.println(currentTime);

Stay tuned

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

 

Neben STR existieren weitere vordefinierte Prozessoren, etwa FMT, um eine Ausgabe wie mit String.format() zu erzielen. Betrachten wir dazu die Methode in Listing 7.

private static void alternativeStringProcessors() {
  int x = 47;
  int y = 11;
  String calculation1 = FMT."%6d\{x} + %6d\{y} = %6d\{x + y}";
  System.out.println("fmt calculation 1: " +calculation1);

  float base = 3.0f;
  float addon = 0.1415f;

  String calculation2 = FMT."%2.4f\{base} + %2.4f\{addon}" +
                        FMT."= %2.4f\{base + addon}";
  System.out.println("fmt calculation 2 " + calculation2);

    String calculation3 = FMT."Math.PI * 1.000 = %4.6f\{Math.PI * 1000}";
    System.out.println("fmt calculation 3 " + calculation3);
}

Als Ausgabe erhält man:

fmt calculation 1:     47  +     11 =     58
fmt calculation 2: 3.0000  + 0.1415 = 3.1415
fmt calculation 3: Math.PI * 1.000 = 3141.592654

Während STR standardmäßig verfügbar ist, muss man FMT auf geeignete Weise importieren:

import static java.util.FormatProcessor.FMT;

Eigene Stringprozessoren

Die Arbeitsweise von STR und FMT ist eingängig. Möchte man selbst steuernd eingreifen, ist es sogar möglich, eigene Template Processors zu erstellen, indem man das Interface StringTemplate.Processor implementiert. Dabei existieren diverse Methoden, die man für eine angepasste Variante nutzen kann. Folgendes Beispiel zeigt sowohl STR als auch die eigene Ausprägung in Form der Klasse MyProcessor im Einsatz:

String name = "Michael";
int age = 52;
System.out.println(STR."Hello \{name}. You are \{age} years old.");

var myProc = new MyProcessor();
System.out.println(myProc."Hello \{name}. You are \{age} years old.");

Betrachten wir die Ausgaben, die auch verschiedene Resultate der internen Methoden zeigen, um das Verständnis dafür zu erhöhen, wie die Abläufe und Verarbeitungsschritte innerhalb eines Template Processor erfolgen. Ziel des eigenen Prozessors ist es, die Werte jeweils speziell mit >> und << zu markieren:

Hello Michael. You are 52 years old.
-- process() --
=> fragments: [Hello , . You are ,  years old.]
=> values: [Michael, 52]
=> interpolate: Hello Michael. You are 52 years old.
Hello >>Michael<<. You are >>52<< years old.

Tatsächlich ist gar nicht viel Arbeit in der Methode process() zu erledigen, insbesondere würde man die Ausgaben weglassen, hier dienen sie lediglich dem Verständnis und der leichteren Nachvollziehbarkeit (Listing 8).

static class MyProcessor implements StringTemplate.Processor<String,
                                    IllegalArgumentException> {
    @Override
    public String process(StringTemplate stringTemplate) 
                  throws IllegalArgumentException {
        System.out.println("\n-- process() --");
        System.out.println("=> fragments: " + stringTemplate.fragments());
        System.out.println("=> values: " + stringTemplate.values());
        System.out.println("=> interpolate: " + stringTemplate.interpolate());
        System.out.println();

        var adjustedValues = stringTemplate.values().stream().
                                            map(str -> ">>" + str + "<<").
                                            toList();

        return StringTemplate.interpolate(stringTemplate.fragments(),
                                          adjustedValues);
    }
}

JEP 431: Sequenced Collections

Das Java Collections API ist eins der ältesten und am besten konzipierten APIs im JDK und bietet die drei Haupttypen List, Set und Map. Was allerdings fehlt, ist so etwas wie eine geordnete Reihenfolge von Elementen in Form eines Typs. Einige Collections haben eine sogenannte Encounter Order/Iteration Order (Begegnungsreihenfolge), d. h., es ist definiert, in welcher Reihenfolge die Elemente durchlaufen werden, etwa

  • List: indexbasiert, von vorne nach hinten
  • TreeSet: indirekt durch Comparable oder übergebenen Comparator definiert
  • LinkedHashSet: gemäß der Einfügereihenfolge

Dagegen definiert etwa HashSet keine derartige Encounter Order – und auch HashMap tut das nicht.

 

Auf Basis der Encounter Order ist auch das erste bzw. letzte Element definiert. Zudem lässt sich so eine Append-Funktionalität für vorn und hinten realisieren. Darüber hinaus ergibt sich auch die umgekehrte Reihenfolge. All das wird von Sequenced Collections adressiert.

Einführende Beispiele

Bis Java 21 war es mühsam, auf das letzte Element einer Collection zuzugreifen. Schlimmer noch – es existieren diverse, jeweils vom Typ der Collection abhängige unterschiedliche Wege:

var lastListElement = list.get(list.size() - 1);
var lastSortedElement = sortedSet.last();
var lastDequeElement = deque.getLast();

Fast ebenso unhandlich waren mitunter Zugriffe auf das erste Element, insbesondere für Sets:

var firstElement = list.get(0);
var firstUnordered = hashSet.iterator().next();
var firstOrdered = treeSet.iterator().next();
var firstLhs = linkedHashSet.iterator().next();

Sich all diese Besonderheiten zu merken, ist mühsam und fehleranfällig. Selbst mit IDE-Unterstützung bleibt noch die mangelnde Eleganz. Schauen wir uns nun Sequenced Collections an und wie diese nicht nur diese Aufgabenstellung, sondern auch das Anfügen und Löschen von Elementen vereinfachen.

Abhilfe: Sequenced Collections

Bislang bietet Java keinen Typ, der eine geordnete Folge von Elementen repräsentiert. Wie angedeutet, füllt Java 21 diese Lücke durch die Sequenced Collections, genauer die Einführung der Interfaces SequencedCollection, SequencedSet und SequencedMap. Sie bieten Methoden zum Hinzufügen, Ändern oder Löschen von Elementen am Anfang oder Ende der Collection sowie zur Bereitstellung einer Collection in umgekehrter Reihenfolge.

Das Interface SequencedCollection

Betrachten wir das Interface SequencedCollection (Listing 9).

interface SequencedCollection<E> extends Collection<E> {
  SequencedCollection<E> reversed();
  void addFirst(E);
  void addLast(E);
  E getFirst();
  E getLast();
  E removeFirst();
  E removeLast();
}

Es ist leicht ersichtlich, dass SequencedCollection das Interface Collection erweitert und Methoden zum Hinzufügen, Ändern oder Löschen von Elementen am Anfang oder Ende bietet. Die Methoden addXyz() und removeXyz() lösen für Immutable Collections eine UnsupportedOperationException aus. Die Methode reversed() ermöglicht die Verarbeitung der Elemente in umgekehrter Reihenfolge. Tatsächlich handelt es sich dabei um eine View, ähnlich wie bei subList(). Dadurch werden Änderungen in der View auch in der Original-Collection sichtbar und vice versa.

Stay tuned

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

 

Ein kurzer Blick hinter die Kulissen zeigt, wie es mit Hilfe von DefaultMethoden möglich wurde, die Interfaces in die bestehende Interfacehierarchie einzupassen (Listing 10).

public interface SequencedCollection<E> extends Collection<E> {
  SequencedCollection<E> reversed();

  default void addFirst(E e) {
    throw new UnsupportedOperationException();
  }

  default void addLast(E e) {
    throw new UnsupportedOperationException();
    }

  default E getFirst() {
    return this.iterator().next();
  }

  default E getLast() {
    return this.reversed().iterator().next();
  }

  default E removeFirst() {
    var it = this.iterator();
    E e = it.next();
    it.remove();
    return e;
  }

  default E removeLast() {
    var it = this.reversed().iterator();
    E e = it.next();
    it.remove();
    return e;
  }
}

Mit dieser Umsetzung sehen wir die Interfacehierarchie und die drei grün markierten neuen Interfaces der Sequenced Collections in Abbildung 2.

Abb. 2: Die Interfacehierarchie (neue Interfaces sind grün markiert) (Bildquelle: [1])

Die Interfaces SequencedSet und SequencedMap

Betrachten wir nun noch die beiden Interfaces SequencedSet und SequencedMap. Beginnen wir mit SequencedSet. Es erweitert Set und basiert auch auf SequencedCollection, allerdings ohne neue Methoden zu definieren und mit einer kleinen Abweichung bei reversed(), das eine kovariante Überschreibung mit geändertem Rückgabewert ist:

interface SequencedSet<E> extends Set<E>, SequencedCollection<E> {
  SequencedSet<E> reversed(); // covariant override
}

Analog zu SequencedCollection bietet SequencedMap die folgenden Methoden:

  • Entry<K, V> firstEntry() – liefert das erste Schlüssel-Wert-Paar
  • Entry<K, V> lastEntry() – liefert das letzte Schlüssel-Wert-Paar
  • Entry<K, V> pollFirstEntry() – entfernt das erste Schlüssel-Wert-Paar und gibt es zurück
  • Entry<K, V> pollLastEntry() – entfernt das letzte Schlüssel-Wert-Paar und gibt es zurück
  • V putFirst(K, V) – fügt ein Schlüssel-Wert-Paar am Anfang ein
  • V putLast(K, V) – fügt ein Schlüssel-Wert-Paar am Ende an
  • SequencedMap<K, V> reversed() – gibt eine View in umgekehrter Reihenfolge zurück

Damit ergibt sich die Interfacedefinition aus Listing 11.

interface SequencedMap<K,V> extends Map<K,V> {
  SequencedMap<K,V> reversed();
  SequencedSet<K> sequencedKeySet();
  SequencedCollection<V> sequencedValues();
  SequencedSet<Entry<K,V>> sequencedEntrySet();
  V putFirst(K, V);
  V putLast(K, V);
  Entry<K, V> firstEntry();
  Entry<K, V> lastEntry();
  Entry<K, V> pollFirstEntry();
  Entry<K, V> pollLastEntry();
}

SequencedMap-API

Das API von SequencedMap fügt sich nicht so gut in die Sequenced Collections ein. Es verwendet NavigableMap als Basis, daher bietet es statt getFirstEntry() die Methode firstEntry() und statt removeLastEntry() bietet es pollLastEntry(). Diese Namen korrespondieren nicht mit denen der SequencedCollection. Allerdings hätte der Versuch, dies zu tun, dazu geführt, dass NavigableMap vier neue Methoden erhalten hätte, die das Gleiche tun, wie die vier anderen Methoden, über die es bereits verfügt.

Sequenced Collections in Aktion

Zum Abschluss wollen wir die neuen Möglichkeiten einmal in Aktion erleben. Dazu definieren wir eine Liste mit Buchstaben und fragen im Anschluss das erste und das letzte Element ab. Danach erzeugen wir mit reversed() eine Collection mit umgekehrter Reihenfolge, die wir durchlaufen, zur Demonstration in einen Stream wandeln und die drei Elemente überspringen sowie schließlich das erste und letzte Element der umgekehrten Reihenfolge abfragen (Listing 12). Die Ausgabe zeigt Listing 13.

public static void sequenceCollectionExample() {
    System.out.println("Processing letterSequence with list");
    SequencedCollection<String> letterSequence = List.of("A", "B", "C", 
                                                         "D", "E");
    System.out.println(letterSequence.getFirst() + " / " +
                       letterSequence.getLast());

    System.out.println("Processing letterSequence in reverse order");
    SequencedCollection<String> reversed = letterSequence.reversed();
    reversed.forEach(System.out::print);
    System.out.println();
    System.out.println("reverse order stream skip 3");
    reversed.stream().skip(3).forEach(System.out::print);
    System.out.println();
    System.out.println(reversed.getFirst() + " / " + 
                       reversed.getLast());
    System.out.println();
}
Processing letterSequence with list
A / E
Processing letterSequence in reverse order
EDCBA
reverse order stream skip 3
BA
E / A

 

Variieren wir nun die Datenstruktur und nutzen Sets zur Verwaltung der Elemente. Zunächst einmal wird durch mehrmaliges Ausführen deutlich, dass die mit Set.of() erzeugten Daten keine fixe Reihenfolge besitzen, sondern dass diese von Ausführung zu Ausführung variiert. Am Beispiel eines TreeSet schauen wir uns die Möglichkeiten der Sequenced Collections, genauer des SequencedSet, an (Listing 14). Die Ausgabe zeigt Listing 15.

public static void sequencedSetExample() {
    // plain Sets do not have encounter order ... 
    // run multiple times to see variation
    System.out.println("Processing set of letters A-D");
    Set.of("A", "B", "C", "D").forEach(System.out::print);
    System.out.println();
    System.out.println("Processing set of letters A-I");
    Set.of("A", "B", "C", "D", "E", "F", "G", "H", "I").
        forEach(System.out::print);
    System.out.println();

    // TreeSet has order
    System.out.println("Processing letterSequence with tree set");
    SequencedSet<String> sortedLetters = 
                         new TreeSet<>((Set.of("C", "B", "A", "D")));
    System.out.println(sortedLetters.getFirst() + " / " + 
                       sortedLetters.getLast());
    sortedLetters.reversed().forEach(System.out::print);
    System.out.println();
}
Processing set of letters A-D
DCBA
Processing set of letters A-I
IHGFEDCBA
Processing letterSequence with tree set
A / D
DCBA

Fazit

Wir haben uns exemplarisch mit den Sequenced Collections als finalem Feature und mit den String Templates als vielversprechendem Preview Feature beschäftigt.

Sequenced Collections bereichern die ohnehin schon ausgefeilte Funktionalität des Collections-Frameworks und vereinheitlichen vor allem Zugriffe und Veränderungen des ersten und des letzten Elements sowie das Bereitstellen einer umgekehrten Reihenfolge.

Für die Konkatenation von fixen und variablen Textbestandteilen zu einem String gab es bisher schon diverse Varianten, alle mit spezifischen Stärken und Schwächen. String Templates werden die Art und Weise, wie man diese Aufgabe bewältigt, revolutionieren und stellen ein Feature dar, das Java wieder ein wenig moderner macht und zu anderen Sprachen wie Python oder Kotlin aufschließen lässt.

Java 21 bringt viele weitere Neuerungen, die die Programmiersprache voranbringen und attraktiver machen. Auch für komplette Newbies wird die Einstiegshürde gesenkt: Dank Unnamed Classes und Instance Main Methods lassen sich kleinere Java-Programme viel schneller und mit deutlich weniger Zeilen und für Anfänger schwierigen Begrifflichkeiten erstellen.

In diesem Sinne: Happy Coding mit dem brandaktuellen Java 21!


Links & Literatur

[1] https://cr.openjdk.org/~smarks/collections/SequencedCollectionDiagram20220216.png

The post Neues in Java 21: JEP 430 und 431 appeared first on JAX.

]]>
Welche Möglichkeiten bietet GraalVM in Spring? https://jax.de/blog/welche-moeglichkeiten-bietet-graalvm-in-spring/ Mon, 20 Feb 2023 15:25:14 +0000 https://jax.de/?p=88357 Dieser Artikel behandelt GraalVM und ihre Möglichkeiten bzw. Integration in Spring. Wir starten mit einer kleinen Historie, um zu verstehen, warum es Graal überhaupt gibt bzw. woher es kommt. Danach sehen wir uns die Funktionalitäten von Graal für Java im Allgemeinen an und danach, inwiefern Spring 6 bzw. Spring Boot 3 Unterstützung für GraalVM anbietet.

The post Welche Möglichkeiten bietet GraalVM in Spring? appeared first on JAX.

]]>
Graal selbst bietet eine Vielzahl von Features an. Bekannt ist es aber vor allem durch das Native Image, das es uns erlaubt, Java-Programme ohne Runtime wie ein klassisches Executable auszuführen. Dadurch hat man deutliche Geschwindigkeitsvorteile, das Programm startet wesentlich schneller und auch der Speicherverbrauch ist auf einem absoluten Minimum. Allerdings muss man bei länger laufenden Anwendungen auf die Optimierungen der JVM verzichten.

Historie: GraalVM

Im Gegensatz zu anderen Gebieten (J***Script) in der Softwareentwicklung ist das Java-Ökosystem auf Beständigkeit ausgerichtet. Man möchte keine Experimente und schon gar nicht alle paar Monate ein neues Tool einsetzen. Java-Programme sind (fast) für die Ewigkeit! Aus diesem Grund ist die Frage des Graal-Ursprungs eine wesentliche. Woher kommt Graal? Ist das irgendein „fancy“ Open-Source-Projekt, dessen Hype in ein paar Monaten vorbei ist? Nein. Von Kurzfristigkeit ist hier weit und breit keine Spur.

Die Anfänge von Graal begannen mit der Überlegung, dass man die Virtual Machine – gemäß dem Motto „eat your own dog food“ – in Java neuschreibt. Normalerweise verwendet man dafür C++. Das Projekt dafür hieß Maxine, hatte seinen initialen Release bereits 2005 und kam aus der Schmiede von Sun Labs, das bekanntlich von Oracle gekauft und mittlerweile auf Oracle Labs umgetauft wurde. Ein Teil dieses Vorhabens war auch das Neuschreiben des C1-Compilers in Java. Dieser läuft in der VM und kompiliert den zuvor durch javac in Bytecode transformierten Code in Maschinencode. Es gab nun die Idee, die Java-Version C1 von Maxine in die „handelsübliche“ HotSpot zu überführen. Nach diesem erfolgreichen Experiment wollte man eine Stufe höher gehen und sich den C2-Compiler vornehmen. Der C2-Compiler gilt als auch als der „Servercompiler“. Im Gegensatz zu C1-„Client Compiler“ liegt der Fokus auf Optimierung, weswegen C2 deutlich mehr Ressourcen und auch Zeit benötigt.

Die Rückführung der Compiler von Maxine in HotSpot nannte man intern die „heilige Mission“ („Holy Quest“). Die Königsdisziplin, also der Ersatz von C2, war dann dementsprechend passend der Gral. Und damit haben wir auch die Herkunft des Namens Graal behandelt, um final die Begriffe richtig einzuordnen. Mit Graal war am Anfang der Compiler genannt und die Integration bzw. was dann schlussendlich eine eigene VM werden sollte, ist GraalVM.

GraalVM emanzipierte sich von Maxine und wurde über die Jahre hinweg innerhalb von Oracle Labs weiterentwickelt. Maxine hingegen ist seit 2017 unter der Obhut der University of Manchester. In weiterer Folge verbesserten sich die GraalVM und ihr Compiler immer weiter. Es kamen neue Features hinzu, die wir im nächsten Abschnitt behandeln. Im Mai 2019 war es schließlich soweit, dass GraalVM als production-ready [1] eingestuft wurde.

GraalVM basiert intern auf HotSpot, verwendet allerdings ihren eigenen Compiler. Gibt es hierbei Inkompatibilitäten? Nein, man muss sich hier keine Sorgen machen, dass Bestandteile der Programmiersprache nicht unterstützt oder anders ausgeführt werden. Für das Native Image, das nicht der VM oder dem Compiler zuzurechnen ist, verhält sich die Sache etwas anders. Dazu aber später mehr.

Man sieht also, dass GraalVM eine sehr lange Entwicklungsgeschichte hat, dass dahinter Oracle steht und es somit eine moderne, performantere Alternative aus eigenem Haus bietet.

Stay tuned

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

 

Das Framework “Truffle” für die GraalVM

Die GraalVM umfasst somit eine VM und einen Compiler. Das ist jedoch nicht alles. Es gibt noch zwei weitere sehr wichtige Features. Der erste Teil, der uns als Spring-Entwickler:in in den meisten Fällen nicht sehr stark tangieren wird, ist Truffle. Es ist ein Framework, mit dem man eigene oder bereits bestehende Programmiersprachen in GraalVM integrieren kann (Abb. 1). Unterstützung des „Who is who“ der Programmiersprachen ist bereits in GraalVM vorhanden. Unter anderem Python, Ruby oder auch JavaScript. Es ist auch möglich, diese Teile untereinander zu mixen. So kann beispielsweise ein in JavaScript geschriebener Programmteil auch von Java aufgerufen werden. Was JavaScript angeht, ist noch wichtig anzumerken, dass es bereits mit Nashorn eine von Oracle entwickelte JavaScript Engine gab, die allerdings mit Java 11 als deprecated erklärt wurde.

Auf Truffle aufsetzend gibt es noch die LLVM Runtime. Extrem vereinfacht ausgedrückt kann man sagen: Was Bytecode für Java, Kotlin oder Scala ist, ist die LLVM für C/C++ oder Rust. Zusammengefasst bedeutet das, dass wir sehr viele Anwendungen, die nicht in Java geschrieben wurden, mit GraalVM sehr einfach in einer JVM zum Laufen bekommen.

Abb. 1: GraalVM-Architektur mit Truffle

Native Image in der GraalVM

Der performante Compiler und die vielen Möglichkeiten, die Truffle mitbringt, machen die GraalVM bereits zu einem Werkzeug, das man in vielen Java-Projekten sofort einsetzen möchte. Der Teil, für den Graal wahrscheinlich bei den meisten der Java-Entwickler:innen bekannt ist, ist jedoch Native Image. Native Image bedeutet nichts anderes als eine Kompilierung zu Maschinencode, wie man es von C++ zum Beispiel kennt, und dem Wegfall der VM. Das ist jedoch mit einigen Einschränkungen versehen, die im Folgenden zu nennen sind.

Bei der Kompilierung zu einem nativen Image muss Graal unsere Codebasis durchforsten und fügt alle Elemente hinzu, die es aus dem reinen Programmcode herauslesen kann. Es startet also bei der public static void main und geht den ganzen Programmfluss durch. Das heißt, dass alle Klassen, die von main aus erreichbar sind, in die Kompilierung mit aufgenommen werden. Sollten irgendwelche kontextuellen Daten, die zum Beispiel während der Laufzeit erst durch das Einlesen von Umgebungsvariablen bekannt sind, dabei sein, werden diese in der Kompilierung fehlen. Konsequenterweise fällt darunter auch die Verwendung von Reflection, wobei wir beispielsweise Methoden aufrufen, oder das dynamische Laden von Klassen. Im fachtechnischen Jargon spricht man hier von der „Closed-World Assumption“, da eben nach der Kompilierung die Tür geschlossen ist und man nachträglich keine weiteren Dinge mehr hinzufügen kann. Bezüglich der Erreichbarkeit der einzelnen Elemente spricht man von Reachability.

Das Native Image ist grundsätzlich immer schneller als die Interpretation des Bytecodes. Es muss jedoch beachtet werden, dass die sogenannte Peak Performance bei Verwendung der VM nicht erreicht werden kann. Die Peak Performance gewinnt die VM, indem sie durch sorgfältiges Profiling unsere Anwendung zur Laufzeit wesentlich besser kompilieren kann, da es weiß, wie die Anwendung verwendet wird. Das setzt allerdings eine langläufige Anwendung voraus. In der neuen Cloudwelt, in der die Instanzen nur eine kurze Lebensdauer haben, wird die Peak Performance ohnehin nicht erreicht.

NEUES AUS DER JAVA-ENTERPRISE-WELT

Serverside Java-Track entdecken

 

Set-up GraalVM

Schauen wir uns das Ganze einmal selbst an und installieren uns die GraalVM auf unserer Maschine. Dazu navigieren wir nach [2]. Unter den ersten Punkten sehen wir die Downloads, in denen wir die Option zwischen Community und Enterprise haben. Wir downloaden nicht die Community Edition, sondern die Enterprise Edition, zum Zeitpunkt dieses Artikels Version 22.3.

Die geneigte Leserschaft wird sich nun sicherlich fragen, wieso die – wahrscheinlich kostenpflichtige – Enterprise Edition, wenn doch die Community Edition gratis zu beziehen sein wird? Der Grund liegt darin, dass die Enterprise Edition für Entwicklungstätigkeiten gratis ist, wir allerdings eine wesentlich höhere Performance bekommen als von der Community. Erst für den produktiven Einsatz sind Gebühren bei der Enterprise Edition notwendig, aber da können wir wieder auf die Community Edition ausweichen.

Wir klicken also auf Download (Abb. 2), was uns zu einem weiteren Bildschirm führt, wo wir Java-Plattform, Betriebssystem und Architecture auswählen. In diesem Artikel wurde Java 17, macOS und aarch64 verwendet. Zum Austesten nehmen wir eine Abwandlung von den offiziellen Graal-Beispielen (Listing 1, Listing 2).

Abb. 2: Downloadbildschirm

public class Main {
  public static void main(String[] args) throws Exception {
    var blender = new Blender();
    for (int j = 0; j < 10; j++) {
      long t = System.nanoTime();
      for (int i = 0; i < 100; i++) {
        blender.run();
      }
      long d = System.nanoTime() - t;
      System.out.println(d / 1_000_000 + " ms");
    }
  }
}
public class Blender implements Runnable {
 
  public static final Colour[][][] colours = new Colour[100][100][100];
 
  @Override
  public void run() {
    var id = new Colour(0, 0, 0);
    for (int x = 0; x < colours.length; x++) {
      Colour[][] plane = colours[x];
      for (int y = 0; y < plane.length; y++) {
        Colour[] row = plane[y];
        for (int z = 0; z < row.length; z++) {
          Colour colour = new Colour(x, y, z);
          colour.add(id);
          if ((colour.r + colour.g + colour.b) % 42 == 0) {
            row[z] = colour;
          }
        }
      }
    }
  }
 
  public static class Colour {
    double r, g, b;
 
    Colour(double r, double g, double b) {
      this.r = r;
      this.g = g;
      this.b = b;
    }
 
    public void add(Colour other) {
      r += other.r;
      g += other.g;
      b += other.b;
    }
  }
}

Führen wir diesen Code einmal mit dem aktuellen OpenJDK 17.0.5 aus (Abb. 3), dauert es auf der Maschine des Autors 58 Sekunden. Tauschen wir nun OpenJDK durch GraalVM aus, reduziert sich die Zeit auf 53 Sekunden. Man sieht, dass die Ausführung mit der GraalVM (Abb. 4) schneller ist, doch nicht unbedingt in einem Ausmaß, das einen vom Hocker haut. Es handelt sich hier allerdings lediglich um eine andere VM mit einem besseren Compiler. Das Native Image ist noch nicht im Einsatz.

Fairerweise ist hier auch anzumerken, dass ein Beispiel, das wir von der GraalVM-Seite nehmen, natürlich für GraalVM optimiert ist. Aber es steht der geschätzten Leserschaft frei, eigene Codebeispiele zu nehmen.

Abb. 3: Ausführung OpenJDK

Abb. 4: Ausführung GraalVM

Native Image allgemein

Es wird Zeit, Gas zu geben. Wir bleiben bei unserem Blender-Beispiel und wandeln es in ein Native Image um.

Installation und Ausführung

Als Erstes müssen wir das Native Image installieren. Mit dem Befehl gu list sehen wir, dass bis dato nur die GraalVM Core installiert ist. Das Native Image beziehen wir mittels

gu install native-image

Bei der Verwendung der Enterprise Edition wird man nun aufgefordert, die E-Mail-Adresse anzugeben. Es wird dann ein Link an diese Adresse verschickt, den es zu bestätigen gilt. Danach kann man die Installation über die Konsole fortsetzen. Nun folgt die eigentliche Kompilierung, bei der wir unseren Blender in Bytecode überführen und von dort das Native Image bauen.

javac Main
native-image Main

Die Ausführung starten wir dann ganz normal (auf macOS) mittels

./main

Und siehe da (Abb. 5), von den anfangs 53 Sekunden (GraalVM), sind wir auf einmal bei acht Sekunden. Dazu sagen wir natürlich nicht nein.

Abb. 5: Ausführung Native Image

Einschränkungen durch Closed-World-Ansatz

Wie oben erwähnt, ist im Closed-World-Ansatz das Nachladen etc. nicht möglich, was auch sehr sinnvoll ist. Das erzeugte Executable läuft ohne JVM und es soll nur die Elemente beinhalten, die es wirklich benötigt. Unnützen Ballast möchten wir abwerfen. Dieses Verfahren wird in der Welt der Frontend Frameworks vom Prinzip her schon länger angewandt. Nur ist es dort unter dem Namen Tree Shaking bekannt.

Stay tuned

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

 

Können wir also unser Beispiel so umkonstruieren, dass wir eine Dynamik hineinbekommen, bei der unser natives Image nicht mehr funktioniert? Wir können Blender mittels Class.forName dynamisch laden. Wir ändern die Main.java dahingehend ab und fügen noch hinzu, dass der Klassenname über Kommandozeile als Argument übergeben wird (Listing 3).

public class Main {
  public static void main(String[] args) throws Exception {
    String className = args[0];
    Runnable blender = (Runnable)Class.forName(className).getDeclaredConstructor().newInstance();
    for (int j = 0; j < 10; j++) {
      long t = System.nanoTime();
      for (int i = 0; i < 100; i++) {
        blender.run();
      }
      long d = System.nanoTime() - t;
      System.out.println(d / 1_000_000 + " ms");
    }
  }
}

Danach kompilieren wir nochmals Main und erstellen das Native Image:

javac Main.java
native-image Main
./main Blender

Es klappt zwar, allerdings mit Warnmeldungen und plötzlich sehr langsam. GraalVM sieht, dass wir hier über Reflection eine unbekannte Klasse laden, und geht auf Nummer sicher, indem es ein „Fallback“ Image generiert. Das ist nicht das, was wir wollen. Das Fallback Image bedeutet, dass die JVM wieder im Spiel ist. Wir müssen bspw. nur das Executable in einen anderen Ordner kopieren und es von dort aus neu ausführen. Es wird nicht funktionieren. Es gibt die Möglichkeit, das Fallback Image zu deaktivieren. Das können wir bei der Erzeugung des Image mittels eines Flags definieren.

native-image Main --no-fallback

Nach Neuausführung bekommen wir danach eine altbekannte Fehlermeldung:

Exception in thread "main" java.lang.ClassNotFoundException: Blender 

Der Grund, wieso überhaupt ein Fallback Image erstellt wird, ist ein rechtlicher [3]. Oracle ist hier offenbar gezwungen, dass das Native Image auf alle Fälle eine lauffähige Anwendung ist – selbst wenn die VM inkludiert ist. Hauptsache, es läuft. Aber sowohl mit als auch ohne Fallback kommen wir zu einem unbefriedigenden Ergebnis. Unsere Anwendung läuft nicht. In diesem Fall müssen wir selbst Hand anlegen und dem Native Image mitteilen, welche Klassen wir dynamisch laden, damit es diese auch während der Kompilierung mit aufnimmt. Das erfolgt mittels eigener Konfigurationsdateien. Diese können und werden bei größeren Umgebungen durchaus ausarten. Bei unserem Beispiel ist das nicht der Fall. Wir müssen lediglich die Blender-Klasse angeben. Dazu erstellen wir eine neue Datei namens reflect-config.json mit dem Inhalt aus Listing 4.

[
  {
    "name": "Blender",
    "allDeclaredConstructors": true
  }
]

Danach starten wir die Kompilierung und Ausführung erneut. Dieses Mal geben wir jedoch die Konfigurationdatei dazu:

native-image Main -H:ReflectionConfigurationFiles=reflect-config.json
./main Blender

Jetzt klappt es ohne Schwierigkeiten. Wir müssen uns aber natürlich im Klaren sein, dass wir durch eine fehlerhafte Konfiguration Fehler in der Laufzeit erzeugen können.

Es können – auch mehrere Konfigurationsdateien eingesetzt werden, und das ist auch der Fall. Konventionsmäßig gibt man diese in den Ordner META-INF/native-image und gruppiert sie dann in einem weiteren Unterordner mittels ihrer groupId und artifactId. GraalVM versucht, so viel wie möglich von dem dynamischen Verhalten abzudecken. Sollten wir bspw. den Klassennamen als statischen String verwenden, brauchen wir weder eine Konfigurationsdatei noch wird ein Fallback Image erstellt:

Runnable blender = (Runnable)Class.forName("Blender").getDeclaredConstructor().newInstance();

Metadata bei großen Projekten

Wir haben gesehen, dass das Native Image mit seinem Closed-World-Ansatz je nach Dynamik einmal mehr und einmal weniger Hilfe benötigt. Das hier vorgeführte Beispiel ist auf das absolute Minimum reduziert. Wir haben keine packages, builden kein jar, geschweige denn setzen wir Maven oder Gradle ein. In einem professionellen Umfeld würden wir hier entsprechende Plug-ins einsetzen, bei denen das Native Image in den Build integriert ist. Auch ist bei Java die Dynamik nicht nur auf Reflection basiert. Es gibt noch Proxies, Ressourcen usw. In diesem Fall gibt die offizielle Dokumentation [4] ausführlich Auskunft.

Das Erstellen der Konfigurationsdateien kann semiautomatisch durchgeführt werden. Eine gängige Variante ist, die Anwendung normal über die JVM hochzufahren, mit ihr zu arbeiten, während im Hintergrund ein Agent läuft, der die Konfiguration automatisch erstellt. Bei unserem Beispiel machen wir das mittels des Befehls:

java -agentlib:native-image-agent=config-merge-dir=META-INF/native-image Main Blender

Bei der Ausführung werden durch die merge-Option Warnungen kommen, weil es das Verzeichnis noch nicht gibt. Das können wir ignorieren. Nachdem wir aber die Konfigurationsdateien bereits in das Standardverzeichnis erzeugt haben, werden sie automatisch beim nächsten native-image Main mitverwendet. Wir können jetzt auch unsere selbstgestrickte reflect-config.json löschen. Wenn wir jetzt nochmals main ausführen, sollte es keine Schwierigkeiten mehr geben.

Für Drittbibliotheken müssen wir das Rad nicht neu erfinden. Es gibt hier beispielsweise das sogenannte GraalVM Reachability Metadata Repository [5] auf GitHub, wo Bibliotheken ihre Konfigurationsdateien hinterlegen. Man kann dort auch sehr schön erkennen, wieso man die Konfigurationen nicht selbst schreiben sollte. Die reflect-config.json von Hibernate 6.1.1 hat zum Beispiel über 12 000 Zeilen.

 

Zusammenfassung zu Native Images

Native Image beschleunigt unsere Anwendungen signifikant. Durch das Closed-World-Prinzip müssen wir jedoch mit Einschränkungen rechnen bzw. durch Metadaten dem Compiler auf die Sprünge helfen. Als Nächstes wenden wir uns dem Einsatz von Native Image in Spring zu.

Native Image in Spring

Spring Ahead-of-Time

Wir haben bereits gesehen, dass bei dynamischen Java-Applikationen der Konfigurationsaufwand nicht zu unterschätzen ist. Wie verhält es sich jetzt mit Frameworks, wie zum Beispiel Spring, bei dem dynamisches Java das Fundament bildet?

Beispielsweise ist der Einsatz von Proxies omnipräsent und Features wie Profile über @Profile sind prinzipiell ausgezeichnet, allerdings schwer vereinbar mit dem Closed-World-Prinzip. Wenn wir uns ansehen, wie andere Frameworks damit umgehen, erkennen wir, dass zum Beispiel Micronaut [6], die komplette Dynamik bereits während des Builds durchführt. Das heißt, dass im kompilierten Bytecode, der dem Native Image vorgelegt wird, bereits alles statisch ist. Bei Spring wird dies bekanntlich während der Laufzeit gemacht.

Befinden wir uns also mit Spring in einer Sackgasse bzw. müsste man Spring von Grund auf neuprogrammieren, damit es ähnlich verfährt wie Micronaut & Co? Dadurch, dass die GraalVM nicht plötzlich und vollkommen unerwartet erschienen ist, hatte Spring natürlich dementsprechend viel Vorlaufzeit, um sich darauf vorzubereiten. Die Arbeiten begannen bereits vor 2019, jedoch wurde 2019 unter dem Namen Spring Native [7] ein GraalVM-spezifisches Projekt in den Incubator-Status erhoben. Es hatte die Aufgabe, Möglichkeiten herauszufinden und auszuprobieren, wie man Spring am besten an die Erfordernisse von GraalVM anpasst. Nach erfolgreicher Mission wurde Spring Native mehr oder weniger als deprecated erklärt und das Resultat floss direkt in Spring 3.0, wo nun die Unterstützung für GraalVM nativ dabei ist.

Das Erstellen eines Native Image in Spring setzt sich aus zwei Teilen zusammen: der Vorbereitung und dem eigentlichen Build des Native Image, das von Graal übernommen wird. Bei der Vorbereitung transformiert Spring den bestehenden Source Code dahingehend, dass er nicht mehr auf Reflection oder sonstigen dynamischen Elementen basiert. Das heißt, die ganzen Konfigurationen werden dahingehend umgeschrieben, dass die main-Methode spezielle BeanFactories aufruft, die das direkte Resultat der Transformation sind. Damit ist es für das Native Image sehr einfach herauszufinden, welche Klassen benötigt werden.

Diese „Transformation“ wird bei Spring die Ahead-of-Time Compilation genannt. Man muss sie nicht zwangsweise verwenden, um ein Native Image zu erstellen. Man kann sie auch direkt innerhalb einer VM ausführen. Das hat dann den Vorteil, dass das Auffinden der Beans nicht mehr zur Laufzeit stattfindet, wodurch man bereits einen schnelleren Start-up hätte. Wir gehen aber einen Schritt weiter und möchten ein Native Image bauen (Abb. 6).

Abb. 6: Graal Native Image mit Spring

Um Spring Boot 3 mit der GraalVM-Integration zu installieren, erstellen wir ein neues Projekt über den Initializr [8] und wählen als Abhängigkeiten GraalVM Native Support sowie Spring Web aus. Die restlichen Optionen lassen wir einfach auf Standard. In diesem Artikel wurde jedoch com.rainerhahnekamp als groupId und graalspring als artifactId verwendet.

Es ist an dieser Stelle anzumerken, dass mit Spring Boot 3 Gradle als standardmäßiges Build-Tool eingesetzt wird. Wer also nach wie vor mit Maven arbeitet, muss das explizit anwählen.

Wir verwenden 1:1 den Blender von früher, statten ihn aber mit @Service aus, sodass er als Bean erkannt wird. Des Weiteren fügen wir einen BlenderController hinzu, in dem der Blender injectet werden soll (Listings 5 und 6).

package com.rainerhahnekamp.graalspring;
 
@Service
public class Blender {
 
  public static final Colour[][][] colours = new Colour[100][100][100];
 
  // Implementierung von Blender aus vorigem Beispiel hineinkopieren
}
package com.rainerhahnekamp.graalspring;
 
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
 
@RestController
@RequestMapping("blender")
public class BlenderController {
  private final Blender blender;
 
  BlenderController(Blender blender) {
    this.blender = blender;
  }
 
  @GetMapping("")
  public String blend() {
    blender.blend();
    return "blended";
  }
}public interface Blender {
  void blend();
}

Mit ./gradlew bootRun starten wir den Server, und wenn wir über den Browser http://localhost:8080/blender aufrufen, sollte nach einiger Zeit blended ausgegeben werden. Durch bootRun wurde aber auch schon Spring AoT aktiv. Der generierte Source Code ist im Verzeichnis build/generated/aotSources. Dort finden wir auch die für GraalVM optimierte Registrierung der Blender und BlenderController als Beans unter den Dateinamen Blender__BeandDefinitions.java sowie BlenderController__BeanDefinitions.java. Noch interessanter ist die Datei SpringGraalApplication__BeanFactoryRegistrations.java, in der alle Bean Definitions zusammenkommen. So sehen wir neben den Blendern auch die Beans, die von Spring direkt kommen.

Theoretisch könnten wir bootRun bzw. den eigentlichen Build bereits mit den AoT-Klassen starten. Für bootRun muss man dafür in der build.gradle folgenden Eintrag hinzufügen:

bootRun {
  systemProperty("spring.aot.enabled", "false")
}

Bei der erneuten Ausführung von bootRun sollte in der Konsole die Meldung Starting AOT-processed SpringGraalApplication  vorkommen. Es ist auch zu beachten, dass bereits eine Menge an Metadatenkonfiguration vorhanden ist. Diese befindet sich unter /build/aotResources/META-INF/ …

Nun lassen wir das Native Image erstellen. Mittels ./ gradlew nativeCompile wird an GraalVM das Native Image übergeben und wir sehen die bekannte Ausgabe, die wir bereits in unseren vorigen Experimenten hatten. Im Unterschied zu vorher wird allerdings die Erstellung des Native Image deutlich länger dauern. Wir müssen berücksichtigen, dass es sich hier nicht um zwei Java-Klassen handelt, sondern das Spring Framework mit Spring MVC dabei ist.

Native Image als Docker Image

Ein Problem von Native Images ist, dass sie speziell auf unseren Rechner zugeschnitten sind. Builde ich also auf macOS, werde ich nicht in der Lage sein, dies auf Windows-Rechnern laufen zu lassen. Das alte Java-Motto „Write once, run anywhere“ ist hier nicht gültig. Diese Aufgabe hat bei modernen Deployments allerdings bereits Docker übernommen. Das heißt, wenn wir unser Native Image in ein dazugehöriges Docker Image stecken, haben wir an und für sich die Plattformunabhängigkeit wieder zurückgewonnen.

Was ist also zu tun? Spring ist bereits mit allem ausgestattet. Wir brauchen lediglich einen laufenden Docker Daemon und können dann mittels ./gradlew buildBootImage unser Native Image direkt in einem Docker Image builden, das wir dann überallhin deployen können.

Stay tuned

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

 

Closed-World in Spring

Das Closed-World-Prinzip trifft natürlich auch auf Spring zu. Wie können wir also dort mit dynamischem Java umgehen? Nun, extrahieren wir erst einmal von unserem Blender ein Interface. Das heißt, unser Blender.java teilt sich nun in zwei Klassen auf und wir verwenden eine eigene Konfigurationsklasse, die die Bean registriert (Listing 7, 8 und 9).

public interface Blender {
  void blend();
}
public class BlenderImpl implements Blender, Runnable {
 
  public static final Colour[][][] colours = new Colour[100][100][100]; 
}
@Configuration(proxyBeanMethods = false)
public class AppConfiguration {
  @Bean
  public Blender getBlender() {
    return new BlenderImpl();
  }
}

Nun geben wir jedoch eine zweite Implementierung von Blender hinzu, die bei dem Profil dev verwendet werden soll. Bei dem Profil default soll nach wie vor BlenderImpl verwendet werden. Dazu ändern wir unsere AppConfiguration.java dahingehend ab (Listing 10).

@Configuration(proxyBeanMethods = false)
public class AppConfiguration {
  @Bean
  @Profile("default")
  public Blender getBlender() {
    return new BlenderImpl();
  }
 
  @Bean
  @Profile("dev")
  public Blender getDevBlender() {
    return new Blender() {
      @Override
      public void run() {
        System.out.println("DevBlender tut nichts");
      }
    };
  }
}

Ein kurzer Check mittels SPRING_PROFILES_ACTIVE=dev ./gradlew bootRun und der Aufruf von http://localhost:8080/blender sollte in der Konsole DevBlender tut nichts erscheinen lassen. Wie erwartet, funktionieren hier die Profile.

Builden wir hingegen das native Image neu und starten es dann mit einem aktiven (Umgebungsvariable-setzen-)Profil dev, dann werden wir beim Hochfahren von Spring sehen, dass hier das dev-Profil aktiv ist. Allerdings wird die Ausgabe DevBlender nicht kommen. Es wird nämlich – egal welches Profil wir setzen – immer BlenderImpl verwendet.

Im AoT-generierten Quellcode werden wir auch keine Spur von unserem DevBlender finden. Es ist nur die Standardimplementierung vorhanden. Der Grund liegt darin, dass bei der Generierung dieser Dateien (also durch AoT) Spring im Hintergrund hochgefahren wird und – in unserem Beispiel – Code erzeugt wird, der die unterschiedlichen Konfigurationsklassen explizit aufruft. Nachdem bei den Tasks nativeCompile bzw. aotClasses kein Profil angegeben wurde, ist das default-Profil im Einsatz. Andersherum ausgedrückt: Das @Profile ist nicht mehr dynamisch, sondern wird zur Build-Zeit fixiert. Man könnte nun theoretisch beim Task nativeCompile das Profil dev setzen. Das würde dann den DevBlender als Bean registrieren. Und es wäre komplett egal, mit welchem Profil wir dann das Native Image starten. Es wird immer der DevBlender sein.

Die Verwendung von @Profile in Verbindung mit Spring AoT ist etwas verwirrend. Aus diesem Grund sollte man auf @Profile beim Einsatz von GraalVM bzw. AoT verzichten. Auch von Ableitungen, wie zum Beispiel @ConditionalOnProperty, sollte man die Finger lassen. Detaillierte Informationen sind in der Dokumentation [9] bzw. im Wiki [10] nachschlagbar.

Was macht man allerdings nun, wenn man trotzdem konditionelle Abhängigkeiten hat? Man könnte die Implementierung des Blenders über eine Property definieren. Wir würden also unsere AppConfiguration.java umgestalten, wie in Listing 11 gezeigt.

@Configuration(proxyBeanMethods = false)
public class AppConfiguration {
  @Value("${app.blender-type}")
  public String blenderType;
 
  @Bean
  public Blender getBlender() {
    if ("dev".equals(this.blenderType)) {
      System.out.println("Providing DevBlender");
      return new Blender() {
        @Override
        public void run() {
          System.out.println("DevBlender tut nichts");
        }
      };
    }
 
 
    System.out.println("Providing BlenderImpl");
    return new BlenderImpl();
  }}

Es gibt nun eine Methode, die abhängig von der gesetzten Property den DevBlender oder BlenderImpl zurückgibt. Zur Laufzeit setzen wir dann den Wert der Property über eine Umgebungsvariable und haben das gewünschte Ergebnis. Es ist jedoch hier zu beachten, dass im Native Image beide Blender-Implementierungen enthalten sind. Das wirkt sich negativ auf die Größe aus. Spring AoT könnte natürlich dasselbe machen und alle Profile in das Native Image laden. Man hat sich allerdings gegen diese Vorgehensweise entschieden.

Spring und Metadata

Gut, wie geht aber nun Spring AoT vor, wenn wir in unserem eigenen Programmcode Reflection verwenden? Dazu laden wir die BlenderImpl über Class.forName dynamisch nach. Die geänderte AppConfiguration sieht nun aus, wie in Listing 12 gezeigt.

@Configuration(proxyBeanMethods = false)
public class AppConfiguration {
  @Value("${app.blender-class}")
  public String blenderClass;
 
  @Bean
  public Blender getBlender() throws Exception {
    return (Blender)
Class.forName(blenderClass).getDeclaredConstructor().newInstance();
  }
}

Durch das Wissen, das wir mittlerweile angesammelt haben, können wir schon voraussagen, was passieren wird. Wir werden einen ClassNotFoundException bekommen. Die BlenderImpl wird über den normalen Programmcode (verfolgt man von der static void main() die imports) nicht erreichbar sein und ist aus dem Grund auch nicht beim Image dabei.

Wenn wir in /build/generated/aotResources/**/reflect-config.json nach BlenderImpl suchen, werden wir nichts finden. Wir können nun einen entsprechenden Eintrag in der reflect-config.json vornehmen oder uns wieder des Agents bedienen. Ist alles kein Problem, nur dass es von Spring eine typensichere Alternative gibt. Diese kommt in der Form des Interface RuntimeHintsRegistrar. Wir implementieren es und erhalten über die zu implementierende Methode registerHints mit RuntimeHints ein Objekt, mit dem wir typensicher den Constructor für die BlenderImpl für die Reflection registrieren können. Wir erstellen dafür eine eigene Klasse, betten diese aber sogleich als statische verschachtelte Klasse in unsere AppConfiguration ein (Listing 13).

@Configuration(proxyBeanMethods = false)
@ImportRuntimeHints(AppConfiguration.RegistryHinter.class)
public class AppConfiguration {
  @Value("${app.blender-class}")
  public String blenderClass;
 
  @Bean
  public Blender getBlender() throws Exception {
    return (Blender) Class.forName(blenderClass).getDeclaredConstructor().newInstance();
  }
 
  public static class RegistryHinter implements RuntimeHintsRegistrar {
    @Override
    public void registerHints(RuntimeHints hints, ClassLoader classLoader) {
      hints.reflection().registerConstructor(BlenderImpl.class.getDeclaredConstructors()[0], ExecutableMode.INVOKE);
    }
  }
}

Wir sehen, dass wir mittels @ImportRuntimeHints explizit angeben müssen, dass es RegistryHinter gibt. Diese wird auch nur dann aktiviert, wenn GraalVM die AppConfiguration als erreichbar ansieht (wofür Spring AoT sorgt). Also noch einmal das Native Image erstellen, beim Starten die Umgebungsvariable APP_BLENDER_CLASS auf den vollen Namen der BlenderImpl setzen, und dann sollte es funktionieren. Zur Sicherheit kann man sich auch in der reflect-config.json davon überzeugen, dass dieses Mal die BlenderImpl auftaucht.

SIE LIEBEN JAVA?

Den Core-Java-Track entdecken

 

Testen von Native Image mit Spring Boot

Wir möchten natürlich überprüfen können, ob das Native Image wie geplant funktioniert. In unserem Fall würde das bedeuten, dass wir einen Test benötigen, der die entsprechende Property setzt. Wir können mit Hilfe von Spring Boot einen sehr einfachen Test schreiben, der aussieht, wie in Listing 14 gezeigt.

@SpringBootTest(properties =
{"app.blenderClass=com.rainerhahnekamp.graalspring.BlenderImpl"})
class BlenderControllerTest {
  @Autowired
  BlenderController controller;
 
  @Test
  void testBlendShouldNotThrow() {
    assertDoesNotThrow(() -> controller.blend());
  }
}

Wir können den Test starten und er wird funktionieren, weil er ganz einfach in der JVM läuft und dort das Class.forName kein Problem ist. Was wir jedoch wirklich möchten, ist, dass dieser Test gegen das Native Image ausgeführt wird. Auch hier hat bereits Spring vorausschauend etwas für uns vorbereitet. Wir müssen lediglich folgenden Befehl ausführen:

./gradlew nativeTest

Hier wird erst einmal das Native Image erstellt und die Tests werden auch dagegen ausgeführt. Bitte bei diesem Beispiel darauf achten, dass es nur diese eine Testdatei gibt, in der auch der richtige Wert für die Property gesetzt wird. Normalerweise kommt der Spring Initializr bereits mit einem vorkonfigurierten Test, der hier fehlschlagen wird.

Abschluss: GraalVM in Spring

„Wo viel Licht ist, ist auch viel Schatten.“ – trifft dieses Sprichwort auch auf GraalVM zu? Man muss hier einerseits Jürgen Höller zitieren, der in seinem Vortrag über Spring 6 [11] meinte, der Einsatz von GraalVM führe zu einer Performancesteigerung. Wie viel das ist, hängt allerdings vom Einsatzfall ab und ist immer hochindividuell. Klar ist, dass der großflächige Produktiveinsatz von GraalVM erst im Entstehen ist. Es wäre illusorisch anzunehmen, dass nicht das eine oder andere Problemchen noch irgendwo auftaucht. Daneben gilt es auch noch andere Aspekte zu beachten, für die jedoch hier auf [12] verwiesen werden soll.

Als Nächstes kann der Autor nur empfehlen, GraalVM auf eine bestehende Spring-Applikation auszuführen. Aber natürlich erst, wenn diese auch auf Spring 6 bzw. Spring Boot 3 läuft. Zur weiteren Vertiefung bieten sich der Vortrag von Stéphane Nicoll und Brian Clozel von der Devoxx 2022 [13] und natürlich die offizielle Dokumentation [14] an.

Cloudarchitekturen zwingen unsere Java-Anwendungen, schneller zu starten und einen kleineren Memory Footprint (Kosten) zu haben. Ist das „alte Java“ für die modernen Zwecke nicht mehr passend? Obwohl es bezüglich des Real-World-Einsatzes noch in den Kinderschuhen steckt, haben wir eine Antwort auf diese Frage: GraalVM.

Stay tuned

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

 


Links & Literatur

[1] https://blogs.oracle.com/java/post/for-building-programs-that-run-faster-anywhere-oracle-graalvm-enterprise-edition

[2] https://www.graalvm.org/

[3] https://github.com/oracle/graal/issues/2648#issuecomment-788780365

[4] https://www.graalvm.org/latest/reference-manual/native-image/dynamic-features/

[5] https://github.com/oracle/graalvm-reachability-metadata

[6] https://micronaut-projects.github.io/micronaut-aot/latest/guide/

[7] https://github.com/spring-projects-experimental/spring-native

[8] https://start.spring.io

[9] https://docs.spring.io/spring-boot/docs/current/reference/html/native-image.html#native-image.introducing-graalvm-native-images.understanding-aot-processing

[10] https://github.com/spring-projects/spring-boot/wiki/Spring-Boot-with-GraalVM

[11] https://www.youtube.com/watch?v=mitWK_DwKGs&t=1923s

[12] https://www.infoq.com/articles/native-java-aligning/

[13] https://youtu.be/TS4DpYSmfXk

[14] https://docs.spring.io/spring-boot/docs/current/reference/html/native-image.html

[15] Podcast mit Thomas Würthinger: https://poddtoppen.se/podcast/1296655154/airhacksfm-podcast-with-adam-bien/from-maxwell-over-maxine-to-graal-vm-substratevm-and-truffle

[16] https://youtu.be/h419kfbLhUI

[17] Würthinger, Thomas: GraalVM History, Status, Vision: https://www.graalvm.org/uploads/workshop-nov-2019/2019-11-25-GraalVMCommunityWorkshop-HistoryStatusVision.pdf

[18] Interview mit Thomas Würthinger: https://www.infoq.com/news/2018/04/oracle-graalvm-v1/

[19] https://openjdk.org/projects/graal/

[20] https://www.graalvm.org/

[21] https://maxine-vm.readthedocs.io/en/latest/#

[22] https://maciejwalkowiak.com/blog/spring-boot-3-native-image-not-a-free-lunch/

The post Welche Möglichkeiten bietet GraalVM in Spring? appeared first on JAX.

]]>
Java-Anwendungen mit Bordmitteln absichern https://jax.de/blog/java-anwendungen-mit-bordmitteln-absichern/ Tue, 03 Jan 2023 14:26:20 +0000 https://jax.de/?p=88195 Aufgabe der IT-Sicherheit ist es vor allem, nicht autorisierte Handlungen von Benutzern eines Computersystems zu verhindern und zu ermitteln. Sie befasst sich insbesondere mit Maßnahmen, die auf absichtliche Handlungen unterschiedlicher Parteien abzielen [1]. IT-Security ist immens wichtig geworden: Im Jahr 2021 wurde der Markt auf knapp 140 Milliarden US-Dollar beziffert; für das Jahr 2029 wird mit bis zu 376 Milliarden US-Dollar Umsatz gerechnet [2]. Folglich sind auch Fachkräfte wie IT-Sicherheitsingenieure oder -analysten sehr gefragt [3].

The post Java-Anwendungen mit Bordmitteln absichern appeared first on JAX.

]]>
Sein eigenes Sicherheitssystem zu basteln, birgt die Gefahr potenzieller Schwächen, die Hacker aufspüren können. Beispielsweise werden Passwörter als Klartext gespeichert oder versendet, die Leitung zwischen Client und Server ist nicht geschützt oder die Entwickler haben gar einen eigenen Verschlüsselungsalgorithmus beziehungsweise ihr eigenes Session-Management entwickelt.

Falls ihr keine IT-Sicherheitsexperten seid, solltet ihr daher lieber auf Altbewährtes setzen. Die Sicherheitsarchitektur von Java, genannt Java SE, ist eine gute Wahl [4]. Sie beinhaltet eine große Zahl an Schnittstellen, Tools und Unterstützung für häufig genutzte Sicherheitsalgorithmen, -mechanismen und -protokolle. Mit der Java-Sicherheitsarchitektur könnt ihr folgende Aufgaben realisieren:

  • Kryptografie

  • Öffentliche Schlüssel generieren

  • Sichere Kommunikation

  • Authentifizierung

  • Zugriffskontrolle

Java SE bringt zudem Erweiterungen sowie Dienste mit, die beim Entwickeln einer sicheren Anwendung unterstützen. Hierzu zählen insbesondere:

  • JSSE (Java Secure Socket Extension): stellt SSL-bezogene Dienste zur Verfügung

  • JCE (Java Cryptography Extension): kryptografische Dienste

  • JAAS (Java Authentication and Authorization Service): Dienste, die sich mit Zugangskontrollen und Authentifizierung beschäftigen

Um eine Anwendung auf sicherheitsbezogene Ereignisse hin zu untersuchen, übergebt ihr dem Java-Befehl das folgende Argument:

-Djava.security.debug

Authentifizierung

Derzeit stehen drei Module für die Authentifizierung unter Java zur Verfügung:

  • Krb5LoginModule: Authentifizierung mittels Kerberos-Protokoll

  • JndiLoginModule: Anmeldung mit Benutzername und Passwort, indem LDAP- oder NIS- Datenbanken zum Einsatz kommen

  • KeyStoreLoginModule: Anmeldung am PKCS-#11-Schlüsselspeicher

Falls ihr euch für das geläufige Verfahren mit Benutzername und Passwort entscheidet, um die Anmeldung der Benutzer zu sichern, dann benutzt am besten das JndiLoginModule. Hierbei könnt ihr auf die Klasse LoginContext zurückgreifen, die ihr wie folgt initialisiert [5]:

LoginContext loginContext = new LoginContext("Sample", new SampleCallbackHandler());
loginContext.login();

Wichtig ist, dass ihr dem Log-in-Handler einen Konfigurationsnamen sowie den CallbackHandler übergebt. Der CallbackHandler legt fest, ob die Eingabeaufforderungen für den Benutzernamen und das Kennwort nacheinander erscheinen oder beides in einem einzigen Fenster eingeblendet wird [6]. In der Konfigurationsdatei jaas.conf gebt ihr dann das erforderliche Log-in-Modul ein, mit dem eure Anwendung die Authentifizierung vornimmt. In der Regel lässt sich das Log-in-Modul mit nur wenigen Zeilen konfigurieren:

Sample {
  com.sun.security.auth.module.JndiLoginModule required;
};

Beim Starten der Java-Anwendung sind weitere Argumente erforderlich. So müssen der SecurityManager ex-tra aufgerufen und der Pfad zur Konfigurationsdatei jaas.conf angegeben werden:

java -Djava.security.manager -Djava.security.auth.login.config=/Pfad/zu/jaas.conf

Wem das nicht ausreicht, der kann auf weitere vordefinierte Log-in-Module [7] der Software AG zurückgreifen [8]. Zudem existieren Bibliotheken, die eine Implementierung mit geringerer Komplexität erlauben. So basiert etwa Apache Shiro auf der Java Security, vereinfacht jedoch die Konfiguration sowie Umsetzung von Authentifizierung, Autorisierung, Kryptografie und Benutzermanagement [9].

Stay tuned

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

 

Zugriffskontrolle

Aber auch mit der Java Security kann eine Autorisierung für Benutzer implementiert werden. Hierbei ist eine Konfigurationsdatei mit der Endung .policy erforderlich, die sich mit policytool aus der JDK-Umgebung erstellen lässt (Abb. 1). Laut der Dokumentation des Java Security API läuft policytool zwar demnächst ab, doch gibt es derzeit keinen guten Ersatz dafür. policytool verfügt über eine grafische Oberfläche und lässt sich durch Eingabe des Befehls policytool auf der Konsole starten. Per Klick auf Policy-Eintrag hinzufügen öffnet sich ein neues Fenster, in dem sich unter anderem eine Berechtigung definieren lässt. Eine neue Berechtigung erzeugt man per Klick auf Berechtigung hinzufügen. Im Fenster Berechtigungen wird zunächst die Art der Berechtigung ausgewählt, in unserem Beispiel handelt es sich dabei um eine FilePermission, also Dateiberechtigung. Sie lässt sich auf eine bestimmte Datei oder auf alle Dateien unter Zielname anwenden. Bei Aktionen können zulässige Dateirechte wie lesen oder schreiben ausgewählt werden. Das Feld signiert von kann leer sein.

Abb. 1: Berechtigungen mit policytool erstellen

Nachdem alle Einstellungen vorgenommen wurden, kann die .policy-Datei mit Datei | Speichern abgespeichert werden:

/* AUTOMATICALLY GENERATED ON Mon Sep 12 18:46:47 CEST 2022*/
/* DO NOT EDIT */
grant {
  permission java.io.FilePermission "<<ALL FILES>>", "read,write";
};

Die konkrete Umsetzung der Zusatzkontrolle mit Java-Bordmitteln lässt sich bewerkstelligen wie in Listing 1 gezeigt. Wichtig ist, dem Java-Befehl beim Starten des Java-Programms folgende Argumente zu übergeben:

-Djava.security.manager -Djava.security.policy=/Pfad/zu/Datei.policy

Um die Ressourcen vom SecurityManager beschützen zu lassen, muss dieser zunächst initialisiert werden. Anschließend könnt ihr mittels der Methode securityManager.checkPermission überprüfen, ob beispielsweise ein Benutzer eine Datei löschen darf.

SecurityManager SecurityManager = System.getSecurityManager();
String path = "files/test.txt";
try {
  if (securityManager != null) {
    securityManager.checkPermission(new FilePermission(path, "delete"));
    System.out.println("it seems as if you're allowed to delete it");
  }
} catch (AccessControlException e) {
  System.out.println("Not allowed to perform this action: "+e.getMessage());
}

Geheimschlüssel

Werden Nachrichten, Benutzeranmeldedaten und weitere sensible Daten unverschlüsselt über das Netzwerk verschickt, haben Hacker leichtes Spiel. Die Kryptografie wird in Java daher sehr ernst genommen. Speziell das Framework Java Cryptography Architecture (JCA) wurde daher in die JDK- beziehungsweise JRE-Umgebung integriert [10]. Das JCA-Framework kann nützlich sein, wenn ihr euch unter anderem mit den folgenden Themen beschäftigen wollt:

  • digitale Signaturen

  • Message Digests (MD): kryptografische Hashfunktion [11]

  • Zertifikate sowie Zertifikatsvalidierung

  • Verschlüsselung (symmetrische/asymmetrische Block- und Stromchiffren)

  • Generierung sowie Verwaltung von Schlüsseln

  • sichere Zufallszahlengenerierung

Verschlüsselungsalgorithmen, die die Vertraulichkeit von Daten schützen, können entweder symmetrischer oder asymmetrischer Natur sein. So handelt es sich bei Geheimschlüsseln (Secret Keys) um symmetrische Algorithmen, da derselbe Schlüssel sowohl zur Verschlüsselung als auch zur Entschlüsselung benutzt wird. Anders verhält es sich bei asymmetrischen Algorithmen, bei denen unterschiedliche Schlüssel für die Verschlüsselung und die Entschlüsselung zum Einsatz kommen [1].

SIE LIEBEN JAVA?

Den Core-Java-Track entdecken

 

Ein Beispiel für symmetrische Algorithmen stellt der „Data Encryption Standard“ (DES) dar. Obwohl DES bereits in den 1970ern entwickelt und mittlerweile von diversen fortgeschrittenen Standards überholt wurde, ist er immer noch in Gebrauch. DES kommt vor allem im kommerziellen sowie im Finanzsektor zum Einsatz. Die eigentliche Schlüsselgröße von DES beträgt zwar 56 Bit, allerdings hat sich heutzutage vor allem die Option Triple DES mit einer Schlüsselgröße von 3 x 56 Bit durchgesetzt [1].

import java.security.spec.KeySpec;
import javax.crypto.Cipher;
import javax.crypto.SecretKey;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.DESedeKeySpec;
import org.apache.commons.codec.binary.Base64;
 
public class TrippleDes {
 
  private static final String UNICODE_FORMAT = "UTF8";
  public static final String DESEDE_ENCRYPTION_SCHEME = "DESede";
  private KeySpec ks;
  private SecretKeyFactory skf;
  private Cipher cipher;
  byte[] arrayBytes;
  private String myEncryptionKey;
  private String myEncryptionScheme;
  SecretKey key;
 
  public TrippleDes(String sKey) throws Exception {
    myEncryptionKey = sKey;
    myEncryptionScheme = DESEDE_ENCRYPTION_SCHEME;
    arrayBytes = myEncryptionKey.getBytes(UNICODE_FORMAT);
    ks = new DESedeKeySpec(arrayBytes);
    skf = SecretKeyFactory.getInstance(myEncryptionScheme);
    cipher = Cipher.getInstance(myEncryptionScheme);
    key = skf.generateSecret(ks);
  }
  public String encrypt(String unencryptedString) {
    String encryptedString = null;
    try {
      cipher.init(Cipher.ENCRYPT_MODE, key);
      byte[] plainText = unencryptedString.getBytes(UNICODE_FORMAT);
      byte[] encryptedText = cipher.doFinal(plainText);
      encryptedString = new String(Base64.encodeBase64(encryptedText));
    } catch (Exception e) {
      e.printStackTrace();
    }
    return encryptedString;
  }
 
  public String decrypt(String encryptedString) {
    String decryptedText=null;
    try {
      cipher.init(Cipher.DECRYPT_MODE, key);
      byte[] encryptedText = Base64.decodeBase64(encryptedString);
      byte[] plainText = cipher.doFinal(encryptedText);
      decryptedText= new String(plainText);
    } catch (Exception e) {
      e.printStackTrace();
    }
    return decryptedText;
  }
}

Unter Java könnt ihr Triple DES implementieren wie in Listing 2 dargestellt [12]. Triple DES wird im Konstruktor eingerichtet, dem der Geheimschlüssel als Argument im Klartext übergeben wird. Im weiteren Verlauf der Einrichtung kommt die SecretKeyFactory zum Einsatz, um die Byterepräsentation des Geheimschlüssels in ein SecretKey-Objekt umzuwandeln. Anhand der Implementierung der encrypt– und decrypt-Methoden lässt sich gut erkennen, dass die Entschlüsselung eine Umkehrfunktion der Verschlüsselung ist. Anschließend könnt ihr einen String wie folgt ver- und entschlüsseln:

TrippleDes triple = new TrippleDes("Caesarsalad!Caesarsalad!");
String encPw = triple.encrypt("password123");
String decPw = triple.decrypt(encPw);

Abhörsichere Leitung

Vertrauliche Kommunikation über einen Kanal wie das öffentliche Internet erfordert die Verschlüsselung von Daten. Aus diesem Grund ist es absolut notwendig, das Verschlüsselungsprotokoll Transport Layer Security (TLS), ehemals bekannt als Secure Socket Layer (SSL), einzusetzen. Andernfalls könnte ein Hacker in Versuchung geraten, einen Packet Sniffer einzusetzen, der den Netzwerkverkehr analysiert. Beispielsweise lassen sich sensible Daten wie Kreditkartennummern vor dem Versand unverschlüsselt speichern. Die einfachste Lösung, um weder den Server noch den Client mit einem zu Fehlern neigenden Verschlüsselungscode zu überladen, besteht darin, Secure Sockets einzusetzen.

Als Beispiel soll eine verschlüsselte Kommunikation zwischen Server und Client dienen [13]. Beide verwenden einen Secure Socket, wobei die Daten in Form von JSON-Daten gesendet und empfangen werden. Wie sich der Server, bestehend aus einem Secure Socket, realisieren lässt, zeigt Listing 3. Falls der Server Teil einer Client-/Server-Anwendung ist, sollte er die Thread-Klasse erweitern. Dadurch wird die Anwendung multitaskingfähig. So kann der Server parallel mit den angeschlossenen Clients kommunizieren, während er weitere Aufgaben erledigt.

import java.io.*;
import java.net.*;
import java.security.KeyManagementException;
import java.security.KeyStore;
import java.security.KeyStoreException;
import java.security.NoSuchAlgorithmException;
import java.security.UnrecoverableKeyException;
import java.security.cert.CertificateException;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.net.*;
import javax.net.ssl.KeyManagerFactory;
import javax.net.ssl.SSLContext;
import javax.net.ssl.SSLServerSocket;
import javax.net.ssl.SSLServerSocketFactory;
 
public class ServerConnection extends Thread {
  private final int port;
  private AtomicBoolean running = new AtomicBoolean(false);
  private ExecutorService pool;
 
  public ServerConnection(int p) {
    this.port = p;
    pool = Executors.newFixedThreadPool(10000);
  }
 
  public void run() {
    running.set(true);
    this.setupSecureSocket();
  }
 
  public void setupSecureSocket() {
    try {
      ServerSocketFactory factory = ServerSocketFactory.getDefault();
      ServerSocket socket = factory.createServerSocket(port);
      System.out.println("creating server socket");
      while (running.get()) {
        try {
          Socket s = socket.accept();
          Runnable r = new ServerThread(s);
          pool.submit(r);
          
        } catch (IOException ex) {
          System.out.println("Exception accepting connection");
        } catch (RuntimeException ex) {
          System.out.println("Unexpected error"+ex.getMessage());
        }
      }
      System.out.println("shutting down server");
      pool.shutdown();
      socket.close();
      
    } catch (BindException ex) {
      System.out.println("Could not start server. "+ex.getMessage());
    } catch (IOException ex) {
      System.out.println("Error opening server socket: "+ex.getMessage());
    }
  }
  
  public void interrupt() {
    running.set(false);
  }
  
  private boolean isRunning() {
    return running.get();
  }
}

In Listing 3 wird der Server initialisiert, indem die Portnummer als Argument übergeben wird. Außerdem wird mittels des ExecutorService-Objekts die Zahl der Threads festgelegt, die der Server verarbeiten kann. So können in diesem Beispiel bis zu 10 000 Clients mit dem Server verbunden werden.

Da die Klasse ServerConnection von der Thread-Klasse die Methoden und Attribute erbt, ist es zusätzlich erforderlich, die bekannte run-Methode zu überschreiben. In diesem Fall reicht es aus, die selbst definierte Methode setupSecureSocket() aufzurufen. Dort wird zunächst der Secure Socket des Servers initialisiert, was dazu führt, dass der Server auf einem sicheren Kanal mit der Portnummer XY lauscht. Solange die Variable running vom Typ AtomicBoolean den Wert true aufweist, kann der Server neue Clients zulassen, indem jeder Client als ein Runnable-Objekt zum Pool hinzugefügt wird. Die Klasse ServerConnection stellt weitere Hilfsmethoden zur Verfügung, mit denen sich der Server steuern lässt. So kann mittels Aufruf der Methode interrupt() jederzeit das Verwalten der Clients unterbrochen werden. Der Aufruf der Methode isRunning() zeigt an, ob der Server noch läuft.

Stay tuned

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

 

Bei der Anmeldung am Server wird jedem Client ein ServerThread-Objekt vom Typ Runnable zugeordnet (Listing 4). Die Implementierung der Runnable-Schnittstelle sorgt dafür, dass die run-Methode nicht mehr explizit aufgerufen werden muss. Zusätzlich wird beim Anlegen des ServerThread-Objekts der Socket des Clients übergeben, um damit den BufferedWriter sowie InputStreamReader zu initialisieren.

Der Aufruf von start() erfolgt in der überschriebenen run-Methode und sorgt dafür, dass der booleschen Variable shutdown der Wert false zugewiesen wird. Durch den anschließenden Aufruf von getIncomingData() wird der InputStreamReader gestartet, sodass in der while-Schleife konstant Nachrichten vom Client empfangen und zu einem JSON-Objekt geparst werden. Daneben beinhaltet die ServerThread-Klasse die Methode disconnect(), die die Verbindung zum Client kappt. Hierbei wird die boolesche Variable shutdown auf true gesetzt, was dazu führt, dass die while-Schleife unterbrochen wird.

import org.json.simple.JSONObject;
import org.json.simple.parser.JSONParser;
import org.json.simple.parser.ParseException;
import java.net.Socket;
import java.io.Writer;
import java.io.BufferedWriter;
import java.io.OutputStreamWriter;
import java.io.InputStreamReader;
import java.io.IOException;
import java.io.BufferedInputStream;
import java.io.Reader;
import java.io.BufferedReader;
 
public class ServerThread implements Runnable {
  private final Socket connection;
  private Writer out;
  private Reader in;
  private BufferedReader reader;
  private volatile boolean shutdown;
 
  public ServerThread(Socket s) {
    this.connection = s;
    
  }
 
  public void run() {
  
    try {
      this.out = new BufferedWriter(new OutputStreamWriter(connection.getOutputStream(), "US-ASCII"));
      
      System.out.println("client address: " + this.connection.getRemoteSocketAddress());
      {
        this.start();
        getIncomingData();
      } catch(IOException ex) {
        System.out.println("Error talking to " + this.connection.getRemoteSocketAddress()+" "+ex.getMessage());
      }
    }
    
    private void getIncomingData() {
      JSONParser parser = new JSONParser();
      
      try {
        this.in = new InputStreamReader(new BufferedInputStream(connection.getInputStream()));
        this.reader = new BufferedReader( this.in);
        String line = "";
        while(!shutdown){
          if ((line =  this.reader.readLine()) != null) {
            System.out.println("line: "+line);
            JSONObject json = (JSONObject) parser.parse(line);
            System.out.println("reading in json "+json);
          } else {
            this.in.close();
            disconnect();
          }
        }
        
      } catch(IOException ex) {
        System.out.println("could not read in data: " + ex.getMessage());
      } catch(ParseException ex) {
        System.out.println("could not parse JSONObject: " + ex.getMessage());
      }
    }
    
    public void disconnect() {
      try {
        this.connection.close();
        shutdown();
        System.out.println("closing client socket ");
      } catch (IOException ex) {
        System.out.println("could not close client socket " + ex.getMessage());
      }
    }
    
    public void shutdown() {
      shutdown = true;
    }
    
    public void start() {
      shutdown = false;
    }
  }
}

Auch beim Client muss ein Secure Socket erstellt werden, um darüber eine Verbindung zum Server aufzubauen. Die Umsetzung des Clients erfolgt durch die Klasse ClientConnection (Listing 5). So werden beim Erstellen des ClientConnection-Objekts sowohl die Portnummer als auch der Hostname des Servers als Argumente übergeben.

Das Herzstück der ClientConnection ist die connect-Methode, da dort der Secure Socket mittels SSLSocketFactory eingerichtet wird. Zudem werden dort sowohl der BufferedWriter, der zum Schreiben von Daten an den Server gebraucht wird, als auch der InputStream, über den Daten vom Server empfangen werden, initialisiert. Erwähnenswert ist in diesem Zusammenhang die Methode disconnect(), welche die Verbindung zum Server beendet. Durch den Aufruf von write() können Stringnachrichten an den Server verschickt werden.

import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.Writer;
import java.net.Socket;
import java.util.concurrent.atomic.AtomicBoolean;
import javax.net.SocketFactory;
import javax.net.ssl.SSLSocketFactory;
 
public class ClientConnection implements Runnable {
  private Socket socket;
  private OutputStream out;
  private Writer writer;
  private InputStream in;
  private String host = "";
  private int port = 0;
  private AtomicBoolean running = new AtomicBoolean(false);
 
  public ClientConnection(String host, int p) {
    this.host = host;
    this.port = p;
  }
  
  public void run() {
    read();
  }
 
  public void connect() {
    try {
      SocketFactory factory = SSLSocketFactory.getDefault();
      this.socket = factory.createSocket(host, port);
      this.socket.setSoTimeout(100000);
      this.out = this.socket.getOutputStream();
      this.writer = new BufferedWriter(new OutputStreamWriter(this.out, "UTF-8"));
      this.in = this.socket.getInputStream();
      System.out.println("Could establish connection to server.");
    } catch (IOException ex) {
      System.out.println("Could not connect to server."+ex.getMessage());
    }
  }
  
  public void write(String msg) {
    try {
      if(this.writer != null) {
      this.writer.write(msg);
      this.writer.flush();
    } else {
      System.out.println("Could not write to server.");
    }
  } catch (IOException ex) {
    System.out.println("Could not write to server. "+ex.getMessage());
  }
}
 
public void disconnect() {
  if (this.socket != null) {
    try {
      this.socket.close();
      System.out.println("disconnected from server");
    } catch (IOException ex) {
      System.out.println("could not disconnect.");
    }
  }
}
 
public boolean isConnected() {
  if ( this.socket != null ) {
    System.out.println("connected to server");
    return true;
  } else {
    System.out.println("not connected to server");
    return false;
  }
}
 
public void read() {
  //TODO: read in from server
  }
}

Die hier vorgestellte Client-/Serveranwendung kann anschließend in der Main-Methode aufgerufen werden. Den Server initialisiert ihr dabei unter Angabe der Portnummer wie folgt:

ServerConnection server = new ServerConnection(9200);
server.run();

Genauso wird der Client gestartet, außer das hier noch ein kleiner Test stattfindet. So versendet der Client persönliche Daten als JSON-String (Listing 6).

ClientConnection client = new ClientConnection("127.0.0.1", 9200);
client.connect();
 
JSONObject jObj = new JSONObject();
jObj.put("Name", "Max Mustermann");
jObj.put("Product-ID", "67x-89");
jObj.put("Address", "1280 Deniston Blvd, NY NY 10083");
jObj.put("Card Number", "4000-1234-5678-9017");
jObj.put("Expires", "10/29");
try {
  TimeUnit.SECONDS.sleep(30);
} catch (InterruptedException e) {
  e.printStackTrace();
}
client.writeToServer(jObj.toJSONString()+"\n");

Trotz des einfach gehaltenen Codes werden die vertraulichen Daten verschlüsselt an den Server gesendet. Überprüfen könnt ihr das unter Linux mit dem Kommandozeilentool Tcpflow [14]. Denn Tcpflow ist eine Art Packet Sniffer, der den gesamten oder auch einen Teil des TCP-Verkehrs aufzeichnet und in eine einfache Datei schreibt. Um beispielsweise den Port mittels Tcpflow abzuhören, der für dieses Beispielprogramm benutzt wird, verwendet ihr folgenden Befehl:

tcpflow -i any port 9200

Beim Aufruf von Tcpflow werden ein oder mehrere Dateien angelegt, welche die Daten beinhalten, die zwischen Client und Server gesendet worden sind. Dabei ähneln die Dateinamen dem folgenden Beispiel:

127.000.000.001.38226-127.000.000.001.09200

Unschwer zu erkennen ist, dass Daten vom Client mit der Portnummer 38226 an den Server mit der Portnummer 9200 gesendet worden sind. Schaut ihr euch allerdings den aufgezeichneten Netzwerkverkehr im Texteditor an, so werdet ihr abgesehen von kryptischen Symbolen keinerlei persönliche Daten entdecken (Abb. 2). Allerdings kann auch der implementierte Server mit den verschlüsselten Daten nichts anfangen, was daran liegt, dass der Server nicht weiß, welches Verschlüsselungsverfahren zum Einsatz kommt.

Abb. 2: Aufzeichnung verschlüsselter Daten

Zertifikatehandel

Der Server aus Listing 3 hat zwar den Vorteil, dass er relativ unkompliziert umsetzbar ist, allerdings unterstützt die factory, die von ServerSocketFactory.getDefault() zurückgegeben wird, lediglich die Authentifizierung [15]. Um die Verschlüsselung serverseitig zu implementieren, sind mehr Codezeilen nötig, was die Implementierung des Servers komplexer macht.

Zunächst legt ihr einen Keystore, einen Truststore sowie ein Zertifikat für den Server an. Genauso geht ihr beim Client vor, was allerdings nicht erforderlich ist, falls der Client allen Zertifikaten des Servers vertraut. Unter Linux stehen euch für solche Zwecke die Kommandozeilentools keytool sowie openssl zur Verfügung [16]:

  1. privaten RSA-Schlüssel generieren: $ openssl genrsa -out diagserverCA.key 2048

  2. x509-Zertifikat erstellen: $ openssl req -x509 -new -nodes -key diagserverCA.key -sha256 -days 1024 -out diagserverCA.pem

  3. PKCS12 Keystore anhand des zuvor generierten Privatschlüssels und Zertifikats erzeugen: $ openssl pkcs12 -export -name server-cert -in diagserverCA.pem -inkey diagserverCA.key -out serverkeystore.p12

  4. PKCS12-Keystore in einen Keystore vom Format JKS konvertieren: $ keytool -importkeystore -destkeystore server.keystore -srckeystore serverkeystore.p12 -srcstoretype pkcs12 -alias server-cert

  5. falls der Client mit Zertifikaten arbeitet, das Clientzertifikat zum Truststore des Servers hinzufügen: $ keytool -import -alias client-cert -file ../client/diagclientCA.pem -keystore server.truststore

  6. Serverzertifikat zum Truststore des Servers hinzufügen: keytool -import -alias server-cert -file diagserverCA.pem -keystore server.truststore

Anschließend ist es erforderlich, die Methode setup-SecureSocket() aus Listing 3 durch den Code aus Listing 7 zu ersetzen. Dabei definiert ihr zunächst den SSLContext (siehe setupSSLContext()), indem ihr eine TrustManagerFactory erstellt. Allerdings kann die TrustManagerFactory dem Wert 0 entsprechen, sofern eine KeyManagerFactory unter Angabe des Schlüsseltyps erzeugt wird. Dann erzeugt ihr ein KeyStore-Objekt vom Typ „JKS“ und befüllt es mit dem Server-Keystore, den ihr zuvor mit keytool erzeugt habt. Zusätzlich initialisiert ihr die KeyManagerFactory unter Angabe des Keystores und des dazugehörigen Passworts. Zu guter Letzt initialisiert ihr den SSLContext, indem ihr den Key-Manager vom Typ KeyManagerFactory, den Trust-Manager vom Typ TrustManagerFactory sowie eine sichere Zufallszahl, die SecureRandomNumber, dem SSLContext als Argumente übergebt. Die letzten beiden Argumente können null sein, falls die Standardeinstellungen ausreichen.

MEHR PERFORMANCE GEFÄLLIG?

Performance & Security-Track entdecken

 

Zusätzlich braucht der Server einen Secure-Server-Socket, um darüber mit den Clients zu kommunizieren. Hierfür kommt eine SSLServerSocketFactory zum Einsatz, womit sich unter Angabe der Portnummer ein SSLServerSocket-Objekt erstellen lässt (siehe setupSSLSocket()). Außerdem lässt sich der Server-Socket weiter einrichten. So brauchen sich Clients nicht zu authentifizieren (vgl. setNeedClientAuth()).

Die letzte Konfiguration betrifft die Verschlüsselungsalgorithmen, die der Server unterstützt. In der Methode setupCipher() werden zunächst die von der aktuellen Java-Umgebung unterstützten Verschlüsselungssuiten geladen. Danach wird durch all diese Verschlüsselungssuiten iteriert, indem nur jene ausgewählt werden, die anonyme und unbestätigte Verbindungen zum SSLServerSocket zulassen. Das ist dann der Fall, wenn im Namen der Suite der String anon vorkommt. Das neue Array wird anschließend der Methode setEnabled-CipherSuites() übergeben.

(..)
private static final String SERVER_KEYSTORE = "server/server.keystore";
(..)
 
public void setupSSLSocket() {
  SSLContext context = setupSSLContext();
  try {
    SSLServerSocketFactory factory = context.getServerSocketFactory();
    SSLServerSocket socket = (SSLServerSocket) factory.createServerSocket(port);
    socket.setNeedClientAuth(false);
    socket.setWantClientAuth(false);
    setupCipher(socket);
    System.out.println("creating server socket");
    while (running.get()) {
      try {
        Socket s = socket.accept();
        Runnable r = new ServerThread(s);
        pool.submit(r);
      } catch (IOException ex) {
        System.out.println("Exception accepting connection");
      } catch (RuntimeException ex) {
        System.out.println("Unexpected error"+ex.getMessage());
      }
    }
    System.out.println("shutting down server");
    pool.shutdown();
    socket.close();
  } catch (BindException ex) {
    System.out.println("Could not start server. "+ex.getMessage());
  } catch (IOException ex) {
    System.out.println("Error opening server socket: "+ex.getMessage());
  }
}
 
public SSLContext setupSSLContext() {
  SSLContext context = null;
  try {
    context = SSLContext.getInstance(“SSL”);
    KeyManagerFactory kmf =KeyManagerFactory.getInstance("SunX509");
    KeyStore ks = KeyStore.getInstance("JKS");
    char [] pw = "boguspw".toCharArray();
    ks.load( new FileInputStream(SERVER_KEYSTORE), pw);
    kmf.init(ks, pw);
    context.init(kmf.getKeyManagers(), null, null);
  } catch (NoSuchAlgorithmException e) {
    System.out.println("wrong algorithm: "+e.getMessage());
    e.printStackTrace();
  } catch (KeyStoreException e) {
    System.out.println("wrong keystore algo: "+e.getMessage());
    e.printStackTrace();
  } catch (CertificateException e) {
    System.out.println("certificate invalid: "+e.getMessage());
    e.printStackTrace();
  } catch (UnrecoverableKeyException e) {
    System.out.println("wrong password for keystore: "+e.getMessage());
    e.printStackTrace();
  } catch (KeyManagementException e) {
    System.out.println("could not initialize context: "+e.getMessage());
    e.printStackTrace();
  } catch (FileNotFoundException e) {
    System.out.println("file not found: "+e.getMessage());
    e.printStackTrace();
  } catch (IOException e) {
    e.printStackTrace();
  }
  return context;
}
 
// anonyme Verschlüsselungssuiten hinzufügen
  public void setupCipher(SSLServerSocket socket) {
    String [] supported = socket.getSupportedCipherSuites();
    String [] anonCipherSuitesSupported = new String [supported.length];
    int numAnonCipherSuitesSupported = 0;
    for (int i = 0; i < supported.length; i++) {
      if(supported[i].indexOf("_anon_") > 0 ) {
        anonCipherSuitesSupported[numAnonCipherSuitesSupported++] = supported[i];
      }
    }
    String [] oldEnabled = socket.getEnabledCipherSuites();
    String [] newEnabled = new String[oldEnabled.length + numAnonCipherSuitesSupported];
    System.arraycopy(oldEnabled, 0, newEnabled, 0, oldEnabled.length);
    System.arraycopy(anonCipherSuitesSupported, 0, newEnabled, oldEnabled.length, numAnonCipherSuitesSupported);
    socket.setEnabledCipherSuites(newEnabled);
  }
(...)

Darüber hinaus braucht der Client einen Socket, der mit dem SSLServerSocket kompatibel ist. In der connect-Methode der Klasse ClientConnection (siehe Listing 5) ersetzt ihr zunächst den Client-Socket durch die folgenden Zeilen:

SSLContext context = setupSSLContext();
SSLSocketFactory socketFactory = context.getSocketFactory();
SSLSocket socket = (SSLSocket) socketFactory.createSocket(host, port);

Außerdem stellt ihr beim Client ein, welche Zertifikate akzeptiert werden. So erlaubt die selbstdefinierte Methode setupSSLContext() alle eingehenden Zertifikate (Listing 8) [17].

// alle Zertifikate akzeptieren
public SSLContext setupSSLContext() {
  SSLContext sc = null;
  TrustManager[] trustAllCerts = new TrustManager[] { new X509TrustManager() {
    public void checkClientTrusted(X509Certificate[] chain, String authType) throws CertificateException {
      // TODO Auto-generated method stub
    }
    public void checkServerTrusted(X509Certificate[] chain, String authType) throws CertificateException {
      // TODO Auto-generated method stub
    }
 
    public X509Certificate[] getAcceptedIssuers() {
      // TODO Auto-generated method stub
      return null;
    }
} };
 
// Trust-Manager installieren
try {
  sc = SSLContext.getInstance("SSL");
  sc.init(null, trustAllCerts, new java.security.SecureRandom());
} catch (NoSuchAlgorithmException e) {
  e.printStackTrace();
} catch (KeyManagementException e) {
  e.printStackTrace();
}
return sc;

Dank der eingerichteten Verschlüsselung kann der Server die verschlüsselte Kreditkarteninformation entschlüsseln und weiterverarbeiten (Abb. 3).

Abb. 3: Ausgabe von verschlüsselten Daten in lesbarer Form

XML-Signaturen

Daten, die in XML-Dateien exportiert und über das Internet versendet werden, sind ebenfalls von Missbrauch bedroht. Um ihre Integrität zu gewährleisten, könnt ihr den Empfehlungen des W3C folgen. Denn XML-Signaturen eignen sich zur Sicherung von Daten jeglicher Art. Hierfür stellt das Java API das Paket java.xml.crypto zur Verfügung. Damit unterstützt es die Erzeugung und Validierung von XML-Signaturen gemäß den empfohlenen Richtlinien.

Stay tuned

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

 

In der Praxis lässt sich ein XML-Dokument wie in Listing 9 implementieren [18]. Hierbei wird der Klasse MySignature sowohl der Pfad des unsignierten als auch der des neuen XML-Dokuments übergeben. Die selbstdefinierte Methode createSignature() signiert das XML-Dokument in drei Schritten [19]:

  1. Schlüsselpaar bestehend aus privatem und öffentlichem Schlüssel generieren

  2. XML-Dokument importieren (siehe MySignature.importXML())

  3. Original-XML-Dokument mittels des Schlüsselpaars signieren und ein weiteres XML-Dokument zusammen mit der digitalen Signatur erzeugen

Mittels der Klasse aus Listing 9 lässt sich ein bestehendes XML-Dokument wie folgt signieren:

import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.security.InvalidAlgorithmParameterException;
import java.security.KeyException;
import java.security.KeyPair;
import java.security.KeyPairGenerator;
import java.security.NoSuchAlgorithmException;
import java.util.Collections;
import java.util.Iterator;
 
import javax.xml.crypto.MarshalException;
import javax.xml.crypto.dsig.CanonicalizationMethod;
import javax.xml.crypto.dsig.DigestMethod;
import javax.xml.crypto.dsig.Reference;
import javax.xml.crypto.dsig.SignatureMethod;
import javax.xml.crypto.dsig.SignedInfo;
import javax.xml.crypto.dsig.Transform;
import javax.xml.crypto.dsig.XMLSignature;
import javax.xml.crypto.dsig.XMLSignatureException;
import javax.xml.crypto.dsig.XMLSignatureFactory;
import javax.xml.crypto.dsig.dom.DOMSignContext;
import javax.xml.crypto.dsig.dom.DOMValidateContext;
import javax.xml.crypto.dsig.keyinfo.KeyInfo;
import javax.xml.crypto.dsig.keyinfo.KeyInfoFactory;
import javax.xml.crypto.dsig.keyinfo.KeyValue;
import javax.xml.crypto.dsig.spec.C14NMethodParameterSpec;
import javax.xml.crypto.dsig.spec.TransformParameterSpec;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.parsers.ParserConfigurationException;
import javax.xml.transform.Transformer;
import javax.xml.transform.TransformerConfigurationException;
import javax.xml.transform.TransformerException;
import javax.xml.transform.TransformerFactory;
import javax.xml.transform.dom.DOMSource;
import javax.xml.transform.stream.StreamResult;
 
import org.w3c.dom.Document;
import org.w3c.dom.NodeList;
import org.xml.sax.SAXException;
 
public class MySignature {
  private String path = "";
  private String output = "";
  public MySignature(String p,String dest) {
    this.path = p;
    this.output = dest;
  }
  public void createSignature() {
    XMLSignatureFactory fac = XMLSignatureFactory.getInstance("DOM");
    try {
      Reference ref = fac.newReference("", fac.newDigestMethod(DigestMethod.SHA384, null), Collections.singletonList (fac.newTransform (Transform.ENVELOPED, (TransformParameterSpec) null)),null, null);
      // SignedInfo-Element erstellen
      SignedInfo si = fac.newSignedInfo(fac.newCanonicalizationMethod(Canoni-calizationMethod.INCLUSIVE, (C14NMethodParameterSpec) null), fac.newSignatureMethod(SignatureMethod.RSA_SHA384, null), Collections.singletonList(ref));
        KeyPairGenerator kpg = KeyPairGenerator.getInstance("RSA");
        kpg.initialize(2048);
        KeyPair kp = kpg.generateKeyPair();
        // KeyValue mit DSA-PublicKey erstellen
        KeyInfoFactory kif = fac.getKeyInfoFactory();
        KeyValue kv = kif.newKeyValue(kp.getPublic());
        KeyInfo ki = kif.newKeyInfo(Collections.singletonList(kv));
        // XML-Dokument importieren
        Document doc = importXML(path);
        // DOMSignContext erstellen und den DSA-PrivateKey
        // sowie den Ort des Eltern-Elements angeben
        DOMSignContext dsc = new DOMSignContext(kp.getPrivate(), doc.getDocumentElement());
        // XMLSignatur-Objekt anlegen
        XMLSignature signature = fac.newXMLSignature(si, ki);
        // Kuvertierte Signatur zusammenstellen und generieren
        signature.sign(dsc);
        // neues Dokument abspeichern
        OutputStream os = new FileOutputStream(this.output);
        TransformerFactory tf = TransformerFactory.newInstance();
        Transformer trans = tf.newTransformer();
        trans.transform(new DOMSource(doc), new StreamResult(os));
    } catch (..)
    // TODO: Ausnahmen hinzufügen
  }
}
// XML-Signatur überprüfen
public boolean isXmlDigitalSignatureValid() throws Exception {
  boolean validFlag = false;
  Document doc = importXML(this.output);
  NodeList nl = doc.getElementsByTagNameNS(XMLSignature.XMLNS, "Signature");
  if (nl.getLength() == 0) {  
    throw new Exception("No XML Digital Signature Found, document is discarded");  
    }
    // DOMValidateContext-Objekt anlegen und einen KeyValue-KeySelector
    // sowie Kontext des Dokuments angeben
    DOMValidateContext valContext = new DOMValidateContext(new KeyValueKeySelector(), nl.item(0));  
    XMLSignatureFactory fac = XMLSignatureFactory.getInstance("DOM");  
    XMLSignature signature = fac.unmarshalXMLSignature(valContext);  
    validFlag = signature.validate(valContext);  
    if (validFlag == false) {
      System.err.println("Signature failed core validation");
      boolean sv = signature.getSignatureValue().validate(valContext);
      System.out.println("signature validation status: " + sv);
      // den Validierungsstatus jeder Referenz prüfen
      Iterator i = signature.getSignedInfo().getReferences().iterator();
      for (int j=0; i.hasNext(); j++) {
        boolean refValid = ((Reference) i.next()).validate(valContext);
        System.out.println("ref["+j+"] validity status: " + refValid);
      }
    } else {
      System.out.println("Signature passed core validation");
    }
    
    return validFlag;
    }
 
    // XML-Dokument importieren
    public Document importXML(String sPath) {
      DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
      dbf.setNamespaceAware(true);
      Document doc = null;
      try {
        doc = dbf.newDocumentBuilder().parse(new FileInputStream(sPath));
      } catch (SAXException | IOException | ParserConfigurationException e) {
      e.printStackTrace();
    }
    return doc;
  }
}
String path = "files/music-jaxb-en.xml";
String signedXML = "files/envelopedSignature.xml";
MySignature xmlSecurity = new MySignature(path,signedXML);
xmlSecurity.createSignature();

Abbildung 4 zeigt das signierte XML-Dokument, bei dem die digitale Signatur innerhalb des Wurzeltags eingefügt wird. Der hierfür verwendete Algorithmus, genannt RSA-SHA384, orientiert sich dabei an den Vorgaben von W3C.

Abb. 4: Signiertes XML-Dokument

Darüber hinaus lässt sich die Signatur des XML-Dokuments auf Richtigkeit hin validieren. Die selbst definierte Methode aus Listing 9, genannt isXmlDigitalSignatureValid(), lädt zunächst den Inhalt des Signature-Tags und übergibt diesen zusammen mit dem KeyValueKeySelector-Objekt (Listing 10) dem DOMValidateContext. Dann werden die eingelesenen XML-Knoten in Objekte umgewandelt und als XMLSignature-Objekt gespeichert. Das XMLSignature-Objekt bekommt anschließend das zuvor erstellte DOMValidateContext-Objekt übergeben. Die validate-Methode gibt true zurück, falls die Signatur laut den Regeln des W3C erfolgreich validiert worden ist; ansonsten gibt sie false zurück.

In Listing 10 ist der KeySelector definiert, der den öffentlichen Schlüssel aus dem KeyValue-Element abruft und ihn zurückgibt. Wenn der Schlüsselalgorithmus nicht mit dem Signaturalgorithmus übereinstimmt, wird der öffentliche Schlüssel ignoriert.

import java.security.KeyException;
import java.security.PublicKey;
import java.util.List;
import javax.xml.crypto.AlgorithmMethod;
import javax.xml.crypto.KeySelector;
import javax.xml.crypto.KeySelectorException;
import javax.xml.crypto.KeySelectorResult;
import javax.xml.crypto.XMLCryptoContext;
import javax.xml.crypto.XMLStructure;
import javax.xml.crypto.dsig.SignatureMethod;
import javax.xml.crypto.dsig.keyinfo.KeyInfo;
import javax.xml.crypto.dsig.keyinfo.KeyValue;
 
public class KeyValueKeySelector extends KeySelector {
 
  public KeySelectorResult select(KeyInfo keyInfo,KeySelector.Purpose purpose,AlgorithmMethod method,XMLCryptoContext context)throws KeySelectorException {
 
    if (keyInfo == null) {
      throw new KeySelectorException("Null KeyInfo object!");
    }
    SignatureMethod sm = (SignatureMethod) method;
    List list = keyInfo.getContent();
 
    for (int i = 0; i < list.size(); i++) {
      XMLStructure xmlStructure = (XMLStructure) list.get(i);
      if (xmlStructure instanceof KeyValue) {
        PublicKey pk = null;
        try {
          pk = ((KeyValue)xmlStructure).getPublicKey();
        } catch (KeyException ke) {
          throw new KeySelectorException(ke);
        }
        // prüfen, ob der Algorithmus kompatibel mit der Methode ist
        if (algEquals(sm.getAlgorithm(), pk.getAlgorithm())) {
          return new SimpleKeySelectorResult(pk);
        }
      }
    }
    throw new KeySelectorException("No KeyValue element found!");
  }
 
  private static boolean algEquals(String algURI, String algName) {
    if (algName.equalsIgnoreCase("DSA") && algURI.equalsIgnoreCase("http://www.w3.org/2009/xmldsig11#dsa-sha256")) {
      return true;
    } else if (algName.equalsIgnoreCase("RSA") && algURI.equalsIgnoreCase("http://www.w3.org/2001/04/xmldsig-more#rsa-sha384")) {
      return true;
    } else {
      return false;
    }
  }
}

Das in Listing 10 angelegte KeyValueKeySelector-Objekt wird benötigt, um Schlüssel für die Validierung der digitalen Signatur zu finden. Alternativ kann direkt ein öffentlicher Schlüssel angegeben werden. Allerdings ist es oft nicht vorhersehbar, welcher Schlüssel passt.

 

Der KeyValueKeySelector (Listing 10) leitet die abstrakte Klasse KeySelector ab. Er versucht, einen angemessenen öffentlichen Schlüssel zu ermitteln, indem er auf Daten der KeyValue-Elemente aus dem KeyInfo-Tag eines XMLSignature-Objekts zurückgreift. Diese Implementierung überprüft nicht, ob der Schlüssel verlässlich ist. Sobald der KeyValueKeySelector einen geeigneten öffentlichen Schlüssel findet, wird dieser über das SimpleKeySelectorResult-Objekt zurückgegeben (Listing 11).

import java.security.Key;
import java.security.PublicKey;
 
import javax.xml.crypto.KeySelectorResult;
 
public class SimpleKeySelectorResult implements KeySelectorResult {
  private PublicKey pk;
  
  public SimpleKeySelectorResult(PublicKey pk) {
    this.pk = pk;
  }
  
  public Key getKey() {
    return pk;
  }
  
}

In Listing 12 könnt ihr sehen, wie sich die selbst definierte Klasse MySignature initialisieren und ausführen lässt.

String path = "files/music-jaxb-en.xml";
String signedXML = "files/envelopedSignature.xml";
MySignature xmlSecurity = new MySignature(path,signedXML);
xmlSecurity.createSignature();
try {
  xmlSecurity.isXmlDigitalSignatureValid();
} catch (Exception e) {
  e.printStackTrace();
}

 

Links & Literatur

[1] Gollmann, D.: „Computer Security“; Wiley, 2011

[2] Marktgröße: https://www.fortunebusinessinsights.com/industry-reports/cyber-security-market-101165

[3] Gehälter: https://mondo.com/blog-highest-paid-cybersecurity-jobs/

[4] Java SE: https://docs.oracle.com/javase/9/security/toc.htm

[5] Basics of Java Security: https://www.baeldung.com/java-security-overview

[6] CallbackHandler: https://docs.oracle.com/javase/7/docs/api/javax/security/auth/callback/CallbackHandler.html

[7] webMethods LoginModule: https://documentation.softwareag.com/webmethods/wmsuites/wmsuite9-7/Security_Infrastructure/9-7_Security_Infrastructure/using/modules.htm

[8] webMethods Integration: https://tech.forums.softwareag.com/pub/webmethods-integration-free-trial-download-thank-you

[9] Apache Shiro: https://shiro.apache.org

[10] JCA: https://docs.oracle.com/javase/9/security/java-cryptography-architecture-jca-reference-guide.htm

[11] Message Digest: https://docs.racket-lang.org/crypto/digest.html

[12] Triple DES unter Java: https://stackoverflow.com/questions/20227/how-do-i-use-3des-encryption-decryption-in-java

[13] Secure Socket: https://www.ibm.com/docs/en/i/7.2?topic=jsse-changing-your-java-code-use-secure-sockets-layer

[14] Tcpflow: https://www.technomancy.org/networking/tcpflow-howto-see-network-traffic/

[15] Harold, E. R.: „Java Network Programming“; O’Reilly, 2014

[16] Keystore, Truststore, Zertifikate: https://unix.stackexchange.com/questions/347116/how-to-create-keystore-and-truststore-using-self-signed-certificate

[17] SSLContext: http://www.java2s.com/example/java/security/trust-all-certificate.html

[18] XML-Signatur: https://docs.oracle.com/javase/9/security/java-xml-digital-signature-api-overview-and-tutorial.htm

[19] XML-Signatur-Tutorial: https://www.c-sharpcorner.com/UploadFile/5fd9bd/xml-digital-signature-in-java/

The post Java-Anwendungen mit Bordmitteln absichern appeared first on JAX.

]]>
Verdächtige Gerüche https://jax.de/blog/verdaechtige-gerueche-durch-code-smells-zum-besseren-code/ Fri, 03 Jun 2022 11:52:41 +0000 https://jax.de/?p=86883 Will man bestehenden Code verbessern, ist es nicht immer leicht, zu entscheiden, wann man mit dem Refactoring [1] anfängt und wann man es beendet. Die Idee der „Code Smells“ hilft dabei. Der Begriff beschreibt Strukturen im Code, die überarbeitet werden sollten. In diesem Artikel betrachten wir eine Sammlung wichtiger Code Smells und führen an einem Beispiel Schritt für Schritt ein Refactoring durch, durch das wir eine übelriechende Codestruktur verbessern.

The post Verdächtige Gerüche appeared first on JAX.

]]>
Die Idee der Code Smells ist über zwanzig Jahre alt, aber immer noch aktuell. Der Begriff wurde von Kent Beck geprägt und erlangte durch das Buch „Refactoring“ [2] von Martin Fowler eine weite Verbreitung. Fowler und Beck haben 22 Smells identifiziert und für deren Beseitigung einen umfangreichen Refactoring-Katalog erstellt. Laut der Software Craftsmanship Community gehört dieses Buch zu den Klassikern, die jeder Entwickler gelesen haben sollte, denn es verändere die Art und Weise, wie wir Software entwickeln [3]. Auch in dem Standardwerk „Clean Code“ von Robert C. Martin sind Code Smells ein fester Bestandteil. „Uncle Bob“ listet in seinem Buch nicht nur die Smells von Fowler und Beck auf, sondern ergänzt sie um seine eigenen Erfahrungen.

Fünf Kategorien

Die klassischen 22 Smells von Fowler und Beck werden ohne eine Kategorisierung oder Beziehung zueinander vorgestellt. Da einige Code Smells eng miteinander verwandt sind und die gleichen Symptome zeigen, haben Lassenius und Mäntylä in einer wissenschaftlichen Ausarbeitung vorgeschlagen, eine Kategorisierung einzuführen [4]. Das Ergebnis waren folgende fünf Kategorien: Bloaters, Change Preventers, Dispensables, Couplers und Object Orientation Abuser. Diese Kategorisierung ist mittlerweile sehr verbreitet und Code Smells lassen sich damit sehr einfach erklären. Abbildung 1 zeigt die fünf Kategorien mit der Einordnung der Code Smells, die wir in diesem Artikel näher betrachten werden.

Das Ziel ist es, eine Übersicht über die große Anzahl von Code Smells zu bekommen und erste Hinweise mitzunehmen, anhand welcher Kriterien wir sie jeweils wiedererkennen.

Abb. 1: Fünf Kategorien von Code Smells

Bloaters

Die Kategorie Bloaters umfasst Smells, die den Code aufblähen. Es handelt sich dabei um Strukturen, durch die der Code sehr groß und unbeweglich wird. Solche Strukturen entstehen nicht zwingend von Anfang an, sondern über eine gewisse Zeit, gerade, wenn technische Schulden nicht abgebaut werden. Ab einer bestimmten Größe des Codes wird es für Entwickler sehr schwer, Erweiterungen oder Modifikationen daran durchzuführen. Für sehr umfangreiche Codestrukturen ohne Modularität ist es schwierig bis unmöglich, sie unter Test zu bringen. Die Smells dieser Kategorie lassen sich aufgrund ihrer großen Präsenz sehr einfach erkennen.

Eine davon ist die „lange Methode“. Es ist deutlich einfacher, die Logik einer kurzen Methode als die einer langen im Kopf zu behalten. Eine Methode sollte keine 100 Zeilen und bestenfalls keine 20 Zeilen groß sein, hier waren sich Kent Beck und Robert C. Martin bereits vor zwanzig Jahren einig [5]. Ihre Empfehlung liegt bei ca. fünf Zeilen pro Methode. Fakt ist: Je kleiner die Methoden sind, desto einfacher sind sie zu verstehen und desto einfacher lassen sie sich erweitern. Dies hat sich in vielen Projekten bewährt. In der Praxis, gerade in Legacy-Systemen, kommen leider schon mal Methoden mit 1 000 Zeilen vor. Hierbei handelt es sich dann um einen sehr übelriechenden Code, der nach Verbesserung geradezu schreit.

Durch lange Methoden entstehen indirekt auch lange Parameterlisten, da die benötigten Werte auf irgendeinem Weg in die Methode gelangen müssen. Keine wirkliche Alternative ist die Verwendung globaler Daten, denn auch hier muss der Entwickler die Herkunft der Daten interpretieren und verstehen. Je länger die Parameterliste, desto höher die Komplexität. Aus Testsicht sind lange Parameterlisten sehr schwierig, denn es muss jede mögliche Konstellation mit Tests abgedeckt werden. Hier wird empfohlen, ab drei Parametern darüber nachzudenken, ob man zum Beispiel ein Parameterobjekt einführt [5].

 

 

Die „umfangreiche Klasse“, auch als „Gottobjekt“ bekannt, bezeichnet eine Klasse, die zu viel weiß oder zu viel tut. Da in so einem Konstrukt die Übersicht schnell verloren geht, schleichen sich auch gern Redundanzen ein. Ein bekanntes Prinzip, um diesem Code Smell entgegenzuwirken, ist das Single Responsibility Principle (SRP). Jede Klasse sollte nur eine Verantwortlichkeit haben.

Bei „Datenklumpen“ handelt es sich um verschiedene Datenelemente, die häufig zusammen auftreten, in Feldern, aber auch in Parameterlisten. Das Konstrukt ist meistens durch gleiche Präfixe oder Suffixe erkennbar (Abb. 2). Ein sehr einfacher Test, um Datenklumpen zu identifizieren, ist es, ein Feld aus dem Klumpen zu löschen. Wenn die Felder nicht für sich alleine stehen können, dann handelt es sich um einen Datenklumpen. Dieser Code Smell würde auch in die Kategorie „Object Orientation Abuser“ passen, denn aus jedem Datenklumpen wird eine eigene Klasse extrahiert.

Abb. 2: Datenklumpen extrahieren

Change Preventers

Code Smells dieser Kategorie verhindern bzw. verlangsamen jegliche Erweiterung von Software. Bei einer Änderung an einer Stelle ist zwingend auch eine Änderung an einer anderen Stelle notwendig. Damit erhöht sich der Aufwand von Anpassungen drastisch. Diese Kategorie enthält zwei Code Smells, die zwar eng miteinander verwandt, aber im Grunde das genaue Gegenteil voneinander sind.

Abb. 3: Change Preventers

Wenn eine Klasse oder Methode aus verschiedenen Gründen und auf verschiedene Art und Weise immer wieder geändert wird, dann handelt es sich dabei um den Code Smell „divergierende Änderungen“. Eine Klasse wird zum Beispiel an drei Stellen geändert, um eine Datenbankänderung durchzuführen. Die gleiche Klasse muss aber auch an vier ganz anderen Stellen geändert werden, um einen neuen Datentyp einzuführen. Bei diesem Code wird das Single Responsibility Principle (SRP) verletzt. Die Klasse enthält mehrere Zuständigkeiten und sollte aufgeteilt werden.

Genau das Gegenteil von divergierenden Änderungen ist die sogenannte „Chirurgie mit der Schrotflinte“ (engl. Shotgun Surgery). Hierbei müssen bei jeder Änderung zwingend auch Anpassungen an weiteren Stellen durchgeführt werden. Die Modifikationen für die Änderung sind weit verstreut, dadurch kann es äußerst aufwendig sein, alle relevanten Stellen zu identifizieren. Um diese komplexe Struktur aufzulösen, sollte man zuallerst die schlecht voneinander getrennte Programmlogik bündeln und im Anschluss in eigene Strukturen extrahieren. Denn das oberste Ziel sollte sein, eine Änderung immer nur an einer einzigen Stelle durchführen zu müssen.

Dispensables

Die Kategorie „Dispensables“ repräsentiert entbehrlichen und sinnlosen Code. Hierzu gehören Code Smells wie „redundanter Code“, „träges Element“, „spekulative Generalisierung“ und „Kommentare“. Das Fehlen dieser Konstrukte würde den Code deutlich sauberer und leichter verständlich machen.

Redundanter Code kann in verschiedenen Ausprägungen vorkommen. Wenn es sich bei der Redundanz um komplett identischen Code handelt, dann lässt sich dieser sehr einfach identifizieren und beseitigen. Mittlerweile kann auch die IDE sehr gut unterstützen, um identischen Code über das gesamte Projekt zu finden. Handelt es sich bei den Redundanzen jedoch um ähnlichen, aber nicht vollständig identischen Code, dann kann das Identifizieren und Beseitigen schon aufwendiger werden. Eines der wichtigsten Prinzipien für sauberen Code ist das DRY-Prinzip (Don’t Repeat Yourself). Die Kernaussage ist, redundanten Code zu vermeiden.

Wenn eine Klasse zu wenig tut, sollte ihre Daseinsberechtigung hinterfragt werden. Es könnte sein, dass sie in der Vergangenheit mehr Verantwortung hatte und durch eine Überarbeitung zu einem „trägen Element“ (engl. lazy class) geworden ist. Hierbei muss man konsequent sein und das Ziel haben, unnötige Komplexität zu entfernen.

Die „spekulative Generalisierung“ entsteht, wenn Entwickler mit der Prämisse „Früher oder später brauchen wir die Funktionalität“ programmieren. Es wird Funktionalität umgesetzt, die nicht verwendet wird. Dies erhöht die Komplexität des Gesamtsystems unnötig und sollte vermieden werden. Hier gelten die zwei Prinzipien: KISS – Keep it Simple, Stupid und YAGNI – You ain’t gonna need it.

Zu Kommentaren haben Fowler und Beck eine klare Meinung: Es handele sich um Deodorant für Code Smells und sei damit selbst ein solcher [2]. Wenn Entwickler übelriechenden Code schreiben und ihnen das bewusst ist, sprühen sie etwas Deodorant drüber, indem ein Kommentar dazu verfasst wird. Daher sollten Kommentare immer kritisch betrachtet werden: Was will man damit verbergen?

Stay tuned

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

 

Couplers

Die Code Smells dieser Kategorie führen zu einer übermäßigen Kopplung zwischen Klassen. Für die leichte Wandelbarkeit einer Software strebt man jedoch eine möglichst lose Kopplung zwischen Bausteinen an sowie eine hohe Kohäsion (starke Bindung) innerhalb eines Bausteins [6]. Enge Kopplung führt dazu, dass Änderungen in einer Klasse automatisch auch Änderungen in einer anderen Klasse verursachen.

Zu dieser Kategorie gehören Smells wie „Featureneid“, Insiderhandel, Mitteilungsketten und Vermittler. Die letzten beiden entstehen, wenn die enge Kopplung durch eine übermäßige Delegation ersetzt wird.

Abb. 4: Featureneid und Insiderhandel

Wenn eine Methode mehr an den Daten einer anderen Klasse interessiert ist als an den Daten der eigenen Klasse, dann sprechen wir von Featureneid (Abb. 4). Ein typisches Szenario ist beispielsweise, wenn eine Methode eine Berechnung durchführen will und dafür ein Dutzend Getter-Methoden einer anderen Klasse aufruft, um sich die Daten für die Berechnung zu holen. Bei so viel Neid sollte hinterfragt werden, ob die Methode in der richtigen Klasse ist.

Beim Insiderhandel tuscheln zwei Klassen hinter dem Rücken der offiziellen Schnittstelle. Diese Kopplung ist nicht auf Anhieb ersichtlich und nur schwer zu entdecken, da die Absprachen der Klassen heimlich erfolgen. Bei der Vererbung ist dieses Konstrukt oft zu beobachten, wenn die Unterklasse mehr über die Basisklasse weiß als notwendig. Daraufhin verschafft sich die Unterklasse einen Vorteil, in dem sie mit den Daten der Basisklasse arbeitet. In diesem Fall sollte die Vererbungshierarchie hinterfragt werden.

Abb. 5: Mitteilungsketten und Vermittler

Eine Mitteilungskette (Abb. 5) entsteht, wenn ein Objekt A die Daten von Objekt D benötigt und dies nur durch unnötige Navigation durch die Objekte B und C erreicht. Objekt A fragt nach Objekt B, danach das Objekt C und anschließend Objekt D mit den relevanten Daten.

A.getB().getC().getD().getTheNeededData()

Jede Änderung an den Beziehungen der beteiligten Objekte zwingt den Aufrufer ebenfalls zu einer Änderung. Durch die Refactoring-Maßnahme „Delegation verbergen“ kann die Mitteilungskette verkürzt werden. Bei Delegation verbergen muss der Aufrufer (Objekt A) nicht zwingend alle Objekte kennen, wenn zum Beispiel Objekt B die Verbindung zu Objekt C und D verbirgt.

A.getB().getTheNeededDataThroughCandD()

Somit muss bei Änderungen nicht immer der Aufrufer (Objekt A) geändert werden. Durch solch eine Maßnahme könnte aber auch der Code Smell „Vermittler“ entstehen. Wenn eine Klasse hauptsächlich aus einfachen Delegationen besteht und sonst keinen eigenen Mehrwert bietet, dann sprechen wir von einem Vermittler. Der Vermittler ist an der Stelle eine unnötige Komplexität, wir sollten versuchen, darauf zu verzichten.

Object-oriented Abuser

Diese Kategorie enthält übelriechenden Code, bei dem die objektorientierten Prinzipien falsch oder unvollständig umgesetzt wurden. Vererbung und Polymorphie sind die grundlegenden Konzepte der Objektorientierung. Wenn diese Konzepte falsch angewendet werden, so leiden darunter hauptsächlich die Wartbarkeit und Wiederverwendbarkeit. Object-oriented Abuser enthält Code Smells wie wiederholte Switch-Anweisung, alternative Klassen mit unterschiedlichen Schnittstellen und temporäres Feld.

Wenn sich Switch-Anweisungen mit identischer Steuerungslogik wiederholen, dann ist dies ein Anzeichen für das Fehlen von objektorientiertem Design. Die gleiche Fallunterscheidung anhand eines Typs ist an verschiedenen Stellen im Code verteilt. Möchte man einen weiteren Typ für die Fallunterscheidung hinzufügen, so muss man zwingend alle Switch-Anweisungen finden und dort die Erweiterung durchführen. Das ist sehr mühsam, unübersichtlich und fehleranfällig, doch das Problem lässt sich sehr elegant durch Polymorphie lösen. Wir schauen uns im nächsten Abschnitt diesen Code Smell anhand eines Beispiels etwas genauer an.

Beim ausgeschlagenen Erbe (engl. Refused Bequest) übernimmt eine Unterklasse nicht alle Daten oder Funktionen der Oberklasse, sondern unterstützt nur bestimmte Aspekte. Dazu kann es kommen, wenn die Vererbung verwendet wird, um bestimmte Aspekte der Oberklasse wiederzuverwenden, ohne dass die zwei Objekte etwas miteinander zu tun haben. Bei diesem Code Smell sollte die Vererbungshierarchie hinterfragt werden.

MEHR PERFORMANCE GEFÄLLIG?

Performance & Security-Track entdecken

 

 

Klassen untereinander austauschen zu können, ist ein großer Vorteil der objektorientierten Programmierung. Dies wird durch die Verwendung von Schnittstellen erreicht. Um Klassen untereinander auszutauschen, sollte man aber auch die gleichen Schnittstellen verwenden. Der Code Smell „alternative Klassen mit unterschiedlichen Schnittstellen“ kann entstehen, wenn beim Erstellen einer Klasse eine äquivalente Klasse übersehen wird. Dann wird eine ähnliche Klasse mit einer anderen Schnittstelle erzeugt. Die Schnittstellen müssen in so einem Fall vereinheitlicht werden.

Ein nur temporär genutztes Feld in einer Klasse kann zu großer Verwirrung führen. Der Entwickler muss die Fragen „Wo wird es verwendet?“ und „Warum wird es nur da verwendet?“ klären. Das erhöht unnötig die Komplexität, denn die Erwartungshaltung ist, dass ein Objekt alle seine Felder benötigt und benutzt und das nicht nur unter bestimmten Konstellationen. Ein temporäres Feld sollte am besten in eine eigene Klasse extrahiert werden und sämtlicher Code, der mit dem Feld arbeitet, sollte auch umgezogen werden.

Der Weg zu besserem Code

Das Erkennen von übelriechendem Code ist erst die halbe Miete. Uns ist dadurch zwar bewusst, welche Konstrukte überarbeitet werden sollten, doch nun stellt sich die Frage, wie wir übelriechenden Code beseitigen, ohne die Funktionalität der Software zu gefährden.

Ein Refactoring sollte immer in kleinen Schritten durchgeführt werden. Ein großes Big Bang Refactoring geht selten gut. Bei der Überarbeitung ist eine entsprechende Testabdeckung sehr wichtig. Dies kann jedoch eine Herausforderung sein, speziell im Umfeld von Legacy Code. Aber auch für Legacy-Systeme gibt es Ansätze, um die Testabdeckung aufzubauen, z. B. kann man mit der umgedrehten Testpyramide beginnen und diese mit der Zeit drehen.

Fowler und Beck haben für jeden identifizierten Code Smell eine Anleitung verfasst, welche Schritte notwendig sind, um ihn aufzulösen. Wir schauen uns weiter unten in Listing 1 exemplarisch so ein Vorgehen an. Da die Ausprägungen von Code Smells sehr unterschiedlich sein können, ist nicht immer eine Anleitung notwendig, gerade wenn es sich um einfache Smells handelt. Erfahrungsgemäß lassen sich diese bereits durch folgende toolgestützte Refactoring-Maßnahmen beseitigen:

  • Methode/Variable verschieben
  • Methode/Variable umbenennen
  • Methode/Variable extrahieren
  • Methode/Variable inline platzieren

Diese Maßnahmen werden von der Entwicklungsumgebung (IDE) unterstützt. Es lohnt sich, diese Unterstützung der IDE im Detail zu kennen und zu verwenden, denn das gibt dem Entwickler eine zusätzliche Sicherheit beim Refactoring.

Anhand des Beispiels in Listing 1 schauen wir uns den Code Smell „wiederholte Switch-Anweisungen“ etwas genauer an. Wir analysieren zum einen die übelriechenden Stellen im Code und schauen, wie wir ihn mit Hilfe der Anleitung aus dem Refactoring-Katalog [2] verbessern können. Bei dem Beispiel handelt es sich um das Parrot-Refactoring Kata [7], das in verschiedenen Programmiersprachen existiert.

Die Klasse Parrot (engl. für Papagei) enthält zwei Methoden mit den redundanten Switch-Anweisungen. Die Methode getSpeed bestimmt die Geschwindigkeit des Papageis und die Methode getPlumage den Zustand des Federkleides. Es gibt die drei Papageientypen „European“, „African“ und „Norwegian Blue“, die unterschiedliche Ausprägungen für Geschwindigkeit und Gefieder haben. Würde ein neuer Papageientyp hinzukommen, so müsste man alle Fallunterscheidungen anpassen. Dies widerspricht dem Open Closed Principle (OCP) und lässt sich mit Polymorphie sehr gut lösen.

public class Parrot {
// ...
double getSpeed(ParrotTypeEnum type) {
  switch (type) {
    case EUROPEAN:
      return getBaseSpeed();
    case AFRICAN:
      return getBaseSpeed() - getLoadFactor() * numberOfCoconuts;
    case NORWEGIAN_BLUE:
      return (isNailed) ? 0 : getBaseSpeed(voltage);
  }
  throw new RuntimeException("Should be unreachable");
}
 
String getPlumage(ParrotTypeEnum type) {
  switch (type) {
    case EUROPEAN:
      return "average";
    case AFRICAN:
      return numberOfCoconuts > 2 ? "tired" : "average";
    case NORWEGIAN_BLUE:
      return voltage > 100 ? "scorched" : "beautiful";
  }
  throw new RuntimeException("Should be unreachable");
}
// ...
}

Bedingung durch Polymorphie ersetzen

Im Folgenden schauen wir uns in Kürze die Anleitung aus dem Refactoring-Katalog von Fowler und Beck an. Es handelt sich dabei um ein bewährtes Vorgehen zum Auflösen unseres Code Smells „wiederholte Switch-Anweisungen“.

  1. Klassen für die Polymorphie erstellen
  2. Fabrikfunktionen zur Rückgabe der richtigen Instanzen erstellen
  3. Im aufrufenden Code die Fabrikmethoden verwenden
  4. Methoden in der Unterklasse überschreiben
  5. Verschieben der bedingten Funktionen aus der Basisklasse in die Unterklassen

Das ganze Refactoring ist auf sehr kleine Schritte aufgeteilt. Nach jedem Schritt wird geprüft, ob der Code kompilierbar ist und die automatisierten Tests noch funktionieren.

Im ersten Schritt werden die Klassen für die Polymorphie erstellt. Das Ergebnis sind die Klassen EuropeanParrot, AfricanParrot und NorwegianParrot, die von der Oberklasse Parrot erben. Im zweiten Schritt werden die entsprechenden Fabrikfunktionen erstellt und im nächsten Schritt in die aufrufenden Stellen eingebunden. Die ersten drei Schritte haben nur die neue objektorientierte Klassenstruktur erzeugt. Die gesamte Logik ist weiterhin in der Oberklasse Parrot und wird von den Unterklassen verwendet. Dies hat den großen Vorteil, dass eine neue Klassenstruktur in bestehenden Code eingeführt werden kann, ohne die Funktionalität zu beeinträchtigen.

Im vierten Schritt überschreiben die Unterklassen die Methoden getSpeed und getPlumage und rufen vorerst über super (Listing 2) die Methoden der Oberklasse auf, in der noch immer die Switch-Anweisungen ausgeführt werden.

public class AfricanParrot extends Parrot {
// ...
@Override
public double getSpeed() {
  return super.getSpeed();
}
@Override
public double getPlumage() {
  return super.getPlumage();
}
}

Erst im fünften Schritt wird die Logik aus den Switch-Anweisungen in die entsprechenden Unterklassen verschoben, und das Refactoring ist fast vollbracht. Zum Schluss haben die Methoden getSpeed und getPlumage in der Parrot-Klasse keine Funktionalität mehr und können damit abstract werden. Das Ergebnis aller Schritte inklusive der Bereinigung zum Schluss zeigt Listing 3.

public abstract class Parrot {
 
  public static Parrot createEuropeanParrot() {
    return new EuropeanParrot();
  }
  public static Parrot createAfricanParrot( int numberOfCoconuts) {
    return new AfricanParrot(numberOfCoconuts);
  }
 
  public static Parrot createNorwegianBlueParrot(double voltage, 
boolean isNailed) {
    return new NorwegianBlueParrot(voltage, isNailed);
  }
 
  public abstract double getSpeed();
  public abstract double getPlumage();
  // ...
}
 
public class AfricanParrot extends Parrot {
  // ...
  @Override
  public double getSpeed() {
    return Math.max(0, getBaseSpeed() - getLoadFactor() * numberOfCoconuts);
  }
 
  @Override
  public String getPlumage() {
    return numberOfCoconuts > 2 ? "tired" : "average";
  }
  // ...
}
 
public class EuropeanParrot extends Parrot{
  // ...
  @Override
  public double getSpeed() {
    return getBaseSpeed();
  }
 
  @Override
  public String getPlumage() {
    return "average";
  }
  // ...
}
 
public class NorwegianBlueParrot extends Parrot{
  // ...
  @Override
  public double getSpeed() {
    return (isNailed) ? 0 : getBaseSpeed(voltage);
  }
 
  @Override
  public String getPlumage() {
    return voltage > 100 ? "scorched" : "beautiful";
  }
  // ...
}

Dieses Vorgehen zeigt uns, wie iterativ eine komplexe Strukturänderung im Code erfolgen kann. Auch wenn die Schritte teilweise zu kleinteilig wirken, sind sie in der Praxis, wenn der Code deutlich umfangreicher ist, genau richtig. Nur durch solche kleinen Schritte und die ständige Ausführung der Tests kann ein komplexes Refactoring ohne Seiteneffekte erfolgreich durchgeführt werden.

Fazit

In diesem Artikel haben wir anhand von fünf Kategorien 18 Code Smells betrachtet. Um die schlechten Gerüche im Code wieder loszuwerden, sollte als Erstes die Testabdeckung sichergestellt werden. Weitere Sicherheit bieten toolgestützte Refactorings, mit denen bereits gute Ergebnisse erzielt werden. Bei komplexen Code Smells lohnt es sich, auf bewährtes Vorgehen zurückzugreifen. Ein Beispiel hierfür haben wir uns im Detail angeschaut.

Mit der Metapher Code Smells steht uns seit mehr als zwanzig Jahren ein Werkzeug zur Verfügung, mit dem wir schlechten Code identifizieren und bereinigen können. Wir sollten von diesen Erfahrungen profitieren und nicht alle Fehler selbst machen. Sich mit den Code Smells auseinanderzusetzen, hat zwei wesentliche Vorteile. Zum einen erkennen wir leichter die Strukturen, die überarbeitet werden sollten, und zum anderen entwickeln wir ein besseres Bewusstsein dafür, welche Fehler wir vermeiden sollten, wenn wir neuen Code schreiben. Beide Aspekte führen zu dem Ergebnis, dass die Qualität der Software besser wird.

Stay tuned

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

 

 

Links & Literatur

[1] https://de.wikipedia.org/wiki/Refactoring

[2] Fowler, Martin: „Refactoring: Improving the Design of Existing Code“; Addison-Wesley, 2018

[3] Mancuso, Sandro: „The Software Craftsman: Professionalism, Pragmatism, Pride“, Pearson Education, 2014

[4] Mäntylä, M. V. and Lassenius, C.: „Subjective Evaluation of Software Evolvability Using Code Smells: An Empirical Study“, Journal of Empirical Software Engineering, vol. 11, no. 3, 2006, pp. 395–431.

[5] Martin, Robert C.: „Clean Code: A Handbook of Agile Software Craftsmanship“, Prentice Hall, 2008

[6] Starke, Gernot: „Effektive Softwarearchitekturen: Ein praktischer Leitfaden“, Hanser Fachbuch, 2002

[7] https://github.com/emilybache/Parrot-Refactoring-Kata.git

The post Verdächtige Gerüche appeared first on JAX.

]]>
Alles im Blick https://jax.de/blog/alles-im-blick/ Fri, 11 Mar 2022 13:59:28 +0000 https://jax.de/?p=85794 Wie funktionieren APM Agents in der Java Virtual Machine im Detail? Was ist bei der Instrumentierung zu beachten und welche Besonderheiten von APM-Agenten muss man berücksichtigen? Und warum werden oftmals nur die bekanntesten Java-Frameworks unterstützt? Diesen Fragen gehen wir in diesem Beitrag auf den Grund.

The post Alles im Blick appeared first on JAX.

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

APM als Säule der Observability

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

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

Instrumentierung innerhalb der JVM

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

Stay tuned

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

 

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

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

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

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

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

 

Agents und ihre Features

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

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

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

Elastic APM Agent

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

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

ElasticApmAttacher.attach();

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

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

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

Programmatische Spans und Transactions

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

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

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

 

Elastic APM Log Correlation

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

Automatische Instrumentierung mit K8s

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

MEHR PERFORMANCE GEFÄLLIG?

Performance & Security-Track entdecken

 

Distributed Tracing mit RUM

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

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

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

Abb. 3: RUM-Dashboard mit Ladezeiten und Browserstatistiken

APM in der Zukunft

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

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

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

Stay tuned

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

 

Schlusswort

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

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

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

 

Links & Literatur

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

[2] https://opentelemetry.io

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

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

[5] https://pyroscope.io

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

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

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

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

[10] https://prodfiler.com

[11] https://px.dev

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

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

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

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

The post Alles im Blick appeared first on JAX.

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

The post Die Untiefen reaktiver Programmierung appeared first on JAX.

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

Nichtlineare Programmierung mit RxJS

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

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

Stay tuned

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

 

Alles neu?

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

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

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

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

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

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

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

Die Kunst des Beobachtens

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

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

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

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

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

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

 

Wenn Beobachter Beobachter beobachten

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

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

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

NEUES AUS DER JAVA-ENTERPRISE-WELT

Serverside Java-Track entdecken

 

Ein Internet voller Versprechungen

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

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

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

Blockadebrecher

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

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

Retries, Timeouts und take(1)

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

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

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

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

 

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

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

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

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

 

Zustände

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

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

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

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

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

Stay tuned

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

 

Resumée

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

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

 

Links & Literatur

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

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

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

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

The post Die Untiefen reaktiver Programmierung appeared first on JAX.

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

The post Mit Quarkus gegen Monolithen appeared first on JAX.

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

Der Monolith

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

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

Stay tuned

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

 

Alles neu?

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

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

Den Monolithen erwürgen

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

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

Strangler Pattern

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

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

ALL ABOUT MICROSERVICES

Microservices-Track entdecken

 

Evolution statt Revolution

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

Entscheidungsprozess

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

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

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

Anti-corruption Layer

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

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

Abb. 1: Schrittweise Ablösung des Monolithen

Nieder mit dem Monolithen

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

  1. Wir verhindern einen neuen Big Ball of Mud

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

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

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

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

 

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

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

Abb. 2: Projektstruktur in der Übersicht

Frontend

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

Abb. 3: Frontend-Projektstruktur in IntelliJ

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

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

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

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

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

Backend

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

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

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

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

 

Runnable

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

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

mvn -pl runnable -am compile quarkus:dev

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

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

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

 

Ausblick

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

Stay tuned

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

 

Links & Literatur

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

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

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

[4] https://quarkus.io

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

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

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

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

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

The post Mit Quarkus gegen Monolithen appeared first on JAX.

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

The post Modularisierung und kognitive Psychologie appeared first on JAX.

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

Parnas hat immer noch Recht!

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

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

Stay tuned

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

 

Chunking ➔ Modularisierung

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

Abb. 1: Chunking

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

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

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

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

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

DIE KUNST DER SOTWARE-ARCHITEKTUR

Architecture & Design-Track entdecken

 

Module als zusammenhängende Einheiten

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

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

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

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

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

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

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

 

Module mit modularen Schnittstellen

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

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

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

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

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

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

Module mit loser Kopplung

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

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

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

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

Modularisierung durch Muster

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

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

 

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

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

Abb. 3: Muster auf Klassenebene = Mustersprache

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

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

Hierarchisierung ➔ Modularisierung

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

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

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

Abb. 4: Architektur mit vier Modulen

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

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

Abb. 5: Das geplante Architekturmuster ist schlecht umgesetzt

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

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

Abb. 6: Gut umgesetztes Architekturmuster

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

Abb. 7: Zyklus aus 242 Klassen

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

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

 

Stay tuned

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

 

Zusammenfassung: Regeln für Modularisierung

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

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

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

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

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

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

 

Links & Literatur

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

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

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

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

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

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

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

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

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

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

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

The post Modularisierung und kognitive Psychologie appeared first on JAX.

]]>