PSE Seance 4-5 : Première partie

De Ensiwiki
Aller à : navigation, rechercher

Mycomputer.png  Deuxième Année  CDROM.png  Informatique 

Dans ce TP, nous nous intéressons aux mécanismes qui permettent à des ordinateurs de communiquer entre eux à travers un réseau. Nous allons réaliser un petit système réparti.

Motivations

Au début de l’informatique, on exploitait simplement des ordinateurs. Maintenant, on construit des systèmes informatiques mettant en jeu de nombreux ordinateurs interconnectés par des réseaux. L’étude des systèmes d’exploitation s’est donc naturellement étendue au cours du temps à l’étude des systèmes dits ”répartis”.

TCP/IP (vu en cours de Réseau) est un protocole incontournable pour la communication à travers le réseau et le concept client/serveur est la méthode pour exploiter ce protocole. La plupart des systèmes répartis, ont donc naturellement une architecture client/serveur. Comme le nom l’indique, on distingue deux rôles dans cette architecture :

Le serveur
Il fonctionne en permanence et attend des demandes de connexion.
Le client
Il prend l’initiative de la communication en demandant au serveur une connexion. Il transmet ensuite généralement une requête au serveur à travers la connexion, à laquelle le serveur peut répondre.


En fait, une fois que la connexion entre serveur et client est ouverte, ils peuvent échanger autant de messages qu'ils le veulent, sans restriction d’ordre ou de quantité. Ils doivent toutefois être conçus pour se comprendre l’un l’autre en respectant un protocole préétabli. Ce protocole est spécifique à l’application et distinct de TCP/IP qui est le protocole réseau permettant aux communications de fonctionner.

Dans le TP, nous allons simplement utiliser TCP/IP et nous concevrons un protocole d’application.

Programmation sockets

Les sockets sont une interface de programmation très répandue permettant la mise en oeuvre de communications sur TCP/IP.

Traitement d’une connexion

  • Démarrer le programme eclipse.
  • Créer un nouveau Projet Java en acceptant les réglages proposés par défaut.
  • Par clic droit sur le répertoire src dans le bandeau Package (à gauche), créer une nouvelle classe (Package : pse, Name : Server, cocher public static void main()).
  • Dans le corps de la méthode main copier le code suivant :
 ServerSocket s = new ServerSocket(20000);
 System.out.println("Serveur en écoute sur le port " + s.getLocalPort());
 Socket conn = s.accept();
 System.out.println("Connexion reçue depuis " + conn.getRemoteSocketAddress());
 InputStream in = conn.getInputStream();
 while (true) {
   int c = in.read();
   if (c == -1)
     break;
   System.out.print(c);
   if ((c >= 32) && (c <= 126))
     System.out.print(" " + (char)c);
   System.out.println();
 }
 conn.close();
 System.out.println("Fin du flux de données");

  • Corriger l'indentation en sélectionnant le code en en faisant Ctrl-i (Menu Source => Correct Indentation).
  • Lire la documentation de ServerSocket en cliquant dessus, puis, successivement, en pressant F2 et en lisant l'onglet Javadoc en bas. Lire ensuite la documentation de accept.
  • Sauver.
  • Accepter les suggestion d’eclipse (les ampoules avec une croix rouge à gauche du code; cliquer dessus pour voir les choix; double-cliquer sur votre choix) quant à "l’import" des classes java.net.ServerSocket, java.net.Socket et java.io.InputStream et ajouter throws Exception sur la méthode main pour que le code compile sans erreur. Lancer le programme par clic droit sur le fichier .java : Run as → Java Application. Observer ce qui se passe.
  • Sur le poste de travail d’à côté, ouvrir un terminal et taper :
 telnet <nom_du_poste_du_prog_java> 20000
  • Observer que le programme serveur a progressé.
  • Taper du texte dans le telnet puis l’envoyer avec Entrée. Vérifier qu'il a été reçu côté serveur. Envoyer une autre ligne. Toujours dans le telnet, saisir la combinaison de touches Ctrl + AltGr + ] (+ éventuellement Entrée), puis quand vous avez le prompt la commande : quit. Vérifier que le programme serveur s’est terminée.
  • Essayer à nouveau la commande telnet maintenant que le serveur est arrêté.
  • Démarrer à nouveau le programme serveur. Réessayer la commande telnet sans faire quit. Arrêter le programme serveur en cliquant sur le raccourci Terminate (juste au dessus de la console d’affichage). Remarquer que la commande telnet s’est immédiatement terminée.
  • Essayer de lancer le programme serveur deux fois de suite. Que se passe-t-il ? Noter que vous pouvez basculer l’affichage entre les deux programmes via le raccourci Display Selected Console. Penser à nettoyer l’affichage des programmes terminés en cliquant sur le raccourci Remove All Terminated Launches.
  • Pour aller plus loin si vous avez le temps, vous pouvez comprendre en détail les sockets et le travail réalisé par le code en lisant les pages info de la librairie C de votre linux/freebsd en tapant info libc sockets dans votre terminal, dans emacs avec Ctrl-h i, ou encore ici [1].

Envoi d'une réponse

Une connexion TCP est un flux de communication bidirectionnel. Un serveur peut recevoir des données provenant d'un client (c'est ce que nous venons d'illustrer), mais il peut aussi en envoyer au client.

  • Modifier le programme serveur pour qu'il compte les caractères reçus et renvoie le total courant au programme client à travers l' OutputStream de sa socket à chaque fois qu'un caractère est reçu. Pour ce faire voir pouvez utiliser la méthode println() de l'objet :

new PrintStream(conn.getOutputStream())

Le même objet doit être réutilisé pour plusieurs écritures successives (faire new une seule fois). Pour faire une analogie avec le premier TP, un PrintStream correspond à la fonction sprintf() et l'OutputStream à la fonction console_putbytes(), sauf que l'écriture ne se fait pas sur le terminal mais à travers la connexion TCP. La méthode println() transforme la valeur passée en paramètre en une suite de caractères (les chiffres qui composent le nombre) terminée par un saut à la ligne.

  • Tester un dialogue avec ce serveur.

Traitement de plusieurs connexions en séquence

La durée de vie d'un serveur n'est pas limitée au traitement d'un seul client.

  • Modifier le programme serveur pour qu'il reboucle sur l'appel de la méthode accept de la socket serveur quand le traitement de la connexion précédente est terminé.
  • Sur le poste d'à côté démarrer deux sessions telnet en même temps dans deux terminaux différents. Envoyer des lignes de textes via ces deux appels de telnet. Observer qu'une seule des deux connexions est traitée. L'autre est bloquée. Maintenant terminer le telnet qui a été traité par le serveur (avec Ctrl + AltGr + ], quit). Observer que c'est maintenant l'autre connexion qui est traitée.

A partir de maintenant, les programmes serveurs que vous écrivez ne se terminent plus d'eux même, sauf en cas d'erreur. Il faut les arrêter explicitement (raccourci Terminate) quand vous voulez tester une modification.

Traitement de plusieurs connexions en parallèle

Pour l'instant, les connexions sont traitées l'une après l'autre. On peut tirer parti du fait que le système d'exploitation gère plusieurs processus pour traiter plusieurs connexions en même temps. L'API select() d'Unix et son équivalent java.nio.channels.Selector sont une autre solution. Enfin, il est possible d'utiliser plusieurs processus légers (Threads). C'est cette solution que nous adopterons par la suite. En Java, la création d'un processus peut être demandée au système en créant un objet de classe java.lang.Thread. Ici, on vous montre comment créer un nouveau processus léger qui exécute une fonction en parallèle du processus qui l'a créé :

 
 // le mot cle final est necessaire pour que la methode run()
 // puisse acceder a ce parametre
 final int threadId = X;
 Thread t = new Thread() {
     public void run() {
         // une fonction separee qui est le "main" du nouveau processus
         corpsDeProcessus(threadId);
     }
 };
 t.start();
 

Ainsi, pendant qu'un processus léger est bloqué, par exemple en attente de données sur un read(), l'autre peut continuer à travailler pour traiter une autre connexion.

  • Adapter votre programme pour qu'il crée NB_THREADS threads (valeur paramétrable, par exemple 5 ou 10) qui bouclent tous sur l'appel d' accept. Tester le nouveau comportement en reproduisant le test de la section précédente. Observer que les deux connexions progressent simultanément. Il n'est pas nécessaire qu'une connexion soit terminée pour que l'autre soit traitée.
  • Modifier le programme serveur pour qu'il crée les threads à la demande. C'est-à-dire qu'à chaque fois qu' accept() retourne une nouvelle socket de connexion, on démarre un nouveau thread qui appelle accept() puis on traite la nouvelle connexion.

Programmation d'un client intelligent

Maintenant vous allez écrire un programme client qui dialogue avec ce serveur.

  • Sur la machine d'à côté, lancer eclipse sur un autre compte utilisateur (Si vous travaillez seul, utilisez un autre workspace eclipse sur la même ou sur l'autre machine). Créer un nouveau programme Java (Package : pse, Name : Client, cocher public static void main()).

Ce programme doit commencer par créer un objet java.net.Socket via le constructeur Socket(String host, int port), en lui donnant comme host le nom de machine où le programme serveur tourne et comme numéro de port celui du programme serveur (20000 dans notre exemple).

Avec la méthode getOutputStream() on obtient un flux sur lequel on peut écrire à travers un java.io.PrintStream pour envoyer des nombres et/ou du texte. C'est le PrintStream qui transforme les nombres en caractères qui représentent le nombre. Par exemple le nombre 25 est envoyé au programme serveur sous la forme du caractère 2 puis du caractère 5.

Après avoir envoyé quelque chose au serveur, lire ce qu'il renvoie en retour. On le fait en récupérant les données venant sur l' InputStream de la socket du client, de la même manière que le serveur lit sur son InputStream.

  • Tester ce programme et remarquer qu'il ne se termine pas.

En effet, ni du côté serveur ni du côté client l'ordre de terminer la connexion n'a été donné. Les deux côtés restent en attente sur l'appel read(). Il est nécessaire que l'un des deux côté donne l'ordre de terminer la connexion en appelant la méthode close() sur la socket, ce qui a pour effet de lancer le protocole de terminaison de la connexion TCP sous-jacente et donc termine l'appel read() de l'autre côté par un retour de -1. On va décider dans notre exemple de le faire côté client après avoir reçu tout le texte attendu en écho de ce qu'on a envoyé.

  • Modifier les programmes client et serveur en conséquence et observer que la connexion se termine correctement.

Sérialisation

Le procédé de la sérialisation consiste à transformer un objet du langage de programmation en une séquence d'octets qui peut être stockée en mémoire, sur un disque, ou transférée à travers un réseau. Par exemple en Java le type int tient sur 32 bits. Nous allons voir que sa sérialisation consiste à écrire les 4 octets de ces 32 bits les uns à la suite des autres.

Types primitifs

  • Modifier le programme serveur pour qu'il ne renvoie plus rien au client. Garder tout de même l'affichage sur la console des données reçues.
  • Modifier le programme client pour qu'il utilise un ObjectOutputStream au lieu d'un PrintStream et appeler sa méthode writeInt(int) avec en paramètre un nombre supérieur à 255 (qui tient donc sur plusieurs octets).

Il faut bien penser à appeler flush() sur l' ObjectOutputStream après écriture des données, car sans ça toutes les données ne seront pas envoyées (à cause d'un buffer entre l' ObjectOutputStream et la socket).

Le programme client ne recevant rien du serveur reste bloqué sur read(). On le terminera par clic sur Terminate.

  • Observer les données reçues par le serveur. Modifier la valeur envoyée. Remplacer l'appel à writeInt() par un appel à writeShort(). Mettre deux appels de writeShort() l'un après l'autre. Est-ce que le serveur a le moyen de faire la différence entre 2 appels de writeShort() ou 1 seul appel de writeInt() ? Séparer les deux appels de writeShort() par un appel de flush(). Voit-on une différence ?

Types objets

  • Observer la différence entre un appel à writeChars(String) et à writeObject(String) avec la même chaîne de caractères en paramètre.
  • Créer une nouvelle classe simple qui ne contient qu'un seul champ (non statique) de type String. Essayer d'envoyer un objet de cette classe au serveur et noter l'erreur reçue.
  • Ajouter implements java.io.Serializable sur la classe que vous avez créée en ignorant l'avertissement à propos du champ manquant serialVersionUID. Voyez maintenant que l'envoi fonctionne et regardez ce que reçoit le serveur. Ajouter maintenant un deuxième champ avec la même chaîne de caractère que pour le premier (même valeur). Que constate-t-on ?

Désérialisation

L'opération inverse, de transformation de la séquence d'octets en objet existe également.

  • Modifier le programme serveur pour que la lecture se fasse à travers un ObjectInputStream :

new ObjectInputStream(conn.getInputStream());

La lecture d'un objet se fait en appelant la méthode readObject(), ce qui suppose que le programme client a uniquement fait un writeObject().

  • Tester d'abord que ça fonctionne en passant comme objet une simple chaîne de caractères. Essayer ensuite avec l'objet simple défini à la section précédente. Expliquer l'erreur obtenue et comment la corriger.
  • Quand le passage de l'objet du client au serveur fonctionne, ajouter un passage d'objet dans l'autre sens (du serveur au client), en retour. Quand le client reçoit l'objet de réponse, il ferme la connexion par appel de close() sur la socket. Nous venons de définir un protocole de communication entre client et serveur.

Construction d'un annuaire interrogeable

Nous allons maintenant, sur cette base, construire un annuaire interrogeable à distance dans lequel on peut ajouter, consulter ou retirer des couples : (nom, téléphone).Vous trouverez dans la partie suivante une implémentation en utilisant RMI (bibliothèque Java pour les applications réparties).

Notre système définit trois opérations :

void ajouter(String nom, String tel); String consulter(String nom); String retirer(String nom);

On peut les numéroter de 1 à 3. La table contenant les informations peut être construite en utilisant le type java.util.Hashtable. Elle doit être contenue dans le progamme serveur. Un programme client peut demander au serveur d'effectuer une des trois opérations en lui envoyant à travers une connexion le numéro de l'opération (par writeInt()), suivi de ses paramètres (par writeObject()), puis en attendant un objet en retour qui est le résultat de l'opération (par readObject()). Dans le cas de la fonction sans valeur de retour (void) il vaut mieux tout de même attendre un retour d'un objet (par exemple "ok") pour être sûr que l'opération a été effectuée.

  • Programmer le serveur et des clients qui demandent au serveur d'effectuer ces opérations, en passant divers paramètres.

Tant que le programme serveur n'est pas arrêté, la table peut évoluer en fonction des requêtes des clients. Un client peut notamment consulter ce qu'un autre aura ajouté avant.

  • Modifier les programmes serveur et client de manière à permettre l'exécution de plusieurs requêtes l'une après l'autre à travers la même connexion.

Sérialisation et cache

Dans cette partie, nous allons manipuler une liste de chaîne de caractère pour comprendre comment fonctionne certains détails de la sérialisation des objets en Java. La sérialisation est utilisée ici dans un cadre réseau, mais elle est également utilisé pour enregistrer l'état d'objets dans des fichiers.

  • Créez un seul objet chaîne modifiable de type StringBuffer à une valeur de votre choix et insérez le 5 fois de suite dans une List<StringBuffer>. Le type List n'est pas un type complet mais juste une interface. Pour vraiment instancier une liste, vous utiliserez par exemple le type ArrayList. Pour le reste des manipulations, le type List devrait être suffisant.
  • Affichez le contenu de la List dans la console,
  • Récupérez le premier élément de la liste (cf. List.get(int)), modifiez sa chaîne et réaffichez le contenu de la liste dans la console.

Comprendre les caches d'objets des ObjectStream

  • Transférez la liste 2 fois entre le Client et Serveur avec les ObjectInput/OutputStream : la première fois en envoyant directement l'objet liste puis la seconde en envoyant la taille de la liste et en faisant une boucle pour envoyer chaque élément séparément. Reconstruisez les deux listes sur le serveur de manière symétrique. Affichez leur contenu.
  • Récupérez le premier élément de chacune des deux listes sur le serveur et modifiez-les avec des valeurs différentes. Affichez le contenu des deux listes.
  • Que peut-on en déduire de la sérialisation des objets en java ?

Utilisation des caches

Nous allons à nouveau transférer la liste 2 fois entre le client et serveur avec les Object-Input/Output-Stream

  • envoyez la liste une première fois depuis le client au serveur
  • sur le client, modifiez la valeur du premier élément de la liste et ajoutez-le une nouvelle fois à la liste (qui a donc 6 éléments)
  • envoyez à nouveau la liste
  • récupérez et affichez les deux listes sur le Serveur.
  • Faîtes de même mais en fermant puis en ré-ouvrant la connexion entre les deux envois de la liste.
  • Récupérez et affichez les deux listes sur le Serveur.
  • Que peut-on en déduire de la sérialisation et des caches des objets en java ?

Objets non serializable

Parfois il est utile ou nécessaire que certains attributs d'un objet ne soient pas transmis pendant une sérialisation. Pour cela il suffit de marquer l'objet comme transient

Pour illustrer ceci, nous allons créer un objet dont le but est de contenir l'IP de la machine courante.

  • Créez une classe contenant un attribut de type InetAddress et un entier.
  • Ajoutez lui une méthode String toString() affichant la valeur de l'InetAddress et de l'entier.
  • Dans le constructeur de l'objet, renseignez l'attribut avec l'IP de la machine courante (indication: le type InetAddress a plusieurs fonctions static. cf sa documentation) et fixez la valeur de l'entier à une valeur arbitraire.
  • Créez un objet de ce type et envoyez-le au serveur. Affichez sa valeur à la réception sur le serveur.
  • Si votre serveur est bien sur une machine distincte du client, affiche-t-il l'IP de la machine serveur ? Pourquoi ? Le constructeur de type est-il appelé lors de la reconstruction (indication: quelle est la valeur de l'entier ?) ?
  • Marquez l'attribut comme transient ? Que se passe-t-il lors de l'affichage de l'InetAddress sur le serveur ?

Il est possible de surcharger les fonctions standards de sérialisation de désérialisation en les implantant dans un objet.

  • implantez les fonctions private void writeObject(ObjectOutputStream out) throws IOException; et private void readObject(ObjectInputStream in) throws IOException, ClassNotFoundException; afin de toujours afficher l'IP de la machine courante. Pour cela vous pouvez utiliser deux méthodes des ObjectStream passés en arguments defaultWriteObject() et defaultReadObject(), qui effectuent le protocole de base.