Das steckt hinter dem Open-Source-Projekt Vavr

Funktional(er) mit Java programmieren: Vavr – der Java-Slang

Robert Winkler, Tim Riemer

©Shutterstock/Jemastock

Mit Java 8 hat die funktionale Programmierung in Java Einzug gehalten. Allerdings fehlen in der Standardbibliothek noch viele Features, die man von anderen Programmiersprachen wie Scala kennt. Das Open-Source-Projekt Vavr möchte diese Lücke füllen und stellt unter anderem persistente Datenstrukturen, algebraische Datentypen und bessere funktionale Schnittstellen für Java 8 bereit.

Vavr  stellt viele neue persistente Datenstrukturen, algebraische Datentypen – Summen- und Produkttypen – und bessere funktionale Schnittstellen für Java 8 bereit. Alle Datentypen bauen auf einen von drei Basisdatentypen auf: Tuple, Lambda und Value. Jeder Datentyp in Vavr, der einen Wert repräsentiert, ist vom Basisdatentyp Value abgeleitet. Bei der Namensgebung der Datentypen orientiert sich Vavr stark an Scala (Abb. 1). Das Besondere an den Datenstrukturen von Vavr ist, dass sie persistent und somit unveränderlich sind.

Abb. 1: Vavr-Übersicht

Abb. 1: Vavr-Übersicht

Unter dem Begriff „Persistenz“ versteht man in der Informatik häufig, dass Daten auf einem nicht flüchtigen Datenträger gespeichert werden. Mit dem Begriff „persistente Datenstruktur“ ist dieses Verhalten nicht gemeint. Es meint eher, dass eine persistente Datenstruktur, wenn sie verändert wird, immer eine veränderte Kopie von sich selbst zurückliefert und ihren vorherigen Zustand behält. Man kann also sagen, dass persistente Datenstrukturen unveränderlich sind, da sie effektiv nach ihrer Erstellung nicht mehr modifiziert werden können.

Damit das Verändern von persistenten Datenstrukturen nicht zu speicherintensiv ist, können sie sich im Speicher vorgehaltene Werte teilen. Das lässt sich am besten an einem Beispiel einer verketteten Liste erläutern. Listing 1 zeigt, wie mit Vavr eine Liste erstellt und modifiziert werden kann. Zunächst wird eine Liste mit drei Werten erstellt und anschließend der erste Wert der Liste modifiziert (Abb. 2). Der zweite Aufruf erzeugt eine Kopie der Liste, die sich die gemeinsamen Knoten mit der ursprünglichen Liste teilt (Abb. 3).

Abb. 2: Liste mit drei Werten, der erste Wert modifiziert

Abb. 2: Liste mit drei Werten, der erste Wert modifiziert

Abb. 3: Kopie der Liste, teilt sich Knoten mit ursprünglicher Liste

Abb. 3: Kopie der Liste, teilt sich Knoten mit ursprünglicher Liste

 
// = List(1, 2, 3)
List list1 = List.of(1, 2, 3);

// = List(0, 2, 3)
List list2 = list1.replace(1,0);

Das gleiche Verfahren funktioniert auch bei komplexeren Datenstrukturen wie Queues, TreeSets oder TreeMaps. Vavrs Datenstrukturen haben viele hilfreiche Operatoren, die man aus funktionalen Programmiersprachen oder RxJava kennt, wie map, flatMap, foldLeft, foldRight, scanLeft, scanRight, sortBy, groupBy, distinct, dropRight oder dropUntil. Es wird großen Wert darauf gelegt, dass es einfach ist, Java-8-Datentypen in Vavr-Datentypen zu konvertieren und auch wieder zurück.

Lesen Sie auch: Der Vergleich von imperativen und funktionalen Algorithmen in Java 8

Hier kommen die Tupel

Tupel haben in der Standardbibliothek von Java schon immer gefehlt. Es gibt viele Bibliotheken, die Tupel mit zwei oder drei Elementen bereitstellen. Vavr bietet persistente Tupel mit bis zu acht Elementen an. Listing 2 zeigt, wie man Tupel erstellen, modifizieren und auf die Elemente zugreifen kann. Tupel sind praktisch, wenn eine Funktion mehr als einen Rückgabewert hat und es sich nicht um ein Entweder-oder-Ergebnis handelt. Bei einem Entweder-oder-Ergebnis würde sich der algebraische Datentyp Either als Rückgabewert eignen. Hierzu aber mehr im Verlauf des Artikels.

// = (Java, 8)
Tuple2<String, Integer> Java8 = Tuple.of("Java", 8);

// = (Vavr, 2)
Tuple2<String, Integer> Vavr2 = Java8.map(
  s -> s + "slang",
  i -> i / 4
);

// = "Vavr"
String first = Vavr2._1;
// = 2
int second = Vavr2._2;

Funktionen mit bis zu acht Parametern

In der funktionalen Programmierung dreht sich alles um Funktionen, deren Komposition und die Transformationen von Werten mithilfe von Funktionen. Java 8 stellt hierfür lediglich die Schnittstellen Supplier bereit, die keinen Parameter entgegennimmt, Function, die einen Parameter entgegennimmt, und BiFunction, die zwei Parameter entgegennimmt. Im Gegensatz hierzu bietet Vavr Funktionen mit bis zu acht Parametern an. Diese stehen unter den Namen Function0 bis Function8 zur Verfügung. Ähnlich dazu gibt es auch Funktionen, die eine Checked Exception werfen können. So erzeugt man mit einem Lambda-Ausdruck eine Vavr-Funktion, die zwei Integer summiert:

 Function2<Integer, Integer, Integer> sum = (a, b) -> a + b;

Vavrs funktionale Schnittstellen bieten weitere Methoden: Komposition, Lifting, Currying und Memorization.

Zusammenhänge schaffen

Durch Komposition ist es möglich, Funktionen miteinander zu verketten. Das Prinzip ist der Mathematik bekannt: Durch Anwendung einer Funktion auf dem Resultat einer anderen Funktion entsteht eine neue Funktion. Die Funktionen f: X -> Y und g: Y -> Z können zu einer Funktion h: g(f(x)) mit h: X -> Z verkettet werden. In Vavr stehen dafür die Methoden andThen sowie compose zur Verfügung (Listing 3 und 4). Diese Methoden haben aber auch mit dem Function-Interface in Java 8 Einzug gehalten.

Function1<Integer, Integer> plusOne = a -> a + 1;
Function1<Integer, Integer> multiplyByTwo = a -> a * 2;

Function1<Integer, Integer> add1AndMultiplyBy2 = plusOne.andThen(multiplyByTwo);

then(add1AndMultiplyBy2.apply(2)).isEqualTo(6);
Function1<Integer, Integer> plusOne = a -> a + 1;
Function1<Integer, Integer> multiplyByTwo = a -> a * 2;

Function1<Integer, Integer> add1AndMultiplyBy2 = multiplyByTwo.compose(plusOne);

then(add1AndMultiplyBy2.apply(2)).isEqualTo(6);

Funktionen mit Lifting umwandeln

Lifting erlaubt es, eine partielle Funktion in eine totale Funktion umzuwandeln, die den Typ Option zurückgibt. Wie bei der Komposition erklären wir das Vorgehen kurz von der mathematischen Seite. Eine partielle Funktion von X nach Y ist eine Funktion f: X‘ -> Y, wo X‘ eine Teilmenge von X ist, das heißt die Funktion f erfordert nicht, dass jedes Element aus X auf ein Element in Y abgebildet werden muss. Somit arbeitet eine partielle Funktion nur für einige Eingabewerte korrekt und wirft bei einer ungültigen Eingabe einen Fehler. Die Methode divide in Listing 5 ist eine partielle Funktion, die nur einen Divisor ungleich 0 akzeptiert, ansonsten wird eine ArithmeticException geworfen. Mithilfe des Operators lift kann nun aus divide eine totale Funktion erzeugt werden, die jede Eingabe verarbeitet, ohne eine ArithmeticException zu werfen. Der Operator lift fängt alle Exceptions einer Funktion und liefert dann ein leeres Option zurück. In Listing 5 wird daher bei einem ungültigen Divisor die ArithmeticException gefangen und None zurückgegeben. Andernfalls wird das Ergebnis zurückgeliefert.

Function2<Integer, Integer, Integer> divide = (a, b) -> a / b;

Function2<Integer, Integer, Option<Integer>> safeDivide = Function2.lift(divide);

// = None
Option<Integer> i1 = safeDivide.apply(1, 0); 

// = Some(2)
Option<Integer> i2 = safeDivide.apply(4, 2);

Partielle Anwendung mit Currying

Currying erlaubt die partielle Anwendung einer Funktion durch das Umwandeln einer Funktion mit n Argumenten in eine modifizierte Funktion mit n – 1 Argumenten. Mathematisch bedeutet das beispielsweise, dass eine Funktion mit zwei Argumenten f: A1 x A2 -> B in eine modifizierte Funktion mit einem Argument f’: A1 -> (A2 -> B) umgewandelt wird. Das Festsetzen eines oder mehrerer Parameter erfolgt in Vavr von links nach rechts (Listing 6).

Function2<Integer, Integer, Integer> sum = (a, b) -> a + b;
// Parameter a wird mit 2 festgeschrieben
Function1<Integer, Integer> add2 = sum.curried().apply(2); 

then(add2.apply(4)).isEqualTo(6);

Memoization: nur beim ersten Mal

Memoization ermöglicht es, dass eine Methode nur beim ersten Aufruf ausgeführt wird und bei weiteren Aufrufen der gespeicherte Wert zurückgeliefert wird. Das Beispiel in Listing 7 erzeugt bei der ersten Ausführung eine Zufallszahl und liefert danach immer den gespeicherten Wert zurück.

Function0<Double> hashCache = Function0.of(Math::random).memoized();

double randomValue1 = hashCache.apply();
double randomValue2 = hashCache.apply();

then(randomValue1).isEqualTo(randomValue2);

Algebraische Datentypen für jeden Tag

Vavr bietet mit Option, Try und Either interessante algebraische Datentypen, die für den täglichen Gebrauch geeignet sind. Man nennt diese Datentypen auch Summen- oder Variantentypen, da es für jeden Typ (Interface) nur eine fixe Anzahl an Varianten (Instanzen) gibt. Für Option wären das Some und None. Für Try wären das Success und Failure. Für Either wären das Left or Right. Im Folgenden zeigen wir praktische Anwendungsfälle für diese Datentypen.

Optionale Werte

Vavrs Option kennt man als Optional in Java 8. Es repräsentiert einen optionalen Wert. Warum sollte man Vavrs Option benutzen? Es verfügt über mehr Operatoren und kann in andere Datentypen wie Try oder Either konvertiert werden. Ein weiterer Vorteil von Vavrs Option ist, dass die Klasse serialisierbar ist. Listing 8 zeigt, dass sich das API nicht stark von Optional unterscheidet.

// = "Hello world"
String helloWorld = Option.of("Hello")
  .map(value -> value + " world")
  .getOrElse(() -> „Recover");

Try: Erfolg oder Misserfolg?

Interessanter ist der Datentyp Try, der eine Berechnung repräsentiert, die entweder erfolgreich durchgeführt (Success) oder mit einem Fehler beendet wurde (Failure). Viele Java-Applikationen sind voll von Seiteneffekten, da Methoden Laufzeitfehler (Unchecked Exceptions) schmeißen können und dieses Verhalten nicht an der Signatur der Methode erkennbar ist. Diese Laufzeitfehler durchbrechen den normalen Ablauf eines Programms und können zu schwerwiegenden Fehlern führen, wenn sie nicht alle irgendwo im Programm abgefangen und behandelt werden. Besser ist es, wenn die Signatur einer Methode klarstellt, dass die Methode einen Fehler schmeißen kann, aber nicht zwischen Unchecked und Checked Exceptions unterschieden wird.

Lesen Sie auch: Funktionale Programmierung leicht gemacht: JVM-Sprache Eta stellt sich vor

Hierfür kann der Datentyp Try als Rückgabewert von einer Methode verwendet werden. Im Gegensatz zu Checked Exceptions wird der Entwickler nicht dazu gezwungen, alle Checked Exceptions zu behandeln oder weiterzuleiten. Listing 9 zeigt, dass Try einige Operatoren hat, die nur ausgeführt werden, wenn die Berechnung erfolgreich durchgeführt wurde. Andere wurden nur ausgeführt, wenn die Berechnung mit einem Fehler beendet wurde. Der Operator map wird nur ausgeführt, wenn es ein Success ist, ansonsten kann mit dem recover-Operator ein Failure behandelt und ein anderer Wert zurückgeliefert werden.

// Service Interface mit Checked exceptions
public interface HelloWorldService {
  String sayHelloWorld(String name) throws BusinessException;
}

// Service mit Try als Rückgabewert
public class HelloWorldService {
  Try<String> sayHelloWorld(String name){
    return Try.of(() -> backendDao.sayHelloWorld(input));
  }
}

// BusinesService aufrufen
String result = helloWorldService.sayHelloWorld("Robert")
  .map(value -> value + " and all readers")
  .recover(throwable -> "Handle exception and recover")
  .get();

Either als Rückgabewert

Der DatenTyp Either eignet sich als Rückgabewert, wenn Funktionen ein Entweder-oder-Ergebnis zurückliefern. Either kann auch als Alternative für Option und Try verwendet werden. Per Konvention wäre dann Left vergleichbar mit None oder Failure und Right vergleichbar mit Some oder Success. Aufgrund der Konvention operiert der Operator map nur, wenn Either eine Instanz von Right ist und macht nichts, wenn Either eine Instanz von Left ist (Listing 10).

Either<Integer, String> either = Either.right("Hello world");

// = "HELLO WORLD"
String result = either.map(value -> value.toUpperCase())
  .get();

Lazy lässt sich Zeit

Da es in Java 8 kein lazy Keyword wie in Scala gibt, gibt es in Vavr den Datentyp Lazy, der einen Wert repräsentiert, der unter Umständen noch nicht berechnet wurde. Lazy könnte man mit einem Supplier vergleichen. Sie haben aber einen wesentlichen Unterschied: Bereits berechnete Werte werden gecacht (Listing 11).

Lazy<Double> lazy = Lazy.of(Math::random);
lazy.isEvaluated(); // = false
lazy.get();         // = 0.123 (random generated)
lazy.isEvaluated(); // = true
lazy.get();         // = 0.123 (cached)

CircuitBreaker, Retry und RateLimiter

Das Open-Source-Project Resilience4j ist eine leichtgewichtige Fehlertoleranzbibliothek, die von Netflix Hystrix inspiriert wurde, aber speziell für funktionale Programmierung ausgelegt wurde. Sie ist leichtgewichtig, weil sie nur Vavr und RxJava als Abhängigkeit hat, die wiederum keine weiteren Abhängigkeiten haben. Netflix Hystrix hingegen hat eine Abhängigkeit zu Netflix Archaius, das weitere Abhängigkeiten zu Guava oder Apache Commons Configuration mit sich bringt. Resilience4j stellt Funktionen höherer Ordnung (Higher-order Functions) bereit, mit denen funktionale Schnittstellen, Lambda-Ausdrücke und Methodenreferenzen mit einem CircuitBreaker geschützt werden können. Man kann diese Funktionen höherer Ordnung mit dem Decorator-Pattern vergleichen, nur dass keine Decorator-Klassen erstellt werden müssen. Neben dem CircuitBreaker Decorator werden auch Funktionen höherer Ordnung (Decorator) angeboten, um fehlgeschlagene Funktionen automatisch zu wiederholen (Retry) oder die Rate von Funktionsaufrufen zu limitieren (RateLimiter). Man kann mehrere dieser Decorators kombinieren.

Listing 12 zeigt, wie man einen Lambda-Aufruf, der ein Backend aufruft, mit einem CircuitBreaker schützen und fehlgeschlagenen Aufrufe, die in einer IOException resultieren, automatisch wiederholen kann. Sowohl CircuitBreaker als auch Retry haben viele Konfigurationsmöglichkeiten. Mithilfe von Try kann man einen Fallback ausführen, wenn der Aufruf trotz Wiederholungen fehlgeschlagen ist.

// = CircuitBreaker-Instanz mit default-Konfiguration
CircuitBreakerConfig circuitBreakerConfig = CircuitBreakerConfig.custom()
  .recordFailure(throwable -> throwable instanceof IOException)
  .build();
CircuitBreaker circuitBreaker = CircuitBreaker.of("testName", circuitBreakerConfig);

// = Maximal 3 Wiederholungen und ein fixes Intervall zwischen den Wiederholungen
RetryConfig config = RetryConfig.custom()
  .maxAttempts(3)
  .waitDuration(Duration.ofMillis(100))
  .retryOnException(throwable -> throwable instanceof IOException)
  .build();
Retry retryContext = Retry.of("id", config);

// Dekoriere den Aufruf zum HelloWorldService
Try.CheckedSupplier<String> decoratedSupplier = Decorators
  .ofCheckedSupplier(() -> helloWorldService.sayHelloWorld("Robert"))
  .withCircuitBreaker(circuitBreaker)
  .withRetry(retryContext)
  .decorate();

Try<String> result = Try.of(supplier)
  .recover(throwable -> "Hello Recovery");

Der CircuitBreaker ist eine Zustandsmaschine mit den Zuständen CLOSED, OPEN und HALF_OPEN. Der initiale Zustand ist CLOSED. Der CircuitBreaker speichert den Status – erfolgreich oder fehlgeschlagen – von Aufrufen in einem Ring-Bit-Buffer ohne rollendes Zeitfenster. Erfolgreiche Aufrufe werden als 1 Bit und fehlgeschlagene Aufrufe als 0 Bit gespeichert. Der Ring-Bit-Buffer hat eine konfigurierbare fixe Größe und speichert die Bits in einem long-Array. Das bedeutet, dass man in einem Array mit 16 long-(64-Bit-)Werten den Status der letzten 1024 Aufrufe speichern kann. Diese Speicherform spart viel Arbeitsspeicher.

Der Vorteil an einem Ring-Bit-Buffer ohne rollendes Zeitfenster ist, dass man nicht vorab wissen muss, mit welcher Frequenz das Backend aufgerufen wird, da kein Status gelöscht wird, wenn ein Zeitfenster abgelaufen ist. Wenn die Anzahl der fehlgeschlagenen Aufrufe einen konfigurierbaren Schwellwert überschreitet, wechselt der CircuitBreaker in den Zustand OPEN und weist weitere Aufrufe mit einer Resilience4jOpenException für ein konfigurierbares Zeitintervall ab. Nach Ablauf des Zeitintervalls wechselt der CircuitBreaker in den Zustand HALF_OPEN und lässt eine konfigurierbare Anzahl an Aufrufen wieder durch, um festzustellen, ob das Backend wieder zuverlässig antwortet.

Wenn die meisten Aufrufe erfolgreich sind und der Schwellwert unterschritten wird, dann wechselt der CircuitBreaker wieder in den Zustand CLOSED, ansonsten wechselt er erneut in den Zustand OPEN und der Kreislauf beginnt aufs Neue. Netflix Hystrix macht im Vergleich nur einen einzigen Aufruf im HALF_OPEN-Zustand. Dieses Verhalten ist unzuverlässig, wenn ein Backend hinter einem Load Balancer aus mehreren Hosts besteht und nur ein einziger Host unzuverlässig antwortet, aber genau dieser getestet und dann das gesamte Backend erneut blockiert wird.

Mit dem RateLimiter kann die Frequenz von Aufrufen zu einem Backend limitiert werden. Zum Beispiel kann limitiert werden, dass ein Backend nur einmal pro Sekunde aufgerufen werden darf, bevor der Aufruf mit einer RequestNotPermittedException abgewiesen wird (Listing 13).

RateLimiterConfig config = RateLimiterConfig.custom()
  .limitRefreshPeriod(Duration.ofSeconds(1))
  .limitForPeriod(1)
  .build();
RateLimiter rateLimiter = RateLimiter.of("backendName", config);

// Dekoriere den Aufruf zum HelloWorldService
Try.CheckedSupplier<String> supplier = Decorators
  .ofCheckedSupplier(() -> helloWorldService.sayHelloWorld("Robert"))
  .withRateLimiter(rateLimiter)
  .decorate();
Try<String> result = Try.of(supplier);

Sowohl CircuitBreaker, Retry als auch RateLimiter emittieren einen Eventstream mithilfe von RxJava, falls es einen Konsumenten gibt. Diese Events enthalten Informationen, die für das Monitoring eines Programms interessant sind; zum Beispiel die Antwortzeit von Aufrufen, die Fehler, die dazu führen, dass der CircuitBreaker getriggert wird, oder Zustandswechsel. Listing 14 zeigt, wie man nur die Error-Events eines Resilience4j konsumieren und in einem Circular-Buffer speichern kann, um nur die letzten zehn Events zu speichern. RxJava macht es mit Operatoren wie buffer oder window einfach, Events zwischenzuspeichern und sie in einem bestimmten Intervall stapelweise in eine Metrikendatenbank wie InfluxDB oder Prometheus zu schreiben.

CircuitBreaker circuitBreaker = CircuitBreaker.ofDefaults("testName");
CircularEventConsumer<CircuitBreakerOnErrorEvent> circularEventConsumer = new CircularEventConsumer<>(10);
circuitBreaker.getEventStream()
  .filter(event -> event.getEventType() == Type.ERROR)
  .cast(CircuitBreakerOnErrorEvent.class)
  .subscribe(circularEventConsumer);

List<CircuitBreakerOnErrorEvent> bufferedEvents = circularEventConsumer.getBufferedEvents();

Resilience4j bietet außerdem eine Schnittstelle an, mit der Metriken, wie die Anzahl fehlgeschlagener Aufrufe oder die aktuelle Fehlerrate, überwacht werden können (Listing 15).

CircuitBreaker.Metrics metrics = circuitBreaker.getMetrics();
float failureRate = metrics.getFailureRate();
int bufferedCalls = metrics.getNumberOfBufferedCalls();
int failedCalls = metrics.getNumberOfFailedCalls();

Fazit

Funktionale Programmierung als Programmierparadigma ist zu Beginn für einen Java-Entwickler sehr gewöhnungsbedürftig. Aber die Eingewöhnungsphase lohnt sich. Nach einiger Zeit macht man sich immer häufiger Gedanken über referenzielle Transparenz und passt auf, dass Funktionen keine Seitteneffekte haben. Die Testbarkeit des Codes verbessert sich somit und Funktionen lassen sich miteinander kombinieren. Die Vavr-Community ist mitlerweile schon recht groß, und man kann in Zukunft sicher noch einige neue Features in Vavr erwarten.

Verwandte Themen:

Geschrieben von
Robert Winkler
Robert Winkler
Robert Winkler arbeitet als Softwarearchitekt und Lead Developer bei der Deutschen Telekom AG – Group Innovation in Darmstadt. Seine Schwerpunkte liegen auf den Themen Softwarearchitektur, Continuous Delivery und Entwicklung von Backend-Systemen. Er ist der Ersteller von Swagger2Markup. Twitter: @rbrtwnklr Webseite: http://www.robwin.eu/
Tim Riemer
Tim Riemer
Tim Riemer ist als Softwarearchitekt mit Schwerpunkt Integration bei der FNT GmbH in Heiligenhaus beschäftigt. Neben seinem Job befasst er sich mit aktuellen Themen rund um Softwarearchitektur, Spring Boot, Build-Automation und Kotlin. Twitter: https://twitter.com/zordan_f
Kommentare

Schreibe einen Kommentar

Deine E-Mail-Adresse wird nicht veröffentlicht.