JAX https://jax.de/ Java, Architecture & Software Innovation Wed, 11 Dec 2024 11:09:35 +0000 de-DE hourly 1 https://wordpress.org/?v=6.5.2 Architektur ist nicht Kubernetes: Diana Montalions Vision für Systemarchitektur auf der W-JAX https://jax.de/blog/moderne-systemarchitektur-systems-thinking-w-jax/ Tue, 19 Nov 2024 15:04:34 +0000 https://jax.de/?p=106834 In ihrer mitreißenden Keynote auf der W-JAX in München stellt Diana Montalion eine neue Sichtweise auf moderne Systemarchitektur vor. Sie zeigt, dass echte Innovation weit über Tools wie Kubernetes hinausgeht. Stattdessen rückt sie Systems Thinking und die kunstvolle, flexible Gestaltung von Beziehungen zwischen Systemkomponenten in den Fokus. Mit ihrer Vision macht sie deutlich, dass moderne Software-Architektur mehr ist als Technik – sie ist eine soziotechnische Disziplin, die technisches Know-how und menschliche Zusammenarbeit nahtlos miteinander vereint.

The post Architektur ist nicht Kubernetes: Diana Montalions Vision für Systemarchitektur auf der W-JAX appeared first on JAX.

]]>

Architektur neu denken!

Eines der kontroversesten Wörter in der heutigen Technologiekultur ist „Architekt“. Was bedeutet Architektur wirklich? Diese Frage sorgt immer wieder für hitzige Diskussionen und Missverständnisse. In ihrer Keynote auf der W-JAX in München wirft die renommierte Expertin Diana Montalion einen frischen Blick auf das Thema. Sie verdeutlicht, dass Architektur weit mehr ist als die Implementierung von Tools wie Kubernetes. Stattdessen geht es um das Entwerfen von Beziehungen zwischen Systemkomponenten und die Fähigkeit, diese Muster flexibel an wechselnde Bedingungen anzupassen. Dies erfordert fundierte mentale Modelle und kollaboratives Arbeiten, um diese weiterzuentwickeln.

Diana Montalion, Autorin des O’Reilly-Buchs Learning Systems Thinking: Essential Nonlinear Skills & Practices for Software Professionals, blickt auf über 18 Jahre Erfahrung in der Software-Entwicklung und -Architektur zurück. Sie hat unter anderem für Organisationen wie Stanford, die Gates Foundation und The Economist gearbeitet und war Principal Systems Architect für die Wikimedia Foundation.

In ihrer Keynote betont Diana, dass moderne Architektur eine soziotechnische Disziplin ist – eine Mischung aus sozialen und technischen Fähigkeiten. Sie beschreibt, wie gutes Architektendenken effektives Systemdenken strukturiert und erklärt die fünf wesentlichen Qualitäten, die einen guten Architekten von einem großartigen unterscheiden.

Die wichtigsten Take-aways

  • Architekturdenken: Systeme sollten nicht isoliert betrachtet werden. Es ist entscheidend, das gesamte Ökosystem zu berücksichtigen und zu verstehen, wie verschiedene Komponenten zusammenarbeiten, um echte Effizienz und Effektivität zu erreichen.
  • Silos aufbrechen: Abteilungsübergreifende Zusammenarbeit ist notwendig, um die Integration verschiedener Technologien und Teams zu verbessern. Teams müssen effektiv kommunizieren und kooperieren können.
  • Kultureller Wandel: Ein grundlegender Kulturwandel in Unternehmen ist erforderlich, um moderne Architekturen erfolgreich zu implementieren. Vertrauen und Autonomie der Teams sowie die Bereitschaft, neue Wege zu gehen, sind hierbei essenziell.
  • Einsatz geeigneter Werkzeuge: Technologien wie Kubernetes können hilfreich sein, dürfen jedoch nicht das zentrale Element des Architekturdenkens werden. Sinnvolle und ganzheitliche Designansätze, die weit über spezifische Tools hinausgehen, sind entscheidend.

The post Architektur ist nicht Kubernetes: Diana Montalions Vision für Systemarchitektur auf der W-JAX appeared first on JAX.

]]>
JavaFX-Animationen auf dem Prüfstand: Node oder Canvas? https://jax.de/blog/javafx-rendering-effizienz-node-canvas-vergleich/ Mon, 07 Oct 2024 08:22:14 +0000 https://jax.de/?p=90204 JavaFX bietet vielseitige Möglichkeiten für Benutzeroberflächen, doch die Wahl zwischen Nodes und Canvas ist entscheidend für die Performance. Dieser Artikel vergleicht beide Methoden und zeigt, wie sich Animationen auf Geräten wie dem Raspberry Pi auswirken. Mit einer Demoanwendung wird demonstriert, wie Canvas die Effizienz steigern kann, um die richtige Komponente für leistungsstarke JavaFX-Anwendungen zu wählen.

The post JavaFX-Animationen auf dem Prüfstand: Node oder Canvas? appeared first on JAX.

]]>
Kürzlich habe ich mit der Installation eines JDK experimentiert, das JavaFX enthält. Das vereinfacht die Ausführung von JavaFX-Anwendungen, da man die JavaFX-Laufzeitumgebung nicht separat herunterladen muss, beispielsweise von der Gluon-Website [1]. Ich habe diese Experimente auf einem Raspberry Pi durchgeführt und eine kleine Testanwendung verwendet, die eine Menge sich bewegender Punkte auf den Bildschirm bringt. Dabei habe ich bemerkt, dass die Leistung bei vielen dieser Kreisobjekte langsamer wird.

Ich habe schon mehrmals gelesen, dass ein Canvas für diese Art von Anwendungsfall viel effizienter sein kann, und das hat mich dazu veranlasst, eine Testanwendung mit „Bouncing Balls“ (Abb. 1) zu erstellen, die es einfach macht, Nodes und Canvas zu vergleichen.

Abb. 1: Meine Testanwendung

Node versus Canvas

In JavaFX sind sowohl Nodes als auch Canvas Teil des Scene Graphs, aber sie haben unterschiedliche Use Cases. Die Wahl zwischen den beiden hängt oft von den spezifischen Anforderungen Ihrer Anwendung ab. Sie verwenden Nodes für statische Inhalte wie Eingabeformulare, Datentabellen, Dashboards mit Diagrammen … Das ist in der Regel bequemer und effizienter. Das Canvas bietet Ihnen mehr Flexibilität, wenn Sie dynamische oder benutzerdefinierte Inhalte erstellen müssen.

JavaFX Node

javafx.scene.Node ist die Basisklasse und alle visuellen JavaFX-Komponenten erweitern sie. Das geht mehrere „Schichten“ tief. Zum Beispiel Button > ButtonBase > Labeled > Control > Region > Parent > Node.

Zusammengefasst:

  • Ein Node in JavaFX repräsentiert ein Element des Scene Graph.
  • Dazu gehören UI-Steuerelemente wie Buttons, Labels, Text Fields, Shapes, Images, Media, Embedded Web Browser usw.
  • Jeder Node kann im 3D-Raum positioniert und transformiert werden, er kann Events handlen und es können Effekte auf ihn angewendet werden.
  • Node ist eine Basisklasse für alle visuellen Elemente.
  • Die Verwendung von Nodes wird als „Retained Mode Rendering“ bezeichnet.

SIE LIEBEN JAVA?

Den Core-Java-Track entdecken

 

Das sind einige typische Komponenten, die von Node abgeleitet sind:

Label label = new Label("Hello World!");
Button button = new Button("Click Me!");

JavaFX Canvas

javafx.scene.canvas erweitert ebenfalls Node, fügt aber spezielle Funktionen hinzu. Sie können Ihren eigenen Inhalt auf dem Canvas zeichnen, indem Sie eine Reihe von Grafikbefehlen verwenden, die von einem GraphicsContext bereitgestellt werden.

Zusammengefasst:

  • Sie zeichnen auf einem Canvas mit einem GraphicsContext.
  • Das direkte Zeichnen auf einem Canvas wird als „Immediate Mode Rendering“ bezeichnet.
  • Das gibt Ihnen mehr Flexibilität, ist aber weniger effizient, wenn sich der Inhalt nicht oft ändert.

In diesem Beispiel wird ein Rechteck gezeichnet:

Canvas canvas = new Canvas(400, 300);
GraphicsContext gc = canvas.getGraphicsContext2D();
gc.setFill(Color.BLUE);
gc.fillRect(50, 50, 100, 70);

Demoanwendung

Die Demoanwendung kann im GitHub Gist unter [2] gefunden werden. Sie enthält Code, um eine Menge sich bewegender Kreise zu erzeugen – sowohl als Nodes als auch gezeichnet auf einem Canvas. Der Wert am Anfang des Codes definiert, welcher Ansatz verwendet wird:

private static int TYPE_OF_TEST = 1; // 1 = Nodes, 2 = Canvas

Nodes verwenden

Wenn Sie Nodes verwenden, wird dem Bildschirm ein Bereich hinzugefügt, in dem Bälle eingefügt werden. Bei jedem Ball handelt es sich um einen Circle Node mit einer Bewegungsmethode (Listing 1).

class BallNode extends Circle {
  private final Color randomColor = Color.color(Math.random(), 
    Math.random(), Math.random());
  private final int size = r.nextInt(1, 10);
  private double dx = r.nextInt(1, 5);
  private double dy = r.nextInt(1, 5);

  public BallNode() {
    this.setRadius(size / 2);
    this.setFill(randomColor);
    relocate(r.nextInt(380), r.nextInt(620));
  }

  public void move() {
     if (hitRightOrLeftEdge()) {
      dx *= -1; // Ball hit right or left 
    }
    if (hitTopOrBottom()) {
      dy *= -1; // Ball hit top or bottom
    }
    setLayoutX(getLayoutX() + dx);
    setLayoutY(getLayoutY() + dy);
  }

  ...
}

Canvas verwenden

Wenn Sie das Canvas verwenden, ist jeder Ball ein Datenobjekt, und alle Bälle werden bei jedem Tick auf das Canvas gezeichnet (Listing 2).

class BallDrawing {
  private final Color fill = Color.color(Math.random(), 
    Math.random(), Math.random());
  private final int size = r.nextInt(1, 10);
  private double x = r.nextInt(APP_WIDTH);
  private double y = r.nextInt(APP_HEIGHT - TOP_OFFSET);
  private double dx = r.nextInt(1, 5);
  private double dy = r.nextInt(1, 5);

  public void move() {
    if (hitRightOrLeftEdge()) {
      dx *= -1; // Ball hit right or left
    }
    if (hitTopOrBottom()) {
      dy *= -1; // Ball hit top or bottom
    }
    x += dx;
    y += dy;
  }

  ...
}

Verschieben der Objekte

Die Anwendung verwendet eine Timeline, um alle fünf Millisekunden weitere Objekte hinzuzufügen und sie zu verschieben (Listing 3).

Timeline timeline = new Timeline(new KeyFrame(Duration.millis(5), t -> onTick()));
timeline.setCycleCount(Timeline.INDEFINITE);
timeline.play();

private void onTick() {
  if (TYPE_OF_TEST == 1) {
    // Add ball nodes to the pane
    for (var i = 0; i < ADD_BALLS_PER_TICK; i++) {
      paneBalls.getChildren().add(new BallNode());
    }

    // Move all the balls in the pane
    for (Node ballNode : paneBalls.getChildren()) {
      ((BallNode) ballNode).move();
    }
  } else if (TYPE_OF_TEST == 2) {
    // Add balls to the list of balls to be drawn
    for (var i = 0; i < ADD_BALLS_PER_TICK; i++) {
      ballDrawings.add(new BallDrawing());
    }
    
    // Clear the canvas (remove all the previously balls that were drawn)
    context.clearRect(0.0, 0.0, canvas.getWidth(), canvas.getHeight());

    // Move all the balls in the list, and draw them on the Canvas
    for (BallDrawing ballDrawing : ballDrawings) {
      ballDrawing.move();
      context.setFill(ballDrawing.getFill());
      context.fillOval(ballDrawing.getX(), ballDrawing.getY(),
        ballDrawing.getSize(),  ballDrawing.getSize());
    }
  }
}

Ausführen der Anwendung

Zum Ausführen der Anwendung habe ich folgenden Ansatz gewählt:

  • den Code in einer Datei FxNodesVersusCanvas.java speichern
  • eine Java-Laufzeitumgebung mit JavaFX installieren, z. B. von Azul Zulu [3] oder mit SDKMAN [4]: sdk install java 22.0.1.fx-zulu
  • JBang installieren, entweder von [5] oder mit SDKMAN: sdk install jbang
  • die Anwendung starten mit: jbang FxNodesVersusCanvas.java

Leistung im Vergleich

Natürlich hängt die Leistung vom System ab, auf dem Sie die Anwendung ausführen. Wie Sie im Video unter [6] und in Abbildung 2 sehen können, habe ich es sowohl auf einem Apple Mac Studio als auch auf einem Raspberry Pi 5 ausgeführt. Das Ergebnis ist konsistent, da man ungefähr zehnmal mehr Objekte zum Canvas verglichen mit der Anzahl der Nodes hinzufügen kann, bevor die Framerate einbricht. Das ist kein „wissenschaftliches Ergebnis“, aber es vermittelt einen guten Eindruck davon, was mit Canvas erreicht werden kann.

  • Raspberry Pi wird bei 3k Nodes deutlich langsamer als bei 30k Nodes auf Canvas
  • Mac wird bei 15k Nodes langsamer als bei 150k auf Canvas

Das Bild zeigt zwei nebeneinander angeordnete JavaFX-Demos mit der Überschrift „JavaFX Demo, Nodes versus Canvas“. Beide Fenster zeigen eine große Menge bunter Punkte, die zufällig auf weißem Hintergrund verteilt sind. In der oberen Zeile wird Java-Version 17.0.1 und JavaFX-Version 22.0.1 verwendet. Das linke Fenster zeigt eine Anzahl von 7.210 Punkten und eine Bildrate von 5 FPS (Frames per Second), während das rechte Fenster 7.232 Punkte mit einer Bildrate von 16 FPS anzeigt. Es handelt sich offenbar um einen Vergleich der Leistung von JavaFX-Nodes versus Canvas.

Abb. 2: Das laufende Experiment

Fazit

Eine große Anzahl visueller Komponenten in einer typischen JavaFX-Benutzeroberfläche würde eine schlecht gestaltete Anwendung darstellen. Stellen Sie sich ein langes Registrierungsformular mit Hunderten von Eingabefeldern und Beschriftungen vor … Das würde Ihre Benutzer in den Wahnsinn treiben. Aber in anderen Fällen, in denen Sie eine komplexe Animation oder eine fortgeschrittene Benutzerschnittstellenkomponente erzeugen wollen, ist die Möglichkeit, auf dem Canvas zu zeichnen, ein idealer Ansatz.


Links & Literatur

[1] https://gluonhq.com/products/javafx/

[2] https://gist.github.com/FDelporte/c74cdf59ecd9ef1b14df86e08faa0c56

[3] https://www.azul.com/downloads/?package=jdk-fx#zulu

[4] https://sdkman.io

[5] https://www.jbang.dev

[6] https://www.youtube.com/watch?v=nJGRW5xP_AE

[7] https://leanpub.com/gettingstartedwithjavaontheraspberrypi/

[8] https://www.elektor.com/getting-started-with-java-on-the-raspberry-pi

The post JavaFX-Animationen auf dem Prüfstand: Node oder Canvas? appeared first on JAX.

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

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

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

Stay tuned

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

 

Mit JEP 473 wird eine Erweiterung des Stream API zur Unterstützung benutzerdefinierter Intermediate Operations umgesetzt. Darunter versteht man Verarbeitungsschritte wie das Filtern und Transformieren, die sich zu komplexeren Aktionen verbinden lassen. Bisher gab es zwar diverse vordefinierte Intermediate Operations, eine Erweiterungsmöglichkeit war allerdings nicht vorgesehen. Eine solche ist jedoch wünschenswert, um Aufgaben realisieren zu können, die zuvor nicht ohne Weiteres oder nur mit Tricks und eher umständlich umzusetzen waren.

Einführung

Nehmen wir an, wir wollten alle Duplikate aus einem Stream herausfiltern und für ein Kriterium angeben. Um es einfach nachvollziehbar zu halten, betrachten wir einen Stream von Strings und als Kriterium deren Länge.

Hypothetisch wäre das wie folgt mit einer Intermediate Operation in Form einer fiktiven Methode distinctBy() bezüglich der Länge umsetzbar, indem man String::length als Kriterium definiert:

var result = Stream.of("Tim", "Tom", "Jim", "Mike").
  distinctBy(String::length).   // hypothetisch
  toList();

// result ==> [Tim, Mike]

Bitte beachten Sie, dass ich mich bei einigen Beispielen von jenen aus dem Original-JEP [1] inspirieren lassen und diese angepasst oder erweitert habe.

Abhilfe mit den bisherigen Möglichkeiten

Schauen wir uns einmal an, wie sich Duplikate bezüglich der Stringlänge mit den bisherigen Möglichkeiten des Stream API vermeiden lassen (Listing 1) – den dazu ebenfalls benötigten Record DistinctByLength stelle ich weiter unter vor.

jshell> var result = Stream.of("Tim", "Tom", "Jim", "Mike").
  ...>                      map(DistinctByLength::new). // #1
  ...>                      distinct().                 // #2
  ...>                      map(DistinctByLength::str). // #3
  ...>                      toList();
result ==> [Tim, Mike]

Schauen wir uns einmal an, wie sich Duplikate bezüglich der Stringlänge mit den bisherigen Möglichkeiten des Stream API vermeiden lassen (Listing 1) – den dazu ebenfalls benötigten Record DistinctByLength stelle ich weiter unter vor.

record DistinctByLength(String str)
{
  @Override
  public boolean equals(Object obj)
  {
    return obj instanceof DistinctByLength(String other) &&
      str.length() == other.length();
  }

  @Override
  public int hashCode()
  {
    return str == null ? 0 : Integer.hashCode(str.length());
  }
}

Dieser Record ist lediglich ein Wrapper um einen String und besitzt dazu ein Attribut str sowie die korrespondierende Zugriffsmethode. Damit wir den Record für unseren Zweck verwenden können, müssen wir die Methoden equals() und hashCode() auf die Stringlänge ausgerichtet überschreiben. In der Implementierung von equals() verwenden wir das Pattern Matching bei instanceof in Kombination mit Record Patterns, wodurch sich der Sourcecode sehr kompakt halten lässt.

SIE LIEBEN JAVA?

Den Core-Java-Track entdecken

 

Beispiel Gruppierung

Ein weiteres Beispiel für den Bedarf an selbst definierten Intermediate Operations ist die Gruppierung der Daten eines Streams in Abschnitte fixer Größe. Zur Demonstration sollen jeweils vier Zahlen zu einer Einheit zusammengefasst, also gruppiert werden. Für unser Beispiel sollen nur die ersten drei Gruppen ins Ergebnis aufgenommen werden. Auch hier wird wieder der an den JEP angelehnte Sourcecode mit einer fiktiven Methode windowFixed() gezeigt (Listing 3).

record DistinctByLength(String str)
{
  @Override
  public boolean equals(Object obj)
  {
    return obj instanceof DistinctByLength(String other) &&
      str.length() == other.length();
  }

  @Override
  public int hashCode()
  {
    return str == null ? 0 : Integer.hashCode(str.length());
  }
}

Neu: Interface Gatherer und die Methode gather()

Im Lauf der Jahre ist aus der Java-Community einiges an Vorschlägen und Wünschen für Intermediate Operations als Ergänzung für das Stream API eingebracht worden. Oftmals sind diese in ganz spezifischen Kontexten sinnvoll. Hätte man sie alle ins JDK integriert, hätte dies das API allerdings ziemlich aufgebläht und den Einstieg in das (ohnehin schon umfangreiche) Stream API (weiter) erschwert. Um aber dennoch die Flexibilität benutzerdefinierter Intermediate Operations zu ermöglichen, wird ein ähnlicher Ansatz wie bei den Terminal Operations und dem Extension Point in Form der Methode collect(Collector) und des Interface java.util.stream.Collector verfolgt. Durch diese Kombination lassen sich Terminal Operations bei Bedarf individuell ergänzen.

Um flexibel neue Intermediate Operations bereitstellen zu können, offeriert das Stream API nun eine Methode gather(Gatherer) in Kombination mit dem Interface

java.util.stream.Gatherer. Wollten wir die zuvor besprochene distinctBy()-Funktionalität selbst realisieren, so könnten wir dazu einen eigenen Gatherer implementieren – das würde jedoch den Rahmen dieser Einführung sprengen.

Ausgewählte Gatherer

Praktischerweise sind zur Umsetzung einiger Vorschläge und Wünsche aus der Java-Community nach spezifischen Intermediate Operations bereits ein paar Gatherer in das JDK aufgenommen worden. Sie sind in der Utility-Klasse java.util.stream.Gatherers definiert. Zum Nachvollziehen der Beispiele ist folgender Import nötig:

jshell> import java.util.stream.*

windowFixed

Um einen Stream in kleinere Bestandteile fixer Größe ohne Überlappung zu unterteilen, dient windowFixed() aus dem JDK. Greifen wir das zweite Beispiel aus der Einführung auf und schauen uns an, wie einfach es sich jetzt mit JDK-Basisfunktionalität realisieren lässt.

 

Nachfolgend wird per iterate() ein unendlicher Stream von Zahlen erzeugt und durch Aufruf von windowFixed(4) jeweils in Teilbereiche der Größe vier untergliedert. Mit limit(3) wird die Anzahl an Teilbereichen auf drei begrenzt und diese werden durch Aufruf von toList() in Form einer Liste als Ergebnis bereitgestellt (Listing 4).

jshell> var result = Stream.iterate(0, i -> i + 1).
  ...>                      gather(Gatherers.windowFixed(4)).
  ...>                      limit(3).
  ...>                      toList()
resultNew ==> [[0, 1, 2, 3], [4, 5, 6, 7], [8, 9, 10, 11]]

Beim Unterteilen in Bereiche fester Größe gibt es einen Spezialfall zu beachten: Enthält ein Datenbestand nicht genügend Elemente, um die gewünschte Teilbereichsgröße zu füllen, enthält der letzte Teilbereich weniger Elemente. Als Beispiel dient ein Stream mit einem durch Aufruf von of() erzeugten fixen Datenbestand der Werte 0 bis einschließlich 6. Dieser wird mit windowFixed(3) in Teilbereiche der Größe drei unterteilt, wodurch der letzte Teilbereich nur ein Element enthält, nämlich die Zahl 6 (Listing 5).

jshell> var result = Stream.of(0, 1, 2, 3, 4, 5, 6).
  ...>                      gather(Gatherers.windowFixed(3)).
  ...>                      toList()
result ==> [[0, 1, 2], [3, 4, 5], [6]]

windowSliding

Neben dem Unterteilen in jeweils unabhängige Teilbereiche kann auch eine Untergliederung mit Überlagerungen von Interesse sein. Um einen Stream in kleinere Bestandteile fixer Größe mit Überlappung zu unterteilen, dient die Methode windowSliding() aus dem JDK.

Wieder wird per iterate() ein unendlicher Stream von Zahlen erzeugt und durch Aufruf von windowSliding(4) jeweils in Teilbereiche der Größe vier untergliedert, allerdings mit einer Überlappung bzw. Verschiebung um ein Element. Mit limit(3) wird die Anzahl an Teilbereichen auf drei begrenzt. Wie zuvor werden diese durch Aufruf von toList() in Form einer Liste als Ergebnis bereitgestellt (Listing 6).

jshell> var result = Stream.iterate(0, i -> i + 1).
  ...>                      gather(Gatherers.windowSliding(4)).
  ...>                      limit(3).
  ...>                      toList()
result ==> [[0, 1, 2, 3], [1, 2, 3, 4], [2, 3, 4, 5]]

Betrachten wir die Auswirkungen auf das zuvor als Spezialfall aufgeführte Beispiel eines Streams wieder mit den Werten 0 bis inklusive 6. Statt mit windowFixed() wird hier windowSliding() genutzt. Dadurch wird der Datenbestand in sich überlappende Teilbereiche untergliedert. Dementsprechend tritt hier die Situation eines unvollständigen letzten Teilbereichs nicht auf, sondern es werden fünf Teilbereiche mit je drei Elementen erzeugt (Listing 7).

jshell> var result = Stream.of(0, 1, 2, 3, 4, 5, 6).
  ...>                      gather(Gatherers.windowSliding(3)).
  ...>                      toList()
result ==> [[0, 1, 2], [1, 2, 3], [2, 3, 4], [3, 4, 5], [4, 5, 6]]

Bei dieser Art von Operation kann der zuvor behandelte Spezialfall eines Datenbestands mit einer nicht ausreichenden Menge an Elementen normalerweise nicht auftreten. Das ist lediglich dann möglich, wenn die Länge der Eingabe kleiner als die Window-Größe ist – in dem Fall besteht das Ergebnis aus der gesamten Eingabe, wie es nachfolgend für einen Datenbestand von drei Werten und eine Window-Größe von fünf zu sehen ist. Das Ergebnis ist eine Liste, die wiederum eine Liste mit drei Elementen enthält (Listing 8).

jshell> var resultNew = Stream.of(1, 2, 3).
  ...>                         gather(Gatherers.windowSliding(5)).
  ...>                         toList()
resultNew ==> [[1, 2, 3]]

fold

Dazu, nämlich die Werte eines Streams miteinander zu verknüpfen, dient die Methode fold(). Sie arbeitet ähnlich wie die Terminal Operation reduce(), die ein Ergebnis aus einer Folge von Elementen erzeugt, indem wiederholt eine Operation zur Kombination, beispielsweise + oder * für Zahlen, auf die Elemente angewendet wird. Dazu gibt man einen Startwert und eine Berechnungsvorschrift an. Diese Letztere fest, wie das bisherige Ergebnis mit dem aktuellen Element verknüpft wird.

Stay tuned

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

 

Nutzen wir dieses Wissen als Ausgangsbasis für ein Beispiel mit fold(). Damit lässt sich die Summe der Werte mit 0 als Startwert und einer Addition als Berechnungsvorschrift wie in Listing 9 berechnen.

jshell> var crossSum = Stream.of(1, 2, 3, 4, 5, 6, 7).
  ...>                        gather(Gatherers.fold(() -> 0L,
  ...>                          (result, number) -> result + number)).
  ...>                        findFirst()
crossSum ==> Optional[28]

Bedenken Sie, dass gather() einen Stream als Ergebnis zurückgibt. Hier ist das ein einelementiger Stream. Um daraus einen Wert auszulesen, dient der Aufruf von findFirst(), der ein Optional<T> liefert, weil theoretisch der Stream auch leer sein könnte.

Als Berechnungsvorschrift können wir alternativ etwa eine Multiplikation mit einem Startwert von 1 nutzen und so für die spezielle Wertefolge von 1 bis 7 die Fakultät berechnen (Listing 10). Ganz allgemein handelt es sich um eine Multiplikation der gegebenen Zahlen (Listing 11).

jshell> var result = Stream.of(1, 2, 3, 4, 5, 6, 7).
  ...>                      gather(Gatherers.fold(() -> 1L,
  ...>                        (result, number) -> result * number)).
  ...>                      findFirst()
result ==> Optional[5040]
jshell> var result = Stream.of(10, 20, 30, 40, 50).
  ...>                      gather(Gatherers.fold(() -> 1L,
  ...>                        (result, number) -> result * number)).
  ...>                      findFirst()
result ==> Optional[12000000]

Aktionen für abweichende Typen

Was passiert, wenn wir zur Kombination der Werte auch solche Aktionen ausführen wollen, die nicht für die Typen der Werte, hier int, definiert sind? Als Beispiel wird ein Zahlenwert in einen String umgewandelt und dieser gemäß dem Zahlenwert durch Aufruf der Methode repeat() der Klasse String wiederholt (Listing 12).

jshell> var repeatedNumbers = Stream.of(1, 2, 3, 4, 5, 6, 7).
  ...>                               gather(Gatherers.fold(() -> "", 
  ...>                                 (result, number) -> result + 
  ...>                                 ("" +   number).repeat(number))).  
  ...>                               toList()
repeatedNumbers ==> [1223334444555556666667777777]

Varianten mir reduce()

Nur der Vollständigkeit halber seien hier die vorherigen Berechnungen als Varianten mit reduce() gezeigt. Weil reduce() eine Terminal Operation ist, lässt sie keine weitere Verarbeitung im Stream mehr zu – zudem funktionieren die Aktionen nur auf den Typen der Werte, womit sich das zuletzt gezeigte Beispiel nicht umsetzen lässt (Listing 13). Genau wie bei fold() werden bei reduce() ein Startwert und eine Berechnungsvorschrift angegeben. Daraus entsteht dann ein Ergebniswert.

jshell> var sum = Stream.of(1, 2, 3, 4, 5, 6, 7).
  ...>                   reduce(0, (result, number) -> result + number)
sum ==> 28

jshell> var result = Stream.of(1, 2, 3, 4, 5, 6, 7).
  ...>                      reduce(1, (result, number) -> result * number)
result ==> 5040

jshell> var result = Stream.of(10, 20, 30, 40, 50).
  ...>                      reduce(1, (result, number) -> result * number)
result ==> 12000000

scan

Sollen alle Elemente eines Streams zu neuen Kombinationen zusammengeführt werden, sodass jeweils immer ein Element dazukommt, kommt scan() zum Einsatz. Die Methode arbeitet ähnlich wie fold(), das die Werte zu einem Ergebnis kombiniert. Bei scan() wird dagegen für jedes weitere Element ein neues Ergebnis produziert.

 

Zunächst nutzen wir dies für die Ermittlung von Summen (Listing 14). Danach kombinieren wir Texte statt Ziffern nur durch Abwandlung des Startwerts, für den wir hier einen Leerstring nutzen, wodurch das + zu einer Stringkonkatenation wird (Listing 15).

jshell> var crossSums = Stream.of(1, 2, 3, 4, 5, 6, 7).
  ...>                         gather(Gatherers.scan(() -> 0, 
  ...>                           (result, number) -> result + number)).
  ...>                         toList()
crossSums ==> [1, 3, 6, 10, 15, 21, 28]
jshell> var crossSums = Stream.of(1, 2, 3, 4, 5, 6, 7).
  ...>                         gather(Gatherers.scan(() -> 0, 
  ...>                           (result, number) -> result + number)).
  ...>                         toList()
crossSums ==> [1, 3, 6, 10, 15, 21, 28]

Man könnte auch eine n-malige Wiederholung realisieren – dabei wird schön der Unterschied zu fold() deutlich (Listing 16).

jshell> var repeatedNumbers = Stream.of(1, 2, 3, 4, 5, 6, 7).
  ...>                               gather(Gatherers.scan(() -> "",
  ...>                                 (result, number) -> result +   
  ...>                                 ("" + number).repeat(number))). 
  ...>                               toList()
repeatedNumbers ==> [1, 122, 122333, 1223334444, 122333444455555, 122 ... 3334444555556666667777777]

Fazit

In diesem Artikel haben wir uns mit den Stream Gatherers als Preview-Feature beschäftigt. Zunächst habe ich erläutert, warum diese Neuerung für uns Entwickler nützlich und hilfreich ist. Danach wurden diverse bereits im JDK vordefinierte Stream Gatherers anhand von kleinen Anwendungsbeispielen vorgestellt. Insbesondere wurden auch Randfälle und Besonderheiten beleuchtet. Dadurch sollten Sie einen guten ersten Überblick gewonnen haben und fit genug sein, um eigene Experimente zu starten.

Neben den Stream Gatherers enthält Java 23 viele weitere Neuerungen, die die Programmiersprache voranbringen und attraktiver machen. Diese Modernisierung sollte dazu beitragen, dass Java weiterhin konkurrenzfähig bleibt und sich in modernen Anwendungsbereichen behauptet und insbesondere auch zu anderen derzeit populären Sprachen wie Python oder Kotlin aufschließt. Auch für komplette Newbies wird die Einstiegshürde gesenkt: Dank JEP 477 (Implicitly Declared Classes and Instance Main Methods (Third Preview)) lassen sich kleinere Java-Programme viel schneller und mit deutlich weniger Zeilen sowie für Anfänger schwierigen Begrifflichkeiten erstellen.

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

Stay tuned

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

 

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

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

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

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

Stay tuned

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

 

Schnellere Releases

Oracles Entscheidung, die Auslieferung neuer Java-Releases zu beschleunigen, wirkt sich auf das gesamte Ökosystem aus. Java 21 war aufgrund der Inklusion diverser Preview-Technologien ein besonders interessantes Release, das rasch angenommen wurde. Genauer: Nach der Auslieferung wurden binnen sechs Monaten 1,4 Prozent der überwachten Applikationen umgestellt – die Vorgängervariante Java 17 erreichte in der entsprechenden Zeitspanne nur eine Umstellung von 0,37 Prozent.

Relevant ist auch, dass Nicht-LTS-Versionen in den Ergebnissen von New Relic eine untergeordnete Rolle spielen: Weniger als 2 Prozent der untersuchten Programme nutzen sie produktiv.

Mindestens ebenso relevant ist die Frage, welches JDK beziehungsweise welche Runtime zur Ausführung verwendet wird – Oracle hat in der Vergangenheit durch eigenwillige lizenzpolitische Entscheidungen einiges an Goodwill verspielt, was sich in den Zahlen widerspiegelt (Abb. 1).

Abb. 1: Das Wachstum anderer Anbieter erfolgt fast ausschließlich auf Oracles Kosten (Bildquelle: [1])

Der Höhenflug von Amazon war dabei von kurzer Dauer, mittlerweile liegt der Großteil des Wachstums im Bereich des von der Eclipse Foundation verwalteten Adoptium. Azul Systems ist mehr oder weniger konstant, auch Red Hat und BellSoft erfreuen sich einer loyalen Nutzerschaft. SAP und Ubuntu konnten ihre (minimalen) Mindshare-Anteile indes nicht wirklich halten.

Ressourcen und ihr Management

Neue Designparadigmen wie Serverless und Microservices wurden von der Java-Community schnellstmöglich umgesetzt bzw. angenommen. Das geht mit interessanten Veränderungen hinsichtlich des Ressourcenbedarfs der individuellen Komponenten eines Systems einher.

Neue Designparadigmen wie Serverless und Microservices wurden von der Java-Community schnellstmöglich umgesetzt bzw. angenommen. Das geht mit interessanten Veränderungen hinsichtlich des Ressourcenbedarfs der individuellen Komponenten eines Systems einher.

Kennzahl 2023 [%]
1 bis 4 57,7
5 bis 8 25
9 bis 16 8,2
17 bis 32 4,1
33 bis 64 3,0
Mehr als 64 2,0

Tabelle 1: Verfügbare logische Prozessoren nach Java-Anwendungen im Jahr 2023

Im Bereich der JVM-Speicherzuweisungen setzt sich dieser Trend dagegen nicht fort. Sehr kleine VMs sind nach wie vor sehr beliebt, während sehr große Speicherbereiche eine (eher geringe) Schrumpfung zeigen. Der Gutteil der Systeme kommt indes mit weniger als 2 GB aus, was die im Markt vorherrschende Meinung von Java als Speicherfresser relativiert.

SIE LIEBEN JAVA?

Den Core-Java-Track entdecken

 

Insbesondere im Embedded-Bereich wird Java wegen der durch den Garbage Collector (GC) systemimmanenten „Denkpausen“ kritisiert. Die diversen JVM-Anbieter begegnen diesem Problem seit einiger Zeit durch neuartige GC-Algorithmen, die insbesondere auf mehrkernigen Systemen die Minderung der Probleme ermöglichen (Abb. 2). Zu berücksichtigen ist dabei, dass die verschiedenen VMs unterschiedliche Standardeinstellungen mitbringen – die Umstellung von Java 11 auf G1 und der damit einhergehende Zuwachs an darauf basierenden Systemen belegt, dass viele Installationen nach dem Prinzip „defaults are fine“ agieren.

Abb. 2: Garbage Collectors, die von Java-LTS-Versionen genutzt werden (Bildquelle: [1])

Der Rückgang im Bereich des Klassikers Serial ist im Zusammenhang mit der oben besprochenen Änderung an der Konstruktion der Systeme interessant – er hält das Gesamtsystem an, ist aber auf ressourcenbeschränkten Systemen am effizientesten.

Im Fall von G1 gilt aufgrund des Aufräumens kleiner Speicherbereiche, dass das System insbesondere auf nebenläufigen Maschinen für weniger Ärger sorgt. Der Garbage Collector kann seine Stärken vor allem dann ausspielen, wenn die JVM mehr als 4 GB Speicher zugewiesen hat. Die feinere Unterteilung macht die Aufräumprozesse effizienter.

Im Fall von G1 gilt aufgrund des Aufräumens kleiner Speicherbereiche, dass das System insbesondere auf nebenläufigen Maschinen für weniger Ärger sorgt. Der Garbage Collector kann seine Stärken vor allem dann ausspielen, wenn die JVM mehr als 4 GB Speicher zugewiesen hat. Die feinere Unterteilung macht die Aufräumprozesse effizienter.

Frameworks am Puls der Zeit

Die Verfügbarkeit von Modularisierungssystemen wie Maven oder die in Gradle integrierte Artefaktverwaltung animieren Entwickler dazu, Komponenten aus dem Ökosystem zur Erfüllung der anstehenden Aufgaben heranzuziehen.

 

Die erste in diesem Zusammenhang wichtige Frage betrifft die Art der Datenspeicherung: Schon aus dem Enterprise-Fokus folgt, dass Java-Applikationen häufig mit Datenbankservern aller Sorten interagieren. Die Herkunft von Java aus dem Sun- bzw. Oracle-Umfeld spiegelt sich in einer klaren Marktdominanz der Oracle Database wider, die in fast 20 Prozent der von New Relic überwachten Java-Applikationen zum Einsatz kommt (Tabelle 2).

Datenbank Nutzerschaft [%]
Oracle Database 17,3
PostgreSQL 14,4
MySQL 12,5
MongoDB 7,4
DynamoDB 4,9
SQL Server 4,4
Cassandra 2,7
Elasticsearch 2,5
MariaDB 1,4
Redshift 0,3

Tabelle 2: Die beliebtesten Datenbankserver [1]

Dabei dominieren klassische, auf SQL basierende Datenbanken: Werden die Anteile der drei Bestplatzierten addiert, erhält man einen Gesamtwert von 44,2 Prozent. An vierter Stelle folgt MongoDB; Java-Datenbanken wie die Graphdatenbank Neu4J sind überhaupt nicht auf den Rangplätzen anzutreffen.

Ein weiteres Thema betrifft den Verbreitungsgrad der Kryptographienutzung: Aus den von New Relic erhobenen Zahlen lässt sich ableiten, dass 41 Prozent der überwachten Applikationen auf die ein oder andere Weise auf eine Kryptographiebibliothek zurückgreifen. Das muss aber nicht unbedingt durch eine explizite Willensäußerung des Entwicklerteams bedingt sein, es ist genauso gut vorstellbar, dass die Bibliothek als Dependency einer anderen Bibliothek Eingang in die Build-Artefaktliste findet.

 

Jedenfalls zeigt die Verteilung der verwendeten Bibliotheken nur wenig Überraschendes (Abb. 3). Der erste Platz geht an den Klassiker Bouncy Castle, während die in diversen Spring-Frameworks inkludierte Spring Security den zweiten Platz einnimmt.

Abb. 3: Meistgenutzte Verschlüsselungsbibliotheken für Java-Anwendungen (Bildquelle: [1])

In seinem Report weist New Relic darauf hin, dass man ein baldiges deutliches Wachstum von Amazon Corretto erwartet. Ursache dafür ist demnach erstens die Vereinheitlichung der Software-Supply-Chain und zweitens die im Allgemeinen sehr gute Performance der diversen von Amazon implementierten Algorithmen.

Eine weitere im Report gestellte Frage betrifft die Art, wie Java-Applikationen Logging-Informationen sammeln. SLF4J wird dabei von 83 Prozent der Entwickler benutzt und damit ein Framework, das wie in Abbildung 4 schematisch dargestellt als Abstraktionsschicht zwischen der Applikation und dem jeweiligen Logging-Framework fungiert und zu einer Steigerung der Flexibilität beiträgt.

Abb. 4: SLF4J abstrahiert zwischen Applikationscode und dem jeweiligen Logging-Framework

Neben diesem genutzten Shortcut gilt, dass sich Log4j nach wie vor als absoluter Platzhirsch im Bereich der Logging-Frameworks etabliert hat: In 76,4 Prozent der von New Relic überwachten Applikationen findet sich eine Abhängigkeit auf diesen Universallogger. An zweiter Stelle steht JBoss (Abb. 5).

Abb. 5: Die beliebtesten Logging-Frameworks (Bildquelle: [1])

Stack Overflow als Pulsmesser

Dass die Anzahl der im Entwicklerfragedienst Stack Overflow zu bestimmten Technologien sichtbaren Interaktionen eine gute Benchmark für die Popularität der jeweiligen Technologie darstellt, soll in den folgenden Schritten als gegeben angenommen werden. Im Hause NewRelic bietet man mit GenAI seit einiger Zeit etwas Vergleichbares an, das auf Java-Entwickler fokussiert ist. Abbildung 6 zeigt, wie sich die Anfragen an diese KI über die verschiedenen Kategorien verteilen. Unter Learning versteht man dabei im Hause New Relic dabei nicht Machine Learning. Vielmehr handelt es sich um Fragen, die man auch als „How to“-Questions bezeichnen würde.

Abb. 6: Entwicklerfragen an die New-Relic-KI nach Themen (Bildquelle: [1])

Fazit

Die von New Relic erhobenen Informationen geben Entwicklern und Nutzern einen Überblick über den Zustand der Java-Entwicklung als Ganzes. Die rasche Annahme neuer Technologien zeigt, dass Sorgen um das Ableben der Java-Entwicklung gelinde gesagt vollkommen übertrieben sind.

Stay tuned

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

 


Links & Literatur

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

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

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

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

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

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

Ein Producer erstellt dabei Nachrichten, wie in Abbildung 1 gezeigt, während ein Consumer sie verarbeitet.

Abb.1: Einfacher Nachrichtenaustausch

Der Message Broker ist häufig ein zustandsbehaftetes System – eine Art Datenbank – und vermittelt Nachrichten zwischen Producer und Consumer. Als zustandsbehaftetes System hat ein Message Broker die Aufgabe, Nachrichten vorzuhalten und abrufbar zu machen. Ein Producer schreibt also Nachrichten in den Broker, während ein Consumer sie zu einer beliebigen Zeit lesen kann. Exactly once, also einmaliges Ausliefern, bedeutet, dass der Producer genau eine Nachricht produziert und der Consumer diese genau einmal verarbeitet. Also ganz einfach, oder?

Stay tuned

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

 

Es kann so einfach sein

Da eine Kommunikation zwischen den Systemen über eine Netzwerkebene stattfindet, ist nicht gewährleistet, dass die Systeme über den gleichen Wissensstand verfügen. Es muss also einen Rückkanal geben, der einzelne Operationen bestätigt, um einen Zustand zu teilen. Nur so wird sichergestellt, dass Nachrichten, die erstellt wurden, auch korrekt angekommen und verarbeitet worden sind. Aktualisiert sieht der Fluss also eher so aus, wie es Abbildung 2 zeigt.

Abb. 2: Durch Bestätigungen wird der Zustand zwischen Systemen geteilt

Erstellt ein Producer eine Nachricht, die vom Consumer gelesen werden soll, benötigt dieser eine Bestätigung (Abb. 2, Schritt 2). Nur dadurch weiß der Producer, dass die Nachricht korrekt im Broker persistiert vorliegt und er sie nicht erneut übertragen muss.

Der Consumer wiederum liest die Nachricht und verarbeitet sie. Ist die Verarbeitung fehlerfrei abgeschlossen, bestätigt der Consumer das. Der Broker muss die Nachricht deshalb nicht noch einmal ausliefern.

Immer Ärger mit der Kommunikation

Bei einer verteilten Kommunikation über ein Netzwerk kann es leider immer passieren, dass diverse Kommunikationskanäle abbrechen oder Fehler entstehen – und zwar an vielen Stellen: beim Erstellen, vor dem Konsumieren und danach. Genau diese Eigenschaft macht es so schwer oder gar unmöglich, eine Exactly-once-Semantik zu erreichen.

Angenommen, ein Producer produziert eine Nachricht (Abb. 3). Um sicherzustellen, dass diese auch vom Broker gespeichert wurde, wartet der Producer auf eine Bestätigung. Bleibt sie aus, ist nicht garantiert, dass der Broker diese Nachricht wirklich ausliefern wird.

Abb. 3: Der Producer erhält keine Bestätigung und sendet die Nachricht erneut

Der Producer muss diese Nachricht folglich erneut ausliefern. Genau das ist in Schritt 2 der Abbildung 3 auch geschehen, weshalb der Producer in Schritt 3 eine weitere Nachricht mit demselben Inhalt sendet. Da jetzt zwei Nachrichten vorliegen, verarbeitet der Consumer in den Schritt 4 und 5 beide Nachrichten – wohl eher nicht „exactly once“. Die Nachricht wird durch den Retry-Mechanismus „at least once“ – mindestens einmal, nicht genau einmal – übertragen. Denn wie im Bild zu erkennen ist, überträgt der Producer dieselbe Nachricht zweimal, um sicherzustellen, dass sie mindestens einmal vom Broker bestätigt wurde. Nur so ist sichergestellt, dass die Nachricht nicht verloren geht.

ALL ABOUT MICROSERVICES

Microservices-Track entdecken

 

Natürlich kann die Bestätigung auch ignoriert werden. Schritt 2 kann also ausbleiben. Ein Retry-System würde folglich fehlen. Der Producer überträgt also eine Nachricht, ohne auf eine Bestätigung des Brokers zu warten. Kann der Broker die Nachricht selbst nicht verarbeiten oder wegspeichern, hat er keine Möglichkeit, das Fehlschlagen oder eine erfolgreiche Operation zu quittieren. Die Nachricht würde „at most once“ – maximal einmal oder eben keinmal – übertragen werden. Exactly once ist also grundsätzlich ein Problem verteilter Anwendungen, die mittels Bestätigungen funktionieren.

Leider ist das noch nicht das Ende der Fahnenstange, wenn die Nachricht Ende zu Ende, also vom Producer bis zum Consumer, betrachtet wird. Denn es existiert in einem solchen System zusätzlich ein Consumer, der die Nachrichten wiederum einmalig verarbeiten muss. Selbst wenn garantiert wird, dass der Producer eine Nachricht einmalig erzeugt, ist ein einmaliges Verarbeiten nicht garantiert.

Abb. 4: Ein Consumer verarbeitet die Nachricht und versucht diese danach zu bestätigen

Es kann passieren, dass der Consumer wie in Abbildung 4 gezeigt die Nachricht in Schritt 3 liest und in Schritt 4 korrekt verarbeitet. In Schritt 5 geht die Bestätigung verloren. Das führt dazu, dass die Nachricht mehrmals, aber mindestens einmal – at least once – verarbeitet wird.

Abb. 5: Ein Consumer bestätigt die Nachricht vor dem Verarbeiten

Es ist natürlich umgekehrt auch möglich, die Nachricht vor dem Verarbeiten zu bestätigen. Der Consumer lädt also die Nachricht und bestätigt sie direkt. Erst dann wird in Schritt 5 von Abbildung 5 die Bearbeitung der Nachricht erfolgen. Schlägt jetzt die Bearbeitung fehl, ist die Nachricht in Schritt 4 bereits bestätigt worden und wird nicht erneut eingelesen. Die Nachricht wurde wieder maximal einmal oder keinmal – at most once – verarbeitet.

Wie also zu erkennen, ist es leicht, At-most-once- und At-least-onceSemantiken in den verschiedenen Konstellationen sowohl auf Producer- als auch auf der Consumer-Seite herzustellen. Exactly once ist aber aufgrund der verteilten Systematik ein schwieriges Problem – oder gar unmöglich?

SIE LIEBEN JAVA?

Den Core-Java-Track entdecken

 

Lösungen müssen her

Für eine Möglichkeit, eine Exactly-once-Semantik zu erreichen, muss die Verarbeitung der Nachrichten einer Applikation eine bestimmte Eigenschaft unterstützen: Idempotenz. Idempotenz bedeutet, dass eine Operation, egal wie oft sie verarbeitet wird, immer dasselbe Ergebnis zur Folge hat. Ein Beispiel dieses Prinzips könnte das Setzen einer Variablen im Programmcode sein. Hier gibt es etwa die Möglichkeit, dies über Setter oder eben relative Mutationen zu implementieren.

Zum Beispiel setAge oder incrementAge. Die Operation person.setAge(14); kann beliebig oft nacheinander ausgeführt werden, das Ergebnis bleibt immer dasselbe, nämlich 14. Hingegen wäre person.incrementAge(1) nicht idempotent. Wird diese Methode unterschiedlich oft hintereinander ausgeführt, gibt es verschiedene Ergebnisse, nämlich nach jeder Ausführung ein Jahr mehr. Genau diese Eigenschaft der Idempotenz ist der Schlüssel, um eine Exactly-once-Semantik zu etablieren.

Angewandt auf die Systeme von zuvor bedeutet das, dass eine At-least-once-Semantik mit der Eigenschaft der Idempotenz zu einer Exactly-once-Verarbeitung führen kann. Wie eine At-least-once-Semantik umgesetzt werden kann, zeigt das zuvor beschriebene Bestätigungssystem. Was fehlt, ist also ein System von Idempotenz in der Verarbeitung. Aber wie kann eine Verarbeitung von Nachrichten idempotent gemacht werden?

Um das zu erreichen, muss der Consumer die Möglichkeit haben, einen lokalen, synchronisierten Zustand zu erhalten. Um den Zustand einer Nachricht zu erhalten, muss diese eindeutig identifizierbar sein. Nur so werden das Aufsuchen und eine Deduplizierung der Nachricht ermöglicht.

Abb. 6: Eine idempotente Verarbeitung

Anders als zuvor speichert der Consumer mit jedem Aufruf in Schritt 4 der Abbildung 6 die Nachricht zunächst in einer lokalen Zustandshaltung. An dieser Stelle kann, sofern die Nachricht bereits lokal vorhanden ist, ein erneutes Speichern vernachlässigt werden. In Schritt 5 wird die Nachricht bestätigt. Schlägt die Bestätigung fehl und wird die Nachricht folglich erneut übertragen, ist das kein Problem, da in Schritt 4 das erneute Speichern der Nachricht verhindert werden kann. An dieser Stelle lebt also die Idempotenz. Beim Bearbeiten kann der Consumer nun selbst entscheiden, ob eine Verarbeitung notwendig ist, z. B. indem zu einer Nachricht ein Status eingeführt und dieser in Schritt 6 lokal abgefragt wird. Steht dieser bereits auf Processed, muss nichts getan werden. Umgekehrt muss eine verarbeitete Nachricht den Status korrekt aktualisieren.

Stay tuned

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

 

Fazit

Verteilte Systeme haben ein grundsätzliches Problem, eine Exactly-once-Semantik herzustellen. Es kann auf Infrastrukturebene entweder zwischen at least once oder at most once gewählt werden. Erst durch die Eigenschaft der Idempotenz kann auf dem Applikationslevel sichergestellt werden, dass Nachrichten genau einmal von Ende zu Ende verarbeitet werden.

Natürlich ist das nicht kostenlos. Es bedeutet, dass die Applikation selbst eine Verwaltung von Nachrichten übernehmen muss und deren Zustand verwaltet – wirklich exactly once ist das natürlich auch nicht, es kommt diesem durch die Eigenschaft der Idempotenz jedoch im Ergebnis sehr nahe.

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

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

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

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

Die Verbesserungen gehen aber weit über den Release-Zyklus hinaus. Bahnbrechende Innovationen wie Project Loom (leichtgewichtige virtuelle Threads) und ZGC (Low-Pause Garbage Collection) haben die Java-Entwicklung nachhaltig verändert.

SIE LIEBEN JAVA?

Den Core-Java-Track entdecken

 

Das Projekt Panama verbessert die Entwickler:innen-Erfahrung durch eine sicherere und leistungsfähigere Methode zur Interaktion mit nativen Codebibliotheken. Das Projekt Amber führt datenorientierte Programmierfunktionen wie Records und Pattern Matching ein und macht Java ausdrucksstärker.

Die Zukunft ist vielversprechend, aber Goetz räumt auch ein, dass es Herausforderungen gibt. Library-Upgrades können durch Abhängigkeiten von älteren Funktionen behindert werden. Um diese Lücke zu schließen, schlägt er einen schnelleren Release-Zyklus für das Library-Ökosystem vor, um mit der Innovation von Java Schritt zu halten.

Diese Keynote ist vollgepackt mit Erkenntnissen für Java-Entwickler:innen. Sehen Sie sich die Aufzeichnung an, um mehr über diese Fortschritte zu erfahren und zu sehen, was Java als Nächstes erwartet!

Stay tuned

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

 

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

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

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

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

Abb. 1: Namespace Pollution

Lösungen für diese Probleme sowie eine Alternative für den mit Java 17 abgekündigten Security Manager [1] bieten Java-Module. Der Einsatz von Modulen ist eine grundlegende Entscheidung, da das Java-Modul-System nur ganz oder gar nicht verwendet werden kann. Wenn man es nutzt, kommen neben den Vorteilen auch grundsätzliche Veränderungen im Verhalten dazu. Im Open-Source-Projekt m-m-m [2] nutze ich das JPMS extensiv und mit großer Begeisterung. Ein weiteres großes Java-Open-Source-Projekt, das JPMS konsequent nutzt, ist Helidon [3]. Insgesamt wurde JPMS aber bisher vom Java Ecosystem nur sehr zögerlich angenommen.

Stay tuned

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

 

Dieser Artikel versteht sich als eine logische Fortsetzung von [4] und will Mut machen, sich mit dem JPMS auseinanderzusetzen. Er geht auf Details und Best Practices ein, die über die typischen Hello-World-Tutorials zu Java-Modulen im Web hinausgehen.

Java-Module

Begriffe wie Modul oder auch Komponente werden in der IT nicht präzise definiert und daher in verschiedenen Kontexten unterschiedlich verwendet, was zu großer Verwirrung führen kann. Relativ gut kann man sich darauf einigen, dass es sich dabei um geschlossene Funktionseinheiten mit definiertem API handelt und dadurch eine bessere Kapselung erreicht wird. In Java ist ein Modul eine Einheit, wie man es von einer JAR-Datei oder einem Maven-Modul kennt, die über einen spezifischen Moduldeskriptor verfügt (oder für die als Automatic Module ein Moduldeskriptor dynamisch erzeugt wird).

Der Moduldeskriptor

Dieser Deskriptor wird im Source Code in der Datei module-info.java im Default-Package definiert. Es handelt sich hierbei um ein völlig anderes Konstrukt als Class, Interface, Enum, Annotation oder Record. Beim Build wird aus dem Modul eine JAR-Datei, die im Wurzelverzeichnis eine module-info.class-Datei enthält. Der Modul-Deskriptor definiert eine ganze Reihe wichtiger Eigenschaften des Moduls:

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

Modulname und Packages

Der Name des Moduls dient als globaler, weltweit eindeutiger Identifier, um das Modul zu referenzieren. Er folgt den gleichen Syntaxregeln wie ein Package, ist davon aber prinzipiell völlig unabhängig. Nutzer von Maven oder Gradle können es sich als Kombination von Group-ID und Artifact-ID vorstellen. Es hat sich als Best Practice herausgestellt, Package- und Modulnamen sowie Artifact-ID in Einklang zu bringen. Das bedeutet, dass sich der Modulname als Namensraum bzw. Präfix in den Packages, die im Modul enthalten sind, wiederfindet. Diese Konvention hat auch den Vorteil, dass Entwickler, die ein Modul verwenden möchten, einfach zwischen Modulnamen und Packages hin und her mappen können, ohne in einer externen Dokumentation nachschauen zu müssen. Eine Klasse aus einem bekannten Package zu verwenden, heißt zwar noch nicht, dass der Modulname mit dem Package-Namen übereinstimmt, aber wenn der Modulname ein Präfix davon ist, kann er in modernen IDEs mit Autovervollständigung gezielt gefunden werden.

Zudem sind alle Java-Entwickler bereits mit den Best Practices für Packages samt den Namensräumen als „umgedrehten Domainnamen“ vertraut. Zur Veranschaulichung stellen wir uns als konkretes Beispiel vor, dass wir für awesome-restaurant.com arbeiten und gerade das Backend für Onlinereservierungen und -bestellungen bauen. Der Java-Code verwendet für Bestellungen bereits Packages wie com.awesome_restaurant.online.order.domain oder com.awesome_restaurant.online.order.repository. Da ein Minuszeichen in Package-Namen nicht zulässig ist, wurde es beim Mapping der Domain auf Java-Packages durch einen Unterstrich ersetzt (man kann es natürlich auch weglassen oder durch einen Punkt ersetzen).

Möchte ich nun das Backend als Java-Modul bereitstellen, könnte meine module-info.java-Datei so aussehen:

module com.awesome_restaurant.online.order {
  requires jakarta.persistence;
  // ...
  exports com.awesome_restaurant.online.order.domain;
  // ...
}

Abhängigkeiten von anderen Modulen

Abhängigkeiten werden in der module-info.java-Datei durch das Schlüsselwort requires eingeleitet. Am Ende folgt der Name des angeforderten Moduls. Im obigen Beispiel ist das jakarta.persistence, der neue Modulname der JPA aus Jakarta EE.

Optional kann requires noch einer der beiden folgenden Modifier folgen:

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

Damit kommen wir zur ersten starken Einschränkung bei der Verwendung des JPMS: In meinem Modul sehe ich fremden Code nur dann, wenn er aus einem Modul kommt, das als Abhängigkeit per requires importiert wird (inklusive transitiver Abhängigkeiten). Das betrifft sogar Klassen aus dem JDK – bis auf solche, die aus dem Modul java.base stammen. Das ist super, weil es das eingangs erwähnte Problem der Namespace Pollution löst.

 

Gleichzeitig bedeutet es aber auch, dass ich nur Code importieren und auf ihn zugreifen kann, wenn er selbst wiederum als Java-Modul bereitsteht. Und damit beginnt schon ein großes Dilemma: Die coole Open-Source-Bibliothek, die ich gerne nutzen möchte, ist kein Modul? Dann kann ich sie in meinem Modul erst mal nicht verwenden.

An dieser Stelle möchten wir die verschiedenen Arten von Java-Modulen differenzieren:

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

Neue Spielregeln für Packages und Reflection

Die Spielregeln beim JPMS sind vom Prinzip her das Gegenteil von dem, was man als Java-Entwickler mit den normalen Classpaths gewohnt ist: Im normalen Java ist grundsätzlich alles sichtbar und erlaubt, außer, wir schränken es mit Schlüsselwörtern, wie z. B. private ein. Packages selbst sind immer public. Beim JPMS ist mein eigener Code zunächst nur innerhalb meines Moduls sichtbar. Will ich das ändern, muss ich Packages explizit exportieren. Hierzu verwenden wir in der module-info.java-Datei das Schlüsselwort exports, gefolgt von dem Package, das wir veröffentlichen möchten. Das geht nur für Packages, die im Modul auch existieren und nicht leer sind. Ich muss also jedes einzelne Package, das ich exportieren möchte, explizit auflisten.

Eine Syntax, die alle Packages automatisch exportiert, ist nicht vorgesehen. Das ist auch sinnvoll, denn so wird verhindert, dass ein nachträglich hinzugefügtes Package aus Versehen exportiert und damit öffentlich wird. Es geht hierbei um das Geheimnisprinzip, das der erfahrene Java-Entwickler aus der Softwarearchitektur kennt. Nur beim Automatic Module werden automatisch alle Packages exportiert und damit das gesamte Verhalten von JARs im Classpath für Module simuliert.

Die Restriktionen gehen aber noch weiter: Ich kann keinen Code in ein Package platzieren, in das bereits ein anderes Modul Code platziert – auch wenn ich als Autor dieses anderen Moduls alles unter meiner Kontrolle habe und sogar dann nicht, wenn das Package nicht exportiert ist. Dieses sogenannte Split-Package-Konstrukt wird in konventionellem Java manchmal eingesetzt, um Sichtbarkeiten bewusst zu hintergehen: Ich lege meine eigene Klasse ins Package org.hibernate, um an eine Methode von Hibernate zu kommen, die protected oder default als Sichtbarkeit hat. Und wenn dieser Hack nicht reicht, hole ich den Holzhammer raus und nutze Reflection und mache mit der Methode setAccessible(true) alles zugänglich. Um die Katze vollständig aus dem Sack zu lassen: Reflection geht in JPMS per Default auch nicht mehr, außer, sie wird explizit erlaubt.

Als ich mich zum ersten Mal mit dem JPMS auseinandergesetzt habe, war spätestens das der Moment, wo ich geschluckt und das Thema erstmal beiseite gewischt habe. Das ist vermutlich auch der Grund, warum viele Artikel im Web so negativ über das JPMS berichten und vom Einsatz abraten. Das ist aber sehr schade, denn meines Erachtens gründet diese Reaktion ausschließlich in der Macht der Gewohnheit.

IT ist ständig im Wandel und Patterns, die vor zehn oder zwanzig Jahren unser Denken bestimmt haben, sind heute überholt und wurden durch neue ersetzt. Aus Sicht von IT-Security und Zero Trust ist das Design des JPMS genial für einen Neustart. Sind nicht gerade Reflection und Serialisierung die Wurzel aller Übel der gravierenden CVEs in der Java-Welt? Und ist nicht die Verwendung von (Deep) Reflection zur Laufzeit spätestens seit dem Erscheinen von GraalVM, Quarkus und Co. ein Konstrukt des vergangenen Jahrtausends? Mit JEP 411 wurde der Security Manager beerdigt und man sollte sich die Frage stellen, ob man nicht auf einem sinkenden Schiff segelt, wenn man sich nicht mit dem JPMS auseinandersetzt.

Wer sich in diesem Sinne auf das JPMS einlassen kann, der wird sich nach einer gewissen Eingewöhnungszeit fragen, warum Java nicht von Anfang an so konzipiert wurde. Mit dem konkreten Ausprobieren kommt auch die Erfahrung, um die Vor- und Nachteile abwägen zu können und zu wissen, in welchem Vorhaben der Einsatz sinnvoll ist und wo eher nicht. Der Vollständigkeit halber sei noch erwähnt, dass das Schlüsselwort open vor module im Moduldeskriptor das ganze Modul per Reflection freigibt oder das Schlüsselwort opens dies nur für ein explizites Package erlaubt (analog zu exports). Im Allgemeinen rate ich von diesem Konstrukt jedoch ab.

Stay tuned

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

 

In meiner Zeit vor dem JPMS verwendete ich in meinen Packages Segmente wie api oder impl, um das auszudrücken, was ich ohne JPMS nicht ausdrücken konnte. Mit externen Tools wie SonarQube konnte ich dann ungewollte Zugriffe aus einem api-Package auf ein impl-Package aufdecken. Inzwischen kann ich auf künstliche Segmente wie api verzichten und über meinen Moduldeskriptor festlegen, welches Package zum API gehört und daher exportiert wird. Die Implementierungen können immer noch in impl-Sub-Packages liegen, wenn man es explizit machen möchte. Da diese für andere Module unsichtbar sind, können die Packages und sämtliche enthaltenen Klassen nach Belieben umbenannt werden, ohne Gefahr zu laufen, dass ein Nutzer des Moduls beim nächsten Release Compilerfehler erhält. Das Beste ist, dass alle diese internen Klassen und ihre Methoden nach Belieben public sein dürfen und der Spagat zwischen flexibler interner Nutzung und Verhinderung ungewollter externer Nutzung beendet ist.

Was aber, wenn mein Projekt aus vielen Modulen besteht und ich bestimmte Zugriffe für eigene Module erlauben und für fremde Module verbieten will? Kein Problem! Im Moduldeskriptor kann ich dazu beim exports-Statement nach dem Package und dem Schlüsselwort to einen oder mehrere Modulnamen angeben, auf die der Zugriff eingeschränkt wird. In unserem Beispiel könnte das so aussehen:

exports com.awesome_restaurant.online.order.repository to
com.awesome_restaurant.online.order.app,
com.awesome_restaurant.online.reservation.app;

Wichtig zum Verständnis ist, dass nach exports ein Package kommt und nach to Modulnamen. Wildcards, um gleich einen ganzen Namensraum zu erlauben (z. B. com.awesome_restaurant.*), werden dabei leider nicht unterstützt.

Services 2.0

In Java gibt es seit der Version 1.6 den ServiceLoader, um Services dynamisch anzufordern, die sich über textuelle Dateien unter META-INF/services konfigurieren lassen. Das Konstrukt hat keine übermäßige Verbreitung gefunden und ist vielen Java-Entwicklern unbekannt. In Frameworks wie Spring oder Quarkus hat man mit Dependency Injection bereits mächtigere Werkzeuge für dieses Problem.

Mit dem JPMS erfährt der ServiceLoader aber ein Facelifting und damit eine Wiedergeburt: Im Moduldeskriptor können angebotene und genutzte Services Refactoring-stabil angegeben werden. Damit können Module Implementierungen einer Schnittstelle (oder abstrakten Klasse) anfordern, und beliebige andere Module können dazu Implementierungen bereitstellen. Dieses Konstrukt ermöglicht es, mit purem Java, unabhängig von irgendwelchen Frameworks (und Reflection Magic), dynamische Services umzusetzen. Egal ob mit ServiceLoader oder Frameworks wie Spring haben sich für mich zwei Muster herauskristallisiert:

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

 

In beiden Fällen füge ich im Modul, das auf den Service zugreifen möchte, ein uses-Statement in den Modul-Deskriptor ein und gebe dabei das Interface des Service an:

module org.search.engine {
  uses org.search.engine.extractor.TextExtractor;
  exports org.search.engine;
  exports org.search.engine.extractor;
  // ...
}

Um im Code an Implementierungen des Service TextExtractor zu gelangen, erzeuge ich einen passenden ServiceLoader und iteriere über die verfügbaren Implementierungen:

ServiceLoader<TextExtractor> serviceLoader = ServiceLoader.load(TextExtractor.class);
for (TextExtractor extractor : serviceLoader) {
  register(extractor);
}

Dabei ist es wichtig, dass der Aufruf von ServiceLoader.load im Code des Moduls stattfindet, in dem das uses-Statement definiert ist. Wer eine generische Hilfsklasse in ein anderes Modul auslagert, die den ServiceLoader anhand eines übergebenen Class-Objekts lädt, wird das schmerzlich bemerken. Die Hilfsklasse habe ich in meinem Projekt trotzdem gebaut, muss aber den ServiceLoader außerhalb im verantwortlichen Modul erzeugen und ihn dann an die Hilfsklasse übergeben. Nutzen entsteht aber erst dann, wenn für den Service auch mindestens eine Implementierung bereitgestellt wird.

In einem anderen Modul implementieren wir dazu besagten PdfTextExtractor und stellen diese Implementierung über ein provides-Statement im Moduldeskriptor zur Verfügung:

module com.company.search.extractor.pdf {
  provides org.search.engine.extractor.TextExtractor
  with com.company.search.extractor.pdf.PdfTextExtractor;
}

Eine Anwendung, die das Ganze im Zusammenspiel verwenden möchte, importiert die Suchmaschine samt der gewünschten Plug-in-Module:

module com.awesome_restaurant.online.app {
  requires org.search.engine;
  requires com.company.search.extractor.pdf;
  requires com.enterprise.search.extractor.xps;
  // ...
}

Brauche ich den XPS-Support nicht mehr, entferne ich einfach das entsprechende requires-Statement. Will ich ein anderes Format unterstützen, lade ich das passende Plug-in über eine weitere requires-Anweisung (wobei das requires-Statement optional ist, solange sich das Modul mit dem Plug-in im Modulpfad befindet).

Hier noch einige Tipps aus dem praktischen Umgang damit:

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

Internationalisierung

Java bietet mit ResourceBundle und MessageFormat die Grundlagen für die Internationalisierung einer Anwendung, also für die Unterstützung mehrerer Sprachen z. B. bei Texten für den Endnutzer. Das Laden eines solchen ResourceBundle aus properties-Dateien (z. B. UiMessages_de.properties) lädt jedoch eine Ressource per ClassLoader, was wie ein Reflection-Zugriff gewertet wird. Daher gibt es Probleme, wenn man diese properties-Dateien in einem Modul verpackt und dann generischen Code aus einem anderen Modul diese als ResourceBundle laden will. Solche Aspekte sind in Java nicht klar dokumentiert und es hat mich initial etwas Zeit gekostet, das vollständig zu verstehen. Um nicht das komplette Modul mit dem Schlüsselwort open für Reflection freizugeben, ist die pragmatischste Lösung, Bundles zur Internationalisierung in separaten JAR-Dateien ohne Moduldeskriptor auszuliefern. Sind sie im Class-/Modulpfad, so landen sie im Unnamed Module und können ohne Probleme geladen werden.

SIE LIEBEN JAVA?

Den Core-Java-Track entdecken

 

IDE-Unterstützung

Als ich die ersten Versuche mit Java-Modulen gemacht habe, war die Unterstützung in gängigen Entwicklungsumgebungen katastrophal bis nicht vorhanden. Inzwischen hat sich das deutlich stabilisiert und auch Code Completion und Refactoring werden unterstützt. Ich kann sowohl in IntelliJ als auch in Eclipse komplexe Projekte mit vielen Java-Modulen zuverlässig entwickeln. In Eclipse gibt es noch diverse Bugs bei module-info.java-Dateien mit dem Code-Formatter und insbesondere mit Import-Statements. Ich verzichte daher einfach auf Imports in diesen Dateien und verwende voll qualifizierte Typen. Auch bei IntelliJ gibt es Kinderkrankheiten, wie z. B. IllegalAccessError, weil irgendein Teil von JUnit im Unnamed Module gelandet ist.

Tests

Im einfachen Fall schreibt man seine JUnit-Tests ganz normal wie in Projekten ohne Java-Module. Will man aber im Test ein zusätzliches Testmodul haben, einen Service zum Testen hinzufügen oder Ähnliches, gibt es Probleme. Eigentlich würde man gern unter src/test/java eine zusätzliche module-info.java hinterlegen können, die dann für den Test verwendet wird. Genau das wird auch in der Maven-Dokumentation als Lösung propagiert – wird aber von IDEs gar nicht unterstützt [5]. Als Workaround erstelle ich dann zusätzliche Module, die nur zum Testen dienen, und schließe diese vom Deployment aus, sodass sie nicht im Release veröffentlicht werden.

Entscheidung für oder gegen JPMS

Im nächsten Spring-Boot- oder Quarkus-Projekt würde ich persönlich JPMS trotzdem noch nicht nutzen. Diese Frameworks basieren maßgeblich auf Reflection, und mit Java-Modulen schaffe ich mir hier viele Probleme, ohne großen Nutzen zu stiften. Inzwischen ist es immerhin technisch möglich, was schon mal als großer Fortschritt zu werten ist. Wenn ich ein Backend mit JPMS ausprobieren will, kann ich das mit Helidon tun, das besser für JPMS geeignet ist, jedoch im Vergleich zu Spring Boot und Quarkus eher ein Schattendasein fristet. Micronaut kann mit dem JPMS aktuell gar nicht verwendet werden [6].

Schreibe ich hingegen eine Java-App ohne große Frameworks, sollte ich das JPMS ausprobieren. Entwickle ich eine Bibliothek, die möglichst viele Nutzer erreichen soll, ist die Unterstützung des JPMS Pflicht. Andernfalls schließe ich diverse potenzielle Nutzer von vornherein aus.

Fazit und Ausblick

Im Jahr 2018 konnte man das JPMS zwar schon vollständig nutzen, kämpfte aber gegen Windmühlen. In der heutigen Zeit hat sich das JPMS so weit etabliert, dass die Unterstützung sämtlicher Tools ausgereift und die der wichtigen Java-Bibliotheken gegeben ist. Es ist also an der Zeit, sich intensiver mit dem Thema auseinanderzusetzen und eigene Experimente und Erfahrungen zu machen. Für Entwickler von Bibliotheken ist es heutzutage Pflicht, Moduldeskriptoren (mindestens als Automatic-Module-Name) mitzuliefern.

Wenn sich Java weiter weg von Deep Reflection zur Laufzeit hin zu AoT und CWA entwickelt und auch die Frameworks sich entsprechend weiterentwickeln, könnte die Verwendung des JPMS in der Zukunft zumindest für Green-Field-Projekte zum Standard werden.


Links & Literatur

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

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

[3] https://github.com/helidon-io/helidon

[4] Langer, Angelika; Kreft, Klaus: „Eine Anwendung in vielen Teilen. Project Jigsaw aka Java Module System“; in: Java Magazin 10.2017, https://entwickler.de/reader/reading/java-magazin/10.2017/1976daae3f43cab4336cd7ad

[5] https://github.com/eclipse-jdt/eclipse.jdt.core/issues/1465

[6] https://github.com/micronaut-projects/micronaut-core/issues/6395

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

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

The post Resiliente Kafka Consumer bauen appeared first on JAX.

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

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

Fehlerquelle:

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

Fehlerhäufigkeit:

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

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

Stay tuned

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

 

Kafka-Grundlagen

Um die verschiedenen Strategien der Fehlerbehandlung zu verstehen, ist es notwendig, die grundlegenden Konzepte von Kafka zu kennen. Diese erläutern wir hier kurz. Für Details verweisen wir z. B. auf den Artikel „Kafka 101: Massive Datenströme mit Apache Kafka“ [2].

Broker

Kafka bezeichnet sich als Event-Streaming-Plattform, die als Ersatz für traditionelle Message-Broker benutzt werden kann. Der Message-Broker dient dazu, asynchrone Nachrichten in Topics zu veröffentlichen. Nach einer konfigurierbaren Zeit, der Retention Time, löscht der Broker die Nachrichten.

Producer

Der Producer veröffentlicht Nachrichten mit einem Schlüssel in einem Kafka Topic. Der Broker teilt ein Topic in mehrere Partitionen auf und speichert eine Nachricht in genau einer Partition des Topics. Dabei berücksichtigt der Broker den Schlüssel der Nachricht und schreibt alle Nachrichten mit demselben Schlüssel auch in dieselbe Partition. Je Partition garantiert der Broker, dass die Reihenfolge der Nachrichten erhalten bleibt. Der Producer muss die Schlüssel der Nachrichten also so wählen, dass alle Nachrichten, deren Reihenfolge untereinander erhalten werden muss, denselben Schlüssel haben.

Consumer

Die an den Nachrichten interessierten Consumer registrieren sich für das Topic und können so die Nachrichten vom Broker lesen und verarbeiten. Dabei geben die Consumer die Consumer Group an, zu der sie gehören. Der Broker stellt dann sicher, dass jede Partition genau einem Consumer einer Consumer Group zugewiesen ist.

Problembeschreibung

In unserem Projekt nutzen wir Kafka (Kasten: „Kafka-Grundlagen“), um Änderungen an den Daten, die ein System verantwortet, als Events dieses Systems zu veröffentlichen. Im Domain-Driven Design spricht man hier von den Zustandsänderungen eines Aggregats [3]. Wir erzeugen und verarbeiten die Events im Publish-Subscribe-Modus. Da die Retention Time mit fünf Tagen als endlich vorgegeben ist, enthalten die Events nicht nur die geänderten Daten, sondern immer den kompletten neuen Zustand des Aggregats.

Bezüglich der Performance haben wir recht entspannte Anforderungen: Aus fachlicher Sicht ist es ausreichend, wenn die Events vor Ablauf der Retention Time verarbeitet werden. Dabei ist es jedoch wichtig, dass die Reihenfolge der Events mit demselben Schlüssel erhalten bleibt und kein Event übersprungen oder anderweitig nicht vollständig verarbeitet wird. Events mit demselben Schlüssel nennen wir abhängig.

Nach Mathias Verraes [4] gibt es zwei schwierige Probleme in verteilten Systemen. Die Einhaltung der Reihenfolge ist das erste. Dieses Problem haben wir gelöst, indem die Nachrichten, deren Reihenfolge untereinander erhalten werden muss, denselben Schlüssel bekommen. Das zweite schwere Problem ist die Exactly-once Delivery. Wir können es umgehen, indem wir unsere Consumer idempotent implementieren.

Codebeispiele

Im Folgenden illustrieren wir jede betrachtete Strategie mit einem Codebeispiel. Diese nutzen die MicroProfiles Reactive Messaging und Fault Tolerance. Als Implementierung für beide MicroProfiles benutzen wir die für Quarkus üblichen Bibliotheken von SmallRye. Die Codebeispiele sind auch auf GitHub [5] zu finden.

Bei der Nutzung der Codebeispiele sind zwei Aspekte zu berücksichtigen: die Commit Strategy [6] und die Failure Strategy [7]. Als Commit Strategy haben wir latest gewählt, da beim Default throttled der Timeout throttled.unprocessed-record-max-age.ms [8] während länger dauernder Retries einer fehlerhaften Nachricht erreicht werden kann. Als Failure Strategy haben wir eine eigene Strategie implementiert, die davon ausgeht, dass der Code, der die Nachricht empfängt, die Fehler direkt behandelt und daher keine separate Failure Strategy mehr notwendig ist (Listing 1).

public class CustomKafkaFailureHandler implements KafkaFailureHandler {

  @ApplicationScoped
  @Identifier("custom")
  public static class Factory implements KafkaFailureHandler.Factory {

    @Override
    public KafkaFailureHandler create(
      KafkaConnectorIncomingConfiguration config,
      Vertx vertx,
      KafkaConsumer<?, ?> consumer,
      BiConsumer<Throwable, Boolean> reportFailure) {
        return new CustomKafkaFailureHandler();
    }
  }

  @Override
  public <K, V> Uni<Void> handle(IncomingKafkaRecord<K, V> record,
    Throwable reason, Metadata metadata) {
      return Uni.createFrom()
                .<Void>failure(reason)
                .emitOn(record::runOnMessageContext);
  }

Consumer herunterfahren

Der erste Halt auf unserer Reise führt uns zu einer äußerst einfach umzusetzenden Strategie: Consumer herunterfahren. Hierbei stoppt der Consumer die Verarbeitung und fährt den Prozess, in dem er läuft, herunter. Das macht er in dem Codebeispiel (Listing 2), indem er System.exit() aufruft. Alternativ könnte er z. B. in einer Kubernetes-Umgebung die Liveness-Probe [9] fehlschlagen lassen und so den Containermanager dazu veranlassen, den Prozess herunterzufahren. Diese Strategie, eingeschränkt auf den Kafka-Client des betroffenen Topics, bietet auch SmallRye Reactive Messaging an und nennt sie fail [10], [11].

@Incoming("aggregate-in")
public CompletionStage<Void> consume(Message<String> record) {
  LOGGER.trace("Aggregate received: {}", record.getPayload());
  try {
    controller.process(record.getPayload());
    return record.ack();
  } catch (Exception e) {
    LOGGER.error("Oops, something went terribly wrong with "
      + record.getPayload(), e);
    System.exit(1);
    return null; // never reached
  }
}

Um diese Strategie universell einsetzen zu können, ist es hilfreich, einen Containermanager zu haben, der den heruntergefahrenen Prozess neu startet. So bekommen wir den Retry in Form eines Restart Loops [7] geschenkt.

Der Weg zur Korrektur ist je nach Fehlerursache unterschiedlich: Bei einem Fehler im Producer sendet dieser die korrigierte Nachricht sowie alle abhängigen Nachrichten erneut. Der Consumer muss dann die Offsets der fehlerhaften Nachricht und aller ursprünglichen abhängigen Nachrichten überspringen.

Bei einem Fehler im Consumer ist lediglich der Bugfix zu deployen, dann läuft der Consumer einfach weiter. Bei einem Fehler in der Infrastruktur ist nichts zu tun, der Consumer läuft einfach weiter, sobald der Fehler behoben wurde.

Vorteile:

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

Nachteile:

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

Dead Letter Queue

Die nächste Station unserer Reise ist ein Klassiker der Fehlerbehandlung von asynchronen Nachrichten: die Dead Letter Queue (DLQ). Im Zusammenhang mit Kafka wird diese Strategie oft auch als Dead Letter Topic bezeichnet, bei der Umsetzung mittels Datenbank auch als Dead Letter Table. Tritt ein Fehler beim Verarbeiten einer Nachricht im Consumer auf, sortiert der Consumer die betroffene Nachricht in die namensgebende Warteschlage für „tote“ Nachrichten aus. Anschließend macht er mit der Verarbeitung der nächsten Nachricht weiter (Listing 3). Als Alternative zu einer eigens implementierten Fehlerbehandlung bietet SmallRye die Dead Letter Queue auch als Failure Strategy an [10], [12].

@Incoming("aggregate-in")
public CompletionStage<Void> consume(Message<String> record) {
  LOGGER.trace("Aggregate received: {}", record.getPayload());
  try {
    controller.process(record.getPayload());
  } catch (Exception e) {
    LOGGER.error("Oops, something went terribly wrong with "
      + record.getPayload(), e);
    deadLetterEmitter.send(record);
  }
  return record.ack();
}

Der Weg zur Korrektur ist wieder von der Fehlerursache abhängig. Bei einer semantisch oder syntaktisch fehlerhaften Nachricht schickt der Producer eine korrigierte Nachricht. Die fehlerhafte Nachricht in der DLQ muss daraufhin übersprungen werden. Bei einem Fehler des Consumers muss der Bugfix deployt werden, anschließend müssen die Nachrichten der DLQ wieder eingelesen werden. Auch nach Behebung eines Infrastrukturproblems muss die DLQ wieder eingelesen werden.

Vorteile:

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

Nachteile:

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

DLQ Advanced

Mit Blick auf unsere Problemstellung hat die einfache Dead Letter Queue ein entscheidendes Problem: Sie kann die Reihenfolge von Nachrichten nicht erhalten. Unsere Reise führt deswegen tiefer in die Welt der DLQs. Die Idee: Eine erweiterte Dead Letter Queue nimmt nicht nur Nachrichten auf, die bei der Verarbeitung Probleme machen, sondern auch alle von ihr abhängigen Nachrichten. Dazu muss lediglich der Schlüssel der fehlerhaften Nachricht gespeichert werden. Anschließend können alle abhängigen Nachrichten durch den gleichen Schlüssel identifiziert und ebenfalls in die DLQ geleitet werden (Listing 4).

@Incoming("aggregate-in")
public CompletionStage<Void> consume(KafkaRecord<String, String> record) {
  LOGGER.trace("Aggregate received: {}", record.getPayload());
  try {
    if(controller.shouldSkip(record.getKey())) {
      LOGGER.warn("Record skipped: " + record);
      deadLetterEmitter.send(record);
    } else {
      controller.process(record.getPayload());
    }
  } catch (Exception e) {
    LOGGER.error("Oops, something went terribly wrong with "
      + record.getPayload(), e);
    controller.addKeyToSkip(record.getKey());
    deadLetterEmitter.send(record);
  }
  return record.ack();
}

Der Weg zur Korrektur ist zunächst identisch mit dem der einfachen DLQ. Im Detail wird es jedoch kompliziert. Um die Reihenfolge beim Wiedereinlesen der DLQ weiterhin zu erhalten, muss ihr Einlesen mit dem Einlesen des regulären Topics koordiniert werden. Benutzt man ein Kafka Topic als DLQ, stellen sich weitere Probleme, wie die Verteilung von Schlüsseln in zwei Topics auf unterschiedliche Consumer und die Vermischung von korrigierten und nicht korrigierten Nachrichten in der DLQ. Die Realisierung als Dead Letter Table in der Datenbank löst manche dieser Probleme, verursacht aber auch neue. Wegen dieser steigenden Komplexität haben wir zu diesem Zeitpunkt ein Zwischenfazit gezogen, um nach weiteren Alternativen zu suchen.

Vorteile:

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

Nachteile:

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

Pausieren und Wiederholen

Die Dead Letter Queue ist eine naheliegende Lösung, wenn man von Fehlern ausgeht, die einzelne Nachrichten betreffen. Je mehr Nachrichten betroffen sind – egal, ob durch die Reihenfolge oder die gemeinsame Fehlerursache –, desto komplexer und aufwendiger wird die Lösung. Man kann sich dem Problem einer allgemeinen Fehlerbehandlung aber auch von einem anderen Szenario her nähern: dem Ausfall der Infrastruktur. Dabei spielt es keine Rolle, ob dieser Fehler den Ausfall einer notwendigen Datenbank, des Kafka-Clusters oder der Netzwerkverbindung betrifft. Entscheidend ist: Der Fehler betrifft vermutlich alle Nachrichten. Er lässt sich also nicht durch eine Quarantäne einzelner Nachrichten begrenzen. Die klassische Lösung für diesen Fall kennt jeder von automatischen Telefonansagen: „Bitte versuchen Sie es später noch einmal!“

Der erste Teil dieser Taktik besteht im Pausieren der Arbeit. Beim kompletten Herunterfahren des Consumers provozieren wir jedoch ein kostspieliges Rebalancing durch den Broker (siehe oben). Man kann aber auch das Konsumieren pausieren, solange man nicht den gesamten Consumer anhält. Das verhindert, dass der Broker die Pause als Ausfall des Consumers wertet und ein Rebalancing einleitet.

Der zweite Teil der Taktik ist das Wiederholen (engl. Retry) der Aktion, bei der ein Fehler aufgetreten ist. In unserem Fall bedeutet das auf oberster Abstraktionsebene immer das Konsumieren einer Nachricht. Wenn man außerdem die fehlerhafte Nachricht nicht committet, wird sie der Consumer nach der Pause einfach erneut einlesen und versuchen, sie zu verarbeiten.

Listing 5 zeigt, wie man beide Schritte mit der Retry-Annotation des MicroProfile Fault Tolerance umsetzt. Hiermit pausiert der Consumer für alle Partitionen, die ihm der Broker zugewiesen hat. Alternativ kann man dem Broker auch das Pausieren [13] einer einzelnen Partition direkt mitteilen und später die Verarbeitung mittels resume [14] wieder aufnehmen.

@Incoming("aggregate-in")
@Retry(delay = 2000L, jitter = 400L, maxRetries = -1, maxDuration = 0)
@ExponentialBackoff(maxDelay = 2, maxDelayUnit = ChronoUnit.HOURS)
public CompletionStage<Void> consume(Message<String> record) {
  LOGGER.trace("Aggregate received: {}", record.getPayload());
  try {
    controller.process(record.getPayload());
    return record.ack();
  } catch (Exception e) {
    LOGGER.error("Oops, something went terribly wrong with "
      + record.getPayload(), e);
    return record.nack€;
  }
}

Der Weg zur Korrektur ist wie immer abhängig von der tatsächlichen Fehlerursache. Da Infrastrukturprobleme meist externe Probleme sind, muss das Entwicklerteam bei der Lösung der Fehlerursache oft gar nicht aktiv werden. Tritt ein Infrastrukturproblem auf, können alle Consumer in einer Consumer Group unabhängig voneinander die Arbeit einstellen und in den Wiederholungsmodus wechseln. Sobald das Problem gelöst ist, sorgt das automatische Wiederholen für den Wiederanlauf aller Consumer.

Falls der Fehler beim Producer liegt, muss dieser wie bei allen anderen Strategien auch eine korrigierte Nachricht sowie eventuell weitere abhängige Nachrichten erneut schicken. Der Consumer muss alle vom Fehler betroffenen Nachrichten konsumieren und ignorieren. Bis dahin werden je nach gewählter Umsetzungsvariante entweder alle Nachrichten blockiert, die in der Partition mit der fehlerhaften Nachricht landen oder die in den Partitionen landen, die dem Consumer zugewiesen sind. Für abhängige Nachrichten wird die Partition quasi zu einer erweiterten Dead Letter Queue, die beim Wiedereinlesen allerdings keine Synchronisation benötigt. Ob und wie viele unabhängige Nachrichten vom Pausieren betroffen sind, hängt vom Verhältnis der Anzahl der Kafka-Schlüssel zur Anzahl der Partitionen und ggf. auch von der Anzahl der Consumer ab.

Falls der Fehler im Consumer liegt, blockieren die Partitionen, die von dem Fehler aktuell betroffen sind. Wie stark das Pausieren den Durchsatz bremst, hängt von der Schwere des Fehlers, möglichen Zusammenhängen mit den Kafka-Schlüsseln sowie vom Zufall ab. Sobald ein Bugfix deployt wurde, laufen die pausierten Partitionen durch das automatische Wiederholen ohne operativen Eingriff selbstständig wieder an.

Vorteile:

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

Nachteile:

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

Einfach loggen

Zunächst dachten wir nach dem produktiven Einsatz unseres Systems einige Wochen lang, wir könnten es uns auf der Insel „Pausieren und Wiederholen“ gemütlich machen. Dann mussten wir jedoch einen Abstecher zu einer weiteren Strategie unternehmen, die wir ursprünglich als absurd angesehen und daher direkt aussortiert hatten. Mittlerweile haben wir sie jedoch für spezielle Fälle zu schätzen gelernt. Diese Fälle zeichnen sich dadurch aus, dass ein Sachbearbeiter im ursprünglichen System Daten manuell korrigieren muss. Das verringerte den Durchsatz bei der Strategie „Pausieren und Wiederholen“ so deutlich, dass wir die Nachricht nun mittels einfachem Loggen behandeln. Dabei haben wir sichergestellt, dass nur einzelne Nachrichten so behandelt werden, und setzen diese Strategie somit nicht als Catch-all-Strategie ein.

Diese Strategie loggt die fehlerhafte Nachricht und bzw. oder ihre Metadaten, zählt gegebenenfalls eine Metrik hoch und verarbeitet dann die nächste Nachricht (Listing 6). Auch diese Strategie bietet SmallRye an und nennt sie ignore [10], [15].

@Incoming("aggregate-in")
public CompletionStage<Void> consume(Message<String> record) {
  LOGGER.trace("Aggregate received: {}", record.getPayload());
  try {
    controller.process(record.getPayload());
  } catch (Exception e) {
    LOGGER.error("Oops, something went terribly wrong with "
      + record.getPayload(), e);
  }
  return record.ack();
}

Der Weg zur Korrektur ist hier unabhängig von der Fehlerursache manuell: Die Logmeldung oder die erhöhte Metrik stößt einen manuellen Prozess an, der die Fehlerursache beheben und dann die geloggten und damit nicht verarbeiteten Nachrichten reproduzieren muss. Aufgrund des hohen manuellen Aufwands sollte diese Strategie nur für Fehlerursachen angewandt werden, die einzelne Nachrichten betreffen. Insbesondere scheidet sie damit für Infrastrukturprobleme aus.

In unserem Projekt setzen wir diese Strategie für Nachrichten ein, die der Producer fehlerhaft erstellt hat. Nachdem der Fehler im Producer behoben wurde, lässt der manuelle Prozess die Nachricht und alle davon abhängigen Nachrichten erneut erzeugen.

Vorteil:

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

Nachteile:

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

Fazit

Unsere Reise durch die Untiefen der asynchronen Fehlerbehandlung zeigt, dass je nach konkreter Problemstellung verschiedene Optionen infrage kommen, die auf den ersten Blick absurd erscheinen. Ganz prinzipiell kann man die von uns gefundenen Optionen nach der Granularität des Eingriffs und der Komplexität ihrer Umsetzung sortieren (Abb. 1).

Abb. 1: Strategien im Überblick

Einfach loggen bedeutet de facto keinen Eingriff und ermöglicht so einen hohen Durchsatz. Zudem ist es bestechend simpel. Der Preis ist das Risiko des Verlusts der korrekten Reihenfolge und kompletter Nachrichten. Die klassische Dead Letter Queue verhindert den Datenverlust durch einen minimalen Eingriff, kann aber die Reihenfolge auch nicht sicherstellen. Sie ist dafür auch etwas komplexer, als nur zu loggen. Die erweiterte Dead Letter Queue greift exakt so viel ein wie notwendig, um Datenverlust zu verhindern und die Reihenfolge zu erhalten. Dafür muss man sich aber um eine Reihe von Detailproblemen kümmern. Diese Option ist deshalb die komplexeste. Bei Pausieren und Wiederholen werden u. U. auch unabhängige Nachrichten angehalten, allerdings selten das gesamte Topic. Datenverlust und Reihenfolge sind kein Thema. Die Komplexität ist moderat und beschränkt sich größtenteils auf das Wissen über die Konfiguration von Kafka selbst. Die einfachste Option, die Datenverlust vermeidet und die Reihenfolge einhält, ist das komplette Herunterfahren des Consumers. Das bedeutet aber in den meisten Fällen auch den niedrigsten Durchsatz während eines Fehlers.

Selbstverständlich kann man all diese Lösungen durch weitere kleinere Lösungen ergänzen. Auch wenn wir auf der Suche nach einer Catch-all-Strategie waren: Oft ist es besser, Probleme dort zu lösen, wo sie auftreten. Das heißt, dass z. B. eine Datenbankausnahme auch einfach das Wiederholen des Datenbankaufrufs auslösen kann, statt die Verarbeitung der Nachricht sofort abzubrechen. Für bestimmte fachliche Spezialfälle mögen sogar einfaches Loggen und eine manuelle Lösung durch den Fachbereich akzeptabel sein.

Für unser Projekt haben wir uns am Ende für Pausieren und Wiederholen entschieden. Die erweiterte Dead Letter Queue wurde uns zu komplex und wir waren uns nicht sicher, ob wir trotz dieser Komplexität nicht immer noch manuell in die Produktion würden eingreifen müssen. Pausieren und Wiederholen waren dagegen vergleichsweise simpel zu verstehen und dabei nicht zu restriktiv. Vor allem aber faszinierte uns, dass wir damit effektiv manuelle Eingriffe in der Produktion vermeiden konnten. Das automatische Wiederholen bedeutete letztlich den Beginn einer geradezu magischen neuen Eigenschaft unseres Systems: der Selbstheilung.


Links & Literatur

[1] https://6sense.com/tech/queueing-messaging-and-background-processing/apache-kafka-market-share

[2] https://entwickler.de/software-architektur/kafka-101/

[3] https://www.dddcommunity.org/library/vernon_2011/

[4] https://twitter.com/mathiasverraes/status/632260618599403520

[5] https://github.com/HilmarTuneke/kafka-fehlerbehandlung

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

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

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

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

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

[11] https://quarkus.io/blog/kafka-failure-strategy/#the-fail-fast-strategy

[12] https://quarkus.io/blog/kafka-failure-strategy/#the-dead-letter-topic-strategy

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

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

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

The post Resiliente Kafka Consumer bauen appeared first on JAX.

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

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

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

Ein wichtiger pragmatischer Aspekt funktionaler Programmierung (FP) ist die Verwendung unveränderlicher (immutable) Daten. Diese kann eine Programmiersprache mehr oder weniger gut unterstützen. Dazu gehört auch die Möglichkeit, Funktionen ohne die Verwendung von Zuweisungen zu schreiben. Schließlich zählt die Unterstützung von höherstehenden Abstraktionen wie Monaden zur Standardausrüstung funktionaler Sprachen.

Unveränderliche Daten

Das mit den unveränderlichen Daten klingt eigentlich ganz einfach: Wir verzichten auf Zuweisungen, fertig. Damit das praktikabel wird, gehört allerdings doch mehr dazu. Wenn eine Klasse unveränderliche Objekte beschreibt, ist es meist sinnvoll, einen Konstruktor zu definieren, der für jedes Attribut der Klasse ein Argument akzeptiert, außerdem equals– und hashCode()-Methoden, die ausschließlich über die Gleichheit beziehungsweise die Hashfunktion der Attribute definiert sind, Ähnliches gilt für die toString()-Methode. In Java hat die IDE, die Konstruktor und Methoden automatisch generieren kann, diesen Job traditionell erledigt. Danach liegt aber eine Menge Code herum, der nur das Offensichtliche tut und Übersicht sowie Wartung behindert.

Stay tuned

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

 

Glücklicherweise hat Kotlin sogenannte Data Classes, in denen dieser Code automatisch vom Compiler generiert wird. Das ist so eine Klasse:

data class Rectangle(val length: Double, val width: Double)

Das data vor class sorgt für die automatische Generierung von Konstruktor und Methoden, val sorgt dafür, dass die entsprechenden Attribute nicht verändert werden können. (Weil das so praktisch ist, hat Java längst mit Records nachgezogen.)

Bei funktionalen Sprachen sind Pendants zur Java Collections Library dabei, die allerdings ebenfalls ohne Veränderungen auskommen. Bei Kotlin sind scheinbar unveränderliche Collections dabei. So hängen wir zum Beispiel eine Liste an eine andere Liste:

val list1 = listOf(1,2,3)
val list2 = listOf(4,5,6)
val list3 = list1 + list2

Hier ist list3 eine neue Liste mit den gleichen Elementen wie list1 (die unverändert bleibt) und den Elementen von list2 zusätzlich dahinter. Hinter den Kulissen tut aber die gute alte Java ArrayList ihren Dienst: Das bedeutet, dass die Operation + list2 ein neues Array für list3 anlegt und alle Elemente sowohl von list1 als auch von list2 nach list3 kopiert – und das ist teuer. Aus diesem Grund verwenden die Collection Libraries funktionaler Sprachen sogenannte funktionale Datenstrukturen, die nicht bei jedem Update alles kopieren müssen. Insbesondere ist eine Liste in der funktionalen Programmierung eine konkrete Datenstruktur, die bei allen (anderen) funktionalen Sprachen fest eingebaut ist. Wir können sie aber in Kotlin nachbauen:

sealed interface List<out A>
object Empty : List<Nothing>
data class Cons<A>(val first: A, val rest: List<A>) : List<A>

Eine Liste ist also entweder Empty (die leere Liste) oder eine sogenannte Cons-Liste, bestehend aus erstem Element und rest-Liste (es handelt sich also um eine unveränderliche, einfach verkettete Liste). FP-Pendants zu den ersten beiden Listen von oben können wir so konstruieren:

val list1 = Cons(1, Cons(2, Cons(3, Empty)))
val list2 = Cons(4, Cons(5, Cons(6, Empty)))

Das ist etwas umständlich, verglichen mit listOf, dieses Manko könnten wir aber mit einer einfachen varargs-Funktion beheben. Die plus-Methode auf den eingebauten Kotlin-Listen können wir für die FP-Listen ebenfalls implementieren:

fun <A> List<A>.append(other: List<A>): List<A> =
  when (this) {
    is Empty -> other
    is Cons ->
       Cons(this.first, this.rest.append(other))
  }

Das Beispiel von oben funktioniert mit FP-Listen so:

val list3 = list1.append(list2)

Die append-Methode wirft ein paar Fragen auf: Warum ist sie eine „Extension Method“? Könnte das bei der Rekursion Probleme machen? Dazu später mehr, hier geht es ja erst einmal um unveränderliche Daten: Und tatsächlich verändert append gar nichts, kopiert die Elemente aus list1, hängt aber list2 an, ohne sie zu kopieren. Hinterher teilen sich also list3 und list2 dieselben Cons-Objekte. Weil Listen unveränderlich sind, ist das unproblematisch: Das Programm kann so tun, als wären list2 und list3 völlig unabhängig voneinander. Die append-Methode verbraucht nur so viel Laufzeit und Speicher, wie list1 lang ist – die Länge von list2 ist völlig irrelevant.

Listen sind für viele Situationen geeignet, aber natürlich nicht für alle – wenn list1 lang ist, braucht append auch mehr Zeit und viel Speicherplatz. Entsprechend haben die Collection Libraries funktionaler Sprachen in der Regel auch noch ausgefeiltere Datenstrukturen auf der Basis von Bäumen beziehungsweise Trees im Gepäck, bei denen die meisten Operationen quasi konstant laufen.

SIE LIEBEN JAVA?

Den Core-Java-Track entdecken

 

Solche funktionalen Datenstrukturen gibt es auch für Java und Kotlin in Form der Vavr-Library [1], die neben FP-Listen auch Vektoren mitbringt. Für Vavr gibt es auch das Add-on vavr-kotlin [2], das die Vavr Collections in Kotlin noch etwas idiomatischer zugänglich macht. Zwischenfazit: Funktionale Datenstrukturen sind bei Kotlin nicht standardmäßig dabei, können aber in Form von Vavr nachgeladen werden. Tolle Sache.

Erstklassige Funktionen

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

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

Was das bringt, zeigt die Implementierung des FP-Klassikers map, den eine Funktion (die als Objekt übergeben wird) auf alle Elemente einer Liste anwendet und dann eine Liste der Ergebnisse zurückliefert:

fun <A, B> List<A>.map(f: (A) -> B): List<B> =
  when (this) {
    is Empty -> Empty
    is Cons ->
     Cons(f(this.first),
       this.rest.map(f))
  }

Super-Sache:

val list4 = list3.map { x -> x + 1 }

Das geht natürlich auch in Java, das Pendant sieht fast identisch aus:

var list4 = list3.map(x -> x + 1);

Allerdings funktioniert das in Java nur so richtig gut, wenn der Lambdaausdruck unmittelbar im Methodenaufruf steht. In dem Moment, in dem wir ihn herausziehen, sieht die Sache anders aus. In Kotlin ist das kein Problem:

val inc = { x: Int -> x + 1 }
val list4 = list3.map(inc)

Wir müssen bei inc den Typ von x angeben, weil noch andere Typen die Operation + 1 (Double, Long) unterstützen. Den Typ der Funktion inferiert Kotlin nun als (Int) -> Int. In Java müssen wir uns allerdings beim Typ von inc für ein Interface entscheiden – am Naheliegendsten ist IntUnaryOperator:

IntUnaryOperator inc = x -> x + 1;

Allerdings können wir den nicht an map übergeben – es kommt zu einer Fehlermeldung: „‘map(Function<Integer,B>)’ cannot be applied to ‘(IntUnaryOperator)’“. Das liegt daran, dass map ein Argument des Typs Function<A, B> akzeptiert – wir mussten uns ja für ein spezifisches Interface entscheiden. Leider passen Function und IntUnaryOperator nicht zueinander, weil Function auf Referenztypen besteht, während IntUnaryOperator auf den Value Type int festgelegt ist. Die Konsequenz ist, dass zum Beispiel das Stream-Interface in Java neben map auch mapToInt, mapToDouble und mapToLong anbieten muss, die jeweils ein IntStream, DoubleStream und LongStream produzieren. Das ist umständlich und macht fortgeschrittene Anwendungen von Funktionen höherer Ordnung in Java unpraktikabel – in Kotlin kein Problem, ein weiterer Pluspunkt.

Schleifen und Endrekursion

Zu erstklassiger Unterstützung von Funktionen gehört auch, dass wir in der Lage sein sollten, Funktionen ohne Verwendung von Zuweisungen zu schreiben. Dafür sind append und map gute Beispiele – rein funktional programmiert. Sie haben aber beide denselben Haken: Für lange Listen funktionieren sie nicht, weil sie einen Stack-Overflow verursachen. Das liegt an einigen Eigenheiten der JVM: Sie muss sich merken, wie es nach dem rekursiven Aufruf weitergeht (mit Cons) und verwendet dafür einen speziellen Speicherbereich, den Stack. Dieser Stack ist in seiner Größe beschränkt und viel kleiner als der Hauptspeicher, der eigentlich zur Verfügung stünde. Außerdem ist die Größe beim Start der JVM festgelegt und später nicht mehr zu ändern. Funktionale Sprachen mit eigener Runtime haben dieses Problem meist nicht, weil sie flexiblere Datenstrukturen anstatt eines Stacks mit fester Größe verwenden.

fun <A> List<A>.append(other: List<A>): List<A> {
  var list = this.reverse()
  var acc = other
  while (list is Cons) {
    acc = Cons(list.first, acc)
    list = list.rest
  }
  return acc
}

In Java wäre der einzige Weg, das Problem zu vermeiden, eine Schleife – und Schleifen benötigen Zuweisungen, um von einem Durchlauf zum nächsten zu kommunizieren. In Kotlin sieht das so aus wie in Listing 1. Die neue append-Funktion benutzt zwei Schleifenvariablen: die Eingabeliste und das Zwischenergebnis, den Akkumulator. Die Eingabeliste wird am Anfang umgedreht, weil Listen von hinten nach vorn konstruiert werden.

Das neue append funktioniert, ist aber aus funktionaler Sicht schlecht – insbesondere, weil while-Schleifen fehleranfällig sind. Werden zum Beispiel die beiden Zuweisungen an acc und list vertauscht, funktioniert die Methode nicht mehr. Diese Methode können wir auch ohne Zuweisungen mit einer Hilfsfunktion schreiben, die den Akkumulator als Parameter akzeptiert (Listing 2).

fun <A> List<A>.append(other: List<A>): List<A> =
  appendHelper(this.reverse(), other)

fun <A> appendHelper(list: List<A>, acc: List<A>): List<A> =
  when (list) {
    is Empty -> acc
    is Cons ->
      appendHelper(list.rest, Cons(list.first, acc))
  }

Diese Version kommt ohne Zuweisungen aus und benutzt einen rekursiven Aufruf statt der while-Schleife. (Die Hilfsfunktion könnten wir auch lokal innerhalb von append definieren – ein weiterer Punkt für FP-Support in Kotlin.) Anders als der rekursive Aufruf der ursprünglichen append-Version ist dieser hier ein sogenannter Tail Call – also ein Aufruf, der am Ende der Funktion steht.

 

Solche Tail Calls brauchen eigentlich keinen Platz auf dem Stack – entsprechend bestünde bei appendHelper auch keine Gefahr, dass der Stack überläuft. „Eigentlich“ deshalb, weil die JVM leider auch für Tail Calls völlig unnötig Stackplatz verbraucht. (Das ist ein seit mindestens 1996 bekannter Bug, und FP-Fans fiebern mit jedem neuen JVM-Release darauf, dass endlich ein Fix dabei ist.) Wir können aber den Kotlin-Compiler überreden, den rekursiven Tail Call intern in eine Schleife zu kompilieren, indem wir vor die Funktionsdefinition das Wort tailrec schreiben. Das geht leider nur bei statischen Selbstaufrufen (insbesondere also nicht bei Aufrufen von Methoden, die spät gebunden werden), aber immerhin, mehr ist auf der JVM auch nicht drin. Zwischenfazit: Funktionen sind in Kotlin tatsächlich so erstklassig, wie es auf der JVM geht. Die Richtung stimmt also auch hier.

Typen

Kommen wir nochmal zu den Typen: Wir haben ja schon gesehen, dass Kotlin mit Funktionstypen eine wesentliche Anforderung funktionaler Programmierung erfüllt. Leider hat das Typsystem aber auch einige Tücken. Um die zu verstehen, werfen wir einen Blick darauf, wie die Definition von Listen in der funktionalen Programmiersprache Haskell aussehen würde (wenn Listen da nicht schon eingebaut wären):

data List a = Empty | Cons a (List a)

Die Struktur ist ähnlich wie die Kotlin-Definition, aber zwei Unterschiede fallen auf: In Kotlin steht beim Typparameter A noch ein out davor. Außerdem steht beim Empty-Objekt Nothing als Typparameter von List.

Das kommt daher, dass Kotlin versucht, sowohl OO als auch FP zu unterstützen: OO braucht Klassenhierarchien, was auf der Typebene zu Subtyping führt. Deshalb ist es dort notwendig, dass bei Empty ein Typparameter für List angegeben wird – und da der Typparameter A für den Typ der Elemente der Liste steht, die leere Liste aber keine Elemente hat, müssen wir einen Typ angeben, der zu allen anderen passt. In Kotlin ist das eben Nothing, ein „magischer“ Typ ohne Elemente, der als Subtyp aller anderen Typen fungiert (das Gegenteil von Any sozusagen.) Außerdem ist out notwendig, damit eine Liste aus Elementen bestehen kann, die unterschiedliche Typen haben – solange sie einen Supertyp gemeinsam haben. Falls das kompliziert klingt: ist es. Wir können uns der Sache annähern, indem wir out entfernen. Sofort kommt eine Fehlermeldung, zum Beispiel bei:

val list1 = Cons(1, Cons(2, Cons(3, Empty)))

Abgekürzt lautet diese so: „Type mismatch: inferred type is Int but Nothing was expected“. Das liegt daran, dass Empty den Typ List<Empty> hat, wir aber für die Gesamtliste den Typ List<Int> brauchen. Das out sorgt jetzt dafür, dass List<Empty> ein Subtyp von List<Int> ist und damit alles wieder passt – ein Fall von sogenannter Kovarianz. (Wenn wir in geschrieben hätten, wäre Kontravarianz angesagt und es würde andere Fehlermeldungen geben.)

Das Wort out steht dafür, dass die Methoden von List andere Listen nur als „Output“ produzieren dürfen. Das bedeutet insbesondere, dass wir append nicht als „normale“ Methode innerhalb von List implementieren können, weil sie die zweite Liste als Eingabe akzeptiert. Diese Einschränkung gilt nicht für Extension Methods, weshalb wir append als eine solche definiert haben.

Die Situation ist aber immerhin besser als in Java, das erfordert, die Varianzannotationen an die Methoden zu schreiben. Die Java-Stream-Version von append, genannt concat (ebenfalls als statische Methode realisiert), hat zum Beispiel diese Signatur:

static <T> Stream<T> concat(Stream<? extends T> a,
                            Stream<? extends T> b)

Die Kombination von OO und FP führt also zu einigen Komplikationen, auch wenn Kotlin die Situation besser meistert als Java.

Higher-kinded Types

Kotlin hat sich sichtlich von Scala inspirieren lassen, was die FP-Unterstützung betrifft, aber auch auf einige Aspekte von Scala verzichtet – in der Hoffnung, die aus Scala bekannte Komplexität einzudämmen. Dem sind leider auch nützliche Features des Scala-Typsystems zum Opfer gefallen.

Eine wesentliche Einschränkung gegenüber Scala betrifft sogenannte Higher-kinded Types, die es in Scala gibt, in Kotlin leider nicht. Zur Erläuterung dieser Typen schauen wir uns eine Kotlin-Version des Optional-Typs aus Java an. Er ist nützlich, wenn eine Funktion manchmal ein Ergebnis liefert, manchmal aber auch nicht. Entsprechend gibt es zwei Fälle Some und None, ähnlich wie bei Listen:

sealed interface Option<out A>
object None : Option<Nothing>
data class Some<A>(val value: A) : Option<A>

Wir können für Option eine praktische map-Methode definieren:

fun <A, B> Option<A>.map(f: (A) -> B): Option<B> =
  when (this) {
    is None -> None
    is Some -> Some(f(this.value))
  }

Diese Verwandtschaft mit der map-Methode von List können wir sehen, wenn wir beide Signaturen untereinanderschreiben:

fun <A, B> List  <A>.map(f: (A) -> B): List  <B>
fun <A, B> Option<A>.map(f: (A) -> B): Option<B>

Typen wie List und Option, die eine map-Operation unterstützen, heißen in der funktionalen Programmierung Funktor und spielen dort eine wichtige Rolle. Deshalb liegt es nahe, ein Interface Functor zu definieren, in dem die map-Methode steht. Das könnte so aussehen:

interface Functor<F> {
  fun <A, B> map(f: (A) -> B): F<B>
}

Mit dieser Definition wäre Functor ein Higher-kinded Type: Ein Typkonstruktor (also ein Typ mit Generics), der einen anderen Typkonstruktor als Parameter akzeptiert. Das ist dann aber leider doch ein Schritt zu viel für Kotlin, der Compiler quittiert: „Type arguments are not allowed for type parameters“. Es gibt zwar einen Trick, um Higher-kinded Types zu simulieren, nachzulesen zum Beispiel in dem wunderbaren Buch „Functional Programming in Kotlin“ [3]. Er ist aber so umständlich, dass er in der Praxis kaum anwendbar ist.

Stay tuned

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

 

Beim Typsystem ist in Kotlin also noch Luft nach oben. Dass es geht, zeigt Scala, das Higher-kinded Types unterstützt und für das es entsprechend Libraries mit höherstehenden Konzepten wie Functor gibt, die in Kotlin einfach nicht praktikabel sind. Fairerweise muss man aber zugegeben, dass Higher-kinded Types auch von manchen „richtigen“ funktionalen Sprachen wie F# nicht unterstützt werden.

Monaden

Wo funktionale Programmierung ist, sind Monaden oft nicht weit: Sie sind dort fürs Grobe zuständig und erlauben die Verknüpfung von Funktionen in sequenziellen Abläufen mit Effekten. Effekte sind Dinge, die neben der reinen Berechnung stattfinden – typischerweise Interaktionen mit der Außenwelt. Effekte können zum Beispiel benutzt werden, um in hexagonaler Architektur zwischen Ports und Adaptern zu vermitteln. Für eine komplette Einführung in Monaden fehlt uns hier der Platz, darum skizzieren wir nur, wie sie in Kotlin funktionieren.

Die Idee ist ganz einfach: Ein sequenzieller Prozess wird durch ein Objekt repräsentiert. Für die Objekte erstellen wir einen speziellen Typ (das ist die Monade), in dem genau die Operationen enthalten sind, die in dem Prozess zulässig sind. Zum Beispiel enthält die kleine Demo-Library kotlin-free-monads [4] ein Interface TtyM, um Prozesse abzubilden, die eine textuelle Ausgabe generiereren:

sealed interface TtyM<out A> {
  fun <B> bind(next: (A) -> TtyM<B>): TtyM<B>
}

Die bind-Operation (manchmal auch flatMap genannt) macht zusammen mit einer sogenannten pure-Operation TtyM zu einer Monade. Der Typparameter A ist für das Ergebnis des Prozesses zuständig. Wenn wir die TtyM-Monade benutzen, verwenden wir also nicht mehr println, sondern konstruieren stattdessen ein Write-Objekt, dessen Klasse TtyM implementiert.

Diese Vorgehensweise hat den Vorteil, dass wir das TtyM-Objekt in unterschiedlichen Kontexten ausführen können. Im normalen Betrieb macht das folgende Funktion, die für jedes Write einfach println ausführt:

fun <A> run(tty: TtyM<A>): A

Für automatisierte Tests wollen wir aber wissen, welcher Text ausgegeben wurde, und dafür gibt es eine Funktion, die die Zeilen des Outputs in eine Liste schreibt:

fun <A> output(tty: TtyM<A>, output: MutableList<String>): A

Das ist praktisch, wenn wir schon ein TtyM-Objekt haben. Aber dessen Konstruktion mit Konstruktor- und Methodenaufrufen ist normalerweise ziemlich umständlich, mit vielen geschachtelten Lambdaausdrücken und Klammern. Aus diesem Grund haben die meisten funktionalen Sprachen eine spezielle, deutlich kompaktere Syntax für monadische Programme. Kotlin bringt eine solche Syntax zwar nicht direkt mit, wir können sie aber mit Hilfe von suspend-Funktionen selbst bauen, sodass ein TtyM-Programm zum Beispiel so gebaut werden kann:

TtyDsl.effect {
  write("Mike")
  write("Sperber")
  pure(42)
}

Das Lambdaargument von effect ist eine suspend-Funktion. Das suspend sorgt dafür, dass der Compiler die Funktion in eine Coroutine transformiert. Die wiederum erlaubt der effect-Funktion, eine monadische Form der Funktion zu extrahieren. Wer sich für die Details interessiert, sei an das Kotlin-free-monads-Projekt [4] verwiesen.

Auch hier also ein Pluspunkt für Kotlin, zumindest gegenüber Java. Die Verwendung von suspend-Funktionen für monadische Syntax ist aber gegenüber funktionalen Sprachen deutlich aufwendiger und auch mit einigen Einschränkungen verbunden, sodass immer noch Luft nach oben bleibt.

Fazit

Mit zwei Fragen sind wir in diesen Artikel gestartet:

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

Die Antwort auf die erste Frage ist ein entschiedenes Ja: Kotlin unterstützt die einfache Definition unveränderlicher Daten, kennt Typen für Funktionen, eliminiert die lästige Unterscheidung zwischen Referenztypen und Value Types, kompiliert rekursive Tail Calls korrekt, vereinfacht gegenüber Java den Umgang mit Generics und erlaubt die Definition monadischer Syntax. Das ist mehr als genug, um funktionale Programmierung gewinnbringend zu nutzen, mächtige Abstraktionen zu definieren und architektonisch von der Entkopplung durch Unveränderlichkeit zu profitieren. Wer mehr darüber wissen möchte, dem sei das oben erwähnte Buch [3] ans Herz gelegt. Eine grundsätzliche Einführung in funktionale Programmierung gibt es in meinem eigenen Buch „Schreibe Dein Programm!“ [5].

Die zweite Frage ist schwieriger zu beantworten: Auf der JVM gibt es zwei (weitere) funktionale Sprachen, nämlich Clojure und Scala. Beide sind großartig – während aber Clojure in vielerlei Hinsicht fundamental anders tickt als Kotlin und Java (kein Typsystem, Lisp-Syntax), ist Scala die Verlängerung der Linie von Java zu Kotlin.

 

Scala unterstützt funktionale Programmierung besser als Kotlin: Es gibt Higher-kinded Types, die Monaden funktionieren in Scala deutlich einfacher und außerdem erleichtern sogenannte Implicits es, automatisch die richtigen Implementierungen für Interfaces zu finden. Des Weiteren existiert um Scala herum ein umfangreiches Ökosystem funktionaler Libraries und Frameworks.

Die gegenüber Scala verringerte Komplexität von Kotlin fällt in der Praxis kaum ins Gewicht. Viele der typischen Schwierigkeiten in der täglichen Arbeit gibt es in Kotlin auch. Die IDE-Unterstützung ist für beide ähnlich gut. Wer also funktionale Programmierung mag und die Wahl hat, wird wahrscheinlich mit Scala glücklicher.


Links & Literatur

[1] Vavr, funktionale Library für Java: https://github.com/vavr-io/vavr

[2] Kotlin-Wrapper für Vavr: https://github.com/vavr-io/vavr-kotlin

[3] Vermeulen, Marco; Bjarnason, Rúnar; Chiusano, Paul: „Functional Programming in Kotlin“; Manning, 2021

[4] Monaden-Library für Kotlin: https://github.com/active-group/kotlin-free-monad

[5] Sperber, Michael; Klaeren, Herbert: „Schreibe Dein Programm!“; Tübingen University Press, 2023

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

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

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

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

Gefahr durch Eingaben

Sicherlich ist der erste und wichtigste Schritt, sämtliche Variablen, die durch Formularfelder befüllt werden, auf eine gültige Eingabe hin zu prüfen. Ein gängiges Mittel ist die Validierung über reguläre Ausdrücke, die sicherstellen, dass nur gültige Zeichen und ein korrektes Format zugelassen werden. Möchte man aber beispielsweise formatierte und umfangreiche Artikel ermöglichen, wie es bei einem Content-Management-System der Fall ist, haben die gängigen Schutzmaßnahmen durchaus ihre Schwächen. Denn das Prinzip, möglichst die Eingabe zu beschränken und gefährliche Zeichen herauszufiltern, hat bei formatiertem Text seine Grenzen.

Wollen wir beispielsweise ein Zitat in Anführungszeichen setzen, können wir die beiden Zeichen ‘ und “ nicht einfach aus dem Text entfernen, da diese ja Bestandteil des Inhalts sind. In der Vergangenheit nutzte man bei dieser Problematik einen zusätzlichen Mechanismus, um Schutz vor Cyberangriffen sicherzustellen. Hierfür werden sämtliche kritische Zeichen wie spitze Klammern, Anführungszeichen und Backslashes über ihre HTML Escapes ersetzt. Damit gelten diese als problematisch klassifizierten Sonderzeichen nicht mehr als Quellcode, da aus > ein &gr; wird. Daher sind die soeben beschriebenen Maßnahmen besonders wirkmächtig und bieten bereits einen effektiven Schutz.

Die Schutzwirkung lässt sich allerdings über den Mechanismus CSP erheblich verbessern, da dieser weit über das Prüfen von Nutzereingaben hinausgeht. Sämtliche in einer Webapplikation geladene Ressourcen wie Bilder, CSS und JavaScript lassen sich dank dieser Technologie steuern.

Die Content Security Policy ist ein Projekt des W3C und greift als Bestandteil des Browsers – es sind keine zusätzlichen Plug-ins oder Ähnliches zu installieren. Für aktuelle Browser gilt CSP Level 3. Auf der entsprechenden Webseite des W3C [2] finden Sie eine vollständige Übersicht aller Versionen einschließlich der unterstützenden Webbrowser.

In Umgebungen, die erhöhte Sicherheitsanforderungen erfordern, beispielsweise Onlinebanking, kann die Browserversion des Clients abgefragt werden und bei erheblich veralteten Varianten des Browsers somit der Zugriff verweigert werden.

String userAgent = request.getHeader("user-agent");
String browserName = "";
String  browserVer = "";
if(userAgent.contains("Chrome")){
  String substring=userAgent.substring(userAgent.indexOf("Chrome")).split(" ")[0];
  browserName=substring.split("/")[0];
  browserVer=substring.split("/")[1];
} else if(userAgent.contains("Firefox")) {
  String substring=userAgent.substring(userAgent.indexOf("Firefox")).split(" ")[0];
  browserName=substring.split("/")[0];
  browserVer=substring.split("/")[1];
}

Stay tuned

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

 

Listing 1 zeigt die Möglichkeit, über den request die Identifikation des Browsers auszulesen. Anschließend demonstrieren die beiden Beispiele, wie die Versionsnummer aus dem Text für Chrome und Firefox extrahiert wird. Bei dieser Strategie sollte man sich aber bewusst sein, dass es für Schwindler recht einfache Möglichkeiten gibt, die Nutzung eines anderen Browsers vorzugaukeln.

Damit wird uns noch einmal vor Augen geführt, dass Web Application Security als ganzheitliches Konzept zu verstehen ist, in dem mehrere Maßnahmen als Verbund wirken – denn einzelne Techniken lassen sich durchaus umgehen. Schauen wir uns daher nun den Wirkmechanismus von CSP im Detail an.

Beginnen wir mit einer reinen HTML-Ausgabe, die CSS, Google Fonts, CDN JavaScript, ein YouTube iFrame und Grafiken enthält. Listing 2 dient uns als Ausgangspunkt, ohne den Einsatz von CSP.

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    
    <title>Content Security Policy Demo</title>
    
    <link rel="stylesheet" href="style.css" />
    
    <link
      href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;700&display=swap"
      rel="stylesheet" />
    
    <link
      rel="stylesheet"
      href="https://cdn.jsdelivr.net/npm/[email protected]/dist/css/bootstrap.min.css"
      integrity="sha384-TX8t27EcRE3e/ihU7zmQxVncDAy5uIKz4rEkgIXeMed4M0jlfIDPvg6uqKI2xXr2"
      crossorigin="anonymous" />
  </head>

  <body>
    <main>
      <div class="box">
        <div id="vue"></div>
      </div>

      <div class="box">
        <div class="embed">
          <iframe width="100%" height="500px" frameborder="0"
            src="https://www.youtube.com/embed/3nK6rcAbuzo"
            allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
            allowfullscreen></iframe>
        </div>
      </div>

      <div class="box">
        <div class="grid">
          <img src="https://images.unsplash.com/photo-1535089780340-34cc939f9996?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=80" />
          <img src="https://images.unsplash.com/photo-1587081917197-95be3a2d2103?ixlib=rb-1.2.1&auto=format&fit=crop&w=500&q=80" />
          <img src="https://images.unsplash.com/photo-1502248103506-76afc15f5c45?ixlib=rb-1.2.1&ixid=eyJhcHBfaWQiOjEyMDd9&auto=format&fit=crop&w=500&q=80" />
        </div>
      </div>
    </main>

    <script src="https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.min.js"></script>
    <script nonce="rAnd0m">
      new Vue({
        el: '#vue',
        render(createElement) {
          return createElement('h1', 'Hello World!');
        },
      });
    </script>
  </body>
</html>

Abb. 1: HTML ohne CSP

Wie wir in Abbildung 1 sehen können, handelt es sich hierbei um eine einfache HTML-Seite, die verschiedenen Content lädt. Dazu gehört zum Beispiel auch, das JavaScript-Framework Bootstrap extern über das Content Delivery Network (CDN) jsDelivr zu laden. Wer mit dem Thema CDN noch nicht vertraut ist, findet dazu einen sehr informativen Artikel auf Wikipedia [3]. Stellen wir uns nun einmal ein realistisches Szenario aus der Praxis vor.

Es könnte nun sein, dass die von uns verwendete Bootstrap-Bibliothek eine bekannte Sicherheitslücke für XSS-Angriffe enthält. Der Content des CDN von jsDelivr ist durchaus vertrauenswürdig. Ein Angreifer könnte nun aber versuchen, von einem Webspace eine eigens präparierte JavaScript-Datei über die Bootstrap-Sicherheitslücke einzuschleusen. Ich möchte an dieser Stelle natürlich kein Hackertutorial an die Hand geben und erklären, wie ein solcher Angriff ausgeführt werden kann; ebensowenig möchte ich Ideen verbreiten, welcher Unfug sich über XSS anstellen lässt. Mir geht es darum, eine zuverlässige Methode vorzustellen, die den Angriff wirkungsvoll verhindern kann. Also richten wir unser Augenmerk nun auf die Prävention von XSS.

Schutz durch CSP

Mittels CSP werden nun Meta-Header-Attribute gesetzt, mit denen bestimmt werden kann, was zuverlässige Quellen sind. Das erlaubt es jedem Contenttyp (Ressource, also Bild, CSS und so weiter), explizite, vertrauenswürdige Quellen (Domains) zuzuweisen. Damit lassen sich JavaScript-Dateien so einschränken, dass sie nur vom eigenen Server geladen werden und beispielsweise von CDN jsDelivr. Findet ein Angreifer nun einen Weg, eine XSS-Attacke auszuführen, bei der er versucht, von einer anderen Domain als von den erlaubten Domains Schadcode einzuschleusen, blockiert CSP das Skript von der nicht freigegebenen Domain.

Dazu ein einfaches Beispiel: Wenn wir im <head> in Listing 2 gleich nach dem meta-Tag für die Seitencodierung die Zeile <meta http-equiv=”Content-Security-Policy” content=”default-src ‘self'” /> einfügen und die Datei ausführen, bekommen wir eine Ausgabe wie in Abbildung 2.

Abb. 2: Blocked Content

Der Teil content=”default-src ‘self'” sorgt dafür, dass sämtliche Inhalte, die nicht von der eigenen Domäne stammen, über CSP im Browser blockiert werden. Wollen wir zusätzlich für JavaScript das jsDelivr CDN zulassen, müssen wir den meta-Tag wie in folgt formulieren:

<meta http-equiv="Content-Security-Policy"
  content="default-src 'self';
  script-src 'nonce-rAnd0m' https://cdn.jsdelivr.net; />

Auf der offiziellen Webseite für Content Security Policy [4] finden Sie eine vollständige Auflistung sämtlicher Attribute und eine umfangreiche Liste an Beispielen. So bewirkt der Ausdruck nonce-rAnd0m dass Inline-Skripte geladen werden dürfen. Dazu muss das zugehörige JavaScript mit dem Attribut [script nonce=”rAnd0m”/] gekennzeichnet werden. Damit unser Beispiel aus Listing 2 vollständig funktioniert, benötigen wir für den CSP-meta-Tag folgenden Eintrag (Listing 3).

<meta http-equiv="Content-Security-Policy"
  content="default-src 'self';
  script-src 'nonce-rAnd0m' https://cdn.jsdelivr.net;
  img-src https://images.unsplash.com;
  style-src 'self' https://cdn.jsdelivr.net;
  frame-src https://www.youtube.com;
  font-src https://fonts.googleapis.com;" />

Für Java-Webapplikationen können die Headerinformationen zu den Beschränkungen der CSP über den response hinzugefügt werden. Die Zeile response.addHeader (“Content-Security-Policy”, “default-src ‘self'”); bewirkt wie zu Beginn, dass sämtliche Inhalte, die nicht von der eigene Domäne stammen, blockiert werden.

MEHR PERFORMANCE GEFÄLLIG?

Performance & Security-Track entdecken

 

Zentrale Regelung über Servlet-Filter

Nun ist das applikationsweite manuelle Hinzufügen von CSP-Regeln für jede eigene Seite recht mühselig und zudem noch sehr fehleranfällig. Eine zentrale Lösung für den Einsatz von Java-Servern ist die Verwendung von Servlet-Filtern (Listing 4).

public class CSPFilter implements Filter {

  public static final String POLICY = "default-src 'self'";

  @Override
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
  throws IOException, ServletException {
    if (response instanceof HttpServletResponse) {
      ((HttpServletResponse)response).setHeader("Content-Security-Policy", CSPFilter.POLICY);
    }
  }

  @Override
  public void init(FilterConfig filterConfig) throws ServletException { }

  @Override
  public void destroy() { }
}

Um den Filter dann zum Beispiel im Apache Tomcat zu aktivieren, wird noch ein kleiner Eintrag in der web.xml benötigt (Listing 5).

<filter>
  <filter-name>CSPFilter</filter-name>
  <filter-class>com.content-security-policy.filters.CSPFilter</filter-class>
</filter>
<filter-mapping>
  <filter-name>CSPFilter</filter-name>
  <url-pattern>/*</url-pattern>
</filter-mapping>

Sicher könnte man für sämtliche Applikationen in einem vorgeschalteten Proxyserver wie dem Apache-2-HTTP-Server oder NGNIX alle CSP-Regeln zentral verwalten. Davon ist allerdings aus zwei Gründen abzuraten: Zum einen verlagert sich so die Verantwortlichkeit von der Entwicklung hin zum Betrieb, was zu erhöhtem Kommunikationsaufwand und einem Flaschenhals in der Entwicklung führt. Zum anderen werden die so entstehenden Regeln sehr komplex und entsprechend schwieriger zu lesen beziehungsweise zu warten. Der hier vorgeschlagene Servlet-Filter ist daher eine gute Option, um die Sicherheitsregeln applikationsweit zentral für exakt die entwickelte Anwendung zu verwalten. Zudem passt das auch hervorragend in ein agiles DevOps-Konzept, denn die notwendige Konfiguration ist Bestandteil des Source Codes und über die Versionsverwaltung unter Konfigurationsmanagement zugänglich.

 

Fazit

Wie ich in diesem Artikel zeigen konnte, ist das Thema Web-Application-Security nicht immer gleich ein Fall für Spezialisten. Persönlich empfinde ich es als eine große Errungenschaft der letzten Jahre, wie das Härten von Webanwendungen kontinuierlich einfacher wird. CSP halte ich auf diesem Weg für einen Schritt in die richtige Richtung. Natürlich gilt es weiterhin, stets ein Auge auf aktuelle Entwicklungen zu haben, denn die bösen Buben und Mädels ruhen sich nicht auf ihren Lorbeeren aus und lassen sich ständig neue Gemeinheiten einfallen. Der gerade populär gewordene breite Einsatz von künstlichen neuronalen Netzen wie ChatGPT lässt im Moment nur grob erahnen, was uns künftig noch an Cyberangriffen aus den Tiefen des weltweiten Netzes erwarten wird.


Links & Literatur

[1] https://owasp.org/www-community/attacks/xss/

[2] https://www.w3.org/TR/CSP/

[3] https://de.wikipedia.org/wiki/Content_Delivery_Network

[4] https://content-security-policy.com

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

]]>