dark

Bessere Abstraktion mit IODA

Avatar

#Java #Architecture #IODA

Programmierer mischen oft zwei verschiedene Aspekte ohne viel darüber nachzudenken: den Aspekt der Operation und den der Integration. Aber Kontrollstrukturen und Domänen-Logik als Aspekte der Operation gehören strikt getrennt von Methodenaufrufen als Aspekte der Integration, um Software
änderungsfähig zu halten und damit zukunftsfähig zu sein. Die IODA-Architektur ist das Richtige dafür.

Meistens sieht man im Code von Software-Systemen, wie Logik über alle Abstraktionsebenen verteilt ist: Kontrollfluss-Anweisung und logische Ausdrücke als Repräsentanten der Domain- oder Business-Logik und damit von Domänen-Operationen, mischen sich mit Methodenaufrufen, die abstrahierte, ausgelagerte Logik integrieren. Gerade dieses „Verschmieren“ der Logik über alle Abstraktionsebenen macht das Erfassen und kognitive Durchdringen von Code so schwierig und damit das Erweitern und Verändern von Software-Systemen oft zu einem Albtraum.

Aber warum ist das so? Warum ist das Erfassen von Domänen-Logik in solch einem Code so schwierig? Vermutlich hängt es vor allem damit zusammen, dass wir beim Nachvollziehen des Codes zwischen verschiedenen Abstraktionsdomänen hin- und herspringen müssen. Da sind einmal die Domänenkonzepte der Programmiersprache: Kontrollfluss-Anweisungen, Schleifen und Fallunterscheidungen sowie eingebaute Operationen und Ausdrücke der Programmiersprache. Diese mischen sich mit Aufrufen unserer eigenen Methoden, die wiederum Konzepte unserer eigenen Domäne repräsentieren, die wir modellieren wollen.

Schuld daran ist das Konzept der Methoden, oder allgemeiner ausgedrückt, das Konzept der Sub-Routinen. Dabei sind Methoden nicht per se schlecht. Jedoch sind sie als Programmierkonzept so fundamental und scheinen so wohlverstanden zu sein, dass niemand auf die Idee kommt, diesen Umstand infrage zu stellen. Dadurch sind sie praktisch auf jeder Abstraktionsebene eines Software-Systems als Container für Code wiederzufinden. Aber es ist meistens eben nicht nur ein Container für Logik. Oft ist es der einzige Container um Integration auf Kontrollflussebene zu realisieren, indem andere Methoden derselben Domäne aufgerufen werden.

Wir haben uns bekanntlich Mittel zur Abstraktion geschaffen. Man denke nur an das objektorientierte Paradigma mit seinen Objekten und Klassen, dass heute in fast allen modernen Programmiersprachen anzutreffen ist. Aber auch diese enthalten als kleinste Code-Container Methoden. Und zur Integration auf Kontrollflussebene können in aktuellen Programmiersprachen nur Sub-Routinen verwendet werden.

Alles verschmiert

Sehen wir uns ein Beispiel an.

(Listing 1)
void a(x) {
   val y = …x…;
   z = b(y);
   …z…;
}

Die Sub-Routine a enthält Domänen-Logik, die hier durch …x… und …z… symbolisiert werden soll. Domänen-Logik ist rot markiert. Das können Ausdrücke, Kontrollkonstrukte oder beides sein. Am Ende berechnen diese ein Ergebnis, welches für …x… in der Variable y  abgespeichert wird. Die Sub-Routine b sei ebenfalls von uns definiert, sie wird mit diesem Ergebnis aufgerufen. Der Aufruf integriert bzgl. der Sub-Routine a extern definierte Logik und ist grün markiert. Durch den Aufruf der Sub-Routine b wird wiederum ein Ergebnis berechnet, dass dann nachfolgend in weiterer Domänen-Logik  verarbeitet wird. Dies ist eine typische Routine mit eingebettetem Sub-Routine-Aufruf, wie sie uns häufig im Code begegnet.

Integration und Operation gemischt auf allen Abstraktionsebenen. (Abb. 1)

Die Sub-Routine a vermischt Domänen-Logik mit Integrationslogik. Und so geschieht es auf allen Abstraktionsebenen (Abb.1). Das Erfassen der Semantik von a ist nicht leicht, da zwischen dem bereits abstrahierten Konzept b unserer eigenen Domäne und den Konzepten der verwendeten Programmiersprache in …x… und …z… hin- und her gewechselt werden muss. Die beiden Blöcke …x… und …z… ergeben zwar auch Domänen-Logik – wir bilden beim Erfassen dieses Codes in Gedanken eine temporäre Abstraktion – aber diese muss bei jedem Lesen des Codes neu erfasst werden. Und nach allgemeiner Erfahrung wird Code viel häufiger gelesen als geschrieben. Dieses temporäre Finden und kognitive Erstellen der Abstraktion findet also bei jedem Lesen immer und immer wieder statt.

Auch hier sollte das DRY-Prinzip gelten. Warum nicht auch diesen Code in eine Sub-Routine auslagern?

(Listing 2)
void a(x) {
   val y = a1(x);
   z = b(y);
   a2(z);
}

Jetzt hat die Sub-Routine a nur noch einen Zweck: den der Integration. Es gibt keine Logik-Konstrukte, keine Kontrollfluss-Konstrukte mehr in a. Wenn man sich die Semantik von a erarbeitet, muss man nur noch die Abstraktionen a1, b und a2 kennen, der Rest ergibt sich aus dem Datenfluss – den Variablenzuweisungen und Argumentübergaben – zwischen diesen Abstraktionen.

Prinzip der Trennung von Integration und Operation

Nach diesem Prinzip enthält eine Routine entweder nur Logik, d.h. Transformationen, Domänen-Ausdrücke und Kontrollstrukturen mit Domänen-Ausdrücken sowie Aufrufe zu APIs oder Frameworks, die nicht zur eigentlichen Code-Basis, zur Domäne des Systems gehören. Diese Art von Routinen werden als Operationen bezeichnet.

Oder, eine Routine enthält keinerlei Logik, sondern nur Aufrufe anderer Routinen derselben Code-Basis oder Kontrollstrukturen ohne Domänen-Ausdrücke (z.B. Schleifen, die nur über Daten-Nachrichten iterieren, ohne domänenspezifische Abbruchbedingungen). Diese Routinen werden als Integration bezeichnet. Die strikte Trennung in integrierende und operative Routinen bringt mehrere Vorteile mit sich.

1. Routinen tendieren dazu, sehr kurz zu bleiben. Denn mehr als zehn, 20 oder 30 Zeilen reine Logik oder ausschließlich Routinen-Aufrufe fühlen sich nicht gut an. Da eine Mischung nicht erlaubt ist, werden weitere kleine Routinen extrahiert.
2. Kurze Routinen die nur Logik enthalten, sind leicht zu testen, da sie keine Abhängigkeiten haben.
3. Kurze Routinen die nur Logik enthalten, sind vergleichsweise leicht zu verstehen. Der Routinen-Name kann wirklich selbsterklärend sein.
4. Kurze Routinen die ausschließlich integrieren, sind sehr gut zu verstehen und beschreiben auf einen Blick was geschieht.
5. Die Korrektheit von Integrationen lässt sich sehr leicht durch Augenscheinnahme prüfen. Es ist lediglich festzustellen, ob
Verarbeitungsschritte grundsätzlich in der korrekten Reihenfolge angeordnet sind. Den Rest übernimmt der Compiler bzw. die Testabdeckung der Operationen.
6. Integrationen lassen sich leicht durch Zwischenschieben weiterer Routinen erweitern, um neue Anforderungen zu erfüllen. Die Verständlichkeit bleibt dabei erhalten.

IODA-Architektur

Die konsequente Anwendung des Prinzips der Trennung von Operation und Integration führt zu einer IODA-Architektur. Der Begriff der IODA-Architektur wurde von Ralf Westphal geprägt.

Schema einer IODA-Architektur. (Abb. 2)

In einer IODA-Architektur ist der Code als Baum organisiert mit dem Programmeinstiegspunkt als Wurzel, den Operationen als Blättern und den Integrationen als Astknoten (Abb.2). Auf dem Pfad einer Operation zur Wurzel des Baumes gibt es nur Integrationen. Integration und Operationen kommunizieren untereinander über Daten – von den Datentypen dieser Daten sind sie strukturell abhängig. Operationen können externe API-Routinen aufrufen, um z.B. Seiteneffekte (z.B. UI, System-I/O) auszulösen oder einen Zustand zu verwalten (Datenbankzugriffe). (Abb. 2) zeigt dies in abstrakter Form und macht gleichzeitig deutlich, wo der Name für diesen Architekturansatz herkommt.

Nicht ohne Abhängigkeiten

Abhängigkeiten wird man auch mit der IODA-Architektur nicht ganz los. Im Bild werden sie als Verbindung mit einem Punkt am Ende dargestellt (der Punkt markiert das Element, von dem ein anderes Element abhängt). Aber sie beschränken sich auf das notwendigste und fühlen sich eher natürlich an. Denn Integrationen sind von anderen Integrationen oder Operationen sowie von den Datentypen abhängig, über die diese kommunizieren. Operationen dagegen hängen nur von Datentypen und APIs ab, Integrationen kennen sie nicht.

In einer IODA-Architektur lassen sich Abstraktionen sehr intuitiv erfassen und designen: Jede Integration ist die Abstraktion der Funktionseinheiten (Operationen oder anderer Integrationen) die sie integriert sowie deren Datenverarbeitungsfunktionen. Auf der Abstraktionsebene einer Integration können also sowohl Operationen als auch Integrationen existieren. Operationen werden genau dann verwendet, wenn funktionale Anforderung bereits soweit ausspezifiziert sind, dass eine programmatische Umsetzung mit Hilfe von Kontrollstrukturen und Domänen-Ausdrücken einfach von der Hand geht. Integrationen sind angeraten, wenn weitere Schritte zur Verfeinerung, der Zerlegung in Teilaufgaben (im Sinne von Wirths schrittweiser Verfeinerung), notwendig sind. Jede Integration kann entweder — auf der Ebene ihrer Integration — als abstrahiertes Element gedacht oder aber aufgebrochen und in ihrer internen Struktur analysiert werden. Dies entspräche dann dem Abstieg in den Abstraktionsebenen vom allgemeineren zum konkreteren bis man bei Operationen anlangt. (Abb. 3) versinnbildlicht diesen Vorgang. Man sieht auch: operative und integrative Funktionseinheiten können auf einer Abstraktionsebene gemischt werden.

Abstraktionsebenen einer IODA-Architektur. (Abb. 3)

Flow-Design

Im nachfolgenden Beispiel sollen römische Zahlen in arabische umgewandelt werden, und umgekehrt. Das grundlegende Design der Lösung ist einem Blog-Artikel von Ralf Westphal entnommen, in dem er eine analoge Implementierung in C# beschrieben hat. Viele Diagramm-Ideen sind ebenfalls von dort übernommen. Unsere Implementierung erfolgt jedoch in Java. Den Ansatz, nach dem das Design
entworfen wird, bezeichnen wir als Flow-Design.

Lösungsentwurf

Gestartet wird einfach mit der obersten Applikationsebene: Die Applikation wird durch den Benutzer gestartet. In Flow-Design würde man dies wie in (Abb. 4) zu sehen darstellen. Die leere Klammer symbolisiert ein datenloses Ereignis, wie es das Starten eines Programms darstellt.

Oberste Funktionseinheit convert roman. (Abb. 4)

 

 

Der nächste Verfeinerungsschritt ist die Zerlegung der obersten Funktionseinheit in Unterfunktionseinheiten, die Teilaspekte der Funktionalität von convert roman implementieren sollen. Zuallererst soll die Eingabe des Benutzers eingelesen und als Zahl repräsentiert werden. Danach folgt die eigentliche Umwandlung und die Darstellung des Ergebnisses, wie (Abb. 5) zeigt.

Verfeinerung der Funktionseinheit convert roman. (Abb. 5)

Die neuen Funktionseinheiten stehen für verschiedene Teilaspekte der durch convert roman realisierten Funktionalität auf der nächsten, niedrigeren Abstraktionsebene. Die Funktionseinheit convert roman wird zu einer Integration im Sinne von IODA. Ob die neuen Funktionseinheiten Integration oder Operation sind, haben wir noch nicht entschieden. Jedoch scheint für die beiden äußeren recht klar zu sein, wie diese implementiert werden können. Eine Zahl einzulesen ist einfach. Dafür kann eine API-Funktion der Java-Klassenbibliothek verwendet werden. Für die Darstellung des Ergebnisses gilt ähnliches. Diese beiden Funktionseinheiten benötigen keine weitere Verfeinerung und können direkt als Operation im Sinne von IODA implementiert werden.

Die Funktionseinheit convert enthält jedoch die eigentliche Domänen-Logik und benötigt eine weitere Verfeinerung. Machen wir daraus eine Funktionseinheit, die weitere Funktionseinheiten integriert. Eine Funktionseinheit wird benötigt, um zu entscheiden, ob es sich bei der Eingabe um eine römische oder arabische Zahl handelt. Denn basierend darauf wird die eingelesene Zahl der Funktionseinheit zur Umwandlung römischer oder der Funktionseinheit zur Umwandlung arabischer Zahlen zugeleitet. Davor sollte noch eine Validierung stattfinden. Auch dafür werden Funktionseinheiten benötigt. (Abb. 6) fügt alles zu einem Flow zusammen.


Verfeinerung der Funktionseinheit convert. (Abb. 6)

Die Domänen-Logik steckt in den beiden Funktionseinheiten convert from roman und convert to roman. Die Umwandlung in römische Zahlen ist einfach, da nur eine Abbildung jeder einzelnen arabischen Ziffer auf eine römische Ziffer notwendig ist, die dann aus einem oder mehreren Buchstaben bestehen kann. Die Umwandlung römischer Zahlen dagegen ist schwieriger, da hier unter Umständen mehrere Buchstaben eine arabische Ziffer ergeben (IV in 4). Hier tut weitere Verfeinerung Not, auch weil hier algorithmisches Knowhow gefordert ist. Zum Beispiel:

• Jede römische Ziffer einer römischen Zahl wird in ihre äquivalente arabische Zahl konvertiert. Es entsteht eine Liste von Integern, z.B.
XIV → [10, 1, 5].
• Falls eine Zahl dieser Liste kleiner ist als die nachfolgende, wird die kleinere Zahl in der Liste negiert, z.B.
[10, 1, 5] → [10, -1, 5].
• Über die in den vorhergehenden Schritten erzeugte Liste wird die Summe gebildet, z.B.
[10, -1, 5] → 14.

Dies ergibt das Flow-Design in (Abb. 6). Jetzt scheint keine weitere Verfeinerung notwendig, denn wie die Funktionseinheiten map to values, apply subtraction rule und sum in Java zu implementieren sind, dürfte klar sein. Diese drei Funktionseinheiten sind alle Operationen im Sinne von IODA.


Verfeinerung der Funktionseinheit convert from roman. (Abb. 7)

 

 

Datentypen. (Abb. 8)

Design der Datentypen

Integrationen und Operationen der IODA-Architektur (das „I“ und „O“ in IODA) sind entworfen. Die API-Methoden (das „A“ in IODA), die wir planen zu verwenden, sind nicht der Rede wert. Es handelt sich ausschließlich um Klassen und Methoden der Java-Klassenbibliothek. Die zu verwendenden Datentypen (das „D“ in IODA) verlangen jedoch noch Entwurfsaufwand. Die Datentypen sind schon in abstrakter Form in den Design-Diagrammen enthalten. In (Abb. 8) sind die interessanten Fälle und die Funktionseinheiten die von ihnen abhängen, nochmals
zusammengefasst abgebildet.

Die Verbindungen von den Funktionseinheiten zu den Datentypen sind mit einem gefüllten Kreis am Ende der Kante dargestellt (Markierung von Abhängigkeiten in Flow-Design). Es gibt lediglich folgende Arten von Abhängigkeiten:

• Funktionseinheiten hängen von Datentypen ab, die sie empfangen oder versenden.
• Funktionseinheiten, die Operationen sind, hängen von anderen Funktionseinheiten oder API-Komponenten ab, die Status halten (z.B. Datenbankzugriffskomponenten).
• Funktionseinheiten, die Integrationen sind, hängen von anderen Funktionseinheiten ab, die sie integrieren.

In unserem Design haben wir keinen Status, aber Datentypen durchaus. Datentypen sind eigentlich immer vorhanden. Für den Entwurf von Datentypen wäre objektorientiertes Design ein adäquates Werkzeug. Jedoch wollen wir, dem KISS-Prinzip folgend, keine expliziten, neuen Datentypen für roman number und arabic number einführen. Das erste ist einfach ein Java-String, das zweite ein Java-Integer. Da die Domänen-Logik zu einfach ist, macht es nicht viel Sinn, spezielle Typen dafür zu entwerfen. Der abstrakte Datentyp value list kann einfach als Integer-Array oder als Java-Typ List realisiert werden. Wie auch immer, die getroffene Design-Entscheidung für Datentypen ändert nicht die Basisarchitektur. Operationen arbeiten auf Daten. Sollte das Datentypen-Design signifikant geändert werden, so hat das natürlich Einfluss auf die Implementierung der Operationen, da gerade dies den Umstand einer Abhängigkeit ausmacht. Vor allem deshalb macht es Sinn, sich vorher Gedanken über die Datentypen zu machen.

Andere Design-Dimensionen

Im Software-Universum existieren vier Dimensionen (Abb. 9). Neben der bereits behandelten Dimension des funktionalen Entwurfs sollte man sich auch über die anderen Dimensionen Gedanken machen, da ansonsten schnell schlecht wartbarer Code entstehen und die Erweiterbarkeit und Veränderbarkeit des Software-Systems auf der Strecke bleiben kann.

Dimensionen des Software-Designs. (Abb. 9)

Die Design-Dimension der Domäne (Abb. 10) kümmert sich vor allem darum, was der agilen Software-Entwicklung am wichtigsten ist: Die Entwicklung in Inkrementen. Das kleinste Inkrement ist hier das der Interaktion (mit der Umwelt, Reaktionen auf Umwelteinflüsse).

Interaktionen werden in Dialogen zusammengefasst. Hier sind nicht nur GUI-Dialoge gemeint. Auch die Klasse einer Bibliotheksfassade oder eine Web-Service-Klasse ist ein Dialog in dieser Dimension. Zusammengehörende Dialoge werden wiederum in Modulen zusammengefasst, die gemeinsam einen bestimmten funktionalen Aspekt einer Software realisieren. Dem Anwender tritt eine Software
vordergründig als Applikation gegenüber, die aus Modulen besteht. Beispiele dafür sind ein Windows-Executable, eine Smartphone-App oder eine Webseite. Aber eine Smartphone-App existiert (zumeist) nicht für sich allein. Ihren richtigen Wert kann sie nur im Zusammenspiel mit einer Server-Applikation und den von ihr bereitgestellten Daten entfalten. Eric Evans hat dafür in Domain-Driven-Design den Begriff des Bounded-Context eingeführt, der in dieser Dimension die letzte Abstraktionsstufe darstellt.

Abstraktionsstufen der Domain-Dimension. (Abb. 10)

In unserem recht einfachen Beispiel stellt die eine Interaktion auf der Kommandozeile beim Eingeben der zu konvertierenden Zahl gleichzeitig auch den einzigen Dialog dar, der am Ende die gesamte Applikation ausmacht. In anderen, größeren Applikationen kann darüber hinaus auch Bounded-Context relevant sein.

Host-Dimension

Bei der Host-Dimension (Abb. 11) geht es vor allem um die Laufzeitarchitektur und Qualitäten wie Performance, Skalierbarkeit, Security und Ausfallsicherheit. Hier kümmert man sich vom Kleinen zum Großen, um Objekte, Threads, Prozesse, Geräte und Netzwerke.

Abstraktionsstufen der Host-Dimension. (Abb. 11)

Für unser Beispiel ist auch diese Dimension recht einfach abzuhandeln. Netzwerkfunktionalität ist nicht vorhanden. Das System läuft nach Start auf genau einem Gerät als ein Betriebssystemprozess in dessen Haupt-Thread. Es werden zwar verschiedenste Objekte erzeugt, die jedoch keine Auswirkungen auf die genannten Qualitäten haben.

Container-Dimension

Die letzte Dimension (Abb. 12) ist die der Klassen, Bibliotheken, Komponenten und Services – nach zunehmender Abstraktionshöhe aufgezählt. Oft denken Programmierer beim System-Design vor allem an diese Dimension, während Nicht-Programmierer diese Dimension unbeachtet lassen oder gar nicht kennen. Hier geht es vor allem darum, das Software-System zukunftsfähig zu machen und es erweiterbar zu halten, sprich um Investitionssicherheit. Eine Anforderung die zwar unausgesprochen so gut wie immer vorhanden, aber selten explizit an Software-System und Team gestellt wird.

Abstraktionsstufen der Container-Dimension. (Abb. 12)

Fazit:

Bei IODA und Flow-Design geht es nicht darum, die Paradigmen des objektorientierten oder funktionalen Programmierens über Bord zu werfen. Es geht vielmehr darum, das alte Problem der Abhängigkeiten in Software-Systemen, die automatisch Komplexität induzieren, auf das Notwendigste zu reduzieren und damit das Gesamtsystem zu vereinfachen. Dies alles dient dem Zweck, die Wandelbarkeit von Software-Systemen zu erhalten oder herzustellen. Denn am Ende ist die Wandelbarkeit eines der wichtigsten Wesensmerkmale von  Software. Das objektorientierte und das funktionale Programmiermodell behalten dabei ihre Gültigkeit. Sie werden an den Stellen eingesetzt, an denen ihre Vorteile am besten zu Geltung kommen: Objektorientierung in Datentypen, die zwischen Funktionseinheiten fließen sowie in
Funktionseinheiten die Status verwalten müssen – das funktionale Modell in Funktionseinheiten, die Datentransformationen durchführen.

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

Total
0
Shares
Previous Post
JAVAPRO - Fast Data Streaming

Alles fließt: Fast-Data-Streaming und Messaging gehören zusammen

Next Post

Kotlin DSLs mit Extension-Functions

Related Posts