Flow-Design – Xtend Your Horizon – Teil 3

#JAVAPRO #Architecture #IODA #FlowDesign

Der Flow-Design-Ansatz ist so fundamental wie der objektorientierte oder der funktionale, sodass sich die Umsetzung als eigenständiges Sprachkonstrukt geradezu aufdrängt. Doch es muss nicht gleich eine eigene Programmiersprache sein. In Xtend lassen sich über interne DSLs Spracherweiterungen hinzufügen. Der 3. Teil unserer Serie zeigt, wie eine konkrete interne DSL für Flow-Design in Xtend
aussieht und benutzt wird.

Das Grundprinzip des Flow-Design-Ansatzes ist die gegenseitige Nichtbeachtung der beteiligten Funktionseinheiten einer Abstraktionsebene. Sie sollen sich nicht kennen, um so konsequent Abhängigkeiten zu vermeiden. Nur die integrierende Funktionseinheit, die diese über ihre Ports miteinander verbindet, hat Abhängigkeiten zu den integrierten Funktionseinheiten. Enthält eine integrierende Funktionseinheit keine weitere Logik außer die Funktionseinheiten die sie integriert, implementiert sie das Grundprinzip einer IODA-Architektur, in der Integration und Operation strikt voneinander getrennt werden. Eine Funktionseinheit im Flow-Design folgt dem Muster in (Abb. 1). Nachrichten erreichen eine Funktionseinheit über einen Port, Ergebnisse verlassen eine Funktionseinheit über einen Port. Dies
entspricht dem EVA-Prinzip, das wir auch aus der funktionalen Programmierung kennen und welches Wikipedia als das Grundprinzip der Datenverarbeitung beschreibt. Eine Funktionseinheit ist damit nur von den Typen der einfließenden und ausfließenden Nachrichten abhängig – eine natürliche Abhängigkeit und auch nicht vermeidbar.

Schema einer Funktionseinheit. (Abb. 1)

Schema einer Funktionseinheit. (Abb. 1)

Eine Xtend-Flow-DSL

Die nachfolgend beschriebene DSL ist als Abbild einer ähnlichen DSL für Scala entstanden, da der Autor Xtend im beruflichen Umfeld ausgiebig benutzt. Es ist eine mögliche Realisierung einer Flow-Design-DSL. Andere sind durchaus denkbar. Xtend ist eine JVM-Sprache, die zum Eclipse-Projekt Xtext gehört und schon in der JAVAPRO Ausgabe 1/2017 kurz vorgestellt wurde. Sie transpiliert nach Java, was einige faszinierende Möglichkeiten für Spracherweiterungen eröffnet, wie wir noch sehen werden. Die realisierte DSL verwendet das Xtend-Sprachkonstrukt der Active- Annotation, um Klassen als Funktionseinheiten zu markieren und um Ports zu realisieren. Des Weiteren werden spezielle Operatoren definiert, um das Verbinden der Ports in integrierenden Funktionseinheiten zu spezifizieren und die Ausleitung der Berechnungsergebnisse in Ausgangsports angeben zu können. Aber wie sieht dies im Detail aus? Eine Funktionseinheit wird mit der Active-Annotation @Unit dekoriert. Ports werden als Argument dieser Annotation deklariert, unterschieden nach Ein- und Ausgangsports. So würden z.B. die beiden Ports einer Funktionseinheit ToUpper, wie in (Abb. 2) zu sehen, wie folgt deklariert werden:

Beispiel einer simplen Funktionseinheit. (Abb. 2)

Beispiel einer simplen Funktionseinheit. (Abb. 2)

(Listing 1)
@Unit(
   inputPorts = #[
     @Port(name=“input“, type=String)
   ],
   outputPorts = #[
     @Port(name=“output“, type=String)
   ]
)
class ToUpper {

}

Active-Annotations nutzen den Umstand aus, dass Xtend nach Java transpiliert und nicht direkt Byte-Code erzeugt. Dadurch wird es möglich, die transpilierte Java-Klasse automatisch um zusätzliche Methoden und Funktionalität anzureichern. Das ähnelt im weitesten
Sinne einem Sprach-Makro-Mechanismus.

Eingangsdaten verarbeiten

Wird die Klasse zusätzlich noch mit der Annotation @Operation markiert, entsteht eine Operation im Sinne der IODA-Architektur. Der Active-Annotation-Mechanismus fordert in diesem Fall bei Deklaration eines Eingangsports input die Implementierung einer Methode process$input ein, dessen einziger Eingabeparameter dem Typ der Port-Deklaration entspricht.

(Listing 2)
@Operation @Unit(
   inputPorts = #[
     @Port(name=“input“, type=String)
   ],
   outputPorts = #[
     @Port(name=“output“, type=String)
   ]
)
class ToUpper {
   override process$input(String msg) {
     …
   }

Daten, die eine Funktionseinheit über ihren Eingangsport input erreichen, werden durch diese Methode verarbeitet. Die Methode muss spezifisch implementiert werden und enthält die Logik der Funktionseinheit. Sobald die jeweilige Port-Annotation der Klasse hinzugefügt wurde, fordert der Xtend-Compiler die Implementierung dieser Methode ein. Bei Verwendung der Eclipse-IDEs wird dies auch durch entsprechende Quick-Fix-Vorschläge unterstützt.

Weiterleitung der Berechnungsergebnisse

Die durch die Funktionseinheit in den process$input Methoden berechneten Ergebnisse können über den Operator <= an einen der deklarierten Ausgangsports weitergeleitet werden, damit die nächste Funktionseinheit, die mit diesem Ausgangsport verbunden ist, die Ergebnisse weiterverarbeiten kann. Hier ein Beispiel:

output <= msg.toUpperCase;

Für die Existenz des Ausgangsports output als Feld der Klasse und des auf ihn anwendbaren Weiterleitungsoperators <= sorgt ebenfalls der Active-Annotation-Mechanismus von @Operation. Der Weiterleitungsoperator akzeptiert auch Closures, ein Sprachkonstrukt, dass mit Version 8 nun endlich auch in Java angekommen ist, bei Xtend aber schon seit der ersten Version enthalten und damit auch schon unter Java 6 benutzbar war.

(Listing 3)
output <= [
   if (msg.startsWith(„_“))
       msg.toFirstUpper
   else
       msg.toUpperCase
]

In Xtend ist, wie in vielen anderen funktional assoziierten Sprachen, das Ergebnis des letzten Ausdrucks eines Statement-Blocks immer auch dessen Rückgabewert. Dieser wird damit auch als Rückgabewert der Closure dem Ausgangsport übergeben.

Integration und Operation

Wie im zweiten Teil beschrieben, implementiert Flow-Design automatisch eine IODA-Architektur, der zufolge Operationen und Integrationen getrennt werden sollen. Deswegen gibt es in Xtend-Flow neben operativen auch integrierende Funktionseinheiten. Diese werden mit der Annotation @Integration markiert.

(Listing 4)
@Integration @Unit(
   inputPorts = #[
      @Port(name=“input“, type=String)
   ],
   outputPorts = #[
      @Port(name=“lower“, type=String),
      @Port(name=“upper“, type=String)
   ]
)
class Normalize {

}

Verbinden von Ports

Da integrierende Funktionseinheiten nach IODA nur dazu dienen sollen, andere Funktionseinheiten und deren Ports miteinander zu verbinden, bestehen sie in Xtend-Flow praktisch nur aus Konstruktor-Logik. Im Konstruktor werden die integrierten Funktionseinheiten typischerweise instanziiert (hier könnte jedoch auch eine spezifische Dependency-Injection-Technologie zum Einsatz kommen) und miteinander verbunden. Dazu wird der von Xtend-Flow definierte Verbindungsoperator -> verwendet. Hier ein Beispiel für die Verbindung der Funktionseinheiten in einer integrierenden Funktionseinheit, die dem Flow-Design in (Abb. 3) entspricht.

Beispiel einer integrierenden Funktionseinheit. (Abb. 3)

Beispiel einer integrierenden Funktionseinheit. (Abb. 3)

(Listing 5)
Class Normalize {
   val toLower = new ToLower
   val toUpper = new ToUpper

   new() {
      input -> toLower
      input -> toUpper
      toLower.output -> lower
      toUpper.output -> upper
   }
}

Die letzten zwei Zeilen verbinden die beiden vorher instanziierten, integrierten Funktionseinheiten toLower und toUpper mit den beiden explizit deklarierten Ausgangsports lower und upper der integrierenden Funktionseinheit Normalize. Anstelle der Ausgangsports könnte jetzt auch eine beliebige Kette von integrierten Funktionseinheiten stehen, die die Berechnungslogik der integrierenden Funktionseinheit repräsentiert. Es ist lediglich darauf zu achten, dass der Verbindungsoperator -> ein binärer Operator ist. D.h., eine Verkettung dreier Funktionseinheiten A->B->C müsste in zwei Schritten definiert werden: A->B und B->C.

Der Verbindungsoperator funktioniert sowohl mit voll qualifizierten Portnamen

sender.output -> receiver.input

als auch für Funktionseinheiten mit nur einem Eingangs- und einem Ausgangsport, die bei der Deklaration der Verbindung weggelassen werden können,

sender -> receiver

sowie für gemischte Kombinationen.

sender.output -> receiver
sender -> receiver.output

Wie dies in der DSL-Implementierung bewerkstelligt wird, soll im nächsten Artikel unserer Serie aufgezeigt werden. Dies war die eigentliche Herausforderung bei der Realisierung der DSL.

Implizite Eingangsports

Unter Umständen muss die Verarbeitung eines Datenflusses durch eine Funktionseinheit gestartet werden, ohne dass ein konkretes Datum übergeben wird, beispielsweise beim initialen Start eines Programms. In diesem Fall kann der Eingangsport weggelassen werden. Das Xtend-Flow-Framework wird in diesem Fall einen impliziten Eingangsport vom Typ de.grammarcraft.xtend.flow.data.None hinzufügen.

Folgendes Beispiel definiert eine Funktionseinheit ohne Eingangsport. Sie liest eine Benutzereingabe von der Konsole. Dies ist ein typischer Anwendungsfall für den Start einer Funktionseinheit ohne explizites Eingabedatum:

(Listing 6)
@Operation @Unit(
   outputPorts = #[
     @Port(name=“output“, type=String)
   ]
)
class AskForNumber {..}

Die Verarbeitungsfunktion dieser Funktionseinheit kann dann durch Einleitung der vordefinierten Konstante None gestartet werden:

(Listing 7)
class Program
{
   def static void main(String[] args)
   {
       AskForNumber entry_point
       …
       entry_point <= None
   }
}

In diesem Fall muss in der Funktionseinheit ohne Eingangsport die folgende Methode überschrieben werden, um das datenlose Startereignis zu verarbeiten:

(Listing 8)
class AskForNumber {

   override process$start(None msg) {
      output <= [
        val s = new Scanner(System.in);
        print(‚Enter number: ‚)
        s.nextLine().trim()
      ]
   }

}

Fehlerbehandlung

Es müssen zwei Arten von Fehlern unterschieden werden: Integrationsfehler und Fehler des Domänenmodells. Integrationsfehler sind Programmierfehler, die nicht vom Compiler entdeckt werden können, da ja eine interne DSL realisiert wurde. Sie können immer nur zur Laufzeit entdeckt werden. Bei einer externen DSL wäre das anders. Ein Integrationsfehler liegt immer dann vor, wenn ein deklarierter Ausgangsport mit keinem Eingangsport verbunden und mit keiner Closure belegt ist. Solche Fehler werden über den speziellen Ausgangsport integrationError vom Typ java.lang.Exception signalisiert, der für jede Funktionseinheit implizit definiert ist. Dieser Ausgangsport ist standardmäßig mit einer Closure verbunden, die eine Fehlermeldung auf der Konsole ausgibt. Jedoch kann dieses Verhalten vom Bibliotheksbenutzer geändert werden, indem er diesen Port auf eine anderen Closure oder einen anderen Port umleitet:

fu.integrationError ->
[ log.fatal(„integration error happened: {0}“, exception.message) ]

Da dies bei einer großen Zahl von Funktionseinheiten sehr aufwendig werden kann, stellt die Bibliothek eine Hilfsmethode bereit, um dies für eine Liste von Funktionseinheiten auf einmal zu realisieren:

      onIntegrationErrorAt(#[reverse, collector]) [
           exception | log.fatal(„integration error happened:
{0}“, exception.message)
      ]

Die Variablen reverse und collector referenzieren in diesem Beispiel Instanzen von Funktionseinheiten. Für System-Designer ist natürlich wesentlich interessanter, wie Fehler abgebildet werden, die inhärente Bestandteile des Domänenmodells sind. Dies ist eigentlich ganz einfach: Alle im Domänenmodell definierten Fehler sollten als normale Ausgangsports modelliert werden. Sind sie doch auch nur Nachrichten, die durch das System fließen und auch explizit in der Domäne des Systems behandelt werden müssen. Weitere Betrachtungen zu Laufzeitkosten, Nebenläufigkeit und Zyklen in spezifizierten Datenflüssen sind im Readme des Github-Repositories der Bibliothek nachzulesen. Die Bibliothek selbst kann sehr einfach über Bintray oder Maven-Central in eigene Projekte integriert werden.

Altes Beispiel – neue Implementierung

Eine Realisierung des im 2. Teil unserer Serie entwickelten Beispiels zur Konvertierung römischer Zahlen mit Xtend-Flow ermöglicht eine direkte Gegenüberstellung zur Implementierung in Java. Die ausführliche Beschreibung der Semantik und des Flow-Designs der Lösung kann in der JAVAPRO Ausgabe 3/2018 sowie auf JAVAPRO Online nachgelesen werden.

Sehen wir uns die integrierende Funktionseinheit der höchsten Abstraktionsebene an, die dem Flow-Design in (Abb. 4) entspricht. Wie in (Listing 9) zu sehen ist, stellt die Xtend-Implementierung eine direkte Repräsentation dieses Datenflusses dar.

Flow-Design für convert roman. (Abb. 4)

Flow-Design für convert roman. (Abb. 4)

(Listing 9) Programm für convert roman in Xtend
class Program
{
  def static void main(String[] args)
  {
     val read_number_to_convert = new ReadNumberToConvert
     val display_result = new DisplayResult
     val display_error = new DisplayError
     val convert = new Convert

     // setup flow
     read_number_to_convert -> convert
     convert.result -> display_result
     convert.error -> display_error

     // start flow
     read_number_to_convert <= None
  }
}

ReadNumberToConvert aus der main Methode ist eine operative Funktionseinheit, die nur als Datenquelle dient und deswegen einen impliziten Eingangsport besitzt, dem, wie für AskForNumber beschrieben, keine Nachricht übergeben werden kann. Die leere Klammer als Datentyp des eingehenden Ports von read number in (Abb. 4) repräsentiert gerade ein Signal, das nur dazu dient, den Datenfluss und die Verarbeitung anzustoßen. Die oben schon erwähnte Konstante None startet in Xtend-Flow die Abarbeitung des Datenflusses. Der zugehörige Typ None wird zum Typparameter des Eingangsports dieser Funktionseinheit und damit auch zum Typ des einzigen Parameters der zugehörigen Methode process$start, wie sich in (Listing 10) nachvollziehen lässt.

(Listing 10) Funktionseinheit ReadNumberToConvert
@Operation @Unit(
  outputPorts = #[
    @Port(name=“output“, type=String)
  ]
)
class ReadNumberToConvert {

  override process$start(None msg) {
    output <= [
      val s = new Scanner(System.in);
      print(‚Enter roman or arabic number: ‚)
      s.nextLine().trim()
    ]
  }
}

Die Funktionseinheiten DisplayResult und DisplayError wiederum sind operative Funktionseinheiten, die Datensenken darstellen. Für diese werden nur Eingangsports aber keine Ausgangsports definiert, wie in (Listing 11) beispielhaft für die Funktionseinheit DisplayResult zu sehen ist.

(Listing 11) Funktionseinheit DisplayResult
@Operation @Unit(
  inputPorts = #[
    @Port(name=“input“, type=String)
  ]
)
class DisplayResult {

  override process$input(String msg) {
    println(msg)
  }
}

Zurück zur Implementierung der main Methode in (Listing 9), in der die eben vorgestellten Funktionseinheiten integriert werden. Es fehlt nur noch die Verbindung der Ports der integrierten Funktionseinheiten: Das Verweben der Ports erfolgt deklarativ direkt im Konstruktor der Funktionseinheiten (siehe auch Funktionseinheit Convert, (Listing 12) oder, wie folgt in der main Methode:

read_number_to_convert -> convert
convert.result -> display_result
convert.error -> display_error

Das Ergebnis des Einlesevorgangs durch die Instanz read_number_to_convert der Funktionseinheit ReadNumberToConvert wird direkt an die Funktionseinheitsinstanz convert weitergeleitet, die die Konvertierung der Zahlen vornimmt. Im Falle einer erfolgreichen Konvertierung stellt convert das Ergebnis über den Port .result zur Verfügung. Deswegen ist dieser mit der Funktionseinheitsinstanz display_result verbunden. Im Fehlerfall stellt convert eine Nachricht über den Port .error zur Verfügung, deswegen ist sie an diesen display_error gebunden. Die Spezifikation der Verbindung der Ports entspricht exakt dem Schema des dargestellten Flow-Designs in (Abb. 4). Beides ist ohne großen kognitiven Aufwand ineinander überführbar. Als letzter Punkt in der main Methode wird der Datenfluß durch Einleitung des vordefinierten Datums None in den impliziten Eingangsport der Funktionseinheitsinstanz read_number_to_convert angestoßen.

Abstraktionsebenen sauber getrennt

Die eigentliche Geschäftslogik ist in der Funktionseinheit Convert implementiert, welche in der Variablen convert der main Methode instanziiert wird. Damit werden auch die verschiedenen Ebenen der Abstraktion sauber getrennt. Wie und was konvertiert wird, ist leicht nachvollziehbar in der integrativen Funktionseinheit Convert definiert (Listing 12).

(Listing 12) Funktionseinheit Convert
@Integration @Unit(
  inputPorts = #[
    @Port(name=“number“, type=String)
  ],
  outputPorts = #[
    @Port(name=“result“, type=String),
    @Port(name=“error“, type=String)
  ]
)

class Convert {
  val determine_number_type = new DetermineNumberType
  val validate_roman_number = new ValidateRomanNumber
  val validate_arabic_number = new ValidateArabicNumber
  val convert_from_roman = new ConvertFromRoman
  val convert_to_roman = new ConvertToRoman

  new() {
         number -> determine_number_type

         determine_number_type.romanNumber -> validate_roman_number
         determine_number_type.arabicNumber -> validate_arabic_number

         validate_roman_number.valid -> convert_from_roman
         validate_roman_number.invalid -> error

         validate_arabic_number.valid -> convert_to_roman
         validate_arabic_number.invalid -> error

         convert_to_roman -> result
         convert_from_roman -> result
  }
}

Die im Konstruktor new der Funktionseinheit Convert deklarierten Verbindungen der Funktionseinheiten sind die direkte textuelle Repräsentation des in blau dargestellten Datenflusses in (Abb. 5). Zur Erinnerung ist die Java-Flow-Implementierung in (Listing 13) abgebildet.

Funktionseinheit convert. (Abb. 5)

Funktionseinheit convert. (Abb. 5)

(Listing 13) Funktionseinheit convert in Java
public void convert(String number, Consumer<String> onSuccess, Consumer<String>onError)
{
   RomanConversions.determineNumberType (number,romanNumber -> RomanConversions.validateRomanNumber
  (romanNumber,() -> {
                      int result = FromRomanConversion.convert(romanNumber);
                      onSuccess.accept(Integer.toString(result));
   },
   onError),
   arabicNumber -> RomanConversions.validateArabicNumber
  (arabicNumber,() -> {
                       String result = ToRomanConversion.convert(arabicNumber);
                       onSuccess.accept(result);
   },
   onError)
   );
}

Die korrespondierende Xtend-Flow-Implementierung ist nicht kürzer, aber wesentlich deskriptiver und damit näher am Flow-Design-Schema. Außerdem wird die Vermischung der Abstraktionsebenen im Code vermieden, was die Lesbarkeit deutlich verbessert. Auch das gedankliche Hin- und Herwechseln zwischen Code und grafischer Repräsentation wird wesentlich einfacher.

Operative Funktionseinheit

Als Letztes noch ein Beispiel für eine operative Funktionseinheit mit Eingangs- und Ausgangsports, wieder im Vergleich zur Java-Flow-Implementierung, die in (Listing 14) angegeben ist.

(Listing 14) Funktionseinheit validateRomanNumber in Java
public static void validateRomanNumber (String romanNumber, Runnable isValid, Consumer<String> isInvalid)
{
  if (Pattern.matches(„^[IVXLCDM]+$“, romanNumber.toUpperCase()))
    isValid.run();
  else
    isInvalid.accept(
      String.format(„Invalid roman digit found in ‚%s'“, roman-Number));
}

Im Flow-Design des Beispiels hat die operative Funktionseinheit validate roman number zwei Ausgangsports. In Java ließ sich das nicht anders abbilden als über Continuations. Mit Xtend-Flow lässt sich dies viel expliziter und intuitiver ausdrücken, indem die jeweiligen Berechnungsergebnisse mit dem Operator <= direkt an die deklarierten Ausgangsports weitergeleitet werden, wie in (Listing 15) dargestellt. Weitere Details der Implementierung des Beispiels in Xtend sind auf GitHub veröffentlicht.

(Listing 15) Funktionseinheit validateRomanNumber
@Operation @Unit(
  inputPorts = #[
    @Port(name=“input“, type=String)
  ],
  outputPorts = #[
    @Port(name=“valid“, type=String),
    @Port(name=“invalid“, type=String)
  ]
)
class ValidateRomanNumber {

  override process$input(String romanNumber) {
    if (Pattern.matches(„^[IVXLCDM]+$“, romanNumber.toUpper Case))
      valid <= romanNumber
    else
      invalid <= “’Invalid roman digit found in „«roman Number»““‘;
  }
}

Ausblick

Mit Flow-Design lässt sich eine elegante interne DSL in Xtend realisieren, die den grafischen Konzepten sehr nahekommt und somit im Code ein Reflektieren der Architektur erlaubt. Die Realisierung als eigenständige, externe DSL hätte dagegen weitere Vorteile:  Fehlermeldungen wären wesentlich spezifischer und der Compiler könnte statische Analysen durchführen, wie z.B. die Prüfung auf Zyklen oder unverbundene Ausgangsports. Dabei muss es nicht gleich eine Turing-mächtige Programmiersprache sein. Ein erster Schritt könnte auch nur eine externe DSL sein, mit der integrierende Funktionseinheiten deklariert und die Verwebung der integrierten Funktionseinheiten spezifiziert wird. Dann wären lediglich noch die operativen Funktionseinheiten zu implementieren. Zielsprache der DSL-Kompilierung könnte Java, Xtend-Flow oder Scala-Flow (eine ähnliche Implementierung für Scala) sein. Es wäre auch die Erweiterung einer echten Programmiersprache denkbar. Xtend selbst würde sich da zum Beispiel als ein erster, wenig aufwendiger Versuch anbieten.

 


Denis Kuniß arbeitet bei Diebold Nixdorf in der Plattform-Software. Er hat an der TU Berlin studiert und sich dort mit Compiler-Generierung beschäftigt. Seine Interessen gelten den formalen und domänenspezifischen Sprachen und dem Architekturentwurf. Er entwickelt in Java, Xtend, Scala und mit Xtext und Gradle.

blog.grammarcraft.de
Twitter: @DenisKuniss
https://www.xing.com/profile/Denis_Kuniss
https://www.linkedin.com/in/denis-kuniss-3037929/
https://github.com/kuniss
kuniss@grammarcraft.de

 

Carolyn Molski


Leave a Reply