Wie könnte eine Anwendung aussehen, die dem Prinzip der gegenseitigen Nichtbeachtung folgt? Ich versuche das mal anhand eines simplen Szenarios im Kontrast zu einer mehrschichtigen Anwendung darzustellen.
Das Beispielszenario
Eine Anwendung soll den Index aller Worte in Dokumenten aufbauen. Die Dokumente sind Textdateien (Endung auf .txt) unterhalb eines Wurzelverzeichnisses. Und Worte sind Zeichenketten ohne Leerzeichen, d.h. sie werden durch Leerzeichen von einander getrennt.
Die Anwendung wird mit dem Wurzelverzeichnis gestartet, z.B.
wordindex f:\docs
und liefert den Index in folgender Form auf dem Standard-Output ab:
Zu jedem Wort gelistet in alphabetischer Reihenfolge werden die Dateinamen der Dokumente gelistet, in denen es vorkommt.
Das ist alles natürlich keine große Sache. Aber ich denke, es gibt genug her, um zwei Ansätze zu vergleichen. Der Code für beide liegt in einem github Repository. Wer ihn also komplett sehen möchte, ist herzlich eingeladen.
Ansatz #1: Mehrschichtige Anwendung
Bei der mehrschichtigen Variante will ich mich hier gar nicht lange mit Entwurfsschritten aufhalten. Ist doch durch das Muster “Mehrschichtige Architektur” schon alles vorgegeben, oder?
Da gibt es ein Frontend – das hier zugegebenermaßen dünn ausfällt –, da gibt es Domänenlogik und dann gibt es noch Datenzugriff. Alles simpel, aber vorhanden.
Für jede Schicht mache ich eine Komponente auf, d.h. es gibt einen separaten Kontrakt und eine binäre Implementation (Assembly). In Visual Studio drücke ich das durch mehrere Projekte aus:
Es gibt also drei Projekte – je eines für jede Schicht – und dazu noch eines für die Kontrakte und eines für den Programmstart und den Zusammenbau des Ganzen zur Laufzeit.
Die Interfaces (funktionale Kontrakte) sind simpel. Woher die kommen, lasse ich mal außen vor. Hier fallen sie vom Himmel :-)
Dass ich mich für die Schichten für Interfaces entschieden habe, sollte keine Fragen aufwerfen. Aber bei den Daten… Warum ein Interface für den Index?
Ich erwarte, dass der Index selbst noch wieder eine gewisse Funktionalität enthält. Die will ich aber zum Zeitpunkt der Kontraktlegung nicht festschreiben müssen. Was weiß ich, wie die Domäne die Indizierung implementiert? Sie kann Funktionalität zwischen Indexer und Index verteilen, wie sie mag.
Indem ich also den Index auch nur als Interface in den Kontrakte angebe, sind die Kontrakte schnell geschrieben und die parallele Entwicklung an allen Schichtenkomponenten kann losgehen.
Das mag für manche neu sein – aber am Ende ist das ein alter Hut. Eben Komponentenorientierung.
Abhängigkeiten
Die aus meiner Sicht unseligen Abhängigkeiten bestehen nun zwischen den Implementation der funktionalen Kontrakte. Zur Compilezeit sind sie von Interfaces abhängig, zur Laufzeit von deren Implementationen.
Wenn ich Console testen will, muss ich eine IIndexer-Attrappe haben. Und wenn ich den Indexer testen will, muss ich eine Attrappe für IDocuments haben. So ist das halt. Hört sich ganz normal an, oder? Quasi unvermeidbar. Dafür gibt es ja schließlich Mock-Frameworks.
Bei Programmstart werden die Implementationen dann zusammengesteckt. Das passiert über Dependency Injection:
Auch normal. Dass ich hier keinen DI-Container benutze, ist vernachlässigbar.
Das funktioniert alles tadellos. Aspekte sind erkannt (Schichten) und für produktive Entwicklung und Wiederverwendung in Komponenten herausgelöst. Über Interfaces wird entkoppelt. Funktionale Abhängigkeiten sind sauber ausgerichtet von oben nach unten. Jede Schicht ruft nur die unmittelbar darunter liegende.
Das funktioniert tadellos. Wer mag, probiert es mit dem Code im Repo aus.
Gedanken zum Entwurf
Oben wollte ich noch nichts zum Thema Entwurf sagen. Und auch jetzt möchte ich nicht den Entwurf nachholen. Aber eines Kommentares kann ich mich nicht enthalten:
Als ich die Anwendung entworfen habe, habe ich mich schwer getan. Nicht sehr schwer natürlich. Ist ja alles total triviales Zeugs. Doch ich habe gemerkt, dass ich einen Moment geschwommen bin.
Die Schichten zu finden, war nicht schwer. Die sind ja durch das Muster vorgegeben und für das Szenario auch klar erkennbar. Aber wie dann die Interfaces genau aussehen sollten… dazu fiel mir erstmal nichts ein.
IIndexer war simpel. Da geht es zentral um die Zweck der Anwendung. Also bin ich gleich auf die obige Definition verfallen. Ich habe einfach Wünsch-dir-was gespielt :-)
Aber wie sollte IConsole aussehen? Vor allem aber: Wie sollte IDocuments aussehen? Was würde ein Indexer von IDocuments wollen?
Bei diesem Entwurfsansatz habe ich mich da sehr gehemmt gefühlt. Ich hätte eigentlich mit TDD loslegen müssen zu implementieren, um dabei schrittweise herauszufinden, was ich von IDocuments brauche.
Das habe ich mir angesichts des einfachen Beispiels dann aber gespart und habe einfach mal getippt ;-) Wie sich herausstellte, lag ich da nicht falsch.
In Summe ist mein Eindruck also, dass mir der Ansatz der Mehrschichtigkeit zwar im gaaanz Groben irgendwie hilft – nur dann wird es schnell schwierig. Ich werde nicht geleitet bei der Verfeinerung (Wie sehen die Schichten denn konkret aus?). Und die Abhängigkeiten machen dann bei der Implementation keinen Spaß: Entweder muss ich bei Tests Attrappen einsetzen oder ich muss von unten nach oben entwickeln. Beides macht keine Laune.
Ansatz #2: Nichtbeachtende Anwendung
Jetzt gilt es. Wie sieht eine Anwendung aus, die nicht mehrschichtig ist? Wie kann das Problem mit den funktionalen Abhängigkeiten vermieden werden? Im Repo finden Sie dazu auch einen Vorschlag, die Solution obliviouswordindex:
Von Grundaufbau her sieht sie gar nicht so anders aus. Es gibt wieder drei Projekte für die Aspekte Frontend, Domäne und Persistenz. Warum auch nicht? Es sind Aspekte, die es lohnt, deutlich separat zu implementieren.
Und es gibt wieder ein “Hauptprogramm” und ein Projekt für das, was den Aspekten gemein ist: Daten.
Die Beziehungen zwischen diesen Teilen sehen allerdings anders aus!
Zunächst einmal ist zu bemerken, dass es keine funktionalen Kontrakte gibt. Interfaces für die Aspekte sind unnötig. Es gibt zwischen ihnen ja keine funktionalen Abhängigkeiten. Das Frontend kennt keine Domäne und die Domäne weiß nichts von Persistenz.
Die Implementationen liegen funktional “nichtbeachtend” nebeneinander:
Es gibt lediglich gemeinsame Erwartungen an Daten. Und das ist ja auch völlig ok. Daten sind vergleichsweise “ungefährlich”, wenn sie keine großartige Funktionalität enthalten. Dann können sich Änderungen an Logik nicht so leicht fortpflanzen.
Daten stehen für die unvermeidliche logische Kopplung. Aber nur weil es die gibt, ja, geben muss, heißt das ja nicht, dass darüber hinaus auch noch funktional gekoppelt werden muss.
Das Hauptprogramm ist wieder von allen Implementationen abhängig, weil es sie irgendwie zusammenstecken muss. Aber das ist auch sein einziger Job: die Integration.
Die Komponenten implementieren hingegen Operationen, d.h. Logik.
Das ist die Basis der Architektur des Prinzips der gegenseitigen Nichtbeachtung: die Trennung der Aspekte Integration und Operation. Diese Aspekte gibt es immer. Sie sind domänenunabhängig. Auch das ist anders als bei der Mehrschichtigkeit. Die trennt ebenfalls Aspekte – allerdings sind das inhaltliche.
Vielleicht ist es gar nicht so schlecht, das mal wie folgt gegenüber zu stellen:
Soweit die Grundarchitektur. Da gibt es auch immer noch Abhängigkeiten bei der “Nichtbeachtung”. Sowas, sowas ;-)
Dennoch ist die rein praktische Erfahrung, dass es simpler ist, die Operationsschicht mit den nicht funktional abhängigen Operationen zu implementieren. Es sind schlicht keine Attrappen nötig.
Entwurf
Wie komme ich nun zu diesen Operationen? Das geht über einen schrittweisen Entwurf. Ich muss dafür keinen Test schreiben, sondern einfach das Problem verstehen und einen Lösungsansatz formulieren können.
Es ist im Grunde wie die Erarbeitung einer Sprache. Es geht um Sätze und Worte. Die Operationen entsprechen den Worten, sie sind das Grundvokabular für Sätze. Größeres kann ich dann aus diesem Vokabular und schon gebildeten Sätzen zusammenbauen.
Beim Entwurf für das Beispielszenario könnte das so aussehen: Am Anfang stelle ich mich ganz dumm. Ich weiß nur, dass ich Input von der Kommandozeile bekomme und dass der Index auf dem Standard-Output rauskommen soll. Dazwischen passiert “es” dann irgendwie:
Bei der Ausgabe bin ich mir sicher, dass ich die nicht weiter verfeinern will. Eine IIndex-Datenstruktur letztlich in einen Text zu serialisieren, ist eine Sache von wenigen Zeilen.
Und wie ist es mit der Dokumentenverarbeitung? Da spielt jetzt die Musik. Die einfach so ohne weitere Verfeinerung zu implementieren, wäre mir zuviel. Kann man machen – muss man aber nicht ;-)
Also zoome ich mal rein und denke mir die Verarbeitung wie folgt:
Zuerst werden Dokumente geladen – eines nach dem anderen. Und mit jedem wird der Index erweitert. Aus Load docs fließen also einzelne Dokumente an Build index.
Jetzt wieder die Frage: so lassen oder weiter verfeinern? Jede dieser Funktionseinheiten wäre schon wieder einfacher zu implementieren. Vor allem, weil sie ja auch für sich, also nichtbeachtend realisiert würden. Ich könnte eine Methode Load_docs() entwickeln, die nichts mit einer Methode Build_index() zu tun hätte – und umgekehrt. Das war bei der Mehrschichtigkeit anders.
Aber ich finde die Funktionalität immer noch zu grob. Also verfeinere ich weiter. Dokumente laden, erfolgt in drei Schritten:
Zuerst einmal muss ich den Wurzelpfad aus der Kommandozeile herausfischen, dann suche ich alle überhaupt relevanten Dokumente, also die .txt-Dateien, und schließlich lade ich für jeden Dateinamen den Dateiinhalt.
Bitte beachten Sie, wie aus 1 Input-Wert, dem Wurzelpfad, n Output-Werte, die Dateinamen werden.
Anschließend wird zu jedem Dateinamen aber wieder nur 1 Dokument produziert.
Document wie IIndex sind im “Datenkontrakt” definiert, da sie relevant zwischen Aspekten sind. Andere Datentypen gibt es nicht auf globaler Ebene; innerhalb von Aspekten benutze ich gern dynamische Typen.
Mit den Funktionseinheiten auf dieser Zoomebene bin ich jetzt zufrieden. Eine weitere Zerlegung ist nicht nötig; ich bin bei den Operationen angekommen.
Aber der Indexbau muss noch näher betrachtet werden. Auch dort sehe ich eine Schrittfolge vom Dokument bis zum Index:
Zuerst wird jedes Dokument in seine Worte zerlegt, die ich Funde (occurrence) nenne. Das sind Tupel bestehend aus Wort und Dateiname. Wie angedeutet, definiere ich dafür aber keinen eigenen Typ. Das wäre Overkill. Alle Operationen gehören ja zum Aspekt Domäne. Da kann man sich untereinander so gut kennen, dass man sich auf die Struktur dynamischer Typen verlassen kann.
Der Filterschritt unterdrückt Worte mit weniger als 3 Zeichen. Und bei der Registrierung wird der Fund dem Index hinzugefügt.
Wenn es keine Funde mehr gibt, fließt am Ende der Index hinaus.
Angezeigt wird das Ende eines Stromes durch eine fließende Null. find docs schließt damit den Strom der Dateinamen ab, load doc fährt damit fort nach dem letzten Dokument usw. Das ist ein probates Mittel für die stromorientierte Verbindung von Operationen. Letztlich macht es Rx auch so – allerdings über einen eigenen Event, der das Ende eines Stromes anzeigt. Ein Endesignal muss aber sein. Insofern unterscheiden sich Ströme von Listen (zu denen auch IEnumerable<T> gehört).
Ich hoffe, mit dieser schrittweisen Verfeinerung habe ich niemandes Phantasie strapaziert. Dass so ein Indexaufbau funktioniert, sollte auf der Hand liegen.
Wie weit ich verfeinere, hängt von meinem Vorstellungsvermögen ab. Diese drei Zerlegungsebenen finde ich einfach naheliegend. Aber Sie können das Problem natürlich auch anders angehen. Entscheidend ist nicht, wie Sie konkret zerlegen, sondern dass sie es überhaupt tun. Damit unterscheiden Sie nämlich Integration und Operationen.
Die Operationen get root path, output index, parse doc usw. stellen jetzt das Grundvokabular dar. Das sind alles black boxes. Wie die implementiert werden, ist egal für die Integration. Alle sind aber unabhängig von einander. Sie definieren sehr kompakte Leistungen, wenn man ihnen passenden Input liefert.
Mit diesem Grundvokabular habe ich “Sätze” gebildet, z.B. Load docs. Die liegen auf einem höheren Abstraktionsniveau. Die Zerlegungsebenen sind insofern keine Schichten, sondern Strata im Sinne von Sussmans & Abelsons “Structure and Interpretation of Computer Programs”.
Das kann man von IConsole oder IIndexer bei der mehrschichtigen Architektur nicht sagen. Die Domain ist nicht soetwas wie die Persistence auf höherer Abstraktionsebene, sondern etwas anderes. Eine solche Zerlegung hilft deshalb nur wenig bei der Problemlösung.
Eine schrittweise Verfeinerung wie hier vorgenommen jedoch, die hilft. Es geht auf jeder Ebene nämlich um das Ganze – nur eben mit immer feingranularerem Vokabular.
Implementation
So viele Bildchen, wann kommt denn endlich Code? Bubbles don´t crash, heißt es doch. Bisher wurde nur geredet und nichts substanzielles geschafft – oder?
Weit gefehlt. Alles, was Sie bisher an Bubbles verbunden mit Pfeilen gesehen haben, ist schon Code. Hier der Beweis:
Das sind die “Blubberdiagramme” übersetzt in eine texttuelle Notation (DSL). Die hätte ich auch gleich hinschreiben können – aber Bilder sind übersichtlicher, lassen sich schneller auf einem Blatt notieren im kreativen Prozess und machen es einfacher, einen Entwurf anderen zu erklären.
Aber keine Angst, die hübschen Bildchen sind nicht weg, nur weil ich sie in Text übersetzt habe. Sie lassen sich jederzeit aus dem Text generieren:
Das funktioniert sogar nicht nur in der Entwicklungsumgebung, sondern auch auf dem Binärcode. Falls mal also nur eine .EXE vorliegt, kann der hier eingesetzte Visualizer auch aus ihr diese Integrationen lesen und in Bilder rückübersetzen. Aber das nur am Rande.
Wichtig ist, dass ich rüberbringe, dass die Diagramme nicht nur eine optionale Nettigkeit sind, sondern Code darstellen. Sie entwerfen und Implementieren letztlich gleichzeitig. Entwurf ist damit keine verschwendete Zeit. Sie tun etwas für Ihr Problemverständnis, Sie tun damit etwas für eine saubere Lösungsstruktur – und nun haben Sie auch schon einen wichtigen Teil der Lösung codiert: die Integration.
Die nun noch nötige Implementation der Operationen ist aber auch nicht schwierig. Dafür benutze ich jetzt auch eine 3GL, z.B. C#. Damit können Operationen in Methoden übersetzt werden oder ganze Klassen. Wie es mir von Fall zu Fall am besten passt.
Beispiel TextfileDocuments: Die Operationen find docs und load doc lassen sich schön in statische Methoden übersetzen. Warum auch nicht? Ein Interface ist ja nicht mehr nötig. Zustand gibt es auch nicht. Also spricht nichts gegen statische Methoden:
Da load doc bei jedem Aufruf, also für jeden Dateinamen, ein Dokument erzeugt, ist eine Funktion angebracht. Anders bei find docs. Für 1 Wurzelpfad werden n Dateinamen erzeugt. Da ich die nicht als Liste mit einem Mal zur Weiterverarbeitung leiten möchte, sondern die Dokument für Dokument einzeln stattfinden soll, implementiere ich die Operation als Prozedur mit Continuation für die Dateinamen.
Dass Operationen leichter zu testen sind, zeigt bei diesem Aspekt jedoch noch nicht so sehr. Auch in der mehrschichtigen Anwendung war diese Funktionalität unabhängig.
Anders ist das bei der Domäne. Die ist in der mehrschichtigen Architektur abhängig von der Persistenz. Jetzt aber nicht mehr:
Hier habe ich mich für Instanz-Operationen entschieden, weil die Domäne einen Zustand hat: den Index. Der wird ja langsam aufgebaut (s. Register_occurrence()).
Keine der Operationen kennt nun aber eine Persistenzoperation. Es gibt keine Request/Response-Kommunikation, sondern nur message passing. Klingt für mich übrigens total objektorientiert ;-)
Beachten Sie, wie ich hier dynamische Typen einsetze. Die Operationen vertrauen darauf, dass ihr Input eine passende Struktur hat. Von wo der kommt und wohin ihr Output geht… das wissen sie jedoch nicht. Deshalb sind die einfach zu testen.
Die Abwesenheit von Request/Response lässt Methoden auch tendenziell kleiner sein. Denn wenn Sie merken, dass Sie in einer Operation etwas brauchen, dann müssen Sie sie im Grunde beenden, einen Output erzeugen – und eine andere Operation aufmachen, in die das, was Sie brauchen, als Input einfließt. Aus dem üblichen
void f() {
// vorher
string r = g(42);
// nachher
}
würde
void f_vorher(Action<int> continueWith) {
// vorher
continueWith(42);
}
void f_nachher(string r) {
// nachher
}
Wenn Sie das so sehen, wirkt es natürlich irgendwie umständlich und künstlich. Aber mit ein bisschen Übung, verliert sich diese mentale Reibung. Dann ist das eine ganz natürliche Denkweise; dann können Sie nicht mehr anders. Oder besser: dann wollen Sie nicht mehr anders – müssen jedoch manchmal.
Aber solches Vorgehen ist natürlich kein Zwang. Sie können beim Design entscheiden, bis zu welcher Granularitätsebene Sie so denken wollen – und wann Sie damit aufhören. Wenn Sie durch Entwurf auf Ihre Operationen gekommen sind, dann können Sie sich in deren Implementation auch austoben. Da ist quasi wieder alles erlaubt.
Dennoch sollten Sie die Integration-Operation-Trennung (und den Verzicht auf Request/Response) immer im Hinterkopf behalten. Sie sind es, die zu den kleineren Methoden führen. Eine Methode integriert entweder oder sie operiert. Und wenn sie integriert, dann sind darin keine Kontrollstrukturen und keine Ausdrücke erlaubt. Wie lang kann dann eine Methode noch werden? ;-)
Deshalb ist es auch nicht schwer, eine textuelle oder grafische DSL für die Integration wie oben gezeigt zu realisieren. In der Integration passiert einfach nichts. Keine Logik. Nur “Zusammenstecken”.
Komposition
Da gibt es nun eine Integration notiert in einer obskuren DLS. Und es fliegen eine Menge Operationen im Raum herum. Wie kommt beides zusammen zu einer lauffähigen Anwendung, wie sieht der Kompositionsschritt bei diesem Ansatz aus.
Der ist ganz einfach. Dependency Injection gibt es ja nicht. Allerdings müssen die Integrationsbeschreibung und die Operationen bei einer Runtime registriert werden. (Das ist aber nur eine Möglichkeit, wenn Sie sich eben für die DSL entscheiden. Sie können diese Art der Strukturierung auch anders umsetzen.)
Die Konfiguration der Runtime sucht sich die Operationen aus den angegebenen Assemblies selbst heraus; sie sind ja mit einem Attribut versehen.
Und dann geht es auch schon los. Der Einfachheit halber und weil nicht mehr gebraucht wird, schalte ich die Runtime zur Verarbeitung der Datenfluss-Integration auf synchrone Verarbeitung. Mit den Kommandozeilenargumenten geschickt an den Port .run beginnt die erste Operation ihre Arbeit.
Zusammenschau
Den Unterschied in den zwei Ansätzen sehen Sie hoffentlich:
Erstens: Bei der Mehrschichtigkeit verteilt sich Logik (Kontrollstrukturen und Ausdrücke) über alle Ebenen. Das ist in diesem Beispiel noch gar nicht so deutlich geworden, weil die Schichten so dünn sind. Aber in größeren Szenarien gibt es ja auch innerhalb Schichten Abhängigkeitshierarchien.
Solch verschmierte Logik macht es schwer, schnell und einfach und präzise den Ort zu finden, wo man für eine Verhaltensänderung eingreifen muss.
Und wenn man ihn gefunden hat, dann ist der Test nicht so leicht, weil es in Abhängigkeitshierarchien eigentlich keine Unit Tests gibt, sondern immer nur Integrationstests. Denn wenn Funktionseinheit A von B abhängt und ich für den Test von A eine Attrappe_B brauche, dann teste ich nicht nur, ob in A die richtigen Entscheidungen getroffen werden, sondern auch noch, ob B erwartungsgemäß aufgerufen wird. Ein korrekter Umgang mit B oder gar auch noch mit C und D stellt jedoch eine Integrationsleistung dar: Logik in A wird mit Logik in B und C und D zu etwas größerem Integriert.
Zweitens: Zumindest für mich ist es schwer, Abhängigkeitshierarchien zu entwerfen. Wenn sie einmal da sind, dann kann ich sie malen. Aber wie komme ich dahin? Und vor allem, wie komme ich in agiler Weise dahin? Wenn Agilität bedeutet, in Inkrementen zu denken, wie kann ich solche Hierarchien in Inkrementen wachsen lassen? Und bleiben dann diese Inkremente sichtbar?
Das alles finde ich schwierig, schwierig. Nicht, dass ich nicht selbst lange Jahre so versucht hätte zu entwickeln. Das habe ich wahrlich. Letztlich ist das jedoch im Vergleich zu dem, wie einfach es mir heute fällt, alles ein Krampf gewesen. Den sehe ich auch regelmäßig, wenn Teams versuchen, solche Beispiele im Rahmen von Assessments zu lösen. Das ist dann für mich ein ganz praktischer Beweis dafür, dass in den Köpfen eine Menge Konzepte herumschwirren, wie das mit dem Entwurf gehen könnte und sollte… Nur kommt dann kein Gummi auf die Straße. Aus “Mehrschichtige Architektur, MVC und Entwurfsmuster plus SOLID” resultiert kein systematisches Vorgehen, das erstens zu verständlichen und evolvierbaren Strukturen führt, die zweitens dann auch noch im Team effizient implementiert werden können. Leider, leider ist das so.
Deshalb erlaube ich mir, ernsthafte Zweifel anzumelden, dass das mit der Mehrschichtigkeit großen Wert hat. Nicht zuletzt deshalb, weil wir das ja nun auch schon mehr als ein Jahrzehnt probieren und die Softwaresysteme immer noch nach kurzer Zeit unwartbar werden.
Klar, es gibt Fortschritte. IoC und DI-Container und Mock-Frameworks haben etwas gebracht. Heute wird schon mehr entkoppelt als früher. Aber “mehr” bedeutet noch nicht gleich “genug”. Ich sehe da noch deutlichen room for improvement.
Zwei Beispiele dafür sind die strikte Trennung von Integration und Operation, also ein Integration Operation Separation Principle (IOSP) ;-), sowie das Principle of Mutual Oblivion (PoMO). (Ein bisschen Englisch zwischendurch darf schon mal sein ;-)
Dass Schritte über das Gewohnte hinaus dann erstmal mühsam sind, ist halt so. So ist es immer. Auch von prozeduraler zu objektorientierter Denke war der Übergang mühsam. Oder von Objektorientierung zur Funktionalen Programmierung. Bei Veränderungen knirscht es – was ja aber nicht bedeutet, dass es hinterher nicht besser sein kann. Wenn ich heute 20 Liegestützt kann und mal 25 oder 30 mache, dann tut das auch weh. Aber wenn ich es morgen und übermorgen usw. wieder tue, dann geht der Schmerz vorbei; ich werde kräftiger. Und in einem Monat kann ich 50 Liegestütz. Eine vorher ungeahnte Menge ist möglich, weil ich mich durch Müh und Plage durchgebissen habe.
So ist es mit vielen anderen Fähigkeiten auch, körperlichen wie geistigen. Ob man diesen Weg geht, ist natürlich eine Frage der persönlichen Ökonomie. Lohnt es sich, den Veränderungsschmerz auszuhalten? Das muss letztlich jeder für sich entscheiden. Wer schon 20 Liegestützt kann, mag abwinken. Kraft für mehr braucht er nicht. Und aus dem Alter, wo er Mädels beeindrucken wollte, ist er auch raus ;-) Aber wer sich noch schwer tut beim Entwurf von Software, wer noch vor einem unwartbaren Codeverhau sitzt… kann der sich leisten, Veränderungsschmerz zu vermeiden, der auf dem Weg zu einer anderen Denke entstehen würde?
Naja, ist vielleicht eine Frage für ein längeres Gespräch bei einem Kaltgetränk.
Abhängigkeiten im close-up
An dieser Stelle abschließend noch ein Wort zum Thema Abhängigkeiten, das für mich schon im vorhergehenden Artikel so wichtig war. Dabei sehe ich ja einen großen Unterschied, der Ihnen vielleicht nicht so groß erscheint. Deshalb nochmal durch die Lupe darauf geschaut:
Was ist der Unterschied zwischen
string[] Split(string text) {…}
und
void Split(string text, Action<string[]> parts) {…}
Keiner. Beide Methoden zerlegen einen Text in irgendwelche Teile, z.B. Worte oder Zeilen. Beide Methoden liefern diese Teile in Form eines Array ab.
Dass die erste Methode ihr Ergebnis über den Rückgabewert meldet und die zweite über einen Delegaten, ist im Grunde nur syntactic sugar für den Aufrufer.
…
var parts = Split(“…”);
…
vs
…
string[] parts = null;
Split(“…”, _ => parts = _);
…
Der marginale Unterschied ist jedoch nur oberflächlich. Darunter lauert ein viel größerer. Der besteht nämlich in der Flexibilität der prozeduralen Variante mit dem Delegaten. Dort kann man sich nämlich entscheiden, ob jedes Teil einzeln oder alle Teile zusammen abgeliefert werden. Man kann wählen, ob man eine Liste oder einen Stream produziert. Die Prozedur kann sogar entscheiden, gar kein Ergebnis zu liefern; dann ruft sie den Delegaten einfach nicht auf.
void Split(string text, Action<string[]> parts) {…}
void Split_to_stream(string text, Action<string> part) {…}
Bei der Funktion hingegen gibt es immer ein Ergebnis und nur eines.
Aus meiner Sicht ist es damit schwerer, den Schritt von synchroner zu asynchroner Verarbeitung zu machen. Der Delegatenaufruf stellt ein Ereignis dar und kann leicht asynchron oder gar für jeden Teil parallel verarbeitet werden:
Split_to_stream(“…”, part => new Task(()=>Process(part)).Start());
Das ist für mich der erste Nachteil unserer Fixierung auf Request/Response-Kommunikation. Die hat uns das Leben schon vor 15 Jahren schwer gemacht, als wir versucht haben, objektorientierte Denke einfach so auf verteilte Systeme auszudehnen. Und nun sorgt sie wieder für Reibung. Diesmal beim Übergang zu Mehrkernprozessoren.
Den Ort eines Requests zwangsweise gleichzusetzen mit dem Ort für die Bearbeitung eines Response, ist schlicht eine enge Kopplung. Aber nicht nur für den, der den Request bearbeitet.
Das Problem setzt sich fort beim Aufrufer. Wo ist der Unterschied zwischen
void f() {
…
var parts = Split(text);
…
}
und
void f(Func<string,string[]> Split) {
…
var parts = Split(text);
…
}
Wieder gibt es keinen grundlegenden Unterschied. Die Methode f() benutzt eine Dienstleistung zur Zerteilung von Zeichenketten. Dass die im zweiten Fall als Delegat hineingereicht wird, sozusagen parameter injection, ändert daran nichts. Das ist nur eine Variante von dependency injection, die mal ohne Interface auskommt und nicht auf Klassenebene arbeitet.
In meinem Sinne ist dadurch noch nichts erreicht in puncto Abhängigkeitsreduktion. f() ist in beiden Fällen abhängig:
Um f() allein zu testen, muss eine Attrappe her. Und dann testet man eben nicht nur die Funktionsweise der Logik in f(), sondern auch noch, ob Split() korrekt benutzt wird. Das ist aber ein anderer Aspekt, nämlich der der Integration: spielt ureigene f()-Logik korrekt zusammen mit Split()-Logik?
Das klingt subtil, geradezu wie Haarspalterei. Aber je länger ich mich damit beschäftige, desto größer scheinen mir die negativen Auswirkungen der Vernachlässigung dieser Feinheit.
Der Trick ist einfach, dass f() von einer Semantik abhängig ist, nicht nur von einer Form. Die Ursache dafür ist in diesem Fall der Request/Response-Aufruf.
Jetzt dieses kleine Szenario etwas anders:
void f_produce_text(Action<string> Text_produced) {
…
Text_produced(text);
}
void f_process_text_parts(string[] parts) {
…
}
Sehen Sie den Unterschied jetzt? f_produce_text() ist unabhängig von einer Semantik des Delegaten. Es wird immer noch eine Methode injiziert, aber die tut nichts für den Aufrufer. Sie nimmt nur etwas entgegen und transportiert es weiter. Das macht die “Nichtbeachtung” aus.
Beide Methoden können nun ohne Attrappen getestet werden. Beide Methoden tun jetzt auch nur eines: sie implementieren Logik, d.h. sie stellen Operationen dar.
Natürlich muss dann irgendwo alles zusammenkommen. Das geschieht bei der Integration:
void f(Func<string, Action<string[]>> Split) {
f_produce_text(t =>
Split(t ,
f_process_text_parts));
}
Die Integration ist nun abhängig von konkreter Semantik. Sie stellt selbst Semantik her und braucht dafür Sub-Semantiken. Aber sie enthält keine Logik. Deshalb ist der Test einer Integration einfach. Der kann sich nämlich darauf konzentrieren festzustellen, ob die Subsemantiken korrekt “verdrahtet” sind. Das ist alles. Im Falle von f() ist dafür nur ein Test nötig.
Dass eine Integration abhängig ist von weiteren Integrationen bzw. Operationen, ist unvermeidbar. Aber das ist nicht mehr so schlimm, weil in ihr eben keine Logik steckt. Deshalb ist es ja auch möglich, Integrationen wie oben gezeigt in einer trivialen DSL zu notieren.
Die f()-Integration würde damit notiert so aussehen:
f
f_produce_text, split
split, f_process_text_parts
Also: Ein Test von Integrationen ist einfach, auch wenn dafür Attrappen nötig sind/sein sollten, weil nur wenige Tests gebraucht werden. Meist reicht einer. Und der kann auch noch auf recht hoher Abstraktionsebene angesiedelt sein.
Und ein Test von Operationen ist einfach, weil die Operationen keine funktionalen Abhängigkeiten haben, d.h. sie kennen keine Semantik von Konsumenten ihrer Daten. Attrappen sind nicht für ihre Tests nötig.
Fazit
Ich kann nicht mehr anders denken als im Sinne von IOSP und PoMO. Alles andere kommt mir zäh und umständlich und im Ergebnis wenig flexibel vor. Aber das ist einfach meine Erfahrung nach 3 Jahren konstanter Übung.
Ganz praktisch drückt sich das übrigens darin aus, dass ich keine Mock-Frameworks mehr benutze. Wo ich früher noch mit Rhino Mocks o.ä. rumgehüsert habe, da kann ich mich jetzt entspannen. Falls mal eine Attrappe nötig ist, schreibe ich die in ein paar Zeilen selbst. So bin auch ich weniger abhängig – von Tools.
Dass ich Sie nun überzeugt habe, das Lager der Mehrschichtigkeit bzw. der “Abhängigkeitsdenke” zu verlassen, glaube ich nicht. Aber ich habe Ihnen hoffentlich einen Impuls gegeben, Ihre OO- und Architekturwelt ein bisschen anders zu sehen. Das Übliche ist nicht alles. Es geht auch anders. Und nicht nur die Funktionale Programmierung hat etwas zu bieten.
Vielleicht, ganz vielleicht versuchen Sie ja aber einmal – wenn die Kollegen Sie nicht sehen –, die beiden Prinzipien IOSP und PoMO an einem Beispiel wie dem obigen selbst anzuwenden. Machen Sie Ihr persönliches Coding Dojo mit einer Application Kata. Das Wortindex-Szenario könnten Sie wiederholen oder erweitern (Stoppwortliste, Zeilennummern der Worte je Dokument, Dokumentenindizierung als Service in der Cloud). Oder Sie nehmen sich ein anderes Szenario vor. Bei den Clean Code Advisors finden Sie weitere Application Katas.
Falls Sie Fragen haben, melden Sie sich einfach per Email – oder diskutieren Sie mit in der Google Group oder XING-Gruppe zum Thema Flow-Design/Event-Based Components.
12 Kommentare:
Ja das war jetzt ein ausführlicher und für meinen Geschmack schon zu lange Ausführung wie man diese "Abschaffung von Abhängigkeiten" implementiert. Im Grunde kann man ja die ganzen Überlegungen in einem einzigen Satz zusammenfassen:
Mache keine Abhängigkeiten um eine Programmierart oder Struktur durch zu setzen sonder mache Abhängigkeiten nur um logische Abhängigkeiten von Daten und Funktionsweisen der Anwendung abzubilden.
Meiner Meinung nach schließt sich der Kreis der akademischen Bearbeitung von Programmierproblemen. Zuerst muss man alle möglichen Abstraktionsmöglichkeiten (ja auch Designpattern gehören zu dieser Kategorie) finden um eine möglichst generische Wiederverwendbarkeit und Konformität zu schaffen um dann erkennen zu müssen das die zu höchst abstraktierten Programmier-Paradigmen nur dazu geführt haben, dass man nur schwer zu durchschauende Abhängigkeitsmonster erschaffen hat, die man auch nicht mehr effektiv testen kann.
Zumindest ist jetzt klar, dass man in diesem zu tiefst technischen und praktischen Gebiet der Programmierung, keine geistesnwissenschaftliche Bearbeitung von "Problemen" braucht!
@Wolfgang. Hm... das finde ich interessant, diese Reduktion erstmal auf logische Abhängigkeiten.
Jede Funktionseinheit hat mindestens eine logische Abhängigkeit oder vielleicht allgemeiner: ist logisch gekoppelt an ihre Umwelt.
Solche logische Kopplung muss natürlich irgendwie vermittelt werden. Sie beeinflusst die Funktionseinheit - sonst wär ja keine Kopplung da.
Mir scheint der natürlich Ausdruck logischer Kopplung sind Daten. Daten sind die Leiter von Kopplung. Nur wo Daten ausgetauscht werden, kann auch Kopplung entstehen.
Darum kommen wir nicht herum. Das ist sogar der Zweck von Software.
Wenn wir Glück haben, lassen sich einige Aspekte der Kopplung aber überführen in weniger schlimme Formen. Rein logische Kopplung ist ja nicht schön, weil sie zur Entwicklungszeit nicht prüfbar ist.
Wenn void f(string t) in t sortierte Kunden in Form von CSV-Daten erwartet, dann lässt sich nichts vom Compiler überprüfen.
Wenn die Methode jedoch so aussieht: void f(Kunde[] kunden) - dann kann der Compiler Zusicherungen machen und wir bekommen auch noch Intellisense. Vormals rein logische Kopplung ist zum Teil in physische Umgewandelt worden.
Aber das geht natürlich auch nur ein gewisses Stück. Eine Queue statt einer List ist dafür auch ein Beispiel.
Und wie ist es, wenn nun diese in den Daten steckende Kopplung über die Herstellung von ADTs hinausgeht?
Mein Gefühl ist, dass die Kopplung an Dienstleistungen, also die Beschaffung und Verarbeitung von Daten, übers Ziel hinausschießt.
Wenn eine Funktionseinheit (Operation) Daten bekommt, in denen nur soviel logische Kopplung steckt wie nötig und soviel physische wie möglich, und wenn diese Funktionseinheit dann etwas produziert, für das das selbe gilt, dann haben wir erreicht, was wir erreichen können.
Das Zusammenschalten von Funktionseinheiten zu Größerem ist dann eine ganz andere Sache (Integration). Da ist die Kopplung vor allem logisch; das bisschen an expliziter Signatur ist vernachlässigbar. Die Musik spielt in den Funktionseinheiten, die integriert wird. Was die aber tun, ist nicht formal überprüfbar. Also muss der Integrator an die Bezeichnungen der Integrierten glauben.
Da es sich hier um logische Abhängigkeit handelt, sollte damit in so einfacher Weise wie möglich umgegangen werden. Das, so glaube ich, drückt die DSL aus, die ich verwendet habe. Das, so glaube ich, steckt hinter der Herausarbeitung des Aspekts Integration.
Hallo Herr Westphal,
well done! Grossartig!
Mein Wissen rund um EBC, FD war eher gering; die Vorahnung der letzen Tage und studieren einiger Blog-Artikel steigerte die Neugier, die vollends erfüllt wurde.
Die Diskussion der Abhängigkeiten, die saubere Trennung zwischen Operationen und Integration - Volltreffer. Das gibt nochmals Schub und Motivation in die Thematik und die skizzierte Lösung tiefer einzusteigen. Die DSL ist geöhnungsbedürftig aber gelungen - einfach pur.
RX, Observables waren Handwerkszeug, aber auch ein bisschen ungeliebt - wegen der 1-Argument-Begrenzung (die ich natürlich selbst auf N-Argumente aufbohren könnte), aber eigentlich war das immer noch nicht das Salz in der Suppe. Ich war eher auf Actions und Funcs aus, ich kann fast nicht mehr ohne.
Aktuell favorisiere ich Actions. Actions sind die Eingangspins und ebenso auch die Ausgangspins (analog EBC, aber nicht 1-arg). Da man Funcs auf Actions abbilden kann, erhalte ich in C# Funktionen mit mehreren Rückgabewerten (und keine "künstlicher" Zwang zu 1-Argument-Schnittstellen).
public void Foo(Bar, Baz) {..}
= Action<Bar, Baz>
ist der Eingang und der Ausgang ist z.B.
Action<X,Y,Z>
in Pseudocode:
(X,Y,Z) = Foo(Bar, Baz)
-> mehrfache Rückgabewerte :-)
oder auch so:
Action<Bar, Baz, Action<X,Y,Z>>
Viel, viel Potential. Schön, dass man in D auch Kollegen findet, die das Potential erkennen und es mit diesem Artikel übererfüllen und eine neue Tür aufstossen.
Ich war schon in der täglichen, angestaubten Patterns-Welt und DI (gähn) schon ein wenig verzweifelt, erkennt denn keine die Dinge rund um z.B. Eric Meijer und den Möglichkeiten von C#? Aber nun ein neuer Lichtblick mit einer neuen Geschmacksrichtung.
Immutablility (Muticores), Events, Flows, Continuations, Futures bieten ganz neues Potential, wenngleich Clojures auch gefährlich sind. Eric nennt die 4 (äh 5 Effekte), ja, das Leben macht doch noch Spass - die Java-Leute sind sicher neidisch.
Welche Schnittstelle ist eigentlich am einfachsten zu implementieren?
1) die gar nicht existiert :-)
2) interface IFoo{ void Foo(); }
Zu 1) Hi, hi.
Zu 2) Könnte zu wenig sein, hier scheint sich Kontext / Seiteneffekte zu verbergen.
"interface segregation principle" schreit förmlich nach schmalen Schnittstellen - idealerweise umfassen sie nur eine Methode => Action / Func
Und was sieht man jeden Tag? Breite unsinnige Interfaces, die bei der Implementierung auch noch Zustand verlangen :-(
Composable functions sind noch ein Stück weg, aber bald ist Eisschmelze und der Fluss gewinnt an Kraft.
@Anonym: Danke für den enthusiastischen Kommentar :-) Der gibt mir auch Energie, dran zu bleiben am Thema.
Zu Methoden mit 1 oder n Parametern: Es gibt nur einen Grund, warum ich 0-1 Parameter favorisiere: Mit dieser Einschränkung ist es leicht möglich, Infrastruktur wie die Flow Runtime zu bauen.
Ansonsten unterscheiden sich
void f(int a, string b)
und
void f(Tuple ab)
nicht von einander. Beides ist für mich grundsätzlich ok, wenn man Datenflüsse aufbauen will.
Aber - wie gesagt - ich finde es vorteilhaft, wenn ich generische Operationen für Flüsse definieren kann, weil ich weiß, dass es immer nur max. 1 Parameter gibt.
Vielleicht gebe ich das ja aber auch mal auf? ;-) Denn auch wenn in der Flow Runtime Daten in IMessage Objekte verpackt fließen, könnten sie am Empfangsort auch entpackt werden in mehrere Input-Parameter.
Bisher war mir das jedoch zu aufwändig zu implementieren. Und ich empfinde keine wirkliche Einschränkung durch Methoden mit nur 1 Parameter.
Hallo,
das Thema EBC und Flowdesign verfolge ich nun bereits eine ganze Weile, und verwende EBC auch sehr gerne.
Im Bereich der Programmierung gehöre ich wohl eher zu den sogenannten "Quereinsteigern" (obwohl ich dieses Wort eher nicht mag).
Und das scheint auch mein Problem zu sein. Mir wird das ganze schon fasst zu akademisch, zu theoretisch.
Bei der ursprünglichen EBC, aufgebaut aus Klassen mit Eingangsmethode und Action Events als Ausgängen war alles überschaubar. Der Vergleich zu Bauteilen der Elektronik passte und machte das benutzen einfach.
Warum muss man eine eigene Flow Runtime, DSL usw. haben? Warum eine ständige neue Iteration zum selben Grundproblem?
Für macht es die Sache nur unnötig kompliziert. Mit einfachen EBC kommt jeder zurecht, solange er z.B. C# grundlegend versteht.
Bringt es nicht mehr, wenn man sagt: EBC - So das ist es! So wird es gemacht!
Als nächstes: So benutzt man EBC um Consolen und Dienste zu erstellen. (Wer schreibt heute noch Consolen?)
Dann: So baut man daraus GUI mit WPF oder, oder, oder.
So entstünde eine Art Blueprint an das sich alle halten können und vor allem alle verstehen. Das ist für mich der große und spektakuläre Fortschritt beim Thema EBC.
@thomass: Schade, Thomas, dass du noch nicht die Vorteile der Flow Runtime gegenüber reinen EBCs.
Und schade, dass du missverstehst, du müsstest irgendetwas tun. Musst du aber nicht. Du kannst doch weitermachen wie bisher. Wenn dir EBC genügen, ist doch alles gut. Das gilt genauso für alle die OOP machen oder sonstwas. Wer kein Problem hat, der macht weiter wie bisher.
Allerdings könnte solches Weitermachen Vorteile verschenken. Neulich habe ich ja schon geschrieben, wie der heutige Erfolg der Feind des Besseren ist. Aber da muss jeder für sich wissen, ob er besser werden will.
Wie könntest du mit der Flow Runtime besser werden?
1. Du sparst dir Verdrahtungscode für EBC. Statt dafür Klassenhierarchien aufzubauen, also C# Code zu schreiben, schreibst du die Flows in der Flow Runtime DSL runter. Das ist kürzer, übersichtlicher und kann vom Visualizer auch noch in ein Diagramm verwandelt werden.
2. Du sparst dir für den Verdrahtungscode die Dependency Injection.
3. Du musst eben nicht für jede Operation eine Klasse schreiben, sondern kannst Prozeduren und Funktionen benutzen. Das spart einigen Aufwand.
4. Du kannst mit der Flow Runtime schon länger auch EBC Operationen in Flows nutzen. Die Runtime verdrahtet automatisch.
5. Mit der Runtime kannst du Flows deklarativ async und parallel machen.
6. Mit der Runtime bekommst du Exception Handling mittels Causalities. D.h. wenn in einem Flow - auch einem asynchronen - irgendwo eine Ausnahme auftritt, dann wird die gefangen und kann in einem eigenen Flow verarbeitet werden.
7. Mit der Flow Runtime bekommst du - wie neulich in einem Beitrag beschrieben - sehr einfache Verteilung.
Aber wenn dir das nicht genug ist oder du die Flow Runtime als überkandidelt empfindest, dann mach weiter mit EBC. Das ist kein Problem. Nur, bitte, verlange nicht von allen anderen auch, dort stehenzubleiben. Das wäre schade. Denn dann hätten wir uns ja auch nicht von C# 1.0 zu C# 5.0 entwickelt. Dann hättest du keine Lambda Ausdrücke und kein Linq. Wäre auch schade, oder?
Und was das Console Szenario angeht: Das ist eben einfach hier darzustellen. Keine Infrastrukturkenntnisse nötig. Wenn du aber über EBC in Zusammenhang mit anderen Technologien sprechen möchtest, poste doch etwas in der Google oder XING Gruppe dazu.
@thomass: Noch eins zu "So macht man das mit EBC": Wenn du meine Beiträge und die von Stefan Lieser in der dotnetpro verfolgst, dann siehst du eine Menge von Anregungen von "so macht man das" mit Flow Design - und inzwischen auch mit der Flow Runtime.
Die Szenarien, die er und ich beschreiben, sind weit gefächert. Zu MVP kannst du da zum Beispiel lesen. Oder zu Verteilung.
Und ansonsten: EBC/FD muss man auch nicht überbewerten. Wie man ein Domänenmodell macht, hat damit erstmal nichts zu tun. Oder ob und wie du mit einem ORM umgehst, hat damit auch nichts zu tun.
@thomas:
schau dir das einmal an https://github.com/Vanaheimr/Balder
oder was dieser Herr zu sagen hat
http://www.infoq.com/interviews/meijer-big-data
oder das hier http://channel9.msdn.com/Shows/Going+Deep/Stephen-Toub-Inside-TPL-Dataflow
Ich finde es gibt interssante Herren (und viele mehr, inkl. ralfw), Themen, Ansätze und Lösungen und manchmal verbreiten Menschen Ideen und Hintergrundinformationen. Die Software steht nie still - vielleicht der aktuelle Kontext. Blogs lesen, Ausprobieren, Code lesen, versuchen am Ball zu bleiben und zu lernen - neugierig bleiben und Spass haben.
Ah... now I get the picture :)
Muss ja fast behaupten, dass ich so etwas ähnliches schon gemacht habe, allerdings ist mir der Ansatz mit den Flows noch etwas unbekannt.
Das "Verdrahten" hatte ich regulär per Code gelöst, aber die Einzelteile ebenfalls relativ entkoppelt erstellt.
Super, jetzt darf ich mal wieder was lernen ;)
"Pánta chorei kaì oudèn ménei" (Wikipedia sei Dank)
*Thumbs up*
Hat sich schon einmal jemand FlowDesign im Kontext von Go (golang) angeschaut? Leider bin ich sowohl bei Go als auch FD erst am Anfang des Lernweges aber sehe hier fiel nutzbares 'out of the box'
E.g. kennt Go multiple Rueckgabewerte, functions als values, die demnach auch fliessen koennen und die einfachen channels fuer den Datenfluss. Ich werde mich gerne einmal daransetzen und vielleicht klinkt sich ja jemand ein.
p.s.: ich habe verstanden, dass FD cross-3GL ist aber vielleicht ist Go ja besonders gut geeignet und da 'neu' auch noch nicht so dogmatisiert wie z.B. Java in my opinion.
@Harald: Ich hab neulich auch mal in Go reingeschaut. Sah ganz ordentlich aus ;-) FD geht damit. Das sehe ich auch so.
Und mit Ruby und JavaScript ebenfalls :-)
Java tut sich allerdings schwer. Functions sind da keine first class citizens. Ist halt zu alt die Sprache :-)
ich glaube ich setze mich heute Abend daran, vielleicht auch in Form eines blogs, meine Versuche Go und FD zu kombinieren zu dokumentieren. Vielleicht ergibt sich daraus ja eine weiterfuehrende, interessante Diskussion. Wird zwar etwas schmerzhaft, da ich auf dem irischen Land nur eine 1MB Leitung habe aber was tut man nicht alles fuer die Leidenschaften :-) der Anfang wird zwar Go lastiger um darin eine Basis zu schaffen aber ich denke bereits da koennen sich Kommentare zu FD ergeben.
Kommentar veröffentlichen
Hinweis: Nur ein Mitglied dieses Blogs kann Kommentare posten.