Design Frage - lang



  • Hallo,
    wie der ein oder andere vielleicht weiß, arbeite ich zur Zeit an einem verteilten, rundenbasierten Fußballmanager ohne Spass(TM). Wie es sich für ein Uniprojekt gehört wird hierbei natürlich kräftig mit Kanonen auf Spatzen geschossen. Das heißt konkret: Verteilung über CORBA ( 😮 ) + Datenbank (MySQL).

    Leider bin ich zur Zeit für das Design und die Implementierung der kompletten Serverseite zuständig und damit habe ich mächtige Probleme.

    Wie es sich gehört, will ich natürlich Applikationsschicht und Datenhaltungsschicht trennen. Ich will also weder Applikationsklassen die mit Corba-Zeugs durchsetzt sind noch will ich Persistenzhaltung in den Applikationsklasse. Die Datenbank muss also ohne Änderung an der Anwendungslogik austauschbar sein. Das Corba-Problem ist keins. Das mit der Datenbank dafür umso mehr. Hier muss ist ständig zwischen feiner Granularität (hübsche Schnittstellen) und Transaktionssicherheit hin und her entscheiden.

    Bevor ich nun meine Ansätze aufzeige noch ein Hinweis. Wir verwenden wohl MySQL 3.2 irgendwas. Diese DB bietet keine Transaktionen. Vieles von dem folgenden ist also eher akademischer Natur.

    1.Naiver Ansatz: Adapter/degenrierte Proxy-Lösung:
    --------------------------------------------------
    Die Applikaionsschicht besteht aus *abstrakten* Klassen wie Team, Player, User usw. Diese Klassen haben konkrete getter-Methoden und enthalten alle relevanten Daten. Methoden die den Zustand des Objekts ändern sind rein virtuell, aber mit der App-Logik implementiert.

    Außerdem gibt es zu jede App-Klasse eine passende Abstract Factory die Optionen wie load oder create anbieten.

    Die DB-Schicht enthält abgeleitete Klassen (DBTeam, DBPlayer...). Die rein virtuellen Methoden forwarden erst in die Basisklasse und speichern danach das Ergebnis in der Datenbank.
    Die konkreten Factories laden Daten aus der DB und erzeugen die passenden Objekte.

    Die Vorteile: Die Lösung ist sehr einfach und kommt mit relativ wenigen Klassen aus.

    Die Nachteile:
    Methoden wie Team::transferPlayer müssen natürlich eine Transaktionssemantik haben (als auch stark Exceptionsicher sein). Also entweder komplett ablaufen oder nix an den Teams bzw. am Player ändern. Diese Semantik muss natürlich sowohl auf C++ Ebene (App-Layer), als auch auf DB-Ebene gelten.
    Und hier ist der Haken:
    Die bisherige Lösung ist so feinkörnig, dass auf App-Ebene die Transaktionssemantik nur funktioniert, wenn die DB garantiert immer funktioniert (-> jede set-Methode hat ja den Seiteneffekt, dass die DB verändert wird).

    Die App-Klassen lassen sich also nicht isoliert betrachten und isoliert stark Exceptionsicher machen. Zusätzlich wird es selbst mit einer DB die Transaktionen unterstützt sehr schwierig. Da ja bisher keine Möglichkeit vorgesehen ist, eine kombinierte Aktion (wie z.B. transferPlayer) als eine atomare Aktion ablaufen zu lassen.

    Zusammengefasst: Hier zerhaut eine DB-Exception a) die Datenbankkonsistenz (im schlimmsten Fall) und b) den Zustand der Applikationsschicht. Es bleibt nur der geordnete Rückzug (-> die App-Schicht ist nur schwach Exceptionsicher. D.h. fliegt eine Exception, ist die Anwendung in einem unverhersehbaren Zustand. Das sichere Zerstören der Objekte ist aber *garantiert* möglich

    2. Full blown Proxy
    --------------------
    Wie oben nur dass jede Klasse prinzipiell erstmal in drei Klassen zerlegt wird. Ein reines Interface, eine Implementationsklasse (Applikationsschicht) und eine DBProxy-Klasse die eine Implementationsklasse aggregiert.

    Vorteil: Hier lässt sich die Anwendungslogik wirklich isoliert betrachten und isoliert stark Exceptionsicher machen. Ich denke mit einer entsprechenden DB ließen sich auch relativ leicht DB-Transaktionen realisieren.

    Nachteil: Sehr komplex. Allein schon weil Proxy-Lösungen in der Regel immer vom klassichen Interface-Impl-Impl+Delegation Ideal abweichen.

    3. Reduziere OOP - Der Manager-Ansatz:
    --------------------------------------
    Die App-Klassen sind konkret. Sie implementieren alle Aktionen ganz so als gäbe es keine Persistenzhaltung. Die relevanten Aktionen sind natürlich aus Sicht der Anwendung stark Exceptionsicher.
    Die App-Klassen enthalten aber nur *simple* set und getter. Es gibt keine Methode, bei deren Ablauf mehrere Objekte geändert werden.

    Zusätzlich gibt es ein paar *abstrakte* ManagerKlassen. Wie z.B.
    TransferManager,
    FinanceManager usw.

    Diese Klassen enthalten die komplizierten Methoden (transferPlayer, transferMoney). Sie werden von der DB-Schicht implementiert. Sie verwenden die primitiven Methoden der App-Klassen.
    Etwa so:

    void TransferManager::transferPlayer(Team from, Team to, Player thePlayer, Money price)
    {
    	Transaction guard(from, to, thePlayer);	
    	to.decreaseAssets(price);
    	from.increaseAssets(price);
    	from.removePlayer(thePlayer);
    	thePlayer.setTeamId(to.getId());
    	to.addPlayer(thePlayer);
    
    	// Hier schlägt der Ansatz natürlich wieder MySQL-mäßig fehl
    	DB::save(from);
    	DB::save(to);
    	DB::save(thePlayer);
    	guard.commit();
    }
    

    Im Falle einer Exception macht der guard-Dtor ein Rollback.
    Die Anwendung muss also alle relevanten Aktionen über die Manager-Objekte durchführen.

    Vorteil: Die Lösung scheint mir relativ einfach.
    Nachteil: Die Lösung gefällt mir nicht, da sie die App-Klassen zu totalen Dummbeutel-Klassen degradiert (nur setter+getter).
    Ich würde aber ehrlich gesagt lieber sowas schreiben:
    Player p = ...
    Team from = ...
    Team to = ...
    from.transferPlayer(p, to, price);

    4. App-Hierarchie + Command-Hierachie:
    --------------------------------------
    Mein derzeitiger Favorit:
    Die App-Klassen sind konkret und enthalten wirklich die volle Anwendungslogik. Von transferPlayer über transferMoney bis setTactic.

    Dazu kommt eine Command-Hierarchie. Diese repräsentiert die DB-Schicht.
    Die Command-Hierarchie enthält u.A. Klassen wie UpdatePlayer, UpdateTeam, NewResult ...

    Außerdem gibt es noch eine Klasse die vielleicht Spieltag heißen könnte. Diese Klasse ist letztlich ein Command-Container. Sie bietet zwei wichtige Methoden:
    void Spieltag::add(Command c)
    {
    if (!hasCommand(c.getId())
    commands_.push_back(c);
    }
    void Spieltag::finish()
    {
    for_each c in commands
    {
    c.execute();
    }
    }

    Der Ablauf ist jetzt wie folgt. Während der Runde werden ganz normal die App-Klassen verwendet.
    Der Transfermarkt sammelt lustig alle Angebote.

    Zum Rundenende geht es dann los:
    Unter Verwendung der App-Klasse werden die Ergebnisse berechnet.
    Für jedes Ergebnis wird ein NewResult-Command in den Spieltag-Container geadded. Außerdem für jedes Team ein UpdateTeam-Command.
    Danach kommen vielleicht die Ereignisse. Wiederum für jedes Ereignis ein Command-Objekt in den Spieltag-Container pushen.
    Zuschauerzahlen und Einahmen werden berechnet.
    Dann kommt der Transfermarkt.
    Alle Transfers werden auf den App-Klassen durchgeführt. Für jeden transferierten Spieler kommen Update-Player-Command in den Container.

    Jedes Command-Objekt erhält eine Referenz auf die betroffene Entität. Als Optimierung werden doppelte Command rausgefiltert.

    Wenn alle Berechnung für eine Runde beendet sind, wird Spieltag.finish() aufgerufen. Und damit alle Änderungen in der DB gespeichert.

    Mit einer "richtigen" Datenbank könnte man das in einer Transaktion erledigen.

    Das schöne an dem Ansatz ist, dass man Spieltage als geschlossene Einheit betrachtet. Geht irgendwann zwischen Spieltagbeginn und Spieltagende irgendwas schief, wird alles auf den Stand des letzten erfolgreichen Spieltags zurückgesetzt.

    Ein Nachteil ist natürlich, dass die Generierung der Commands irgendwie, ähh, künstlich ist. Ich sage nicht mehr

    void Transfer::execute()
    {
        result = from.transferPlayer(to, thePlayer, price);
        // Benachrichtigung der beteiligten Teams abhängig von Result
    }
    

    sondern muss daraus jetzt ein:

    void Transfer::execute()
    {
    result = from.transferPlayer(to, thePlayer, price);
    // Benachrichtigung der beteiligten Teams abhängig von Result
    spieltag.add(updateTeam(from));
    spieltag.add(updateTeam(to));
    spieltag.add(updatePlayer(thePlayer));
    
    }
    

    machen.

    Als 5. Idee hätte ich noch eine Stairway-to-heaven-Lösung, die sich aber nicht so fürchterlich von der 4. unterscheidet.
    Hier müssten bestimmte zentrale Elemente der App-Schicht die App-Objekte entsprechend Downcasten und das Ergebnis dann auch wieder irgendwo speichern:

    void Transfer::execute()
    {
        result = from.transferPlayer(to, thePlayer, price);
        // Benachrichtigung der beteiligten Teams abhängig von Result
       spieltag.add(static_cast<PersistentTeam&>(from));
       spieltag.add(static_cast<PersistentTeam&>(to));
       spieltag.add(static_cast<PersistentPlayer&>(thePlayer));
    }
    

    Spieltag::finish würde dann für alle Objekte deren save-Methode aufrufen.

    So. Soviel von mir.

    Was meint ihr dazu? Habt ihr bessere bzw. einfachere Lösungen?
    Welche meiner Lösungen gefällt euch am Besten?



  • Also mir gefällt Lösung 4. am besten. Ich denke, dass du so einen enormen konsistenz Vorteil hast, gegenüber der anderen Lösungen und das System ist nicht zu komplex.
    Außerdem kannst du so eine leichtere "Zurück" Funktion im Spiel einbauen.



  • Bei MYSQL muss man immer aufpassen da SELECT vor INSERT/UPDATE geht.
    Macht ein anderer USER viele SELECTS dann werden deine INSERT/UPDATES in MYSQL zwischengespeichert und erst ausgeführt wenn Zeit ist. Hier gibt es in der Config einen Wert zum einstellen wieviele INSERT/UPDATES zwischengespeichert werden sollen.

    Sollte der Speicher voll sein wird beim EXECUTE gewartet bis TIMEOUT.

    Ich habe solche Dinge immer so erledigt, dass ich alle INSERT/UPDATES in eine File geschrieben und von einem eigenständigen Process in die DB eintragen ließ.

    Wenn dieser Process auf freie Rechenzeit für INSERT/UPDATE warten muss ist das nicht so schlimm.


Anmelden zum Antworten