Dans un soucis d'evolution de TitBet, nous avons décidé d'implémenter dans le serveur le plus de fonctionnalité possible, ce même si le client actuel ne les supporte pas. Seul une modification future du client sera necessaire pour les prendre en compte. De plus, le serveur est totalement independant du client. Ainsi, n'importe qui peut s'il le desire développer son propre client dans le langage qu'il desire et utiliser toutes les possibilités du serveur.
Le serveur implémenté est donc un serveur multi-parties / multi-joueurs, cela signifie que le serveur peu :
Le client, dans sa version actuelle, ne gère qu'une partie par joueur. Il est donc impossbile pour une même personne de figurer dans plusieurs parties à la fois à moins de lancer plusieurs clients.
Pour gérer une multitude de parties et de joueurs, nous avons implémenté notre gestion du morpion par des liste chainée, n'ayant pas de limites fixes dans le nombre maximum de parties et de joueurs (cf Figure Structure des parties). En effet, les limites auxquelles nous avons été confrontées furent celles de la mémoire (pour l'allocation de nouvelle partie ou de joueur) et du nombre de descripteurs ouverts simultanément
Pour la gestion d'une partie, la seule limite se trouvait dans le nombre de joueur y jouant. Ce dernier est limité au nombre d'icones présentent lors de la compilation. Nous aurions pû utiliser un tableau pour gérer la liste de joueurs dans la partie mais il est peu probable que le nombre maximum de joueur soit atteind. Cela serait injouable ! La solution de garder les listes chainées a pour avantage d'économiser de la mémoire.
\begin{figure}[!hbtp] \begin{center} \epsffile{figures/structure.eps} \caption{Structure des parties} \end{center} \end{figure}
L'architecture de la gestion des parties est constituée autour de la structure
Games. Elle est à la base de tout, elle contient la liste des votes en cours
(ListVote*)
pour chaque partie, la liste de toutes les parties (ListGameItem*)
et
le tableau recensant tous les joueurs (PlayerSock)
qu'ils jouent ou non.
Chaque élément de la liste des parties possède une référence vers la
partie elle-même (Game)
et sa liste de joueur (ListPlayerSockItem*)
.
Chaque élément de la liste de joueurs contient l'indice de ce joueur dans le
tableau de joueur défini dans la structure Games, afin de ne pas dupliquer
les informations.
Chaque partie possède une grille représentant le jeu et la liste des joueurs y participant. Ces joueurs sont identifiés par un numéro propre à la partie, c'est ce même numéro qui est stocké dans la grille.
Pour permettre à un joueur de joindre une partie, un système de vote a été mis en place. Le joueur ne sera accepté que si tous les joueurs ont répondu oui. Un seul vote négatif suffit pour le rejeter.
La gestion des déconnexions des clients en cours de partie est un aspect important du jeu. Trois solutions étaient envisageable :
Nous avons utilisé la dernière méthode, à savoir affecter les coups à un joueur virtuel. Ils sont représenté par un icone représentant un cafard.
Pour déterminer si un joueur gagne ou non une partie, nous ne parcourons pas toute la grille, mais seulement les 4 directions possibles à partir du coup du joueur (cf Figure Recherche du gagnant). On comptabilise les coups successifs de ce joueur et dès qu'un des coups scanné ne lui appartient pas, on arrête la recherche sur cette direction. Si le nombre de coups alignés d'une direction est supérieur ou égal à cinq pour le morpion ou quatre pour le puissance 4, le joueur gagne la partie. Le temps de recherche est ainsi fortement optimisé. \begin{figure}[!hbtp] \begin{center} \epsffile{figures/hit.eps} \caption{Recherche du gagnant} \end{center} \end{figure}
La définition de structures et l'implémentation en couches du serveur permet d'ajouter assez facilement et rapidement de nouveaux types de jeu utilisant une grille.
Pour exemple, initialement le serveur ne devait gérer que le jeu de type Morpion. L'ajout du jeu Puissance 4 fut assez simple, il fut nécessaire de définir le type Puissance4 dans la variable globale (partie) de la librairie morpion.
char* partie[] = {"Morpion", "Puissance4"};
Puis, au niveau de la mémorisation du coup (fonction morpion_hit
), il suffit
de le traiter selon le type de partie et d'appeler la fonction qui vérifie son
état (gagne ou non). Il est évident que cette fonction est propre à chaque
type de jeu, dans notre cas le developppement de cette fonction pour le
puissance 4 fut inutile car l'algorithme utilisé est le même que celui du
morpion (on teste un alignement de pièces sur un échiquier).
L'ajout d'un nouveau type de jeu peut donc se faire sans revoir toute l'architecture du serveur.
Le serveur a la possibilité de se détacher du terminal sur lequel il a été lancé. Par défaut, il se comporte comme un serveur, c'est à dire qu'il se détache de tout l'environnement auquel il était attaché.
L'environnement est nettoyé (variables sensibles pour la sécurité retirées,
ensemble des descripteurs fermés, changement du répertoire par défaut à
la racine. La plupart des signaux sont détournés vers une fonction qui
permet de quitter le serveur proprement. A partir de cet instant, le
serveur ne peut plus communiquer que par l'intermédiaire de SYSLOG
.
La fonction principale qui permet de détacher le processus, setsid()
n'est pas disponible sur toutes les machines que nous avons pu rencontrer.
Il nous a donc fallu trouver d'autres méthodes pour le détacher :
#ifdef HAVE_SETSID
if (setsid() == -1){
perror("Pas pu exécuter setsid()");
exit(EXIT_FAILURE);
}
#else
if (setpgrp(0, getpid()) == -1){
perror("Pas pu exécuter setpgrp()");
exit(EXIT_FAILURE);
}
fd = open("/dev/tty", O_RDWR);
/* On lache le terminal */
if (fd != -1){
ioctl(fd, TIOCNOTTY, 0);
close(fd);
}
#endif
Comme on peut le voir dans cet extrait du source, le test de l'existence
de la fonction setsid()
se fait par l'intermédiaire du programme
autoconf
qui a déclaré une constante du nom de HAVE_SETSID
.
Les méthodes standard d'écoute de sockets et d'attente d'événements ne
permettent pas de gérer plusieurs connexions simultanées. Ceci est dû
au fait que la fonction accept()
est bloquante. Le projet doit pouvoir
écouter un certain nombre de clients en même temps, et donc doit
écouter plusieurs sockets.
Nous avions deux solutions (une troisième, extension de la deuxième que nous avons choisie a été envisagée pour une version future, voir le chapitre Futur du projet pour plus de détails) :
fork()
, permettant au processus principal de
continuer à écouter les nouvelles connexions. Le très
gros désavantage de cette méthode, en dehors de sa lenteur due
au fait qu'à chaque changement, tout le contexte est remplacé par
un nouveau, toutes les variables et données sont dupliquées et
une nouvelle version de ces données apparait dans chaque nouveau
processus créé.
La solution à ce problème est de mettre toutes les informations
en mémoire partagée et de communiquer avec le processus père à
travers des IPC.select()
qui permet d'attendre un événement en
écoutant sur un grand nombre de descripteurs en même temps.L'utilisation de la fonction select()
nécéssite l'utilisation d'une
zone de stockage supplémentaire pour garder la trace des descripteurs
ouverts, ainsi que ceux à écouter. Cette zone est en fait un ensemble de
bits, chacun représentant un descripteur actif, s'il est à 1.
C'est la taille de ce tableau de bits qui limite le nombre de personnes
maximum pouvant se connecter en même temps pour un processus donné.
Ce nombre varie en fonction du système d'exploitation utilisé. Pour
connaitre ce nombre, nous avons ajouté l'option -V
qui, lorsque
utilisée avec le serveur, donne le nombre maximum de descripteurs ouvrables.
Voici quelques exemples selon les machines testées :
Le serveur doit pouvoir traiter les messages qui lui arrivent le plus
rapidement possible. Pour cela, la durée d'identification (ou
d'aiguillage) d'un message doit être la plus courte possible.
La fonction d'attente de message est organisée à la manière d'un
objet. Lorsqu'elle reçoit un message, la fonction génère un objet
qui est renvoyé. Cet objet, de type SockEvent
, contient les
informations suivantes :
Ces événements reflètent différents cas d'erreur possible lors de la réception d'un message.
Cas spécial lorsque le serveur a détecté la déconnexion d'un joueur de "manière violente".
Cas typique d'envoi d'un message d'un client étant déjà connecté et enregistré sur le serveur.
Evénement déclenché lorsqu'un nouveau joueur se connecte.
En fonction de son type, l'objet contenant le message est routé sur différentes parties du serveur. L'objet ayant le type de message le plus courant est envoyé à la couche Protocole de communication. Le message, découpé en deux (mot-clef et arguments), est immédiatement transféré à la fonction spécialisée qui doit la traiter grace à la fonction de hachage.
Cette fonction de hachage renvoie un index dans une structure qui associe le mot clef avec une variable de type fonction :
struct commmands {
char *name;
void (*func)(SockEvent *ev, char *args);
};
La fonction spécialisée vérifie les arguments, et effectue les traitements qui doivent être faits pour ce message.
La sécurité est une part très importante dans la réalisation d'un projet. Plus particulièrement lorsque le projet à développer est un serveur de données sur lequel n'importe qui peut se connecter.
Nous avons volontairement limité le nombre de fichiers accédés, et fait très attention à leur utilisation. Le seul fichier externe utilisé par le serveur est celui qui indique le numéro du processus pour pouvoir l'arrêter plus facilement. Ce fichier, stocké dans /tmp est en accès à tout le monde. Une personne pourrait causer des dégats si, entre l'instant où le fichier est créé et l'instant où l'on écrit, ce fichier était remplacé par un lien vers un fichier non accessible normalement. Pour éviter cela, nous avons testé si le type du fichier était un lien ou non. Tout problème est rapporté par SYSLOG.
Nous avons souhaité connaitre la stabilité du serveur. Pour cela, il nous fallait simuler un ensemble d'utilisateurs se connectant au serveur de différente manière, et effectuant différentes opérations. Le programme de test se trouve dans le répertoire tests/, dans lequel un mini-client a été implémenté.
Nous avons lancé un nombre croissant de clients se
connectant/déconnectant en meme temps. Nous nous sommes
arretés à 236 clients (le nombre de déconnexions étant plus
plus grand que le nombre de connexions, nous n'avons pas
dépassé ce chiffre) accédant au serveur simultanément sans
aucun problèmes. Le serveur peut supporter une
charge variable d'utilisateurs (taille de fd_set
varie selon
le système). Pour vous en assurer, il est possible de
lancer le serveur avec l'option -V, qui donnera
le nombre maximal de clients possibles pour un processus.
Chaque déplacement/assignation de données se fait toujours avec
des tailles fixes et prédéfinies. On utilise, par exemple, des
fonctions telles que strncpy
, et non strcpy
. Pour tester les erreurs
et oublis éventuels, nous avons réalisé un mini-programme qui envoie
des données générées au hasard et de taille très variable.
De cette façon, nous avons pu corriger et rajouter des tests de
débordements possibles pour éviter les coredumps, ou écritures dans
des zones du programme non autorisées.
Par la suite, nous avons utilisé Purify ainsi que Electric Fence, deux applications vérifiant qu'il n'y a aucuns débordement dans le programme.