Railway Oriented Programming in Java

Fachlichkeit in Java programmieren ohne Exception Handling, geht das? Eine Idee aus der funktionalen Programmierung macht es möglich. Beim Railway Oriented Programming wird der Datentyp Monade verwendet, um den erfolgreichen oder fehlerhaften Ausgang einer Operation zu kapseln. Das mag sich hochtrabend anhören, aber der resultierende Code wird lesbarer und verständlicher.

Lesezeit: 15 Min.
In Pocket speichern
vorlesen Druckansicht Kommentare lesen 167 Beiträge
Railway Oriented Programming in Java
Von
  • Stefan Macke
Inhaltsverzeichnis

Fachliche Logik setzen Entwickler am besten durch kurze und prägnante Methoden um, die auf einem hohen Abstraktionsniveau das Problem beschreiben und lösen. Häufig enthalten diese Methoden aber zwischen den eigentlich interessanten Anweisungen viele rein technische Notwendigkeiten zum Behandeln von Fehlersituationen.

Als Beispiel hierfür zeigt das folgende Codestück eine Methode zum Ändern des Passworts eines Benutzers. Dazu werden sein Datensatz anhand seines Benutzernamens aus dem Repository geladen, das Passwort geändert und sein Profil am Ende wieder gespeichert.

public User changePassword(
Username username, Password oldPassword, Password newPassword) {
User user = this.userRepository.find(username);
if (user.isCorrectPassword(oldPassword)) {
user.changePassword(newPassword);
this.userRepository.update(user);
}
return user;
}

In der Praxis können allerdings mehrere Fehlersituationen auftreten

  • username, oldPassword oder newPassword könnten "null" sein.
  • Zum angegebenen Benutzernamen findet sich kein Benutzer.
  • Das alte Passwort stimmt nicht mit dem angegebenen überein.
  • Beim Zugriff auf die Datenbank tritt ein Fehler auf.

Der folgende Code zeigt das gleiche Beispiel, allerdings inklusive der Behandlung obiger Fehler:

public User changePassword(
Username username, Password oldPassword, Password newPassword)
throws UserNotFoundException, InvalidPasswordException,
DatabaseException {
if (username == null) {
throw new IllegalArgumentException("Username cannot be empty");
}
if (oldPassword == null) {
throw new IllegalArgumentException("Old password cannot be empty");
}
if (newPassword == null) {
throw new IllegalArgumentException("New Password cannot be empty");
}

User user = null;
try
{
user = this.userRepository.find(username);
if (user == null) {
throw new UserNotFoundException(username);
}

if (!user.isCorrectPassword(oldPassword)) {
throw new InvalidPasswordException(username, oldPassword);
}
user.changePassword(newPassword);
this.userRepository.update(user);
}
catch (DatabaseException e) {
this.logger.error("Password could not be changed due to
database error");
throw e;
}
return user;
}

Aus dem einfachen Algorithmus ist ein komplexes Monstrum mit verschachtelter Logik geworden. Diese Methode ist nicht nur schwieriger zu verstehen, sie versteckt auch den eigentlichen fachlichen Anwendungsfall hinter vielen technischen Anweisungen zur Fehlerbehandlung. Sicherlich ließe sich die Methode refaktorisieren und zum Beispiel auf mehrere kleinere aufteilen (was in der Praxis dringend anzuraten ist), aber das eigentliche Problem wird dadurch nicht behoben: Code zur Fehlerbehandlung ist eng verwoben mit der fachlichen Logik.

Dass es auch anders geht, zeigt das folgende Codebeispiel. Es implementiert die komplette Fehlerbehandlung, aber auf eine Art und Weise, die den Code kurz und prägnant hält und den fachlichen Anwendungsfall nicht versteckt, sondern sogar verständlicher macht:

public Result<User> changePassword(
Username username, Password oldPasswort, Password newPassword) {
return Result.of(username, "Username cannot be empty")
.combine(Result.of(oldPassword, "Old password cannot be empty"))
.combine(Result.of(newPassword, "New password cannot be empty"))
.onSuccess(() -> userRepo.find(username))
.ensure(user -> user.isCorrectPassword(oldPassword), "Invalid
password")
.onSuccess(user -> user.changePassword(newPassword))
.onSuccess(user -> userRepo.update(user))
.onFailure(() -> logger.error("Password could not be changed"))
.map(user -> user);
}

Statt Exceptions für Fehlersituationen oder "null" für nicht vorhandene Werte zu nutzen, setzt der obige Code auf den generischen Datentypen Result. Er repräsentiert das Ergebnis einer Aktion: Sie kann entweder erfolgreich oder fehlgeschlagen sein. Die verwendeten Methoden combine(), onSuccess(), ensure(), onFailure() und map() liefern fortwährend ein Result-Objekt zurück. Dadurch lässt sich der gesamte Ablauf des Anwendungsfalls als eine einzige Kette an Methodenaufrufen programmieren (s. Fluent API), ohne Fallunterscheidungen einsetzen zu müssen. Das Result-Objekt selbst verhält sich nämlich intern wie eine Weiche für Bahnschienen: Ist es erfolgreich, wird der eine Weg gewählt (und der übergebene Code ausgeführt), ansonsten der andere. Dieses Vorgehen gibt dem Verfahren seinen Namen: Railway Oriented Programming (ROP). Im Folgenden sollen die Ideen hinter dieser Entwicklungsmethode vorgestellt und die einzelnen Bestandteile der Klasse Result erarbeitet werden.

Die Idee, den Ablauf eines Programms analog zu Bahnschienen in einen Erfolgs- und einen Fehlerweg einzuteilen, stammt von Scott Wlaschin, der sie bei der funktionalen Softwareentwicklung mit F# einsetzt. In diesem Video zeigt er ausführlich den Praxiseinsatz des Railway Oriented Programming:

Das Konzept hinter ROP lässt sich aber ohne Weiteres auch auf andere Programmiersprachen übertragen. Und dank der Lambdas in Java 8 ist nun auch eine einfache Implementierung in Java möglich. Das Problem vieler Methoden wie der im zweiten Beispiel gezeigten ist, dass der normale Programmfluss nur durchlaufen wird, wenn bei keinem Zwischenschritt ein Fehler auftritt. Sobald man jedoch an irgendeiner Stelle vom erwarteten Verhalten abweicht (z. B. weil das alte Passwort nicht gültig oder ein Datenbankfehler aufgetreten ist), soll fast immer der restliche Code nicht mehr durchlaufen und stattdessen entweder eine Exception geworfen oder ein Standardwert wie "null" zurückgegeben werden. Der Fehlerfall führt das Programm also wie bei einer Weiche auf eine andere Bahnschiene, die parallel zum normalen Programmfluss verläuft, aber eben komplett daran vorbeiführt und sich bis zum Ende der Befehlskette nicht wieder verlassen lässt (s. Abb. 1).

Wie bei einer Weiche für Bahnschienen wird im Fehlerfall der normale Weg durch den Code verlassen (Abb. 1)

Damit das funktioniert, wird ein Objekt benötigt, das den aktuellen Zustand der Operationskette repräsentiert. Da es um deren Ausgang geht, könnte man es auf Deutsch Ergebnis nennen oder auf Englisch Result. Das Objekt führt nun abhängig vom aktuellen Zustand die übergebene Operation aus (und wechselt dabei gegebenenfalls den Zustand) oder eben nicht. Um die Operationen abhängig vom aktuellen Zustand ausführen zu können, sind sie in einer Form an das Objekt zu übergeben, die es ihm ermöglichen, die Ausführung zu kontrollieren. Hierzu bieten sich Lambda-Ausdrücke an.

Das folgende Beispiel zeigt eine erste Implementierung der Klasse Result in Java:

public final class Result {
private final String value;
private final String error;

private Result(final String value, final String error) {
this.value = value;
this.error = error;
}

public static Result withValue(final String value) {
return new Result(value, null);
}

public static Result withError(final String error) {
return new Result(null, error);
}

public boolean isFailure() {
return error != null;
}

public boolean isSuccess() {
return !isFailure();
}

public String getValue() {
return value;
}

public String getError() {
return error;
}

public Result onSuccess(final Consumer<String> consumer) {
if (isSuccess()) {
consumer.accept(getValue());
}
return this;
}

public Result onFailure(final Consumer<String> consumer) {
if (isFailure()) {
consumer.accept(getError());
}
return this;
}
}

Sie ist ein simpler Wrapper um zwei String-Attribute: value enthält den Wert des Ergebnisses, falls es erfolgreich ist, und error die Fehlermeldung, falls es fehlerhaft ist. Einige Hilfsmethoden erlauben die Abfrage des aktuellen Zustands (isFailure() und isSuccess()) beziehungsweise ermöglichen den Zugriff auf Wert und Fehler des Ergebnisses (getValue() und getError()). Erzeugt wird das Objekt über die Factory-Methoden withValue() beziehungsweise withError(), damit sich nicht gleichzeitig ein Wert und ein Fehler setzen lassen.

Die Methoden onSuccess() und onFailure() zeigen nun die eigentliche Aufgabe von Result. Sie führen die übergebene Operation (java.util.function.Consumer<String>: eine Funktion, die einen String als Parameter bekommt und keinen Rückgabewert hat) nur aus, falls sich das Objekt im jeweiligen Zustand befindet. Der Operation wird der Wert des Ergebnisses beziehungsweise des Fehlers übergeben. Als Rückgabewert dient immer das Objekt selbst, damit sich die Aufrufe der Methoden aneinanderreihen lassen, wie im folgenden Beispiel zu sehen ist.

System.out.println("=== Success ===");
Result.withValue("My value")
.onSuccess(value -> System.out.println("The value: " + value))
.onFailure(error -> System.out.println("The error: " + error));

System.out.println("\n=== Failure ===");
Result.withError("My error")
.onSuccess(value -> System.out.println("The value: " + value))
.onFailure(error -> System.out.println("The error: " + error));

Die resultierende Ausgabe ist:

=== Success ===
The value: My value

=== Failure ===
The error: My error

Das vorige Beispiel ist leider noch nicht ganz praxistauglich. Immerhin kann es ausschließlich mit Strings arbeiten. Für echte Projekte ist eine Möglichkeit zu schaffen, beliebige Datentypen für Wert und Fehler verwenden zu können. Die Klasse enthält auch noch keinerlei Fehlerbehandlung. So lässt sich jederzeit mit getError() auf den Fehler zugreifen, selbst wenn dieser gar nicht vorhanden (also "null") ist. Außerdem wären noch ein paar weitere Methoden hilfreich, die häufige Anforderungen umsetzen (etwa die Überprüfung oder die Verarbeitung und Umwandlung des Werts in einen anderen Datentypen).

Ein Ansatz ist, Result in eine generische Klasse umzubauen, die sich mit zwei Datentypen für ihren Wert und ihren Fehler parametrisieren lässt. Außerdem sollten die beiden unterschiedlichen Zustände Success und Failure objektorientiert in eigenen Subklassen modelliert werden. Da der resultierende Code etwas umfangreicher ausfällt als das obige Beispiel, sei an der Stelle darauf verzichtet, ihn in seiner Gesamtheit zu zeigen. Das vollständige Projekt inklusive Unit-Tests können die Leser auf GitHub einsehen.

Die fertige Klasse Result<TSuccess, Tfailure> kann mit beliebigen Datentypen für Wert und Fehler arbeiten und bietet mehrere Methoden an, die es ermöglichen, sie analog zu bekannten Konstrukten aus Java 8 wie java.util.Optional zu verwenden. Optional repräsentiert auch das Ergebnis einer Operation. Allerdings wird statt des erfolgreichen oder fehlerhaften Ausgangs hier dargestellt, ob die Operation einen Wert zurückliefert oder nicht. Das Lesen eines Benutzers aus der Datenbank zu einer ID könnte zum Beispiel ein Optional<Benutzer> zurückgeben, da nicht zwangsläufig ein Benutzer mit der angegebenen ID existieren muss. Der fehlende Benutzer ist aber auch kein Fehlerfall, sondern ganz "normal".

Beide Konstrukte – Optional und Result – implementieren ein abstraktes Konzept: die Monade. Darunter versteht man einen Container für Datentypen, der bestimmte Operationen anbieten und gewissen Regeln folgen muss. Ist das der Fall, lassen sich mehrere Monaden einfach zu komplexen Operationen verketten, wie das im dritten Quellcode-Beispiel zu sehen ist.

Dieser Artikel soll keine wissenschaftliche Einführung in die mathematische Kategorientheorie sein, in der die Monaden ihren Ursprung haben. Vielmehr sollen im Folgenden nur die für die Programmierung relevanten Eigenschaften von Monaden betrachtet werden, die aus drei Funktionen bestehen:

  1. ein Typkonstruktor, der die Monade mit einem Datentypen parametrisiert – im Beispiel Optional<T> bzw.Result<TSuccess, Tfailure>.
  2. eine Funktion, die aus einem normalen Datentyp eine Monade des Datentyps macht, ihn also in der Monade einpackt. Die Funktion heißt in funktionalen Programmiersprachen zum Beispiel return() oder unit(). Beispiel: Optional.of(5) macht aus dem Integer 5 ein Optional<Integer>.
  3. eine Funktion, die eine andere Funktion als Parameter bekommt, die aus dem inneren Datentyp der Monade eine neue Monade eines beliebigen Datentyps erzeugt und diese neue zurückgibt. Der in der ursprünglichen Monade enthaltene Datentyp wird ausgepackt, die übergebene Funktion auf seinen Wert angewendet und das Ergebnis der Operation – wiederum eine Monade, aber gegebenenfalls mit einem anderen Datentyp – zurückgegeben. In funktionalen Sprachen heißt diese Funktion etwa bind() oder flatMap(). Beispiel: Optional.of("text").flatMap(text -> Optional.of(text.length())) liefert als Ergebnis ein Optional<Integer>. Die an flatMap() übergebene Funktion (der Lambda-Ausdruck) bekommt einen String als Parameter und sendet ein Optional<Integer> zurück. flatMap() nimmt also den im Optional enthaltenen String mit dem Wert text, wendet die übergebene Funktion auf ihn an, erhält als Ergebnis ein Optional<Integer>, das die Länge des Strings (4) enthält, und gibt es dann zurück.

Die obigen Funktionen müssen gewisse Regeln einhalten, damit sich von einer echten Monade sprechen lässt. Zum Beispiel muss sich die unit-Funktion als neutrales Element der bind-Funktion verhalten. Wer Interesse an den mathematischen Hintergründen hat, dem sei der entsprechende Wikipedia-Artikel zum Einstieg empfohlen.

Im folgenden Codebeispiel sind die obigen Funktionen am Beispiel von Result zu sehen. withError() und withValue() sind die unit()-Funktionen und verpacken den übergebenen Wert in ein Result, das entweder aus einem Objekt der Subklassen Success oder Failure besteht. flatMap() ist dann polymorph in den beiden Subklassen implementiert und führt die übergebene Funktion im Erfolgsfall aus beziehungsweise macht im Fehlerfall einfach nichts (aufgrund des gegebenenfalls wechselnden Datentypen von TSuccess zu T ist jedoch ein neues Failure-Objekt zu erstellen).

// in Basisklasse Result
public static <TSuccess, TFailure> Result<TSuccess, TFailure>
withError(final TFailure error) {
assertParameterNotNull(error, "Error");
return new Failure<>(error);
}

public static <TSuccess, TFailure> Result<TSuccess, TFailure>
withValue(final TSuccess value) {
assertParameterNotNull(value, "Value");
return new Success<>(value);
}

// in Subklasse Success
public <T> Result<T, TFailure> flatMap(final Function<TSuccess,
Result<T, TFailure>> function) {
return function.apply(getValue());
}

// in Subklasse Failure
public <T> Result<T, TFailure> flatMap(final Function<TSuccess,
Result<T, TFailure>> function) {
return new Failure<>(getError());
}

Aufgrund der vielen Typparameter sieht die Implementierung etwas wild aus, aber der eigentliche Code ist relativ trivial. Die gesamte Klasse Result ist nur circa 50 Zeilen lang (s. Result) und unterstützt noch deutlich mehr Funktionen als die bisher genannten.

In Java 8 gibt es mehrere eingebaute Monaden. Sie alle stellen einen Datentyp in einen operationalen Kontext, dessen teils hohe Komplexität dadurch vor dem Aufrufer verborgen bleibt. Stattdessen lässt sich einfach die einheitliche Schnittstelle der Monaden nutzen, um unterschiedliche Konzepte im Code ähnlich zu verwenden. Die folgende Liste fasst drei dieser Monaden und die in diesem Artikel vorgestellte zusammen.

  • Optional: repräsentiert das Nicht-/Vorhandensein eines Werts und vermeidet ausufernde null-Checks.
  • Stream: repräsentiert potenziell mehrere Werte und vermeidet Iterationen.
  • Promise: repräsentiert das Ergebnis einer asynchronen Operation und vermeidet Thread-Programmierung.
  • Result: repräsentiert das potenziell fehlerhafte Ergebnis einer Operation und vermeidet Exception Handling.

Durch die funktionalen Ansätze, die seit Java 8 Einzug in die Sprache gehalten haben, ist es mit wenig Aufwand möglich, eigene Monaden zu entwickeln, die komplexe Berechnungen oder Prüfungen vor der Außenwelt verbergen. Dadurch lässt sich doppelter Code vermeiden (etwa die immer gleichen for-Schleifen zum Iterieren über Collections), die Fehlerbehandlung vereinfachen (z. B. keine null-Checks und Exceptions mehr nötig) oder die Les- und damit die Wartbarkeit des Codes erhöhen (bpsw. keine eigene Thread-Programmierung mehr).

Um Monaden einzusetzen, muss man nicht erst ein Mathematikstudium absolvieren. Es reicht, ihre grundsätzliche Funktionsweise zu verstehen. Das Beispiel in diesem Artikel verdeutlicht, dass schon wenige Zeilen Code ausreichen, um ein abstraktes Konzept wie das Ergebnis einer Operation umzusetzen.

Dem Ganzen dann einen Namen wie Railway Oriented Programming zu geben, ist womöglich etwas viel des Guten, da es sich weniger um eine völlig neue Art zu programmieren handelt, sondern lediglich die Behandlung von Exceptions vermeidet. Aber der Name trägt vielleicht zum Verständnis des abstrakten Konzepts bei und erleichtert den Einstieg in die funktionale Denkweise und die Idee der Monaden. Denn da Letztere in Java 8 zum Beispiel in Form von Optional oder Stream an vielen Stellen standardmäßig eingesetzt werden, sollte ihrer Verwendung im alltäglichen Code nichts mehr im Wege stehen.

Stefan Macke
ist Softwarearchitekt bei der Alte Oldenburger Krankenversicherung AG. Seit 2007 ist er dort außerdem Ausbilder für Anwendungsentwickler. In seinen aktuellen Projekten beschäftigt er sich mit der Modernisierung von Altanwendungen auf Basis einer serviceorientierten Architektur mithilfe von Java.
(ane)