Het Actor-model visueel in C#

 
10 april 2010

Bij de WK Voetbalpool webapplicatie heb ik het al meerdere malen over het Actor-model gehad, zonder in al te veel technische details te treden. Dat wil ik hierbij rechtzetten door een kleine voorbeeld-applicatie te bouwen. Omdat niet iedereen Scala kent (ikzelf meegerekend) doen we dat in C#. Dat betekent wel dat we het berichtensysteem zelf moeten bouwen; in Scala is dat onderdeel van de standaardbibliotheek, in C# is het een nuttige oefening.

Voor deze applicatie slepen-en-klikken we 2 schermen in elkaar:

Het ActorStarterForm

Het ActorStarterForm

Het ActorForm

Het ActorForm

Het eerste scherm gebruiken we om nieuwe Actoren aan te maken, die worden gerepresenteerd door het tweede scherm. Het eerste scherm is zelf natuurlijk ook een Actor. ActorForms worden geïdentificeerd door hun naam. Het idee is dat je in de ActorStarter een (nieuwe) naam invult en op de creëer-knop drukt. Hiermee wordt een nieuw ActorForm gelanceerd. In het ActorForm tiep je de naam van een actor en een berichttekst, en die tekst komt dan in het berichtenvenster van de geadresseerde actor te staan.

De basis van het Actor-model is dus dat actoren elkaar berichten sturen, en daarmee de ‘business functionaliteit’ implementeren. In dit geval doen we dat heel simpel: een bericht krijgt een afzender, geadresseerde en een berichttekst mee in zijn constructor; deze gaan we uit de invoervelden van de ActorForms halen. In de constructor wordt ook vastgelegd op welke Thread hij gecreëerd wordt, zodat we dat later kunnen achterhalen:

public class Bericht
{
    public String afzender { get; set; }
    public String geadresseerde { get; set; }
    public String berichttekst { get; set; }
    public int ThreadId { get; set; }
 
    public Bericht(String afzender, String geadresseerde, String berichttekst)
    {
        this.afzender = afzender;
        this.berichttekst = berichttekst;
        this.geadresseerde = geadresseerde;
        this._threadId = Thread.CurrentThread.ManagedThreadId;
    }
}

Daarnaast definieer ik nog een speciaal soort bericht, dat naar alle actoren gestuurd zal worden. Of deze manier van subclassen de meest elegante methode is laat ik in het midden, ‘in het echt’ zou je het waarschijnlijk anders doen, bijvoorbeeld met een gezamenlijke abstracte basis-Berichtklasse.

public class WieZijnDaar : Bericht
{
    public WieZijnDaar(String afzender) : base(afzender, "iedereen", "Wie Zijn Daar?") { }
}

Als dit bericht wordt opgevangen door een actor, willen we dat die een berichtje terugstuurt naar de afzender.

De actoren moeten nu met elkaar gaan praten door elkaar deze berichten te sturen. Hoe je dat precies oplost is een keuze die je per applicatie kan maken; praten de actoren direct met elkaar, of heb je een centrale berichtenbus die zorgt dat ze bij de juiste actor terecht komen? Of stuurt de berichtenbus ze door naar iedereen en moeten de actoren zelf maar uitzoeken of ze er iets mee doen? Ik kies in dit geval voor de 2e optie; die is het simpelst, al is dit wel de enige optie waarbij een bericht ook per se een geadresseerde moet hebben. De 3e optie is niet heel veel anders, alleen komt er meer overhead bij kijken omdat elke actor zelf het Bericht moet uitpluizen om te zien of hij de geadresseerde is. De 1e optie is lastiger omdat de actoren elkaar dan al moeten kennen. Als dat nog niet zo is, dan moet er ergens een ‘telefoonboek’ (registry) zijn waarin alle actoren geregistreerd zijn. Bij de 2e of 3e optie heeft de berichtenbus min-of-meer die rol. Daarentegen is het bij optie 3 ook mogelijk dat er meerdere actoren op een enkel bericht reageren; dat kan zeker voordelen en extra mogelijkheden bieden. Dat neigt dan al naar een eventgedreven architectuur, waarbij je bijvoorbeeld ook een logging-actor of een persistentie-actor kan maken.

public class PTT
{
    private static PTT instance = new PTT();
    public static PTT Instance
    {
        get { return instance; }
    }
    private PTT() { }
 
    private Dictionary<string, Queue<Bericht>> postbussen = new Dictionary<string, Queue<Bericht>>();
 
    public void registreerPostbus(String actorNaam, Queue<Bericht> postbus)
    {
        postbussen.Add(actorNaam, postbus);
    }
 
    public void deregistreer(String actorNaam)
    {
        postbussen.Remove(actorNaam);
    }
 
    public void zendBericht(Bericht bericht)
    {
        if (bericht is WieZijnDaar)
        {
            foreach (Queue<Bericht> postbus in postbussen.Values)
            {
                postbus.Enqueue(bericht);
            }
        }
        else
        {
            if (!postbussen.ContainsKey(bericht.geadresseerde))
            {
                zendBericht(new Bericht("PTT", bericht.afzender, bericht.geadresseerde + " is onbekend!"));
            }
            else
            {
                postbussen[bericht.geadresseerde].Enqueue(bericht);
            }
        }
    }
}

Zoals je ziet kom ik nog uit de tijd van voor de privatisering, toen er nog maar een enkele PTT bestond. De PTT heeft een lijst (dictionary) van postbussen, die per stuk worden gerepresenteerd met een Queue<Bericht>, geïndexeerd op de naam van de actoren zelf (althans, we gebruiken straks hun eigen naam als postbus-identifier bij de registratie en deregistratie). Zoals je in methode zendBericht() ziet wordt er hier onderscheid gemaakt tussen of het bericht een WieZijnDaar-bericht is (die wordt naar alle postbussen doorgestuurd) of een gewoon bericht. Die wordt alleen in de bijbehorende postbus geplaatst – tenzij de postbusnaam niet geregistreerd is, dan stuurt de PTT zelf een foutmeldingsbericht. (In het echt zou je daar waarschijnlijk ook weer een aparte subklasse van Bericht voor gebruiken.) Merk overigens op dat de PTT de actoren zelf dus niet eens hoeft te kennen.

Laten we nu eens naar het ActorStarterForm kijken:

public partial class ActorStarterForm : Form
{
    private Queue<Bericht> postbus = new Queue<Bericht>();
    private System.Windows.Forms.Timer timer = new System.Windows.Forms.Timer();
    public ActorStarterForm()
    {
        InitializeComponent();
        PTT.Instance.registreerPostbus("ActorStarter", postbus);
        timer.Interval = 1000; // milliseconden
        timer.Tick += new EventHandler(timer_Tick);
        timer.Start();
    }
 
    void timer_Tick(object sender, EventArgs e)
    {
        this.Invoke(new MethodInvoker(delegate() { VerwerkBericht(); }));
    }
 
    private void VerwerkBericht()
    {
        if (postbus.Count==0) {return;}
        Bericht b = postbus.Dequeue();
        tbBerichten.Text += String.Format("{0} op thread {1} zegt: {2}{3}",
            b.afzender, b.ThreadId, b.berichttekst, Environment.NewLine);
    }
 
    private void ActorStarterForm_FormClosing(object sender, FormClosingEventArgs e)
    {
        timer.Stop();
        PTT.Instance.deregistreer("ActorStarter");
    }
 
    // ...hieronder volgt meer code...
}

Zoals je ziet is dit form zelf ook een actor: er is een postbus aanwezig om berichten in te deponeren, en in de constructor resp. FormClosing event wordt hij de(de)registreerd bij de PTT. Om alles visueel inzichtelijk te maken laat ik deze actor door middel van een timer de berichten uit de postbus verwerken, met een vertragingsinterval van 1 per seconde zodat je visueel kan zien wat er gebeurt. In het echt zou je hier waarschijnlijk een andere constructie voor gebruiken; denk bijvoorbeeld aan een Monitor.Wait()/Monitor.Pulse()-constructie om de actor te notificeren als er een nieuw bericht in de postbus komt, om ze vervolgens meteen allemaal te verwerken.

De verwerking in dit voorbeeld bestaat er eigenlijk domweg uit dat de berichttekst met een klein headertje in het berichtenscherm gezet wordt. De timer werkt natuurlijk ook asynchroon, dus we moeten met een this.Invoke() zorgen dat de berichten op de juiste Thread in het berichtenvenster worden getoond (in methode VerwerkBericht()); deze Invoke() is vooral een .Net-technisch detail dat niet echt met het actor-model an sich te maken heeft. Omdat we tijdens het aanmaken van het bericht hebben opgeslagen op welke thread dat is gebeurt kunnen we dat nu in het headertje zetten, zodat je kan verifieren dat de applicatie werkt met meerdere actoren die asynchroon (dat wil zeggen… zie deze post) op verschillende Threads naast elkaar lopen.

Wat ik nog niet heb laten zien is de code achter de ‘WieZijnDaar’-knop:

private void btnWieZijnDaar_Click(object sender, EventArgs e)
{
    PTT.Instance.zendBericht(new WieZijnDaar("ActorStarter"));
}

en tenslotte het aanmaken van nieuwe actoren:

private void btnCreeer_Click(object sender, EventArgs e)
{
    String naam = tbActorNaam.Text;
    Queue<Bericht> postbus = new Queue<Bericht>();
    ActorForm actor = new ActorForm(naam, postbus);
    PTT.Instance.registreerPostbus(naam, postbus);
    new Thread(new ThreadStart(delegate()
    {
        Application.Run(actor);
    })).Start();
}

Hier zit de crux van het actor-model: er wordt een nieuw actorForm gebouwd en bij de PTT aangemeld, maar deze wordt op een aparte Thread gerund. In dit voorbeeld bestaat de Actor maar uit een enkele klasse; dat hoeft niet per se – vanwege scheiding van belangen wil je in het echt misschien juist meerdere objecten samen als Actor beschouwen. Dat is een design-beslissing die je per keer zal moeten maken.

Tenslotte nog de code van de ActorForms zelf. Het grootste deel ervan is praktisch een kopie van het ActorStarterForm, en met een beetje nadenken kun je hier vast een algemenere gezamenlijke basisklasse voor uitschrijven:

public partial class ActorForm : Form
{
    private Queue<Bericht> postbus = new Queue<Bericht>();
    private Timer timer = new Timer();
 
    private ActorForm()
    {
        InitializeComponent();
    }
 
    public ActorForm(String naam, Queue postbus)
        : this()
    {
        this.postbus = postbus;
        lblNaam.Text = naam;
        timer.Interval = 1000; // milliseconden
        timer.Tick += new EventHandler(timer_Tick);
        timer.Start();
    }
 
    void timer_Tick(object sender, EventArgs e)
    {
        this.Invoke(new MethodInvoker(delegate() { VerwerkBericht(); }));
    }
 
    private void VerwerkBericht()
    {
        if (postbus.Count == 0) { return; }
 
        Bericht b = postbus.Dequeue();
        tbBerichten.Text += String.Format("{0} op thread {1} zegt: {2}{3}",
            b.afzender, b.ThreadId, b.berichttekst, Environment.NewLine);
        if (b is WieZijnDaar)
        {
            verstuur(b.afzender, "Ik ben hier!");
        }
    }
 
    private void btnVerstuur_Click(object sender, EventArgs e)
    {
        verstuur(tbAan.Text, tbBerichttekst.Text);
    }
 
    private void btnWieZijnDaar_Click(object sender, EventArgs e)
    {
        verstuur(new WieZijnDaar(lblNaam.Text));
    }
 
    private void verstuur(string geadresseerde, String berichttekst)
    {
        verstuur(new Bericht(lblNaam.Text, geadresseerde, berichttekst));
    }
 
    private void verstuur(Bericht bericht)
    {
        PTT.Instance.zendBericht(bericht);
    }
 
    private void ActorForm_FormClosing(object sender, FormClosingEventArgs e)
    {
        timer.Stop();
        PTT.Instance.deregistreer(lblNaam.Text);
    }
}

Ook hier zien we weer een zelfde constructie met een Timer die een keer per seconde een bericht uit zijn postbus haalt en de berichttekst met headertje in zijn berichtenvak plaatst. En verschil met de ActorStarterForm is dat als het bericht van type WieZijnDaar is, hij ook een berichtje terugstuurt naar de afzender. Elke actor kan natuurlijk verschillende berichten op verschillende manieren afhandelen. Met sommige zullen zelfs helemaal niets gedaan worden – dit is bijvoorbeeld vaak het geval in een meer Event-driven opzet waarbij de messagebus (de ‘PTT’) de berichten doorstuurt naar alle actoren, en de actoren zelf de filtering doen, in plaats van dat de messagebus ze filtert (zoals bijv. hier gebeurt obv geadresseerde) – de optie 3 van hierboven.

Met deze ‘WieZijnDaar’-constructie kan een actor achterhalen welke actoren er op een bepaald moment ‘live’ zijn. Het grote verschil (en mogelijk nadeel) met een klassieke manier waarbij je met een synchrone aanroep een repository uitvraagt is dat, omdat alle berichten asynchroon afgehandeld worden, je dus moet wachten totdat je alle antwoorden hebt – en je dus ook nooit weet of je alle antwoorden op een gegeven moment hebt binnengekregen.

Zoals je in de bovenstaande code ziet is de enige manier van communicatie met de actoren door middel van het sturen van berichten naar elkaar. Alle methoden in de actoren zijn private, en kunnen dus niet van buitenaf aangesproken worden. De enige uitzondering op deze regel is de PTT, en natuurlijk Bericht, omdat dat in ons model geen actoren zijn. In een taal als Scala, waar actoren deel uitmaken van de basisbibliotheek, kan je zelfs de PTT een actor maken. In C# wordt dat volgens mij lastiger omdat je toch een methode zal moeten aanspreken om de PTT het bericht te laten verzenden. Maar misschien heeft iemand een idee hoe dat wel zou kunnen? Graag een reactie!


Werken met ?
Kijk dan bij onze mogelijkheden voor zowel starters als ervaren engineers.


Categorieën: Development