Wie ich hier erklärt habe, liegen mir die “traditionellen” Komponenten – bei aller Liebe – doch auch ein wenig im Magen. Sie passen noch nicht ganz in meine Vision von wirklich “zusammensteckbaren” Software-Bausteinen. Von Event-Based Components (EBC) erwarte ich mir nun einen Fingerzeig, wie es besser werden kann.
Das Grundmuster für Ihre Implementierung ist denkbar einfach:
- EBC kommunizieren nur über Nachrichten. Anders als bei der Event-Driven Architecture (EDA) ist diese Kommunikation allerdings (zunächst) synchron. Am Programmablauf ändert sich durch EBC also erst einmal nichts.
- EBC erhalten Aufforderungen zu einer Dienstleistungen über Input-Nachrichten; sie erteilen Aufforderungen zu Dienstleistungen über Output-Nachrichten.
- Die Übersetzung dieses Nachrichtenflusses in Code ist schematisch:
- Nachrichten werden durch eine je eigene Klasse repräsentiert
- Input-Nachrichten werden von Methoden ohne Rückgabewert verarbeitet
- Output-Nachrichten werden über Delegaten versandt
Eine Komponente mit einer Input- und einer Output-Nachricht
hat damit einen Kontrakt wie folgt:
interface IEventBasedComponent
{
void ProcessIncomingCommand(IncomingCommand cmd);
event Action<OutgoingCommand> OnOutgoingCommand;
}
Das ist quasi alles, was ich derzeit zum formalen Aufbau von EBC sagen kann. Der Rest sind Folgerungen.
Symmetrische unidirektionale Kommunikation
Zunächst ist z.B. zu bemerken, dass der Nachrichtenfluss immer unidirektional ist. Input und Output fließen immer von der Quelle zum Ziel; im Falle von Aufforderungen bedeutet das, vom Client zum Service.
Nachrichten “haben” keine Resultate wie Methodenaufrufe. Es kann nichts automatisch zurück fließen. Die Kommunikation zwischen EBC ist grundsätzlich symmetrisch. Client und Service unterscheiden sich nicht. Für eine Aufforderung ist der Client die Quelle einer Nachricht an das Ziel Service. Die Aufforderung ist der Output des Clients und der Input des Service. Und falls der Service ein Resultat an den Client zurück geben soll, dann schickt er ihm ebenfalls eine Nachricht, die sein Output und der Input der Clients ist.
Die Begriffe Client und Service, so natürlich sie in der Welt der “traditionellen” Komponenten sind, bekommen in der Welt der EBC also einen neuen Stellenwert. Früher war die Funktionseinheit, die einen Methodenaufruf absetzt, automatisch der Client. Sie fordert damit eine Dienstleistung von einer Funktionseinheit, von der sie abhängt. Client und Service stehen in einem asymmetrischen Verhältnis. Argumente in einem Methodenaufruf sind etwas anderes als der Rückgabewert.
Zwischen EBC fließen jedoch nur Nachrichten. Das fundamentale Verhältnis zweier Funktionseinheiten ist nicht mehr das zwischen Client und Service, sondern das von Quelle und Ziel. Ob die Quelle ein Client und das Ziel ein Service sind, ist nicht mehr eine Frage der Syntax, sondern der Semantik.
Unabhängige Komponenten
Aus der symmetrischen unidirektionalen Kommunikation mit der obigen Codierung ergibt sich, dass Event-Based Components unabhängig von einander sind. Das bedeutet, EBC sind wahrhaft “composable”. Wir können sie ohne Eingriff zusammenstecken. Wo keine Services mehr aufgerufen werden, da gibt es einfach keine Abhängigkeit mehr. Output-Nachrichten fließen einfach nur aus einer EBC hinaus. Ob und von wem sie empfangen werden… das weiß die Komponente nicht.
Wie das geht, zeigt eine erste Version der Interaktion zwischen zwei Komponenten, die ich schon im ersten EBC-Blogartikel benutzt habe. Das Szenario ist unvollständig, weil der Codegenerator fehlt, aber das möchte ich im Moment vernachlässigen:
Hervorhebenswert ist dabei, dass CompilerCoordinator und ParserWorker in einer Client-Service-Beziehung stehen, Anfrage und Antwort jedoch auf getrennten “Kanälen” fließen. Formal gibt es keinen Unterschied zwischen der Aufforderung zum Parsen und deren Resultat. Die Nachrichten fließen nur in die entgegengesetzte Richtung.
Auf den Input “Compile” reagiert der CompilationCoordinator mit dem Output “Parse”; der ist Input für den ParserWorker, der daraus “ASTGenerated” als Output erzeugt; der wiederum für den CompilationCoordinator Input ist und über einen hier nicht sichtbaren weiteren Schritt zum Output “CompilationResult” führt.
Die Kontrakte sehen dafür so aus:
compiler.contract.dll:
interface ICompilerCoordinator
{
void ProcessCompilationRequest(CompilationRequest request);
event Action<CompilationResult> OnCompilationResult;
event Action<ParseRequest> OnParse;
void ProcessParseResult(ParseResult result);
}
parser.contract.dll:
interface IParser
{
void ProcessParseRequest(ParseRequest request);
event Action<ParseResult> OnParseResult;
}
Zusätzlich gibt es noch einen Kontrakt für die Nachrichten, d.h. die gemeinsame Sprache der Komponenten:
message.contract.dll:
class CompilationRequest
{
public string Source;
}
class CompilationResult
{
public Func<double, double> Function;
}
class ParseRequest
{
public string Source;
}
class ParseResult
{
public ASTNode Root;
}
Die Interfaces erscheinen schon ein wenig anders als von den “traditionellen” Komponenten gewohnt – aber der wesentliche Unterschied zeigt sich erst bei Betrachtung der Abhängigkeiten zwischen Komponenten und Kontrakten.
Klassisch sehen die so aus:
Die Compiler-Implementation ist von zwei Interfaces abhängig: einem, das sie exportiert, und einem, das sie importiert. Je mehr Abhängigkeiten eine Komponente hat, desto mehr Kontrakte muss sie also referenzieren.
Dem steht nun die EBC-Variante entgegen:
Zwei Unterschiede fallen auf: Es gibt einen Kontrakt für die Nachrichten, der in diesem Szenario “traditionell” noch nicht nötig war. Viel wichtiger ist jedoch, dass jede Komponentenimplementation nur noch abhängig ist von einem Kontrakt. Das bedeutet, jede Komponente ist nur noch durch einen Kontrakt spezifiziert und nicht durch 1+n. Damit ist schon mal mein 4. Problem mit der “traditionellen” Komponentenorientierung gelöst. (Insgesamt fünf Probleme oder “Bauschmerzen” hatte ich im ersten EBC-Blogartikel genannt.) EBC-Komponenten lassen sich kompakter spezifizieren.
Aber nicht nur ist mein Problem 4 gelöst. Auch Problem 1 hat sich in Luft aufgelöst. Ich hatte bei aller Entkopplung von “traditionellen” Komponenten durch die separaten Kontrakte immer noch Bauchschmerzen, weil Clients von Services abhängig waren. Das ist bei EBC-Komponenten nun nicht mehr der Fall. Auch wenn EBC-Komponenten zusammengesteckt werden können und müssen, sind sie nicht mehr abhängig. Sie produzieren Output und erwarten Input. Das ist aus meiner Sicht etwas anderes; vielleicht nur subtil, aber immerhin.
Jetzt zur Frage, wie EBC-Komponenten “zusammen spielen”? Einen Container gibt es dafür bisher nicht, wenn ich es recht sehe. Aber es ist auch nicht schwer, EBC-Komponenten zusammen zu stecken. Sehen Sie selbst:
// Build
ICompilerCoordinator cc = new CompilerCoordinator();
IParser p = new Parser();
// Bind
cc.OnParse += p.ProcessParseRequest;
p.OnParseResult += cc.ProcessParseResult;
cc.OnCompilationResult += cr => Console.WriteLine(cr.Function(2));
// Run
cc.ProcessCompilationRequest(new CompilationRequest {Source = "2*x"});
Am besten instanziieren Sie zuerst alle Komponentenimplementationen (Build-Phase). Das ist im Gegensatz zur “traditionellen” Komponentenorientierung möglich, da es ja keine Abhängigkeiten mehr gibt, die über Konstruktoren injiziert werden müssten. (Ja, ich weiß, es ginge auch “traditionell” anders über Property-Injection, aber in der Praxis kommt die nicht sehr häufig vor. Sie fühlt sich im Vergleich zur ctor-Injection nicht so natürlich an.)
Wenn alle Komponenten instanziert sind, setzen Sie sie zum größeren Ganzen zusammen (Bind-Phase). Dazu registrieren Sie Input-Verarbeitungsfunktionen als Handler für Output-Events. Beispiel: Der CompilerCoordinator definiert mit dem OnParse-Event den Output ParseRequest. Auf dem registriert die Bindungsphase die Input-Methode ProcessParserequest() der Parser-Komponente. Damit sind CompilerCoordinator und Parser in eine Richtung zusammen gesteckt.
Dass der ParseRequest eine Antwort erwartet, sieht man ihm nicht an. Folglich muss für das ParseResult eine ebensolche Verbindung in umgekehrter Richtung hergestellt werden. EBC-Kommunikation ist eben wahrhaft symmetrisch.
Die Bindung von EBC-Komponenten ist also denkbar simpel. Input-Handler werden auf Output-Events registriert. Fertig. Sie müssen nur die Datenflüsse zwischen EBC-Komponenten identifizieren und wissen sofort, wie Sie sie umsetzen.
Dass das Nachricht für Nachricht in Handler-Event-Paaren geschieht, finde ich nicht schlimm. Im Gegenteil! Verbindungen zwischen Funktionseinheiten lassen sich dadurch viel detaillierter steuern. Sie müssen sich beim Entwurf einer Komponente keine Gedanken machen, welche andere Komponenten Outputs weiter verarbeiten oder Inputs liefern.
So löst sich für mich auch mein Problem Nr. 3 mit der “traditionellen” Komponentenorientierung in Luft auf. Ob Inputs und Output von einer anderen Komponente bedient werden oder von vielen… Das ist egal. Es kann sich deshalb auch während der Entwicklungszeit problemlos ändern. Betroffen sind dann allein die Build-Bind-Phasen, aber nicht die Komponentenimplementationen. Die haben ja keine Abhängigkeiten zu anderen Kontrakten.
Damit ist auch dem Prinzip Genüge getan, dass Komplexes wenige Abhängigkeiten haben sollte und Einfaches durchaus viele Abhängigkeiten haben kann. Denn komplex sind Komponentenimplementationen, die durch EBC quasi abhängigkeitsfrei sind. Build und Bind hingegen sind trivial und dürfen deshalb viele Abhängigkeiten haben.
Mit EBC-Komponenten sind wir also bei Platinen und Bauteilen angekommen. Build+Bind entsprechen einer elektronischen Platine, die Bauteile verbindet. Welche Bauteile das sind, hängt vom Zweck eines Gerätes ab. Dem muss sich die Platine anpassen und die Bauteilauswahl. Die Bauteile jedoch selbst, die bleiben unverändert. Ich nehme den Begriff der Wiederverwendbarkeit nur ungern in den Mund, aber hier passt er mal. Das Potenzial für Wiederverwendbarkeit scheint mir bei EBC-Komponenten dank ihrer Entkopplung und zentralen Definition mittels nur eines Kontraktes höher als bei “traditionellen” Komponenten.
Geschachtelte Komponenten
Hm… vielleicht ist die Analogie mit den elektronischen Bauteilen passender als zunächst gedacht. Ein Software-Bauteil ist dann immer eines, für das es einen EBC-Kontrakt mit einer zugehörigen Implementation gibt. Ob diese Implementation selbst Geschäftslogik ist oder nur Geschäftslogik-Bauteile verdrahtet… das ist egal. Im letzteren Fall wäre sie eine Platine, also ein Aggregat von Bauteilen, das wieder ein Bauteil auf höherer Ebene darstellt.
Software-Platinen könnten hybride Komponenten sein: Einerseits implementieren Sie einen EBC-Kontrakt, andererseits werden sie initialisiert wir “traditionelle” Komponenten. Hier eine “Compiler-Platine” als Beispiel. Deren Kontrakt könnte so aussehen:
interface ICompiler
{
void ProcessCompilationRequest(CompilationRequest request);
event Action<CompilationResult> OnCompilationResult;
}
Dass es da keinen Unterschied zum CompilerCoordinator gibt, ist völlig ok. Wenn der CompilerCoordinator die ganze Interaktion des Compilers mit der Umwelt verkörpert, dann werden dessen “Signalleitungen” zu Inputs- und Outputs der umfassenden Platine.
Die ist selbst eine EBC-Komponente qua Kontrakt – ist jedoch abhängig von ihren Bauteilen. Und die injiziert ihr ein DI Container wie üblich:
class Compiler : ICompiler
{
private ICompilerCoordinator cc;
public Compiler(ICompilerCoordinator cc, IParser p)
{
this.cc = cc;
// Bind
cc.OnParse += p.ProcessParseRequest;
p.OnParseResult += cc.ProcessParseResult;
// Output “von innen” weiterleiten zur “Platine”
cc.OnCompilationResult += cr => this.OnCompilationResult(cr);
}
public void ProcessCompilationRequest(CompilationRequest request)
{
// Input der “Platine” weiterleiten “nach innen”
this.cc.ProcessCompilationRequest(request);
}
public event Action<CompilationResult> OnCompilationResult;
}
EBC und “traditionelle” Komponenten sind also kein Widerspruch, sondern ergänzen sich. “Software-Platinen” sind hybride Komponenten, die nur den Zweck haben, EBC zu aggregieren. Sie dürfen Abhängigkeiten haben, weil sie so simpel sind. Nach außen verhalten Sie sich aber wie EBC.
Im folgenden Bild sind die schwarzen Kästen “atomare” EBC; die offenen Kästen sind “Platinen” – die jedoch genauso wie die schwarzen Kästen Input- und Output-Pins haben. Formal kann man beide also nicht auseinander halten:
Ob die Implementation eines EBC-Kontrakts in einer “atomaren” EBC besteht oder in einer hybriden, das muss auch einerlei sein, denn das kann sich jederzeit ändern, weil ein “Software-Bauteil” keine sichtbaren Abhängigkeiten hat.
Mir scheint solche Schachtelung von Funktionsbausteinen mit EBC einfacher als rein mit “traditionellen” Komponenten. Deshalb lösen EBC auch mein Problem Nr. 5. Die Schachtelungsregeln sind ganz einfach:
- Eine EBC ist ein “Software-Bauteil”.
- Wo zwei oder mehr “Software-Bauteile” mit einander verdrahtet werden, entsteht ein neues “Software-Bauteil”. Dessen Kontrakt besteht aus den in ihm unverdrahtet gebliebenen Input- und Output-Pins seiner Konstituenten.
Bin ich damit bei einem neuen Begriff angekommen, dem “Software-Bauteil”? Gibt es EBC und EBP, also Event-Based Parts? Ist eine Component ein bestimmte Art von Part. Hm… darüber muss ich mal nachdenken. Aber als nächstes will ich erstmal beschreiben, wie die Kommunikation zwischen Parts einfacher werden kann.
PS: Von meinen 5 Problemen sind bisher 4 gelöst. Was ist aber mit Problem Nr. 2? Sind EBC “besser” als “traditionelle” Komponenten, weil in sie nichts mehr injiziert wird? Oder wird noch injiziert, nur anders?
Eine Ctor-Injection oder auch eine Property-Injection eines ganzen Interface scheint mir schwergewichtiger zu sein als die Injektion einzelner Eventhandler. Ganz einfach, weil die übliche DI sich auf ganze Interfaces bezieht. Da wird immer eine Menge auf einmal in eine Komponente hinein gesteckt. Wie schlank ist dem gegenüber die Registrierung einiger Eventhandler.
Deutlich wird das für mich, wenn ich daran denke, was passiert, wenn sich die Zusammensetzung eines importierten Kontrakts ändert. Wird eine Operation herausgelöst in eine andere “traditionelle” Komponente, dann müssen alle abhängigen ihre Injektionsstellen nachführen.
EBC merken davon nichts. Wird ein Output von einem anderen EBP verarbeitet, ändert sich etwas an der Platinen, die beide verdrahtet, aber nichts an der Implementation dort, wo der Output heraus kommt.
Für mich fühlt sich das an wie eine Lösung für mein Problem Nr. 2.
Ha! Wer hätte das gedacht. So entpuppen sich EBC als den “traditionellen” Komponenten überlegen. Die werden, wie oben bei den hybriden Komponenten gezeigt, allerdings nicht ersetzt, sondern bekommen einen neuen Platz zugewiesen.
Wer abhängig ist, der darf nur einfaches tun, z.B. zusammenstecken. Wer unabhängig ist, der darf kompliziertes tun, z.B. Geschäftslogik implementieren. Die Unterscheidung zwischen EBC und “traditionellen” Komponenten scheint mir damit ganz in der Linie des Separation-of-Concerns-Prinzips zu liegen.