Follow my new blog

Dienstag, 23. Februar 2010

Mustergültig – Event-Based Components aufbohren

Wie Sie Event-Based Components (EBC) grundsätzlich “zusammenstecken” habe ich hier gezeigt. Das ist nicht schwer, oder? Nur etwas gewöhnungsbedürftig. Dafür winken aber einige Vorteile gegenüber “traditionellen” Komponenten, finde ich. Meine “Bauchschmerzen”, die ich mit denen bei aller Liebe immer noch latent hatte, sind jetzt weg.

Ob ich Sie damit jedoch automatisch auch für EBC begeistert habe, weiß ich nicht. Das streng nachrichtenorientierte Programmiermodell und die “Verklausulierung” von Serviceaufrufen als Events sind recht weit weg vom Üblichen. Ich will deshalb hier noch ein paar Kommunikationsmuster beschreiben, die Ihnen das Konzept schmackhafter machen.

One-way Nachrichten

Das Grundmuster der EBC-Kommunikation ist die one-way Nachricht. Als solche bezeichne ich eine Nachricht, die vom Empfänger keine Antwort an den Absender erwartet. Bisher habe ich nur Beispiele für one-way Nachrichten gegeben. Selbst die Nachricht ParseResult, die vom Parser als Antwort zurück fließt zum CompilerCoordinator, ist eine solche one-way Nachricht:

interface ICompilerCoordinator

{

   

    event Action<ParseRequest> OnParse;

    void ProcessParseResult(ParseResult result);

}


interface
IParser

{

    void ProcessParseRequest(ParseRequest request);

    event Action<ParseResult> OnParseResult;

}

Formal besteht zwischen ParseRequest und ParseResult kein Unterschied. Die Semantik, dass ein ParseResult als Ergebnis eines ParseRequest versandt wird, ist nur in unserem Kopf. Deshalb sind beide Nachrichten formal gleich.

Wie gesagt: Die Kommunikation zwischen Interaktionspartnern ist bei der Ereignisorientierung grundsätzlich symmetrisch.

One-way Nachrichten sind im Sinne der Command Query Separation das Mittel, um Kommandos zu versenden. Die Implementation ist denkbar einfach: Der Sender definiert einen Event für eine one-way Nachricht als Output; der Empfänger definiert einen passenden Event-Handler. Auf einer “Software-Platine” werden beide mit einander verbunden.

One-way Sender:

event Action<T> OnT;

One-way Empfänger:

void ProcessT(T msg);

Request/Response V1

Auch wenn es in der Ereignisorientierung formal nur one-way Nachrichten gibt, hilft es ja nichts: viele Interaktionen erwarten eine Antwort. Request/Response ist ein typisches Muster in der Kommunikation zwischen Funktionseinheiten. Dafür sind Funktionen gemacht. Auch die obige ParseRequest-Nachricht ist ein solcher Aufruf, der die Produktion eines Resultats anstoßen soll. Ansehen tun sie ihm das aber nicht. Das ist misslich, weil der ereignisorientierte Code dadurch schlechter lesbar ist als üblicher methodenbasierter.

Das können Sie jedoch leicht ändern, indem Sie den Request “aufbohren”. Sie verschicken mit ihm nicht nur Argumente für die Bearbeitung – sondern auch noch den Empfänger für die Antwort:

class ParseRequest

{

    public string Source;

    public Action<ParseResult> ProcessParseResult;

}

Jetzt wird die Nachricht ihrem Namen gerecht. Sie ist ein echter Request, eine Anforderung von Daten. Source steht für die Input-Daten beim Empfänger und die Methode ProcessParseResult für den Empfänger des Outputs.

Die Kontrakte für CompilerCoordinator und Parser werden dadurch einfacher:

interface ICompilerCoordinator

{

    void ProcessCompilationRequest(CompilationRequest request);

    event Action<ParseRequest> OnParse;

}


interface
IParser

{

    void ProcessParseRequest(ParseRequest request);

}

Der Produzent von Resultaten braucht bei solchen Aufträgen keinen “Pin” mehr eigens für ihren Versand. Das macht ihn auch flexibler, weil er dadurch von vielen Clients als Service benutzt werden kann. Jeder Client schickt ja mit seinem Request einen Request-individuelle Antwort-“Pin” mit. Der funktioniert wie eine Return-Adresse bei normalen Funktionsaufrufen.

class CompilerCoordinator : ICompilerCoordinator

{

    public void ProcessCompilationRequest(CompilationRequest request)

    {

        ParseResult pr;

        this.OnParse(new ParseRequest

            {

                Source = request.Source,

                ProcessParseResult = r => pr = r

            });

       

    }

   

Im einfachsten Fall stecken Sie in den nachrichtenindividuellen Event-Handler für das Resultat eine Lambda-Funktion wie hier gezeigt. Ja, das ist etwas umständlicher als ein Funktionsaufruf – aber ich glaube immer noch daran, dass am Ende der Gewinn aus solch ereignisorientierter – oder besser: nachrichtenorientierter – Kommunikation die Umständlichkeit mehr als kompensiert. Außerdem ist der Umstand ja nur für inter-komponenten Aufrufe zu treiben. Die stellen gewöhnlich nur eine Minderheit in Ihrem Code dar.

Das Muster einfach: Die Request-Nachricht bekommt einen eigenen Event (oder auch nur einen Delegate), den ihr Empfänger mit einem Resultat aufruf. Genersich sieht das z.B. so aus:

class Request<TInput, TOutput>

{

    public TInput Input;

    public Action<TOutput> ProcessResult;

}

Client:

event Action<Request<T0, T1>> OnT0WaitingForT1;

Service:

void TransformT0IntoT1(Request<T0, T1> request);

Notifkationen

Den Empfänger in einer Nachricht zu transportieren ist der universelle Weg, um Nachrichten kontextabhängig zuzustellen. Das gilt nicht nur für Resultate, sondern auch für Zwischenergebnisse jeder Art. Das können z.B. Fortschrittsinformationen sein. Wenn Sie den Fortgang einer Operation dem Auslöser mitteilen möchten, versenden Sie am besten einen Endpunkt, dem darüber Bescheid gegeben werden kann:

class ParseRequest

{

   

    public Action<double> ShowProgress;

}

Ein Service muss dann nicht permanent an einen Event-Handler gebunden sein, sondern bekommt für jeden Auftrag womöglich einen neuen mitgeteilt.

ParseResult pr;

this.OnParse(new ParseRequest

    {

        Source = request.Source,

        ProcessParseResult = r => pr = r,

        ShowProgress = p => Console.WriteLine("Fortschritt: {0}", p)

    });

Hier zeigt sich für mich sehr schön die Simplizität der Nachrichtenorientierung. Sie kennt nur wenige Konzepte, die man aber ganz flexibel immer wieder neu kombinieren kann. In einer einführenden Serie zum Application Space in meinem englischen Blog können Sie sehen, was das für verteilte Anwendungen bedeutet. Notifikationen sind sozusagen “first class citizens” bei der Nachrichtenorientierung.

Das Muster ist ganz einfach und unterscheidet sich nicht von dem für Resultate in Request/Response-Szenarien.

Request/Response V2

Ich verstehe, wenn Ihnen eigene Events für Resultats oder Notifikationen nicht so leicht runtergehen. Sie sollten sie allerdings als Muster “drauf haben”. Allemal für die synchrone ereignisbasierte Kommunikation sehe ich jedoch noch eine Alternative. Wir können einen Delegaten speziell für Requests definieren:

delegate void Request<TInput, TOutput>(TInput message,

Action<TOutput> processResult);

Der Kontrakt wird damit wieder einfacher:

  1. Es ist kein Resultat-Event in der Request-Nachricht mehr nötig.
  2. Die Signaturen können sich auf das Wesentliche konzentrieren und werden verständlicher.

Hier der überarbeitete CompilerCoordinator als Client:

interface ICompilerCoordinator

{

   

    event Request<string, ASTNode> OnParseRequest;

}

Ich habe jetzt sogar auf den ParseRequest-Nachrichtentypen verzichtet, weil darin ohnehin nur ein Argument verpackt würde. Die Gesamtsignatur scheint mit jetzt so spezifisch, dass der Typ nicht mehr nötig ist. (Allerdings würde ich immer noch dazu raten, nicht mehr als einen Parameter für die Argumente einer Operation zu benutzen. Fallen Sie nicht zurück in die Gewohnheiten “traditioneller” Komponenten mit ihren ausgefeilten Signaturen. Sie machen es sich damit unnötig schwer, später auf die asynchrone Ereignisorientierung umzusteigen.)

Und hier der Parser als Service:

interface IParser

{

    void ProcessParseRequest(string source, Action<ASTNode> processResult);

}

Beachten Sie, wie der Endpunkt für den Versand des Resultats als Delegat in den Event-Handler hinein gereicht wird. Das ist besser lesbar, weil schon beim Blick auf die Signatur klar ist, dass ein Resultat geliefert werden soll.

Und auch der Client wird in der Implementation einfacher:

class CompilerCoordinator : ICompilerCoordinator

{

    public void ProcessCompilationRequest(CompilationRequest request)

    {

        ASTNode program;

        this.OnParseRequest(request.Source, r => program = r);

       

    }

   

Das Muster ist einfach: Für jeden kontextspezifischen Notifikationsendpunkt übergeben Sie einen an den Nachrichtenempfänger Delegaten als Parameter. Der kann dann ein Resultat liefern oder Fortschrittsinformationen oder noch ganz anderes…

Request/Response V3

Wenn Sie spezielle Delegaten für Requests denken können, dann können Sie das Spiel natürlich auch noch weiter treiben. Auch Folgendes ist möglich:

public void ProcessCompilationRequest(CompilationRequest request)

{

    ASTNode program = this.OnParseRequest.Request(request.Source);

   

}

Huch! Wo ist denn die Ereignisorientierung hin? Der Code feuert den Event mit der Aufforderung zum Parsen, denn er benutzt ja dessen Membervariable OnParseRequest. Aber die Aufforderung liefert ein Resultat. Wie kann das sein?

Eine Erweiterungsmethode auf dem Request<>-Delegatentyp machts möglich:

public static class RequestExtension

{

    public static TOutput Request<TInput, TOutput>(this Request<TInput, TOutput> fireEvent, TInput msg)

    {

        TOutput output = default(TOutput);

        fireEvent(msg, r => output = r);

        return output;

    }

}

Und schon ist sie dahin die schöne Nachrichtenorientierung…

Auch wenn das möglich ist, empfehle ich doch, es nicht zu tun. Sie verschleiern das grundlegende Programmiermodell bis zur Unkenntlichkeit. Das halte ich für Kontraproduktiv im Sinne der Verständlichkeit. Man sieht einfach nicht mehr so deutlich, wo Komponentengrenzen verlaufen. Und es baut eine Hürde für die spätere Migration zu asynchroner Kommunikation auf.

Mit einer anderen Variante könnte ich mich allerdings anfreunden:

public void ProcessCompilationRequest(CompilationRequest request)

{

    this.OnParseRequest

        .Request(request.Source)

        .Receive(astNode =>

            {

               

            });

}

Hier wird dem Request explizit eine Continuation mitgegeben, d.h. Code, der nach seiner Erfüllung ausgeführt werden soll. So ist der Code immer noch lesbar; die bisherige lokale Variable für das Resultat wird gespart; und vor allem funktioniert dieser Stil auch in asynchronen Szenarien.

Möglich wird mit einer Erweiterungsmethode und einer kleinen Fluent Interface Klasse:

public static class RequestExtension

{

    public static RequestContinuation<TOutput> Request<TInput, TOutput>(this Request<TInput, TOutput> fireEvent, TInput msg)

    {

        return new RequestContinuation<TOutput>(processResult =>

            fireEvent(msg, processResult));

    }


     public
class RequestContinuation<TOutput>

    {

        private readonly Action<Action<TOutput>> fireEvent;

    
       
public RequestContinuation(Action<Action<TOutput>> fireEvent)

        {

             this.fireEvent = fireEvent;

        }

   
        public void Receive(Action<TOutput> processResult)

        {

            fireEvent(processResult);

        }

    }

}

Hervorhebenswert ist hier, dass die Erweiterungsmethode eine Funktion höherer Ordnung aus dem Event-Delegaten macht. Der wird also nicht in der Erweiterungsmethode aufgerufen, sondern erst in Receive(). Ein wenig Funktionale Programmierung ist hier also hilfreich, um die Ereignisorientierung angenehmer zu gestalten.

Soweit für dieses Mal mit den Event-Based Components. Ich hoffe, inzwischen konnte ich Ihre Bedenken zerstreuen, dass die unhandlich und schwierig sind. Beim nächsten Mal geht es weiter mit einem Blick auf die Verdrahtung von EBCs.

3 Kommentare:

Matthias Jauernig hat gesagt…

Hallo Ralph,

Unhandlich und schwierig ist relativ, oder? ;-)
Ich finde das ganze Konzept sehr spannend und dein Beitrag ist starker, aber auch echt guter Tobak. Man muss das Ganze wohl mal im größeren Rahmen eingesetzt haben um Vor- und Nachteile aufzuzeigen.

Ich finde die Syntax schon komplexer bzw. zunächst schwerer eingänglich als einen simplen Methodenaufruf. Das ist imho aber eine Gewöhnungsfrage und auch einfach dem abgewandelten Programmiermodell geschuldet (funktionale Programmierung sieht für viele am Anfang auch "komisch" aus, wenn zuvor nur imperativ programmiert wurde).

Dieser Beitrag hat schön meine Bedenken vom 2. Teil zerstreut, dass der Code zersplittert wird. Eine Anmerkung möchte ich trotzdem noch machen.

Ich würde Request<,> als Klasse belassen. So wird sie besser wiederverwendbar und man kann sie z.B. auch anstelle von CompilationRequest verwenden. Ansonsten bekommt man spätestens dann Probleme, wenn man z.B. statt CompilationRequest das Request-Delegate verwenden will. Hier wäre dann ein Aufruf von OnParseRequest.Request() mit dem Input des Request-Delegates nur über Umstände möglich.
Dies zieht ein paar Code-Änderungen in den Klassen und den Extension-Methods nach sich, die imho aber kein Problem darstellen.

Insgesamt also klasse Idee und Ausarbeitung des Themas. Das muss unbedingt mal ausprobiert werden!


Grüße, Matthias

Ralf Westphal - One Man Think Tank hat gesagt…

@Matthias: Ich teile deine Gedanken zu Request/Response. Aus meiner Sicht sollte der Standard eine Nachricht sein, also ein Parameter bei Event und Event-Handler. Dann lassen sich am besten generische Komponenten bauen als "Zwischenstücke".

-Ralf

dalini hat gesagt…

Hmm, erinnert mich - also von den Basics her - an Smaltalk aber da war das Systeminherent, man musste sich (als Objekt) sogar selbst Nachrichten schicken... aber spannendes Thema.