×

Fluent API Design

Einleitung

Fluent Interfaces sind ein Konzept für Programmierschnittstellen in der Software-Entwicklung. Dieser Artikel soll einen kurzen Überblick darüber geben, was Fluent Interfaces eigentlich sind, was sie ausmacht und wie man sie verwendet. Des Weiteren wird anhand eines Beispiels gezeigt, wie man selbst eine Fluent API erstellen kann. Abschließend werden die Vor- und Nachteile beim Einsatz von Fluent Interfaces aufgelistet.

Was sind Fluent Interfaces?

Auf der Webseite von Martin Fowler gibt es bereits seit 2005 einen Artikel, der sich mit dem Thema beschäftigt. Auch in der Wikipedia wird man fündig, wobei in der englischsprachigen Ausgabe sogar Beispiele in diversen Sprachen aufgeführt sind.

Fluent Interface bedeutet auf Deutsch „Flüssige Schnittstelle“ oder treffender „Sprechende Schnittstelle“. Mit Hilfe einer solchen lassen sich komplexe Abläufe im Programmcode wie Sätze in natürlicher Sprache formulieren.

Beispiel 1 (siehe auch https://stefanroock.wordpress.com/tag/dsl)

TimePoint fiveOClock, sixOClock;
...
// Klassisch
TimeInterval meetingTime = new TimeInterval(fiveOClock, sixOClock);
// Fluent
TimeInterval meetingTime = fiveOClock.until(sixOClock);

Beispiel 2

Specification colorSpec = new ColorSpecification();
Specification lengthSpec = new LengthSpecification();
if (colorSpec.and(lengthSpec).isSatisfiedBy(obj)) {
    ...
}

Was macht Fluent Interfaces aus?

Wie die vorherigen Beispiele zeigen, ist ein wesentliches Ziel, die Lesbarkeit von Programmcode zu erhöhen. Dazu wird eine Domain Specific Language (DSL) definiert, die in der Syntax einer Programmiersprache implementiert wird. Dazu später mehr, wenn eine eigene Fluent API entworfen werden soll.

Zur Umsetzung von Fluent Interfaces wird meist Method Chaining (Aneinanderreihung von Methodenaufrufen) eingesetzt, jedoch können sie auch mit Hilfe von Method Nesting (verschachtelte Methodenaufrufe) abgebildet werden.

Beispiele für existierende Fluent APIs

Erstaunlicherweise hält sich die Verbreitung von Fluent APIs bisher in Grenzen. Meist werden sie lediglich bei der Objekterzeugung (Factory- oder Builder-Pattern) und bei Unit Tests eingesetzt. Des Weiteren ist die Java 8 Stream-API im Kommen.

Beispiel 1

String result = new StringBuilder()
                .append("Hello")
                .append(", ")
                .append("World")
                .toString();

Bereits seit Java 5 gibt die Klasse StringBuilder, mit deren Hilfe man Strings effizienter konkatenieren kann als mit dem klassischen +-Operator. Obwohl in diesem Beispiel Method Chaining verwendet wird, wirkt es nicht wirklich „fluent“. Daran ist zu erkennen, dass nicht jedes Interface, das diese Technik einsetzt, gleich als Fluent Interface angesehen werden kann.

Beispiel 2

String[] names = { "Alice", "Bob", "Charlie", "Derek" };
String result = Arrays.stream(names)
                      .filter(name -> name.length() > 5)
                      .findFirst()
                      .orElse("Unknown");

In diesem Beispiel wird aus einer Liste von Namen der erste Name gesucht, der länger als fünf Zeichen ist. Gibt es keinen, so wird „Unknown“ als Ergebnis genommen. Hier sieht man die Java 8 Stream-API im Einsatz und kann sehr schön die hohe Lesbarkeit des Programmcodes erkennen (siehe auch https://jaxenter.de/java-8-stream-api-codegenerierung-mit-fluent-interfaces-915 ).

Beispiel 3

String test = "a" + "b";
assertThat(test, is(equalTo("ab")));

Dies ist ein Beispiel für Method Nesting, das bei Hamcrest eingesetzt wird. Hamcrest ist ein Framework, das bei der Erstellung von Unit Tests unterstützt und Teil von JUnit ist. Hier lässt sich die Zeile 2 fast wie ein natürlicher Satz lesen.

Beispiel 4

create.select(AUTHOR.FIRST_NAME, AUTHOR.LAST_NAME, count())
      .from(AUTHOR)
      .join(BOOK).on(AUTHOR.ID.equal(BOOK.AUTHOR_ID))
      .where(BOOK.LANGUAGE.eq("DE"))
      .and(BOOK.PUBLISHED.gt(date("2008-01-01")))
      .groupBy(AUTHOR.FIRST_NAME, AUTHOR.LAST_NAME)
      .having(count().gt(5))
      .orderBy(AUTHOR.LAST_NAME.asc().nullsFirst())
      .limit(2)
      .offset(1)

jOOQ ist eine Bibliothek, die eine Fluent API für die typsichere Erzeugung und Ausführung von SQL-Queries bereitstellt. Sie kann mit den gängigen Datenbanksystemen „reden“ und ist als Open Source bzw. kommerzielle Variante erhältlich. Am Beispiel kann man sehr gut erkennen, dass eine SQL-Abfrage fast wortwörtlich in Java abgebildet werden kann.

Wir bauen eine eigene Fluent API

Vorüberlegungen

Wie bei jedem Schnittstellenentwurf macht es Sinn, sich vorher zu überlegen, was man eigentlich genau erreichen möchte. Angenommen, es soll eine DateFactory erstellt werden (vgl. https://de.wikipedia.org/wiki/Fluent_Interface), um ein Datum wie folgt erzeugen zu können:

Date date = DateFactory.create().year(2018).month(1).day(13).end();

Es ist sofort ersichtlich, dass die Methoden year, month und day Zahlwerte als Parameter haben. Des Weiteren ist zu überlegen, ob eine Vertauschung von Methodenaufrufen erlaubt ist…​

Date date = DateFactory.create().month(1).year(2018).day(13).end();

…​oder ob sogar Methodenaufrufe entfallen dürfen, wobei festzulegen wäre, was für ein Datum in diesem Falle gewünscht ist:

Date date = DateFactory.create().year(2018).day(13).end();

Erstellung einer Grammatik

Wie bereits eingangs erwähnt, können mit Fluent APIs DSLs abgebildet werden. Eine solche DSL kann mit Hilfe einer Grammatik beschrieben werden. In unserem Beispiel soll diese wie folgt aussehen:

DateFactory ::= 'create' (                      (1) 
Year | Month                                    (2)  
)
Year ::= 'year' '(' [0-9]+ ')' (Month | Day)    (3)  
Month ::= 'month' '(' [0-9]+ ')' Day            (4)      
Day ::= 'day' '(' [0-9]+ ')' End                (5) 
End ::= 'end()'                                 (6)
  1. Zunächst muss eine Methode create aufgerufen werden.
  2. Danach darf ein Year oder ein Month erzeugt werden.
  3. Die Methode year hat einen Zahlwert als Parameter, und anschließend darf ein Month oder ein Day erzeugt werden.
  4. Die Methode month hat ebenfalls einen Zahlwert als Parameter, und es muss ein Day folgen.
  5. Die Methode day hat auch einen Zahlwert als Parameter, und es muss die Methode end aufgerufen werden.
  6. Die Methode end dient in diesem Beispiel als Abschlussmethode.

Für eine bessere Übersicht kann zu dieser Grammatik auch ein Railroad-Diagramm erzeugt werden:

Umsetzungsregeln

Zur Umsetzung dieser DSL können folgende Regeln herangezogen werden:

  • Jedes Schlüsselwort (create, year, month, day und end) wird zu einer Methode.
  • Jede Verbindung (im Railroad-Diagramm von links nach rechts) wird zu einem Interface.
  • Bei einer Auswahlmöglichkeit wird jedes Schlüsselwort zu einer Methode im Interface.
  • Bei optionalen Schlüsselworten erweitert das aktuelle Interface das darauf folgende Interface.
  • Bei sich wiederholenden Schlüsselworten wird immer wieder das aktuelle Interface zurückgegeben.

Anhand dieser Regeln wird es relativ einfach, die Interfaces und Methoden für unsere DSL zu erstellen. Die Interfaces dienen dabei als Mediatoren zwischen den fachlichen Schlüsselworten.

Umsetzung in Java

Eine mögliche Umsetzung kann wie folgt aussehen:

public interface CreateMediator {
     YearMediator year(int year);
     MonthMediator month(int month);
}

public interface YearMediator {
     MonthMediator month(int month);
     DayMediator day(int day);
}

public interface MonthMediator {
     DayMediator day(int day);
}

public interface DayMediator {
     Date end();
}

Man beachte, dass DayMediator in diesem Beispiel das Abschlussinterface abbildet. Man beachte, dass es alternativ auch möglich wäre, dass die Methode day auch direkt ein Objekt vom Typ Date erzeugen könnte.

Bei komplexen DSLs und Grammatiken ist es sinnvoll, für jedes Interface eine eigene Klasse zu erstellen. Aufgrund der Übersichtlichkeit bietet es sich hier jedoch an, eine einzige Klasse zu implementieren.

public class DateCreator implements CreateMediator, YearMediator, MonthMediator, DayMediator {
     private int year = 2000;
     private int month = 1;
     private int day = 1;

     public YearMediator year(int year) {
        this.year = year;
        return this;
     }

     public MonthMediator month(int month) {
        this.month = month;
        return this;
     }

     public DayMediator day(int day) {
        this.day = day;
        return this;
     }

     public Date end() {
        return new Date(this.year, this.month, this.day);
     }

     public static CreateMediator create() {
        return new DateCreator();
     }
}

Die richtige Definition der Methoden und Interfaces

Im obigen Beispiel liefert eine Methode x() immer den x-Mediator zurück. Dadurch kommt es zum Problem der Code-Duplizierung, da die Methoden month und day in mehreren Interfaces auftauchen. Es ist jedoch auch keine Lösung, wenn man von einer Methode das Nachfolge-Interface zurückliefert (z. B. yearMonthMediator). Dadurch werden Kontexte vermischt, Erweiterungen erschwert, und der Code deutlich umständlicher.

Es geht jedoch auch anders. Man definiert zunächst nur Interfaces mit genau einer Methode für die Schlüsselworte. Für Verzweigungen werden zusätzliche Interfaces erstellt, die die Ziel-Interfaces erweitern. Im unserem Beispiel würde dies folgendermaßen aussehen:

public interface CreateMediator {
     public YearMonthMediator create();
}

public interface YearMediator {
     public MonthDayMediator year(int year);
}

public interface MonthMediator {
     public DayMediator month(int month);
}

public interface DayMediator {
     public EndMediator day(int day);
}

public interface EndMediator {
     public Date end();
}

public interface YearMonthMediator extends YearMediator, MonthMediator {
}

public interface MonthDayMediator extends MonthMediator, DayMediator {
}

public class DateCreator implements CreateMediator, YearMonthMediator, MonthDayMediator, DayMediator, EndMediator {
     // ...
}

Bei dieser Herangehensweise wird jede Methode nur einmal definiert. Verzweigungen werden über Erweiterungen der ‚atomaren‘ Interfaces realisiert, wodurch die Arbeit des Entwicklers bei der Implementierung der internen Fluent API deutlich vereinfacht wird.

Auf der anderen Seite wird durch die Vorgehensweise die Komplexität der Interfaces zunächst erhöht, da jede Verzweigung eine zusätzliche Vererbung nach sich zieht. Möglicherweise müssen sogar mehrere Klassen erzeugt werden, die diese ‚Verzweigungsinterfaces‘ implementieren. Es müssen dann Strategien implementiert werden, wie die bisher eingegebenen Parameter zwischen diesen Klassen weitergegeben werden können.

Zusammenfassung

Die Vorteile beim Einsatz von Fluent APIs lassen sich wie folgt zusammenfassen:

  • Sie machen Programmcode lesbarer und verständlicher.
  • Sie sind einfach und intuitiv auch ohne Vorwissen zu benutzen.
    • Die Code-Completion in IDEs schlägt vor, was als Nächstes möglich ist.
    • Die Anzahl und Reihenfolge der möglichen Methodenaufrufe kann eingeschränkt werden.
    • Methodenaufrufe mit vielen ggf. optionalen Parametern können wesentlich verständlicher geschrieben werden, indem man sie auf mehrere Methoden mit maximal einem Parameter aufteilt.

Die folgende Grafik zeigt, womit ein Entwickler seine Zeit bei der Arbeit normalerweise verbringt.

Dadurch wiegen die Vorteile von Fluent APIs umso schwerer, da der Fokus explizit auf der Lesbarkeit und Verständlichkeit liegt.

Natürlich gibt es auch einige Nachteile, die zu bedenken sind:

  • Fluent APIs sind aufwändig zu designen.
  • Der Implementierungsaufwand ist höher als bei klassischen APIs.
  • Die Implementierung kann komplex und unübersichtlich werden.
  • Die Fehleranalyse und das Debugging sind erschwert, da viele Methodenaufrufe in einer Zeile stehen können (siehe auch https://net-developers.de/2010/02/05/php-verkettete-methoden-fluent-interface).
  • Fluent APIs sind daher nicht für jede Art von API geeignet.

Fazit

Folgende Aspekte sprechen für den Einsatz von Fluent APIs:

  • Fluent APIs machen Programmcode ausdrucksstärker und verständlicher.
  • Sie erhöhen die Wartbarkeit von Software.
  • Sie erhöhen die Produktivität der Entwickler.
  • Sie erleichtern den Einstieg in neue Bibliotheken und Frameworks.
  • Sie sparen Zeit und Nerven 😉

Es ist darüber hinaus nicht schwer, selbst Fluent APIs zu erstellen, sofern man einige grundsätzliche Vorüberlegungen anstellt und gewisse Regeln einhält. Damit lassen sich schnell kleine Helferlein erzeugen, die eine Parameterflut in Methodensignaturen vermeiden.

Jedoch kann die Erstellung einer Fluent API schnell sehr komplex und unübersichtlich werden, was den Aufwand für spätere Erweiterungen und Refactorings deutlich in die Höhe treiben kann. Hierbei können Code-Generatoren nützlich sein.

Quellen

Von JHansen | 22.02.2018
JHansen

Softwareentwicklung