RoomServer
RoomServer è la classe
che implementa una stanza di gioco.
Analizziamone le variabili, per iniziare
a valutare le scelte implementative.
Innanzitutto, per come è strutturato
il sistema NetGame, il RoomServer sarà ‘alle
dipendenze’ di un MainServer , il cui riferimento
sarà mantenuto nella variabile mainServer .
Inoltre dovrà essere in contatto con
tutti i RoomServer analoghi della rete; queste
informazioni sono mantenute nell’ Hashtable remoteServers ,
che non è altro che la tabella dei server remoti, in cui ogni
entry è costituita dalla coppia: Nome del MainServer
(Address) – RoomServerAgent .
Infine ad ogni RoomServer
è associato un gioco. La classe RoomServer rappresenta
un Server di stanza generico, e può essere utilizzato per
ospitare qualsiasi tipo di gioco compatibile con NetGame.
Le informazioni sul gioco ospitato sono mantenute nella variabile
IGameServer game . Grazie ad esso è possibile
reperire informazioni sul gioco, come per esempio il numero
massimo e minimo dei giocatori ammessi (siccome IGameServer
implementa l’interfaccia IGame ), inoltre è possibile
creare nuove istanze di server di gioco per ospitare le partite
in esecuzione localmente.
Queste variabili, in particolare remoteServers
e lUsers , danno un’idea di come avvengano le
comunicazioni tra i RoomServer e con i RoomClient
:
Compito primario del RoomServer
è quello di gestire gli utenti presenti nella stanza nonché
le partite.
Ogni RoomServer ha necessità
di conoscere tutti i nomi degli utenti (per esempio per impedire
sul nascere il tentativo di ingresso di un utente con un nick
già in uso o per comunicare agli utenti i nick presenti nella
stanza senza dover richiederli ogni volta ai server remoti).
Ogni Roomserver comunque comunicherà direttamente
solo con gli utenti locali, mentre si rivolgerà agli altri
RoomServer per comunicare in modo indiretto con
gli utenti remoti. Questo si riflette nelle strutture con
cui si mantengono informazioni sugli utenti presenti, infatti
per i remoti basterà un Vector contenente i nick
(che sono stringhe) : Vector rUsers ; mentre per
quelli locali bisognerà per ogni utente avere anche un riferimento
al corrispondente ClientAgent , attraverso il
quale lo si può contattare : Hashtable lUsers
(nick – ClientAgent ).
Per la gestione delle partite il RoomServer
disporrà di più strutture:
Hashtable matches |
E’ la tabella che contiene l’elenco delle partite in
definizione. NomePartita-Lista (Vector ) dei
nick dei giocatori |
Hashtable startedMatches |
E’ la tabella che contiene l’elenco delle partite iniziate.
NomePartita-Lista dei nick dei giocatori |
Hashtable hostedMatches |
E’ la tabella che contiene l’elenco delle partite iniziate
ospitate da questo host. NomePartita-IGameServer
corrispondente |
Hashtable remoteHostedMatches |
E’ la tabella che contiene l’elenco delle partite iniziate
ospitate da un altro host. Nome MainServer -Lista
dei nomi delle partite |
Oltre a queste sono necessarie altre strutture per implementare
il protocollo 2PC. Esse sono :
Hashtable connectingServer |
MainServer e IRoomServerToServer
del server che si sta connettendo |
Hashtable creatingMatches |
Tabella delle partite in creazione. (matchName
- nick del creatore) |
Hashtable enteringMatch |
Tabella degli utenti entranti in una partita. (matchName
- [Players]) |
Hashtable exitingMatch |
Tabella degli utenti uscenti da una partita. (matchName
- [Players]) |
Vector startingMatches |
Tabella delle partite in partenza. |
Organizzazione ad eventi
Sono poi presenti due variabili di tipo
EventDispatcher per comunicare con i client locali
(clientEventDispatcher ) e con i server remoti
(serverEventDispatcher ).
Tali oggetti sono della classe EventDispatcher
ed operano rispettivamente su lUsers e remoteServers
(sono così inizializzati nel costruttore di RoomServer ).
L’utilità di tali oggetti come si è detto sta sostanzialmente
nel disaccoppiare l’attività normale del RoomServer
rispetto alle chiamate remote, che altrimenti lo rallenterebbero
di molto. Facendo uso di queste classi, ed utilizzando la
gerarchia di eventi opportunamente introdotta (cfr. package
netgame .shared .events ),
la comunicazione con i clients avviene semplicemente utilizzando
la seguente istruzione :
clientEventDispatcher.addEvent(/* Evento */);
mentre per comunicare con i servers basta usare :
serverEventDispatcher.addEvent(/* Evento */);
In realtà ciò che accade dietro le quinte
è che il thread interno ai dispatcher, opportunamente sincronizzato,
passa l’evento agli EventHandler intermediari
(ClientAgent e RoomServerAgent )
ed essi a loro volta invocheranno, in base al tipo di evento,
il corrispondente metodo remoto rispettivamente su RoomClient
e RoomServer . Tutto questo accade concorrentemente,
eliminando così almeno in parte le attese altrimenti necessarie
per i completamenti in sequenza delle chiamate remote.
Una soluzione di questo tipo pone però
un problema per quanto riguarda la realizzazione del protocollo
2PC. Per la fase di prepare esso prevede che il coordinatore
conosca il risultato di tale fase su tutti i partecipanti.
In sostanza si vuole che le chiamate remote di prepare siano
sì non bloccanti per il RoomServer , ma anche
sincrone. Per questo si predispone, prima di ogni invocazione
di un metodo prepare, un oggetto della classe Synchronizer
opportunamente configurato con il numero dei partecipanti
da contattare. Tutte le classi che implementano un evento
di prepare del resto accettano, come argomento nel costruttore,
proprio un oggetto Synchronizer .
Quindi tipicamente la fase di prepare si presenta in questo
modo
Synchronizer sync = new Synchronizer(remoteServers.size());
serverEventDispatcher.addEvent(new PrepareXXXEvent(var1, var2, ..., varN, sync));
sync.waitForFinish();
boolean res = sync.getResult();
L’oggetto sync raccoglie
in questo modo i risultati delle prepare sui server remoti.
L’istruzione sync.waitForFinish() ritorna solo
quando
O il sync riceve una risposta negativa
O il sync riceve tutte le risposte
positive
Nel primo caso res assumerà
valore false, mentre nel secondo sarà true. In dipendenza
di tale valore verrà poi posta in atto o la fase di abort
o quella di commit.
Sincronizzazione
interna
Un altro dettaglio implementativo riguardo
ai metodi del 2PC sta nell’acquisizione dei lock delle risorse
coinvolte. Le risorse sono state ordinate nel seguente modo
:
connectingServer, matches, startedMatches,
enteringMatch, exitingMatch, startingMatches, creatingMatches,
hostedMatches, lUsers, rUsers
Occorre richiedere i lock su tali risorse
in questo ordine (naturalmente solo quelle di cui si ha bisogno
in quel caso), senza poterne richiedere uno dopo averne rilasciato
un altro (protocollo 2 phase lock).
La richiesta del lock avviene semplicemente mediante
synchronized (risorsa) {
//Codice in cui si usa la risorsa
}
Tali metodi saranno quindi strutturati
come una serie di blocchi synchronized innestati.
Si è cercato di ritardare il più possibile l’acquisizione
di un lock, cioè richiedendolo nel punto più prossimo possibile
all’uso della risorsa, osservando comunque i vincoli sopra
presentati.
Questo perché potrebbe in realtà non
essercene bisogno, per esempio se un controllo sulle risorse
già acquisite ha rilevato l’impossibilità di continuare l’elaborazione.
Inoltre in alcuni punti è stato possibile
evitare di richiedere i lock delle risorse avendo premura
di realizzare le operazioni in un ordine corretto. Per esempio
in commitEnterMatch() bisogna spostare un utente
dalla lista degli utenti in ingresso nella partita (enteringMatch )
in quella degli utenti iscritti nella partita (matches ).
Se questo spostamento avviene prima togliendo e poi mettendo,
non si hanno problemi di consistenza né di errore, perché
comunque nel caso di ingresso di un nuovo utente si controllerà
per prima la struttura matches.
if (enteringMatch.containsKey(matchName) &&
((Vector)enteringMatch.get(matchName)).contains(nick)) {
((Vector)matches.get(matchName)).add(nick);
((Vector)enteringMatch.get(matchName)).remove(nick);
if (((Vector)enteringMatch.get(matchName)).size()==0)
enteringMatch.remove(matchName);
}
A parte questi dettagli, l’implementazione
dei metodi per il protocollo Two-Phase-Commit, ricalca la
struttura già presentata nel progetto.
Comunicazione
con altre classi
RoomServer implementa l’interfaccia
IRoomServerToServer per la comunicazione con
gli altri RoomServer .
Oltre a questi presenta tutti i metodi
necessari per interfacciarsi da una parte con il MainServer
e dall’altra con i clienti.
Dopo aver creato una stanza di gioco
(opportunamente configurata con l’oggetto IGameServer
corrispondente) il MainServer comunica al RoomServer
i server remoti con cui deve connettersi, cioè i server di
stanza che ospitano lo stesso gioco nella rete a cui appartiene.
Questa operazione è svolta dal metodo
public void connectToRemoteServers(Hashtable remoteS)
La Hashtable passata come
parametro contiene i bindings (indirizzo del MainServer
remoto – riferimento al RoomServer ) su tale nodo
(quindi IRoomServerToServer ).
In realtà al nostro RoomServer
basta conoscere un’unica istanza di RoomServer
remoto. Si rivolgerà infatti ad essa mediante il metodo addServer()
dell’interfaccia IRoomServerToServer , e il RoomServer
contattato si occuperà di mettere in atto il protocollo 2PC
per l’ingresso di un nuovo server, aggiornando tutti gli altri.
Se tutto sarà andato a buon fine, al
ritorno addServer() restituirà lo stato attuale
condiviso della stanza. Il RoomServer dovrà limitarsi
ad aggiornare correttamente il proprio, a creare i RoomServerAgent
relativi ad ogni server remoto e da quel momento in poi potrà
accettare nuove richieste dai client.
Nel caso invece l’ingresso nella rete
non abbia successo, sarà lanciata un’eccezione di tipo FailedConnectionException .
Per quanto riguarda invece i metodi
invocabili dai client, essi non sono altro che quelli dell’interfaccia
IClientAgent implementati da ClientAgent .
Rispetto ad essi però presentano in più il parametro nick
di tipo String , perché a livello RoomServer
bisogna specificare l’utente che effettua l’operazione.
Tali metodi sono dunque i seguenti :
public void joinGameRoom(String nick, ClientAgent
ctsg)
public void leaveGameRoom(String nick)
public void sendMsg(String nick, String testo)
public void sendPrivateMsg(String nick, String dest,
String text)
public void createMatch(String nick, String matchName)
(*)
public void enterMatch(String nick, String matchName)
(*)
public void exitMatch(String nick, String matchName)
(*)
public void startMatch(String nick, String matchName)
(*)
il loro significato è evidente, comunque
per maggiori dettagli si rimanda alla documentazione
generata con javadoc e direttamente al codice.
Alcuni di essi, quelli indicati con
(*), necessitano del protocollo 2PC per realizzare correttamente
il loro lavoro. La loro struttura è stata presentata nella
fase di progetto.
Inizio di una partita
Vediamo ora le operazioni che compie un RoomServer
quando deve avviare una partita. Innanzitutto ricordiamo che
una volta iniziata una partita viene ospitata unicamente sul
RoomServer del giocatore che l’ha iniziata, ovvero
del primo giocatore iscritto alla partita.
Tale RoomServer riceverà
quindi dal primo giocatore la richiesta di iniziare la partita
(tramite invocazione del metodo startMatch() ).
Dopo averne verificato la possibilità (es controllando i vincoli
sul numero di giocatori, ed essersi coordinato con gli altri
server per garantire l’atomicità dell’operazione, le operazioni
che deve compiere sono le seguenti:
- Reperire tutti i client di gioco (
IGameClient )
degli utenti iscritti alla partita
- Istanziare un nuovo server di gioco passandogli i riferimenti
ai clienti
- Avviare la partita sul nuovo server
Per l’esecuzione del punto 1 il RoomServer
dovrà operare diversamente a seconda che il cliente sia locale
o remoto. Nel primo caso infatti potrà richiedere direttamente
il riferimento remoto al GameClient ad esso,
mentre nel secondo caso dovrà passare per il suo RoomServer .
Siccome l’informazione su quale server si trova un client
è mantenuta a livello di MainServer , il RoomServer
dovrà prima di tutto chiedere ad esso per sapere a quale RoomServer
remoto riferirsi.
La creazione di un nuovo server avviene
grazie al metodo newInstance() di IGameServer .
Una volta creato, il nuovo server viene posto nella tabella
hostedMatches .
A questo punto il server di gioco può
essere avviato. Come già si è detto nel progetto, l’attivazione
viene gestita da un thread separato (implementato nella classe
GameStarter ) per evitare che errate implementazioni
del server di gioco blocchino definitivamente il RoomServer .
Le istruzioni che realizzano tutto ciò sono riportate di
seguito
Enumeration enumUsers=pls.elements();
IGameClient igc;
Hashtable gcls = new Hashtable();
String cn;
while (enumUsers.hasMoreElements()) {
cn = (String)enumUsers.nextElement();
if (lUsers.containsKey(cn)) { //se l'utente è locale
igc = ((ClientAgent)lUsers.get(cn)).getGameClient();
}
else { //se l'utente è remoto
String msURL = mainServer.getMainServerForUser(cn);
RoomServerAgent stsg = (RoomServerAgent)remoteServers.get(msURL);
igc = stsg.getGameClient(cn);
}
gcls.put(cn, igc);
}
IGameServer newGame = game.newInstance();
hostedMatches.put(matchName, newGame);
new GameStarter(matchName, newGame, gcls).start();
Sugli altri RoomServer
l’inizio di una partita vedrà la rimozione della partita dalle
tabelle matches e l’inserimento in startedMatches
e remoteHostedMatches .
La fine di una partita viene comunicata
al RoomServer dal GameServer mediante
l’invocazione del metodo finishMatch() .
|