Extend Your Flow Horizon – Flow-Design mit Xtend

Kepler on the Horizon (Copyright NASA, CC license)
Kepler on the Horizon – Copyright NASA, published under CC here

Flow-Design ist ein allgemeingültiges Design- und Programmierparadigma. Ralf Westphal hat dies schon mehrfach gezeigt – natürlich für C#, aber auch für Ruby. Es existiert eine Umsetzung in Java von Olaf Krummnow (leider ist sein Blog nicht mehr erreichbar). Ich habe es hier in meinem Blog für Scala gezeigt.

Natürlich ist jede Sprache unterschiedlich stark prädestiniert für Flow-Design – wie prägnant sich Ports und deren Verbindungen spezifizieren lassen. Um es einigermaßen elegant, ohne viel syntaktisches Rauschen ausdrücken zu können, benötigt man schon Lambda-Ausdrücke in der Sprache.

Ich will es auch noch mal mit einer anderen Sprache des JVM-Universums versuchen: Beruflich habe ich mich in den letzten Monaten viel mit Xtend auseinandergesetzt und damit unter anderem eine Java-Compile-Time-Annotation zur automatischen Generierung von Logging-Code und einen Code-Generator auf Basis von Interface-Spezifikationen implementiert.

Xtend

Xtend fasziniert mich. Es ist eine Sprache, die im Umfeld des Eclipse-Projektes Xtext entstanden ist, mit dem sich sehr stringent externe DSL entwickeln lassen. Xtend ist die Sprache der Wahl für die Implementierung der Semantik einer Xtext-basierten DSL. Sie ist voll integriert in die Eclipse-IDE inklusive Syntax-Highlighting, Content-Assistant, Validierung und Quick-Fix-Unterstützung, nahtloser Integration von Java-Funktionalität und Xtend-Code-Debugging. Viele einführende Video-Tutorials, in denen diese Feature unter anderem gezeigt werden, sind im Channel des Xtext-Teams unter Vimeo zu finden. Auf der letzten JAX 2013 hat Sven Efftinge, einer der Maintainer der Sprache, diese in einem Vortrag vorgestellt.

Genauso beeindruckend, wie die nahtlose Integration in Eclipse, sind die Spracheigenschaften: Es ist eine hybride Sprache, die sowohl objektorientierte wie funktionale Programmierung und damit die für Flow-Design sehr hilfreichen Lamda-Ausdrücke unterstützt. Sie erlaubt über den Mechanismus der sogenannte Extension-Methods vorhandene Typen um ein neue Methoden zu erweitern, was sehr hilfreich ist, um kompakte Lamda-Ausdrücke aufschreiben zu können.

Der Xtend-Compiler erzeugt keinen Byte-Code sondern Java-5-Quellcode. Diese Eigenschaft ist Grundlage der nahtlosen Integration mit der Java-Welt und ermöglicht einen Makro-Mechanismus – „Active Annotations“ genannt. Dadurch kann programmativ Einfluss auf die verschiedenen Phasen des Übersetzungsprozesses nach Java genommen werden – eine Spracheigenschaft, die der eigentliche Anlass für mich war, mit Xtend eine prägnante Flow-Design-Realisierung zu versuchen.

Beruflich fiel die Wahl auf Xtend vor allem aufgrund der aus meiner Sicht sehr gelungenen Code-Template-Ausdrücke – noch nie war der Code einer Template-Programmierung so gut lesbar!

Insgesamt sind die Spracheigenschaften denen von Scala sehr ähnlich, nur dass eben das Java-Typsystem benutzt wird und es keine Traits gibt. Zumindest habe ich mich nach meinen Erfahrungen mit Scala gleich zu Hause gefühlt. Jeder, der eine moderne Programmiersprache – auch und gerade produktiv – im JVM-Universum einsetzen will, sollte auf Xtend mal einen Blick werfen. Durch die Kompilierung in Java-Code sollten dem Einsatz von Xtend auch in sehr restriktiven Unternehmensumgebungen nichts entgegenstehen – einem schlechten Management wird die Entstehung des abzuliefernden Java-Codes egal sein und Team-intern kann man sich durch eine höhere Produktivität die nötigen Freiräume schaffen, die einem das Management nicht bereit ist einzuräumen…

Aber jetzt auf zur Umsetzung eines Flow-Design in Xtend. Das schon bekannte triviale Beispiel soll nun in Xtend realisiert werden:

Triviales Flow-Design

Wobei die Normalize-Funktionseinheit wie folgt gestaltet war:

Normlize - reingezoomt

Eingangsport

Greifen wir uns eine Funktionseinheit mit einem Eingangsport und einem Ausgangsport heraus – ToUpper.

In Xtend benötigt den Eingangsport in zwei Ausprägungen. Einmal als Methodenobjekt, um die damit repräsentierte Methode als Continutation in die Liste eines Ausgangsports eintragen zu können und somit diese Funktionseinheit mit einer anderen zu verbinden. Als weiteres, und weit seltener benutzt, eigentlich nur um einen Flußdurchlauf anzustoßen, wird eine gleichnamige Methode definiert, die auch vom obigen Methodenobjekt referenziert wird.

package class ToUpper extends FunctionUnit {
  public val (String)=>void input = [msg | input(msg)]
  def input(String msg) { processInput(msg) }
  ...
}

Die Methode processInput implementiert die eigentliche Semantik der Funktionseinheit, die hier natürlich recht trivial ist.

Ausgangsport

Wie schon in ScalaFlow ist die Realisierung der Ausgangsports komplexer als die der Eingangsports. Auch in Xtend gibt es keine Events als Sprachelement. Stattdessen werden wie in ScalaFlow Methodenobjekte als Continuations registriert. Dazu dient die folgende Klasse OutputPort, die mit einem Nachrichtentyp parametrisiert wird:

package class OutputPort<MessageType> {

  private val List<(MessageType)=>void> outputOperations = new ArrayList

  def forward(MessageType msg) {
    if (!outputOperations.empty) {
      outputOperations.forEach[
        operation | operation.apply(msg)
      ]
    }
    ...
  }

  def void operator_mappedTo((MessageType)=>void operation) {
    outputOperations.add(operation)
  }

  def void operator_mappedTo(FunctionUnit fu) {
    outputOperations.add(fu.theOneAndOnlyInputPort)
  }
}

Die Klasse definiert ein Methode forward, mit der in der semantischen Implementierung Berechnungsergebnisse der Funktionseinheit an die mit ihr verbundenen Funktionseinheiten weitergeleitet werden können. Die in ToUpper referenzierte processInput-Methode leitet dementsprechend das Ergebnis über eine solche forward-Methode weiter:

private def processInput(String msg) {
  output.forward(msg.toUpperCase);
}

Abschließend gehören zur Implementierung eines Ausgangsports zwei Operationsdefinitionen für das Operatorzeichen „->“. Hier wird sich die Eigenschaft von Xtend zu Nutze gemacht, Operatoren überladen zu können.

Die erste Operator-Methode wird es erlauben, eine Ziel-Funktionseinheit über den Namen eines speziellen Eingangsports mit diesem Ausgangsport zu verbinden; also z.B.:

toUpper.output -> reverse.input

Während die zweite Operationsdefinition es ermöglichen soll, Funktionseinheiten mit nur einem Eingangsport direkt über ihren Namen zu verbinden, wobei der Name des Eingangsports weggelassen wird:

toUpper.output -> reverse

Eine solche Verbindung setzt voraus, dass die Ziel-Funktionseinheit wirklich nur einen Eingangsport hat und die Funktion theOneAndOnlyInputPort der Basisklasse FunctionUnit überschreibt, die das Methodenobjekt des Eingangsports zurückliefert. Diese kanonische Funktion ist notwendig, um eine freie Namenswahl des Ports zu gewährleisten. ToUpper ist auch eine Funktionseinheit mit nur einem Eingangsport und überschreibt deshalb diese Funktion ebenso:

package class ToUpper extends FunctionUnit {
  public val (String)=>void input = [msg | input(msg)]
  ...
  override (String)=>void getTheOneAndOnlyInputPort() {
    return input;
  }
}

Das Prefix „get“ kann in Xtend bei Referenzierung eines Getters weggelassen werden, so dass der Aufruf theOneAndOnlyInputPort und die Definition der Methode getTheOneAndOnlyInputPort dasselbe meinen.

Die vollständige Xtend-Klasse OutputPort ist auf GitHub zu finden.

In ToUpper wird jetzt die Klasse OutputPort verwendet, um einen Ausgangsport zu definieren:

package class ToUpper extends FunctionUnit {
  ...
  public val output = new OutputPort<String>('''«this».output''',
    [forwardIntegrationError]
  )
  ...
}

Als erster Konstruktor-Parameter wird der Name des Ausgangsports erwartet. Hier kommen zur Konstruktion des Arguments Xtend's-Template-Ausdrücke zum ersten Mal in einer minimalen Form zum Einsatz. Diese sind immer in dreifache Hochkommata eingeschlossen und erlauben es, innerhalb der französischen Guillemets-Zeichen « » beliebige Xtend-Ausdrücke zu referenzieren. Typischerweise sind diese Ausdrücke seiteneffektfrei und liefern eine Zeichenkette, die an selbiger Stelle in den Template-String eingebaut wird.

Der zweite Konstruktor-Parameter erwartet ein Methodenobjekt, das in dem Fall angewendet wird, wenn der Ausgangsport ein loses Ende darstellt; also keine Folge-Funktionseinheit registriert wurde. Die Methode forwardIntegrationError ist Teil der Infrastruktur, die über die Basisklasse FunctionUnit bereitgestellt wird.

Als letztes wird ein weiteres Mal eine Operator-Methode für den Operator „->“ definiert.

def void operator_mappedTo((String)=>void operation) {
  output -> operation
}

Dieser dient dazu, Instanzen der Funktionseinheit ToUpper mit anderen Funktionseinheiten zu verbinden, ohne immer den einzigen vorhanden Eingangsport namentlich spezifizieren zu müssen. Damit sind dann also Verbindungen der folgenden Art spezifizierbar.

toUpper -> reverse

Die gesamte Klassendefinition von ToUpper kann auf GitHub eingesehen werden. Alle anderen Funktionseinheiten sind analog realisiert.

Frappierende Ähnlichkeit zu Scala

Verbunden werden alle Funktionseinheiten wieder in einer Klasse RunFlow. Beim Anpassen dieser Klasse nach Kopieren aus der ScalaFlow-Lösung war ich schon sehr erstaunt, dass die Lösung in Xtend der in Scala so sehr ähnelt. Eigentlich ist nur die Syntax der Lambda-Ausdrücke etwas anders:

package class RunFlow {
  def static void main(String[] args) {

    // build 
    println("instantiate flow units...")
    val reverse = new Reverse
    val toLower = new ToLower
    val toUpper = new ToUpper
    val collector = new Collector(", ")
 
    // bind
    println("bind them...")
    reverse -> toLower
    reverse -> toUpper
    toLower -> collector.input1
    toUpper -> collector.input2
    collector.output -> [ msg |
      println("received '" + msg + "' from " + collector)
    ]

    onIntegrationErrorAt(#[toUpper, toLower, reverse, collector],
      [ exception | println("integration error happened: " + exception.message)]
    )

    // run
    println("run them...")
    val palindrom = "Trug Tim eine so helle Hose nie mit Gurt?"
    println("send message: " + palindrom)

    reverse.input(palindrom)

    println("finished.")
  }
}

Selbst die Behandlung von Integrationsfehlern ist sehr ähnlich gelungen. Die eckigen Klammern mit vorangehendem Hash-Zeichen „#[]“ sind eine abkürzende Schreibweise in Xtend für In-Place-Array-Initialisierungen.

Nur Fehler-Ports sind in XtendFlow, anders als in ScalaFlow, immer als normale Ausgangsports zu definieren, wodurch es keine Möglichkeiten gibt infrastrukturell vorzubauen, um alle Fehler-Ports gemeinsam zu behandeln. Im Prinzip sollte aber auch dies gehen, indem man parallel zur OutputPort-Klasse ein fast gleich implementierte ErrorPort-Klasse anlegt. Über diese liese sich dann eine ähnliche Fehlerbehandlungsinfrastruktur realisieren, wie in ScalaFlow.

Active Annotations

Das Schöne an der ScalaFlow-Realisierung war die deklarative Definition der Funktionseinheiten über Traits, wodurch eine sehr kompakte Implementierung von Funktionseinheiten gelingt, die praktisch nur noch semantischen Code und keinen infrastrukturellen Glue-Code mehr enthalten.

Nun, diese Spracheigenschaft gibt es in Xtend nicht. Aber man kann sich mit Annotationen behelfen – Xtend's Active Annotations. Im Unterschied zu Java-Annotationen und aufgrund des Umstandes, das Xtend nach Java kompiliert, ermöglichen es Active Annotations die generierten Java-Klassen on-the-fly mit zusätzlicher Funktionalität anzureichern. Da sich Eingangs- und Ausgangsports sehr schematisch implementieren lassen, wobei sich nur Namen und Typ von Port zu Port ändern, sind diese geradezu prädestiniert für ein ein schematische Anreicherung per Annotationen.

Die Idee ist, die Ports der ToUpper-Funktionseinheit deklarativ spezifizieren zu können, ähnlich wie in ScalaFlow:

@FunctionUnit(
  inputPorts = #[
    @InputPort(name="input", type=String)
  ],
  outputPorts = #[
    @OutputPort(name="output", type=String)
  ]
)
class ToUpper {}

In FunctionUnit.xtend sind die notwendigen aktiven Annotationen und Annotationsprozessoren definiert, um obiges Schema zu realisieren.

Lässt man die Implementierung der Klasse wie oben leer, definiert man sie also nur, wie es bei schrittweiser Verfeinerung während des Systementwurfs typisch ist, so wird man vom Eclipse-System, darauf hingewiesen, dass die Implementierung genau der process-Methoden zur Verarbeitung der über die Eingangsports eingehenden Daten fehlen. In Fall von ToUpper ist es nur die Methode processInput:

Warnung über fehlende Methoden für ToUpper mit QuickFix

Sie soll gerade die semantische Implementierung der Funktionseinheit aufnehmen. Der Name der zu implementierenden Methode wird direkt und automatisch aus dem Namen des deklarierten Eingangsports abgeleitet, wie man bei Änderung des Port-Namens sieht (siehe Cursor):

Warnung über nicht implementierte Methode für ToUpper mit geänderten Pin-Namen

Jetzt kann unter zu Hilfenahme der Quick-Fix-Funktionalität von Eclipse, die Methode implementiert werden. Hier das Ergebnis, dass funktional der händischen Implementierung von ToUpper entspricht:

@FunctionUnit(
  inputPorts = #[
    @InputPort(name="input", type=String)
  ],
  outputPorts = #[
    @OutputPort(name="output", type=String)
  ]
)
class ToUpper {

  override processInput(String msg) {
    output.forward(msg.toUpperCase);
  }
}

Der Implementierungsumfang der Funktionseinheit ist minimal und dies ist nicht nur der Trivialität des Beispiels geschuldet. Auch eine echte Implementierung wäre ganz auf die Semantik der Funktionseinheit beschränkt. Die Flow-Infrastruktur wird ganz deklarativ spezifiziert.

…Eigentlich sieht es ganz wie in ScalaFlow aus, nur dass die Annotationen etwas „gesprächiger“ sind. Das lässt sich wahrscheinlich unter Java-8 optimieren, wenn sich wiederholende Annotationen gleichen Typs erlaubt sein werden. Dann könnte man die Port-Spezifikation für obige Funktionseinheit kompakter spezifizieren. Vielleicht so:

@FunctionUnit(
  @InputPort(name="input", type=String),
  @OutputPort(name="output", type=String)
)
class ToUpper { ...

Das kommt dann der Trait-Lösung in ScalaFlow doch sehr, sehr nahe.

Generische Port-Typen

Nichtsdestotrotz werden Xtend's Active Annotations am Ende auf Java-Annotationen abgebildet. Und diese sind eine späte, aufgesetzte Spracheigenschaft von Java mit vielen Einschränkungen. Dies bemerkt man, wenn versucht wird, einen generischen Typ als Porttyp anzugeben.

	@InputPort(name="input", type=List<String>)

Schnell stellt man fest, dass als Annotationsargumente nur konstante Ausdrücke erlaubt sind. Dies ist jedoch nicht den Active Annotations geschuldet, sondern eine Einschränkung der Java-Annotationen. Generische Typen zählen per Java-Sprachdefinition nicht zu den erlaubten Argumenten einer Annotation.

Jedoch gibt es dafür eine Lösung, die leider nicht mehr so gut lesbar ist – die separate Angabe des generischen Rohtypen und der zugehörigen Typargumente:

	@InputPort(name="input", type=List, typeArguments=String)

Oder für generische Typen mit mehreren Typparametern, z.B.:

	@InputPort(name="input", type=Map, typeArguments=#[Integer,String])

Ein Beispiel dafür findet man in den Unit-Tests FunctionUnitTest.xtend, Test test_complex_typed_ports.

Integrative Funktionseinheiten

Die einzige Aufgabe integrativer Funktionseinheiten – ganz nach dem von Ralf Westpfahl formulierten Prinzip der Integrationsoperationen-Segregation (Integration Operation Segregation Principle – IOSP) – ist die Verbindung anderer Funktionseinheiten zu einem größeren Ganzen. Sie dürfen keine andere Logik enthalten, als die Funktionseinheiten zu verbinden. Insbesondere ist hier jegliche programmative Semantik aus der Domäne fehl am Platze.

In unserem gewählten trivialen Beispiel haben wir mit Normalize genau eine solche integrierende Funktionseinheit mit einem Eingangsport input und zwei Ausgangsports lower und upper. Die Semantik der Funktionseinheit besteht darin, eine einfließende Zeichenkette mit beliebiger Groß- und Kleinschreibung zu normalisieren, indem alle Zeichen einmal in Groß- und das andere Mal in Kleinbuchstaben umgewandelt werden und die beiden Ergebnisse über die jeweiligen Ausgangsports ausgeleitet werden.

@FunctionUnit(
  inputPorts = #[
    @InputPort(name="input", type=String)
  ],
  outputPorts = #[
    @OutputPort(name="lower", type=String),
    @OutputPort(name="upper", type=String)
  ]
)
class Normalize {
  ...
}

Die Klasse RunFlow, die als treibende Flow-Applikation eine Sonderstellung einnimmt, enthält eigentlich auch eine integrative Funktionseinheit. Diese wurde jedoch im Rahmen dieses experimentellen Versuchs nicht explizit gemacht.

Zur Realisierung der oben genannten Semantik werden in Normalize die beiden Funktionseinheiten ToLower und ToUpper verwendet. Dazu müssen die beiden Funktionseinheiten instanziiert werden:

class Normalize {
  val toLower = new ToLower
  val toUpper = new ToUpper

  new() {
    bind();
  }
  ...
}

Gleich im Konstruktor werden sie auch in der lokalen Methode bind mit den Ausgangsports von Normalize verbunden. Die Methode bind ist wie folgt implementiert:

private def bind() {
  toLower.output -> [msg | lower.forward(msg)]
  toUpper.output -> [msg | upper.forward(msg)]
}

Die Ausgabeports beider Funktionseinheiten werden mit jeweils einem Ausgabeport der integrierenden Funktionseinheit verbunden. Dazu wird einfach das Methodenobjekt der forward-Methode der jeweilgen OutputPort-Instanz bei den OutputPort-Instanzen der integrierten Funktionseinheiten registriert – die Ausgabeverbindungen sind geschaffen.

Fehlt noch die Verbindung zum Eingangsport input. Dazu wird einfach die mit dem input-Port assoziierte process-Methode implementiert und die Eingabe an die Eingangsports der beiden integrierten Funktionseinheiten weitergeleitet:

override processInput(String msg) {
  toLower.input(msg)
  toUpper.input(msg)
}

Für die Methode bind wird von Eclipse moniert, sie sei lokal nicht benutzt. Dies ist ein Unzulänglichkeit des Xtend-Compilers, die im Zusammenhang mit Active Annotations auftritt, wenn der Konstruktor manipuliert wird. Diese Warnung kann ignoriert werden, das Problem wird in der nächsten Version von Xtend behoben sein.

Grenzen und Ausblick

Durch die speziellen Annotationen und die definierten Fluss-Operatoren handelt es sich bei dem hier vorgestellten Ansatz praktisch um eine interne DSL im Rahmen von Xtend. Wie schon bei ScalaFlow (siehe „Grenzen einer internen DSL“) sind viele Möglichkeiten moderner IDEs nicht anwendbar und statische Kontextbedingungen nicht prüfbar. Wird z.B. eine Funktionseinheit mit mehreren Eingangsports mit einer anderen Funktionseinheit verbunden, ohne dass ein Eingangsport-Name ausgewählt wird

toUpper -> collector

dann ist dies syntaktisch korrekt, wird aber zur Laufzeit einen Fehler produzieren, da nicht klar ist, an welchen der Eingangsports von Collector die Eingaben weitergeleitet werden sollen. Die Situation ist mehrdeutig. Hier kann nur eine externe DSL weiterhelfen, die Kontextbedingungn prüft. Eine solche soll demnächst mit Hilfe von Xtext entstehen.

Randnotiz

Die oben in Auszügen dargestellte Code kann auf GitHub nachgelesen werden. Der erste, händisch implementierte Flow findet sich im Paket de.grammarcraft.xtend.firstflow. Der mit Hilfe von Annotationen implementierte Flow findet sich unter de.grammarcraft.xtend.annotatedflow. Die Implementierung der Annotationen und Hilfsklassen findet sich unter de.grammarcraft.xtend.flow und de.grammarcraft.xtend.flow.annotations.

One Reply to “Extend Your Flow Horizon – Flow-Design mit Xtend”

  1. Pingback: Nachtrag zu XtendFlow | Beyond Coding

Leave a Reply

Your email address will not be published.