Event Based Components: Architektur von Software in neuem Licht

Je weniger Abhängigkeiten innerhalb von Code bestehen, desto einfacher fallen dessen Wartung und Pflege aus, weswegen Entwickler auf Komponentenebene oft auf das Entwurfsmuster "Inversion of Control" zurückgreifen. Doch genügt diese Art der Entkopplung, um isolierte Komponenten zu entwickeln?

In Pocket speichern vorlesen Druckansicht
Lesezeit: 17 Min.
Von
  • Golo Roden
Inhaltsverzeichnis

Je weniger Abhängigkeiten innerhalb von Code bestehen, desto einfacher fallen dessen Wartung und Pflege aus. Dieser hinlänglich bekannten Erkenntnis begegnen Entwickler auf Komponentenebene in der Regel mit dem Entwurfsmuster "Inversion of Control", indem sie beispielsweise einen Dependency-Injection-Container (DI) zur dynamischen Auflösung der Abhängigkeiten verwenden. Doch genügt diese Art der Entkopplung, um isolierte Komponenten zu entwickeln?

Die Anhänger Event-basierter Programmierung – siehe Ted Faisons "Event-Based Programming" (apress 2006) – und des Flow-orientierten Designs – siehe J. Paul Morrisons "Flow-Based Programming" (CreateSpace 2010) – verneinen das. Ihre Argumentation lautet, dass die gängige Vorstellung komponentenorientierter Entwicklung noch nicht genügt, um Komponenten wirklich voneinander zu isolieren. Stattdessen tut eine Kopplung über einen neuen Mechanismus Not – eben über Events, sodass sie ihre Komponenten als Event-Based Components (EBC) bezeichnen.

Das eigentliche Problem der bisherigen Komponentenorientierung gestaltet sich dabei wie folgt: Die landläufige Vorstellung einer Komponente basiert auf zwei Forderungen. Die erste lautet, dass sich eine Komponente gegen eine andere mit gleichem Kontrakt austauschen lässt. Dieser Austausch ist durch den gleichen Kontrakt für den Verwender frei von Implikationen und erscheint daher transparent. Die zweite Forderung bedingt die Implementierung einer Komponente als Blackbox. Für ihre Verwendung genügt also die Kenntnis des Kontrakts, die interne Implementierung hingegen ist nur für den Entwickler der Komponente von Belang, für den Verwender jedoch irrelevant.

Auch wenn diese Definition nicht jeglichen formalen Anspruch erfüllt, genügt sie dem täglichen Bedarf zahlreicher Entwickler. Das liegt in der Einfachheit der Definition begründet, schließlich ist der Satz: "Eine Komponente ist austauschbar und wird über einen Kontrakt definiert sowie als Blackbox implementiert", leicht zu merken und enthält zudem alle wesentlichen Aussagen.

Gelegentlich wird allerdings nach einer anschaulichen Analogie aus der wirklichen Welt gefragt. Die vermutlich am häufigsten genannte Antwort auf diese Frage lautet, dass Komponenten wie Lego seien. Die Popularität des Vergleichs liegt wiederum in seiner Einfachheit begründet: Schließlich sind auch Lego-Bausteine austauschbar, sie verfügen über einen gemeinsamen Kontrakt und werden als Blackbox implementiert. Doch ist diese Analogie wirklich passend?

Ihrer ursprünglichen Vermutung und der dieser Analogie allgemein entgegengebrachten Sympathie zum Trotz entscheiden sich viele Entwickler im Lauf der Zeit gegen den Vergleich. Allerdings ist kaum ein Entwickler in der Lage, hierfür rationale Gründe zu benennen oder argumentativ zu begründen, warum ihm die Analogie unpassend erscheint – die Entscheidung dagegen fällen sie allein intuitiv und emotional.

Der offensichtliche Unterschied zwischen Komponenten und Lego-Bausteinen besteht darin, dass die einen dynamischer, die anderen hingegen statischer Natur sind. Die Art der Funktionserfüllung ist also eine andere. Doch auch bei näherer Betrachtung ergeben sich keine Hinweise, warum die Analogie unpassend erscheint – schließlich hängt die Funktionserfüllung weitgehend von der internen Implementierung ab, und von dieser abstrahieren sowohl Komponenten als auch Lego-Bausteine durch den jeweils von ihnen verwendeten Kontrakt. Zudem widerspricht die zu Beginn angesprochene weit verbreitete Verwendung von Dependency-Injection-Containern der Vermutung, dass das Problem an dieser Stelle liegen könnte – dienen sie doch dazu, funktionale Abhängigkeiten aufzulösen.

Problematischer erscheint da bereits die Abhängigkeit einer Komponente von ihrem aktuellen Zustand. Da dieser in der objektorientierten Entwicklung per Definition als "privat" angesehen wird, ist es nur auf Umwegen möglich, auf ihn von außen zuzugreifen. Das ist allerdings in Unit-Tests von Nöten, will man damit einzelne Funktionen einer Komponente unabhängig voneinander testen. Hier helfen nur leidige Workarounds:

  • Reflection: Die einfachste Variante, privaten Code von außen zugreifbar zu machen, liegt darin, per Reflection auf ihn zuzugreifen. Das ist auch die Variante, die Visual Studio von Haus aus im Rahmen automatisch generierter Zugriffsklassen für Tests bietet. Die Nachteile liegen auf der Hand: Da der Zugriff mit Reflection naturgemäß nicht typisiert erfolgen kann, ist auf sogenannte Magic Strings zurückzugreifen, die spätestens bei umfangreichen Refaktorisierungen für Probleme und Unannehmlichkeiten sorgen.
  • Vererbung: Als zweite Mittel bietet sich an, den Zugriffsmodifizierer "private" durch "protected" zu ersetzen. Auf diese Art wird privater Code zumindest für abgeleitete Klassen zugreifbar. Da prinzipiell nichts dagegen spricht, auch eine Testklasse von der zu testenden Klasse abzuleiten, lässt sich der Zugriff so herstellen. Doch "private" und "protected" weisen eine unterschiedliche Semantik auf und sind daher nicht bedingungslos gegeneinander austauschbar. Zudem funktioniert der Trick nicht bei versiegelten Klassen, von denen man nicht ableiten kann.
  • InternalsVisibleTo: Als weitere Möglichkeit lässt sich der Zugriffsmodifizierer "private" durch "internal" ersetzen. Wird zusätzlich das Attribut InternalsVisibleTo verwendet, "fühlt" sich der Zugriff auf privaten Code aus Unit-Tests de facto so an wie der Zugriff auf nicht privaten Code. Allerdings trifft der Nachteil der zweiten Variante hier ebenfalls zu – sogar in gravierenderem Ausmaß, da "internal" noch freigiebiger ist als "protected".

Als Ausweg besteht letztlich nur die Möglichkeit, Zustand konsequent als Abhängigkeit zu betrachten und ihn – ebenso wie funktionale Abhängigkeiten – von außen per Dependency Injection injizierbar zu gestalten. Diese Variante ist deutlich aufwendiger, packt das Problem jedoch zumindest an der Wurzel an und stellt die "sauberste" der vorgestellten Varianten dar. Allerdings verfügt sie neben dem hohen Aufwand noch über einen weiteren Nachteil: Sie erfordert die Einführung eines zusätzlichen, nur für Tests gedachten Konstruktors. Die Tests verwenden also nicht mehr den gleichen Konstruktor, der im Produktivcode zur Anwendung kommt.

Es lässt sich festhalten, dass funktionale Abhängigkeiten und die Existenz von privatem Zustand es schwierig gestalten, Komponenten so einfach handhabbar zu machen wie Lego-Bausteine. Doch angenommen, Dependency Injection würde tatsächlich konsequent angewandt, um sämtliche funktionalen und zustandsbehafteten Abhängigkeiten aufzulösen, würden sich Komponenten dann wie Lego-Bausteine anfühlen? Jede Komponente für sich genommen vermutlich ja, doch im Zusammenspiel mangelt es. Denn es kommt eine weitere Art der Abhängigkeit zum Tragen: die sogenannte "topologische Abhängigkeit".

Unter einer topologischen Abhängigkeit versteht man die Abhängigkeit einer Komponente von ihrer Umgebung beziehungsweise von den sie umgebenden Komponenten. Dass diese Abhängigkeiten bei Software existieren, steht außer Frage. Als einfaches Beispiel sei eine Komponente A genannt, die Funktionen einer Komponente B verwendet: Ohne eine Instanz von B ist A nicht lauffähig, was zumindest zur tatsächlichen Laufzeit der Anwendung nicht übermäßig tragisch erscheinen mag.

Allerdings manifestiert sich die topologische Abhängigkeit spätestens beim Testen. Entweder ist dann nämlich neben Komponente A auch Komponente B zu instanziieren (die ihrerseits womöglich wiederum eine Komponente C benötigt), oder es ist ein dediziertes Mock-Objekt zu erzeugen, das als Platzhalter für Komponente B dient. Ersteres macht das für einen Test erforderliche Set-up je nach Länge der Abhängigkeitsketten enorm aufwendig, Letzteres erfordert das zusätzliche Erzeugen zahlreicher Dummy-Objekte, was letztlich wieder aufwendig ist. Auch die vielfach angebotenen Mocking-Frameworks bewirken hierbei eher Linderung als Heilung.

Kurzum, Komponenten fühlen sich nicht wie Lego-Bausteine an, weil sie topologisch von den sie umgebenden Komponenten abhängig und nicht isoliert sind. Lego-Bausteinen hingegen ist es vollkommen gleich, ob sie einzeln oder im Verbund verbaut werden – und wenn im Verbund, spielt die Art des Verbunds für den einzelnen Lego-Baustein keine Rolle. Dependency Injection ist also ein, aber nicht das Hilfsmittel zur Entkopplung. Die Frage lautet also: Wie lassen sich topologische Abhängigkeiten beheben?

Statt zu versuchen, eine neue Lösung von Grund auf zu finden, bietet es sich an, zunächst einmal zu prüfen, wie die prinzipiell gleiche Aufgabe in anderen Domänen gelöst wird. Hierfür drängt sich der Vergleich mit der Elektrotechnik auf, in der einzelne Chips miteinander verdrahtet und als größere Einheiten auf einer Platine untergebracht werden. Chips fühlen sich viel eher wie Lego-Bausteine an, denn auch einem Chip ist es gleich, ob er einzeln oder im Verbund genutzt wird. Was zeichnet einen Chip demnach aus?

Ein Chip ist eine Komponente, die einem Kontrakt genügt und als Blackbox implementiert ist – die klassische Definition einer Software-Komponente also. Der Unterschied zwischen einem Chip und einer klassischen Softwarekomponente liegt in der Verarbeitung der Signale. Ein Chip besitzt Ein- und Ausgänge für elektrische Signale. Diese sind jedoch immer unidirektional: Ein Eingang nimmt stets nur Daten entgegen, ein Ausgang gibt stets nur Daten zurück. Etwas wie einen "Rückgabewert" gibt es nicht – hierfür benötigt man einen gesonderten Ein- und Ausgang.

Genau das ist der Grund, warum Chips isoliert von ihrer Topologie sind: Sie erhalten von irgendwo Signale, und sie senden ihrerseits Signale nach irgendwo. Ob vor beziehungsweise nach ihnen ein anderer Chip sitzt, ob das unterschiedliche Chips sind oder jeweils der gleiche, welcher Art der vorherige beziehungsweise der nachfolgende Chip sind, all das ist ihnen gleich. Die einzige Bedingung zur Zusammenarbeit für Chips lautet, dass die jeweiligen Signale auf die gleiche Art interpretiert werden, damit ein sinnvolles Ergebnis erlangt wird. Was liegt also näher, als zu versuchen, dieses Konzept auch auf Softwarekomponenten zu übertragen?