TP Noté 2023 : Messagerie pour échange de fichiers

1. Contexte et consignes

1.1. Introduction

Dans ce projet, nous réalisons un service qui permet (1) de déposer sur une messagerie des fichiers destinés à un utilisateur, et (2) de récupérer les fichiers que d'autres utilisateurs ont été déposés pour un utilisateur.

Pour réaliser ce service, nous disposons d'un serveur gérant un spool stockant tous les fichiers en attente de téléchargement et d'un client pour communiquer avec ce serveur.

Le client utilise deux requêtes pour échanger avec le serveur :

Les fichiers sont stockés dans des spools, c'est-à-dire des files d'attente qui permettent de conserver les fichiers jusqu'à leur réclamation. Il y a un spool par client (utilisateur) et un spool pour le serveur. Concrètement, il s'agit d'une arborescence de fichiers stockés dans les répertoires data/spool (un sous-répertoire par utilisateur) et date/serverSpool. Ces spools sont structurés pour permettre de stocker des fichiers ayant les mêmes noms sans qu'ils entrent en collision. Tout le code gérant cet aspect du sujet est déjà écrit, et vous n'avez ni à le lire ni à l'étudier. L'énoncé qui suit vous donnera les instructions à écrire toutes les fois qu'il faudra utiliser ces spools.

Voici un petit scénario pour concrétiser le service que nous construisons dans ce sujet :

1.2 Consignes

Vous disposer de 3h pour réaliser ce TP. Vous avez le droit aux documents suivants :

Pour installer le sujet :

En fin de TP :

  1. si ce n'est pas déjà fait, ajoutez les informations à destination des correcteurs dans le fichier readme.txt ;
  2. vérifiez la conformité de vos fichiers aux exigences de SpotBugs ;
  3. supprimez tous les fichiers générés avec un « mvn clean » ;
  4. exécutez les scripts ./cleanAllClientsSpools.sh et ./cleanServerSpool.sh ;
  5. exportez votre projet Eclipse sous la forme d'une archive :
  6. déposez le fichier sur Moodle comme devoir ;
  7. avant de quitter la salle, vérifiez avec l'enseignant présent dans la salle que le fichier téléchargé sur Moodle est correct. L'enseignant charge votre projet dans Eclipse pour faire cette vérification.

2. Présentation du sujet

2.1. Architecture

Le service de messagerie repose sur une architecture client/serveur classique qui communique avec TCP. Cependant, suivant l'avancement du protocole, les données échangées sont de deux natures différentes :

  1. la première phase échange des objets de la classe ProtocolMessage qui servent à indiquer les services demandés et rendus ;
  2. la seconde phase échange de simples octets contenus dans les fichiers qui sont échangés.

Pour gérer ces échanges, nous reprenons une solution classique adoptée par des protocoles comme FTP. Autrement dit, nous mettons en œuvre un service « à la » FTP, mais vous n'avez pas besoin de connaissance particulière sur le protocole FTP. Voici les informations nécessaires pour comprendre l'énoncé :

Le client utilise deux requêtes : PUT et GET. Chacune déroule son propre protocole.

Dès qu'un client se connecte, le serveur lance un nouveau thread pour le gérer et retourne en attente de la connexion du prochain client.

2.2. Le protocole pour la requête PUT

L'envoi d'un fichier à destination d'un autre utilisateur (requête PUT) est effectué en 8 étapes (cf. figure 1) :

  1. le client se connecte sur le serveur en utilisant un FullDuplexMsgWorker ;
  2. le client envoie un ProtocolMessage contenant la requête PUT et les informations sur le fichier à envoyer (destinataire, nom du fichier) ;
  3. le serveur prépare le DTP en créant un nouveau ServerSocketChannel sur un port aléatoire libre ;
  4. le serveur envoie un ProtocolMessage contenant le « nom » de la requête (SEND) ainsi que le numéro du port qu'il a réservé pour le transfert des données ;
  5. le client se connecte au ServerSocketChannel du DTP du serveur ;
  6. le client envoie toutes les données contenu dans le fichier, et le serveur les reçoit et les sauve dans son spool ;
  7. le client ferme la connexion DTP ;
  8. le serveur détecte la déconnexion du DTP du client et sait que le fichier est totalement arrivé. Il ferme le fichier et son DTP.
Figure 1 : Protocole de la requête PUT.
proctocole de la requête PUT

2.3. Le protocole pour la requête GET

La demande des fichiers qui sont destinés à un utilisateur (requête GET) est effectuée en 8 étapes (cf. Figure 2) :

  1. le client se connecte sur le serveur en utilisant un FullDuplexMsgWorker ;
  2. le client envoie un ProtocolMessage contenant la requête GET ;
  3. pour chaque fichier à envoyer, le serveur prépare un DTP en créant un nouveau ServerSocketChannel sur un port aléatoire libre ;
  4. pour chaque fichier à envoyer, le serveur envoie un ProtocolMessage contenant le « nom » de la requête (RECEIVE), le numéro du port qu'il a réservé pour le transfert de données, et les informations sur le fichier (expéditeur, nom du fichier) ;
  5. pour chaque ProtocolMessage contenant la requête RECEIVE, le client se connecte au ServerSocketChannel du DTP correspondant du côté du serveur grâce au port indiqué dans le message ;
  6. sur toutes les connexions établies entre les DTP, le serveur envoie toutes les données contenu dans les fichiers, et le client les reçoit et les sauve dans son spool ;
  7. sur chaque connexion établie, le serveur ferme la connexion DTP quand le fichier est totalement transmis ;
  8. sur chaque connexion établie, le client détecte la déconnexion du DTP du serveur et sait que le fichier est totalement arrivé. Il ferme le fichier et le DTP correspondant.

Côté serveur, chaque DTP est pris en charge par un nouveau thread qui gère la communication en mode synchrone.

Côté client, tous les échanges entre les DTP, mais aussi la réception des ProtocolMessages contenant la requête RECEIVE sont gérés par le thread principal en mode asynchrone avec un Selector.

Notez bien que le client sait qu'il n'a plus aucune requête RECEIVE à recevoir uniquement lorsque le serveur ferme la connexion du PI. Par ailleurs, un DTP récepteur sait que le fichier est totalement reçu uniquement lorsque le DTP émetteur ferme sa connexion.

Figure 2 : Protocole de la requête GET.
proctocole de la requête GET

2.4. Descriptions des classes

Certaines classes sont déjà écrites : soit vous les connaissez déjà, soit le sujet vous donne les instructions pour les utiliser.

Le sujet offre une classe tsp.csc4509.spool.Spool qui prend en charge la création et l'exploration des fichiers. Vous n'avez pas besoin de lire ou étudier ce code. Toutes les fois qu'il faut utiliser une méthode de cette classe, le sujet donne l'instruction à écrire. Sachez juste que les fichiers stockés par le serveur sont dans le répertoire data/serveurSpool et les fichiers reçus par le client « userId » sont dans le répertoire data/spool/userId.

Le sujet offre la classe tsp.csc4509.tcpnio.FullDuplexMsgWorker écrite et utilisée durant le TP. La classe MessType a été enrichie de la constante PROTOCOLMESSAGETYPE qui correspond au type des messages échangés par les PI des clients et des serveurs.

Le sujet offre la classe tsp.csc4509.tpnote.ProtocolMessage et l'énumération tsp.csc4509.tpnote.ProtocolMessageType qui décrivent les messages de protocole à échanger entre les PI des clients et du serveur. Le sujet donne les instructions à écrire lorsqu'il faut les utiliser.

Le sujet offre deux classes d'application :

  1. AppliClient (classe déjà écrite) : cette classe possède une méthode main qui instancie un client (classe Client ci-dessous) ;
  2. AppliServer (classe déjà écrite) : cette classe possède une méthode main qui instancie un serveur (classe Server ci-dessous) ;

D'autres classes sont partiellement écrites et doivent être complétées :

3. Les questions du sujet

3.1. Introduction

Il y a 7 questions :

Le barème est donné à titre d'indication.

Les méthodes présentes dans l'interface SenderReceiver (Question 1), sont utilisées dans le client et dans le serveur. Elles devraient être écrites en premier.

Il est possible d'écrire le client ou le serveur dans l'ordre que vous voulez.

Pour le serveur, la question 4 est utilisée dans les questions 5 et 6, mais ces deux dernières peuvent être faites dans l'ordre que vous voulez. La dernière question (la question 7 de la classe ServerDataTransfertProcessCallable) suppose d'avoir compris l'énoncé des questions 5 et 6 ; mais, vous pouvez répondre à la question 7 sans avoir terminé ces questions 5 et 6.

Des tests unitaires sont fournis pour les questions 1, 2  3 et 4. Suivez les consignes qui vous sont données pour chacun des ses questions pour les utiliser.

3.2. Écriture du SenderReceiver

3.2.1. Introduction

Cette interface fournit des méthodes communes au serveur et au client. Il s'agit des méthodes qui réalisent la réception et l'émission d'un fichier par un SocketChannel en mode synchrone.

3.2.2. Question 1 : écriture de la méthode recvFile()

La méthode recvFile() possède deux paramètres :

  1. FileChannel fcout : la référence sur un canal qui est déjà relié à un fichier ouvert en écriture pour recevoir les données ;
  2. SocketChannel rwChan : la référence sur un canal déjà connecté avec l'émetteur des données à écrire dans le fichier.

Écrivez la méthode recvFile() qui écrit dans le fichier relié à fcout toutes les données qui arrivent de la connexion reliée à rwChan. Pour cette méthode, vous utilisez des ByteBuffer de BUFFSIZE octets.

Les tests :

Une fois votre méthode écrite, vous pouvez la tester. Pour cela, retirez l'annotation @Disable devant la méthode testRecvFile() de la classe de test TestReceiver et exécutez ce test.

3.2.3. Question 2 : écriture de la méthode sendFile()

La méthode sendFile() possède deux paramètres  :

  1. FileChannel fcin : la référence sur un canal déjà relié au fichier ouvert en lecture contenant les données à envoyer ;
  2. SocketChannel rwChan : la référence sur un canal déjà connecté avec le destinataire des données à lire dans le fichier.

Écrivez la méthode sendFile() qui envoie sur la connexion reliée à rwChan toutes les données contenues dans le fichier relié à FileChannel fcin. Pour cette méthode, vous utilisez des ByteBuffer de BUFFSIZE octets.

Les tests :

Une fois votre méthode écrite, vous pouvez la tester. Pour cela, retirez l'annotation @Disable devant la méthode testSendFile() de la classe de test TestSender et exécutez ce test.

3.3. Écriture du Client

3.3.1. Introduction

Dans son constructeur (déjà écrit), la classe Client ouvre un FullDuplexMsgWorker connecté au serveur. Ensuite, elle offre deux services possibles :

3.3.2. Question 3 : écriture de la méthode get() du client

La méthode get() commence juste après l'étape 1 du protocole pour la requête GET (voir la section 2.3 et la figure 2). Elle doit gérer la réception de messages de plusieurs connexions :

Pour gérer tous ces flux sans bloquer, nous utilisons un Selector et donc nous passons toutes les connexions en mode asynchrone.

La structure de cette méthode doit donc ressembler à ce qui suit :

Voici les indications pour la mise en œuvre :

Écrivez la méthode get().

Les tests :

Une fois votre méthode écrite, vous pouvez la tester. Pour cela, retirez les annotations @Disable devant les méthodes testGet1() à testGet5() de la classe de test TestClient.

Attention, ces tests lancent de nombreux threads difficiles à arrêter en cas de bugs dans votre code. De plus, certains de ces tests demandent de ré-initialiser le spool du client. Donc, ne lancez pas ces tests avec votre IDE, les threads pourraient survivre jusqu'à l'arrêt de votre IDE, et le contexte ne serait pas le bon. Il y a 5 scripts présents à la racine du projet pour lancer ces tests un à un : les scripts testGet1.sh à testGet5.sh. Chacun de ces scripts affiche au début et à la fin une phrase avec la raison du test. Utilisez ces scripts pour faire vos tests unitaires.

Une fois ces tests réalisés, replacez les en @Disabled, sinon le mvn install ne fonctionnera plus.

3.4. Écriture du ServerProtocolInterpreterRunnable

3.4.1. Introduction

La classe ServerProtocolInterpreterRunnable contient les instructions du thread démarré par le serveur après chaque accept pour gérer la requête envoyée par ce nouveau client. Ce code prévoit donc la gestion de la requête PUT dans la méthode put() (à écrire) et de la requête GET dans la méthode get() (à écrire). Ainsi, la partie réception de la requête est déjà écrite dans la méthode run() et le traitement des requêtes est expliqué dans les questions qui suivent.

3.4.2. Question 4 : écriture de la méthode createServerSocketChannel() préparant le DTP du serveur

Les requêtes PUT et GET mènent à l'échange de fichiers entre des DTP côté client et des DTP côté serveur. Pour préparer chaque DTP du serveur, il faut lier un ServerSocketChannel pour accepter la connexion du DTP client. Le numéro du port de ce ServerSocketChannel n'a pas d'importance, du moment qu'il est libre. Nous laissons donc le choix de ce numéro au système. Pour cela, il suffit de placer la valeur du port à 0 dans l'objet InetSocketAddress utilisé pour l'appel à bind(). Ensuite (c'est-à-dire lorsque nous en aurons besoin dans la question suivante), il est possible de retrouver l'adresse d'un ServerSocketChannel lié avec la méthode ServerSocketChannel::getLocalAddress().

La méthode createServerSocketChannel() prépare et retourne un ServerSocketChannel lié à un port libre choisi par le système.

Écrivez la méthode createServerSocketChannel().

3.4.3. Question 5 : écriture de la méthode put() du serveur

La méthode put() côté serveur commence juste après l'étape 2 du protocole pour la requête PUT (voir la section 2.2 et la figure 1). Elle doit préparer le DTP du serveur, envoyer la requête SEND, puis créer et démarrer le thread qui met en œuvre le DTP.

La partie qui prépare le DTP est déjà présente dans le code qui vous est fourni. Vous devez compléter cette méthode en envoyant la requête SEND au client, et en créant et démarrant un thread qui fera tourner le ServerDataTransfertProcessCallable contenant le DTP du serveur. Pour la requête SEND, le résultat du call() n'est pas utilisé, et nous ne vous demandons pas de récupérer le résultat par un appel à get().

Écrivez la méthode put().

3.4.4. Question 6 : écriture de la méthode get() du serveur

La méthode get() côté serveur commence juste après l'étape 2 du protocole pour la requête GET (voir la section 2.3 et la figure 2). Elle doit préparer le DTP du serveur, envoyer les requêtes RECEIVE, puis créer et démarrer les threads qui mettent en œuvre le DTP pour chaque fichier à envoyer.

L'instruction déjà présente dans le code (« List pathList = Spool.getFilesListTo(getMessage.getFrom()); ») vous donne la liste de tous les fichiers à envoyer au client. Vous devez itérer sur tous les éléments de cette liste pour :

  1. préparer un ServerSocketChannel lié à un port choisi par le système ;
  2. préparer la requête RECEIVE à envoyer au client ;
  3. envoyer la requête RECEIVE au client ;
  4. créer et démarrer un thread qui fait tourner le ServerDataTransfertProcessCallable contenant le DTP du serveur pour gérer cette requête ;

Voici les instructions pour réaliser les points 1 et 2, en supposant que path est la variable contenant l'élément de la liste sur laquelle vous itérez :

Après avoir créé et démarré tous les threads mettant en œuvre les DTP du serveur, il faut attendre le résultat de chaque call() pour effacer le fichier du spool du serveur après son émission. Si vous mémorisez le résultat de la méthode get() dans une variable nommée path, l'instruction qui détruit le répertoire contenant le fichier et ses informations est « Server.deleteSpoolDir(path); ».

Cette dernière instruction détruit de façon récursive un répertoire. Même si certaines sécurités y ont été placées pour éviter de détruire des fichiers en dehors des spools du serveur, nous vous invitons à mettre cette instruction en commentaire dans votre code pour éviter tout accident sur vos fichiers. N'effacez pas votre tp noté, ou tout autre fichier important ! Si vous voulez nettoyez les spools, trois scripts sont à votre disposition :

Écrivez le code qui complète la méthode get().

3.5. Écriture du ServerDataTransfertProcessCallable

3.5.1. Introduction

Le code de la méthode call() de la classe ServerDataTransfertProcessCallable réalise les instructions du DTP du serveur pour les requêtes PUT et GET. La classe ServerDataTransfertProcessCallable possède trois attributs :

  1. ServerSocketChannel listenChannel : le ServerSocketChannel du DTP sur lequel le client se connecte pour émettre ou recevoir ses fichiers ;
  2. Path path : le chemin du fichier à émettre ou recevoir. Pour ouvrir en lecture le fichier à émettre (cas des requêtes GET), vous devez utiliser l'instruction « FileInputStream fin = Spool.getInputFile(path); ». Pour créer et ouvrir en écriture le fichier à recevoir (cas des requêtes PUT), vous devez utiliser l'instruction « FileOutputStream fout = Spool.getOutputFile(path); » ;
  3. ProtocolMessage message : la requête émise par le client. Pour savoir si cette requête est un ProtocolMessageType.PUT ou un ProtocolMessageType.GET, vous devez utiliser la méthode message.getMessageType().

3.5.2. Question 6 : écriture de la méthode call()

La méthode call() doit accepter la connexion du DTP du client, et ensuite, suivant la requête, elle doit utiliser cette connexion pour soit émettre, soit recevoir le fichier désigné par l'attribut path.

Écrivez le code de la méthode call() en utilisant les méthodes écrites lors des questions 1 et 2.

3.5.3 Test d'intégration

Si vous avez écrit toutes les méthodes, vous pouvez lancer le script ./IntegrationTP.sh qui réalise le scénario présenté en début de sujet.

Notez que si vous avez suivi le sujet, le spool du serveur contient encore les fichiers destinés à Alice, puisque l'instructions qui sert à les effacer doit être en commentaire dans votre code.

4. Conclusion

Ce TP noté est terminé et vous avez écrit le code permettant de gérer une architecture avec un client asynchrone qui échange avec un serveur avec de multiples thread.

Ce programme reste très améliorable :