CSC 4509 – Algorithmique et communications des applications réparties

Portail informatique

Étude de l'application de tchat proposée comme ossature de départ

  • Avant de compléter l'application répartie avec des algorithmes, présenter l'ossature de départ.
  • Profiter de cette présentation pour approfondir des concepts et des idiomes JAVA.
cette page est le manuel de référence de l'ossature de départ de l'application de tchat multiclient multiserveur. Elle est donc très longue. Nous vous proposons de la parcourir une première fois en en comprenant chaque élément et d'y revenir autant que nécessaire ensuite. Nous estimons à environ 3 heures le premier parcours de ce manuel, la plus grande partie étant supposée faite en hors présentiel (peut-être de manière collective et avec la préparation de questions pour le présentiel qui suit).

la page a été rédigée en supposant par défaut une lecture linéaire. Mais, selon vos affinités, vous pouvez lire une ou plusieurs sections dans un autre ordre, par exemple si, après avoir tester l'application (première section), vous préférez commencer par l'architecture répartie pour avoir une vue d'ensemble du code (troisième section).

Test de l'application de tchat

Dans des consoles

Nous fournissons une réalisation fonctionnellement complète de l'application de tchat multiclient et multiserveur.

Pour la tester, nous vous proposons d'utiliser l'architecture qui suit. Notez que les serveurs sont multiclients et que les cycles sont autorisés dans le graphe formé par les serveurs.

          Client0  Client1
           ||     //
           ||    //
           ||   //
          Serveur1 ======= Serveur2
               \\          //
                \\        //
                 \\      //
                  \\    //
                  Serveur3 ======= Serveur4
                     ||             ||   \\
                     ||             ||    \\
                     ||             ||     \\
                  Client2          Client3  Client4

Auparavant, compilez le projet avec Maven (mvn clean install -Dmaven.test.skip=true -DskipTests). Nous vous proposons d'utiliser neuf consoles et d'y lancer les commandes suivantes :

  • quatre serveurs :
    1. ./serveur.sh 1
    2. ./serveur.sh 2 localhost 1
    3. ./serveur.sh 3 localhost 1 localhost 2
    4. ./serveur.sh 4 localhost 3
  • cinq clients :
    1. ./client.sh localhost 1 # => serveur 1
    2. ./client.sh localhost 1 # => serveur 1
    3. ./client.sh localhost 3 # => serveur 3
    4. ./client.sh localhost 4 # => serveur 4
    5. ./client.sh localhost 4 # => serveur 4

Une fois les serveurs et les clients démarrés, vous pouvez entrer des messages dans les consoles des clients et les voir s'afficher dans les consoles des autres clients. Pour terminer un client, entrez la commande « quit » dans la console du client. Faites de même pour terminer un serveur.

Dans un scénario de test d'intégration écrit avec JUnit

Vérifiez que vous avez terminé les exécutions des serveurs et des clients de l'étape précédente avant d'exécuter le test dans Eclipse. Sinon, vous aurez un problème de réutilisation de ports TCP.

$ ps aux | grep java xxxx 58786 2.7 0.5 3735888 42604 pts/1 Sl 18:59 0:00 java xxxx chat.server.Main 1 xxxx 58805 0.0 0.0 6172 828 pts/1 S+ 18:59 0:00 grep java ... $ kill -9 58786 $ ./serveur.sh : ligne 21 : 58786 Processus arrêté $CMD

Comme il est quelque peu fastidieux de procéder avec un nombre important de consoles, nous proposons le concept de « scénario » avec la classe chat.common.Scenario et l'utilisons pour écrire des tests de validation : par exemple, la classe chat.startingframework.TestScenarioStartingFramework réalise le scénario précédent en quelques lignes.

Exécutez le test d'intégration de la classe chat.startingframework.TestScenarioStartingFramework : sélectionnez la classe dans l'explorateur de paquetages, puis utilisez le menu contextuel Run as > JUnit Test. Vous obtenez la même exécution que celle obtenue précédemment, mais sans affichage dans la console de Eclipse. Pour voir des affichages, essayez différents niveaux de journalisation : par exemple Log.setLevel(Log.TEST, Level.INFO) et Log.setLevel(Log.CHAT, Level.DEBUG).

dans Eclipse, ne demandez pas l'exécution de tous les tests unitaires d'un paquetage car cela revient à demander à Eclipse d'exécuter dans une même machine virtuelle et en parallèle tous les scénarios de toutes les classes de test JUnit du paquetage. Vous tombez alors dans le problème de réutilisation des ports TCP.

Pour exécuter le même test dans la console, utilisez la commande qui suit :

$ mvn test -Dtest=chat.startingframework.TestScenarioStartingFramework

Par défaut, les tests JUnit sont exécutés avec la commande mvn install, comme cela est fait dans l'intégration continue avec GitLab CI (cf. fichier .gitlab-ci.yml).

Typiquement, un scénario comme celui de la classe de tests TestScenarioStartingFramework est structuré comme suit :

  1. diverses initialisations : par exemple, la configuration de la journalisation,
  2. démarrage/instanciation des serveurs de tchat, avec des temporisations pour leur laisser le temps de terminer leur configuration,
  3. démarrage/instanciation des clients de tchat avec connexion à leur serveur,
  4. émulation des frappes au clavier de commandes pour les clients et/ou les serveurs,
  5. attente de la fin du scénario pour laisser le temps de terminer les exécutions des algorithmes,
  6. tests divers sur les états des clients et/ou des serveurs.

Les classes de tests étendent la classe Scenario. Les méthodes de classes Scenario::instanciateAServer et Scenario::instanciateAClient retournent une référence vers le serveur ou le client créé (s0, s1, etc., et c0, c1, etc.). Ces références sont utilisées ensuite pour vérifier la correction de l'exécution avec les méthodes de la classe org.junit.jupiter.api.Assertions.

Pour le test d'applications asynchrones, nous utilisons le canevas logiciel Awaitality. Pour lire les appels Awaitility.await().until(...), veuillez vous reporter à la documentation en ligne Usage. Par ailleurs, une configuration par défaut du canevas logiciel est proposée dans la méthode de classe setUp annotée @BeforeAll du test JUnit.

Pour permettre la construction de scénarios pour les tests, nous faisons la distinction entre les classes possédant les mains (chat.server.Main et chat.client.Main) et les classes contenant la logique des processus de l'application répartie (chat.server.Server et chat.client.Client). C'est un idiome JAVA communément utilisé. En voici une description rapide :

  • la méthode Main::main instancie par exemple le serveur. La console et le clavier sont attachés au thread principal de la méthode Main::main. Par exemple, pour lire les messages de la console, un objet BufferedReader est créé pour ensuite appeler la méthode BufferedReader::readLine ;
  • ensuite, comme le serveur doit utiliser un thread pour la lecture de messages en provenance du réseau, la méthode Main::main appelle des méthodes de la classe Server pour gérer ce thread. Par exemple, la méthode Server::startThreadReadMessagesFromNetwork démarre le thread.

Présentation de l'utilisation de concepts et d'idiomes JAVA dans la conception du distributeur de messages du client

Indications de lecture, et motivations et objectifs

Dans cette étape, nous expliquons pourquoi nous avons besoin de ces concepts et idiomes JAVA, et nous montrons où et comment nous les utilisons. Pour cela, nous quittons la vue globale avec des serveurs et des clients en cours d'exécution pour détailler des morceaux de code. Cette étape est la plus coûteuse en temps de lecture. Lors de votre première lecture, nous vous proposons de vous concentrer sur la compréhension des aspects JAVA, notamment en parcourant les liens sur la documentation Javadoc qui sont proposés ; passez ensuite à l'étape qui suit et qui présente l'architecture répartie avec des diagrammes de classes et de séquence UML ; enfin, peut-être sera-t-il intéressant de reparcourir cette étape avant le démarrage de la programmation.

Les extraits de code insérés dans cette page le sont sans les commentaires ; donc, lisez le code source des classes. Par ailleurs, vous pouvez aussi générer le site Web Maven du projet avec la commande « mvn site » pour parcourir la documentation Javadoc (par exemple avec firefox target/site/fr/apidocs/index.html).

L'application de tchat est conçue et mise en œuvre en utilisant l'orientation événement (cf. section 1.2 à la page 14 du cours) : aussi bien le client que le serveur attendent la réception d'un message ou l'écriture de texte écrit dans la console, et réagissent à ces événements, les réactions pouvant inclure des émissions de messages. Afin de faciliter l'insertion d'algorithmes répartis dans l'architecture initiale, nous avons ajouté un mécanisme de distribution des messages : « quel est le type du message reçu ? quelle est la méthode qui traite la réception de ce type de message ? ».

Un casse-tête habituel de la mise en œuvre d'application répartie est l'initialisation des structures de données pour les algorithmes : en l'occurrence, la définition des listes des types de messages des algorithmes avec le « branchement » des actions correspondantes. Nous souhaitons éviter que le programmeur oublie tout ou partie de cette phase d'initialisation lors de la construction des clients et des serveurs, et aussi faciliter la mise en place de ces branchements. Nous pourrions faire le choix, comme cela a été fait dans l'étape 3.a du devoir maison, de structurer les données correspondantes dans des types énumérés (algorithmes et actions, qui correspondent aux différents types de messages utilisés dans les algorithmes), et de définir les actions comme des lambda expressions. Dans l'application de tchat, nous choisissons de créer et d'utiliser des annotations. Cette approche est celle utilisée dans les intergiciels (en anglais, middleware) pour la programmation des applications réparties telles que les applications REST (p.ex. avec Jersey ou Sprint Boot) ou les applications EJB (p.ex. avec Glassfish ou JBoss).

Une seconde exigence est de mettre en œuvre un mécanisme d'interception permettant (1) de retarder la réception d'un message lorsque certaines conditions sont satisfaites, et (2) de ré-introduire son traitement dans le flot d'exécution lorsque d'autres conditions sont satisfaites. Cela est nécessaire pour programmer les tests des algorithmes répartis : par exemple, dans l'algorithme d'élection par vague Écho de Segall, nous souhaitons retarder les messages d'une vague pour laisser le temps de démarrer une seconde vague et ainsi obtenir la concurrence entre deux vagues. Il faut créer les conditions d'exécution pour tester cette situation ; le mécanisme d'interception permet de créer et contrôler une exécution particulière.

Annotation et réflexivité

Nous avons deux types d'entités dans l'application de tchat : les serveurs et les clients. Chacune des entités est identifiée de manière unique par un entier, comme indiqué dans l'interface chat.common.Entity

Chaque entité est composée au minimum de deux threads ; le premier est attaché à la console pour lire et afficher les commandes et les messages ; le second gère les connexions réseau. Pour la lecture des messages en provenance du réseau, nous devons mettre en œuvre un mécanisme d'aiguillage : « étant donné l'entier contenu dans l'entête du message pour le typer, quelle méthode de l'entité doit-elle être appelée ? ». Les types de messages sont définis dans le type énuméré ActionNumber, pour numéro de l'action (ou numéro de type de message). Dans la classe abstraite DistributedEntity, le paramètre de type de l'entité est délimité par le type Entity : pour l'écriture de la définition DistributedEntity<A extends Entity>, cf. la page de tutoriel ici. Le mécanisme d'aiguillage des messages est mis en œuvre dans la classe DistributedEntity : (1) les collections qui permettent, étant donné un numéro d'action ou de type de message, de trouver la méthode à exécuter et la classe du message à traiter, (2) comme montré ci-après, la réflexité JAVA qui permet de construire ces deux collections dans le constructeur, et (3) comme montré ci-dessous, la méthode execute qui appelle la bonne méthode et le bon argument déclarés dans les collections.

Les classes des entités chat.server.Server et chat.client.Client étendent la classe DistributedEntity.

L'annotation est un mécanisme permettant d'ajouter des méta-données aux éléments de la structure interne d'un programme. En JAVA, des méta-données peuvent être ajoutées aux déclarations de classes, d'interfaces, d'attributs, de méthodes, d'arguments d'une méthode, etc. Parcourez rapidement, c'est-à-dire pour information seulement, les pages du tutoriel pointé ici.

Dans les classes Server et Client, les méthodes des actions à appeler lors de la réception de tel ou tel type de message sont annotées avec l'annotation @DistributedMessage, qui précise le numéro du type de message et la classe du message reçu. C'est une nouvelle annotation créée pour l'application de tchat :

@Target({ ElementType.ANNOTATION_TYPE, ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) public @interface DistributedMessage { ActionNumber actionNumber(); Class extends AbstractMessage> contentClass(); }
Avec l'annotation JAVA @Target, nous déclarons que c'est une annotation de méthode. Avec l'annotation JAVA @Retention, nous déclarons que les méta-données sont conservées lors de la compilation et du chargement des classes, ceci afin d'être accessibles et utilisées pendant l'exécution. L'annotation est déclarée comme une interface pour déclarer deux méthodes, qui correspondent aux deux attributs de l'annotation : le numéro de l'action et la classe du message.

Voici un exemple d'utilisation de l'annotation DistributedMessage pour la méthode du Server qui traite les messages de découverte et de construction de la topologie du réseau logique des serveurs (serveurs voisins, voisins des voisins, etc.) :

@DistributedMessage(actionNumber = ActionNumber.TOPOLOGY_IDENTITY_MESSAGE, contentClass = MessageIdentity.class) public synchronized void receiveMessageIdentity(final MessageIdentity content) { ... }
Dans cet exemple :

  • la collection de l'attribut actionMethods du serveur (classe enfant de DistributedEntity) contient une entrée ayant pour clef l'énumérateur TOPOLOGY_IDENTITY_MESSAGE et pour valeur la référence sur la méthode Server::receiveMessageIdentity ;
  • la collection de l'attribut actionMessageClasses contient une entrée ayant pour clef l'énumérateur TOPOLOGY_IDENTITY_MESSAGE et pour valeur la référence sur la classe de message MessageIdentity.

 

La réflexité est la capacité d'un programme, ici écrit en JAVA, à examiner ses propres structures internes (classes, méthodes, arguments, etc., annotations) lors de son exécution. Parcourez rapidement, c'est-à-dire pour information seulement, les pages du tutoriel pointé ici.

Le constructeur de la classe abstraite DistributedEntity parcourt les méthodes de la classe (pour rappel, les classes Server et Client étendent la classe DistributedEntity) pour trouver celles qui sont annotées avec l'annotation DistributedMessage : instruction method.getAnnotation(DistributedMessage.class). Les attributs de ces annotations sont utilisées pour construire les deux dictionnaires actionMethods et actionMessageClasses. Ce code est complexe et nous ne demandons pas que vous soyez capables d'écrire un tel code à la fin du module. Remarquez aussi le type de ces deux dictionnaires et parcourez l'introduction de la documentation de la classe EnumMap, et plus particulièrement, le commentaire : « All basic operations execute in constant time. »

Lors de la réception d'un message, c'est-à-dire dans les classes ReadMessagesFromNetwork du serveur (resp. du client), c'est la méthode DistributedEntity::execute qui est appelée. Pour un message de type num, num étant un entier de l'ensemble values() du type énuméré ActionNumber, la référence sur la méthode du serveur (resp. du client) à appeler est obtenue avec l'appel actionMethods.get(ActionNumber.values()[num]) et la référence sur la classe du message reçu est obtenue avec l'appel actionMessageClasses.get(ActionNumber.values()[num]). La réflexivité JAVA permet, avec la référence d'une méthode meth, d'appeler la méthode avec l'instruction meth.invoke(obj, varargs) : le premier paramètre est la référence de l'objet sur lequel la méthode doit être appelée, les arguments qui suivent sont les objets paramètres de la méthode meth (définie dans le code de l'entité : un serveur ou un client). Par ailleurs, la réflexivité JAVA permet, avec la référence msgClass de la classe du message reçu, de « transtyper » vers le bas l'objet du message content (ayant pour classe parente la classe AbstractMessage) avec l'instruction mgsClass.cast(content). Avant l'appel du transtypage vers le bas, remarquez la vérification du bon type avec le test « msgClass.isInstance(content) ».

Patron de conception Intercepteur et méthodes « par défaut » (default method) des interfaces

Afin de tester les algorithmes répartis dans des conditions variées, nous introduisons un mécanisme d'interception lors de la réception des messages.

Comme vous avez pu le voir dans la méthode DistributedEntity::execute, avant d'appeler la méthode annotée pour traiter le message reçu (instruction « meth.invoke(this, msgClass.cast(msg.get())) »), le message reçu est intercepté : instruction « Interceptors.intercept(this, msg) ». Voici la définition de la méthode de classe Interceptors::intercept.

public static Optional intercept(final Entity entity, final Optional msg) { Objects.requireNonNull(entity, "argument entity cannot be null"); List<Interceptor<? extends AbstractMessage>> listOfInterceptors = COLLECTION_OF_INTERCEPTORS.getOrDefault(entity, Collections.emptyList()); for (Interceptor<? extends AbstractMessage> interceptor : listOfInterceptors) { if (msg.isPresent() && !interceptor.doIntercept(msg).isPresent()) { return Optional.empty(); } } return msg; }

Tout d'abord, notez que nous utilisons la classe Optional pour indiquer que le paramètre msg peut être null. La raison est la suivante : avant d'exécuter la méthode intercept « sur » le message reçu, et si le mécanisme d'interception est activé (cf. Interceptors::isInterceptionEnabled), le transtypage vers le bas avec la méthode cast renvoie null en cas de problème.

La méthode Interceptors::intercept parcourt les collections des intercepteurs préalablement enregistrés (cf. les explications ci-après) pour appliquer la méthode Interceptor::doIntercept. Dans l'un des intercepteurs, le message peut disparaître, c'est-à-dire être retiré pour un traitement différé : par exemple « return Optional.empty() » dans la méthode Interceptor::doIntercept. Cela explique pourquoi le type de retour de Interceptors::intercept est un conteneur Optional.

Donc, lors de l'interception, la méthode de classe Interceptors::intercept parcourt la liste des intercepteurs enregistrés pour appeler la méthode Interceptor::doIntercept sur chacun d'eux. Nous décrivons maintenant ce qu'est un intercepteur (dans notre application de tchat) en utilisant un exemple extrait de la classe de test JUnit TestScenarioStartingFrameworkWithInterceptorsOnClientSide. Voici le code, et les explications suivent.

package chat; public class TestScenarioStartingFrameworkWithInterceptorsOnClientSide extends Scenario { @Test @Override public void constructAndRun() throws Exception { ... Client c1 = instanciateAClient(s1.identity()); ... Interceptors.setInterceptionEnabled(true); // À NE PAS OUBLIER ! Predicate<MessageChat> conditionForInterceptingI1OnC1 = msg -> msg.getSender() == c0.identity(); Predicate<MessageChat> conditionForExecutingI1OnC1 = msg -> true; Consumer<MessageChat> treatmentI1OnC1 = msg -> c1.receiveChatMsgContent(new MessageChat(msg.getSender(), msg.getVectorClock(), msg.getSequenceNumber(), msg.getContent() + ", intercepted at client c1 by i1")); Interceptors.addAnInterceptor("i1", c1, conditionForInterceptingI1OnC1, conditionForExecutingI1OnC1, treatmentI1OnC1); } }
Avant d'exécuter la classe de test, vérifiez le niveau de log de l'instruction Log.setLevel(Log.CHAT, Level.INFO) pour voir les messages de journalisation de l'algorithme de tchat. Une exécution de cette classe de test donnera l'affichage suivant :
Client 1 (client 1 of server 0) receives message 0 from c0 (Client 0), intercepted at client c1 by i1

Un intercepteur, de type chat.common.Interceptor, est défini par les trois composantes suivantes :

  1. le prédicat de l'attribut Interceptor::conditionForIntercepting : qui est appelé lors de la réception d'un message et qui intercepte le message si, appliqué sur le message, il retourne true. C'est un objet de type Predicate<C extends AbstractMessage> prenant en argument le message reçu et retournant un booléen. Dans l'exemple, un message est intercepté s'il provient du client 0 : « msg.getSender() == c0.identity() ». Pratiquement, le message intercepté est géré par un thread (classe TreatDelayedMessageToAClient), qui ré-essaie périodiquement (TreatDelayedMessageToAClient.DELAY = 100 ms) de consommer le message (par la méthode Interceptor::doTreatDelayedMessage) ;
  2. le prédicat de l'attribut Interceptor::conditionForExecuting : qui est appelé par la méthode Interceptor::doTreatDelayedMessage et qui teste le contenu du message qui a été intercepté pour savoir si le message peut être consommé (maintenant). Dans l'exemple, à la fin de la première temporisation de 100ms, le message est consommé inconditionnellement car « conditionForExecutingI1OnC1 = msg -> true » 
  3. la fonction de l'attribut Interceptor::treatmentOfADelayedMsg : qui est appelée par la méthode Interceptor::doTreatDelayedMessage et qui consomme le message intercepté si la fonction conditionForExecuting a retourné true. Dans l'exemple, le message, qui est une chaîne de caractères (c'est un message de tchat), est complété avec la chaîne de caractères « ", intercepted at client c1 by i1" » avant d'être passé au distributeur de messages. Notez, dans cet exemple, que c'est une interception sur un client (ici, c1) d'un message de type MessageChat, et que la méthode du client a appelé pour traiter le message est Client::receiveChatMsgContent. Autrement dit, c'est le programmeur de l'intercepteur qui fait l'aiguillage du message ;-) .

Dans la construction des tests des algorithmes que vous mettez en œuvre dans cette infrastructure, les intercepteurs permettant de tester les scénarios intéressants sont à écrire.

Idiome/Patron d'implémentation JAVA pour la terminaison de Thread et de processus

Dans le client, et de la même façon dans le serveur, lorsque l'utilisateur entre à la console la chaîne de caractères « quit », tous les threads terminent leur exécution et le processus est alors terminé (il n'y a plus de threads). Après la phase de démarrage, les deux threads du client, et de la même façon ceux du serveur, exécutent une boucle (potentiellement infinie) : le premier thread en lecture des lignes de caractères saisies à la console, et le second thread en lecture des messages dans l'appel select sur les sockets.

À la lecture de la chaîne de caractères « quit », c'est une mauvaise pratique que de forcer la terminaison de (ou de suspendre) l'autre thread avec la méthode Thread::stop() (respectivement, Thread::suspend()) : ces deux méthodes sont d'ailleurs annotées deprecated. Une autre mauvaise pratique, encore plus radicale, est de forcer la terminaison du processus avec un appel à System::exit() : imaginez par exemple qu'une écriture sur disque ou sur réseau soit en cours.

Une manière classique d'organiser de tels threads est de remplacer les boucles « while (true) {...} » par des boucles « while (condition d'arrêt) {...} », et dans notre cas, par des boucles « while (!Thread.interrupted()) {...} ». Par ailleurs, à la lecture de la chaîne de caractères « quit », le thread de lecture au clavier interrompt le thread (signale une interruption au thread) qui lit les messages en provenance du réseau : instruction threadToRcvMsgs.interrupt(). Il s'envoie aussi le même « signal » à lui-même : instruction Thread.currentThread().interrupt().

Par exemple, pour le serveur :

  • le thread qui lit les messages sur le réseau, classe chat.server.ReadMessagesFromNetwork :
    package chat.server; public class ReadMessagesFromNetwork implements Runnable { ... @Override public void run() { ... while (!Thread.interrupted()) { try { selector.select(); } catch (IOException e) { COMM.fatal(e.getLocalizedMessage()); return; } ... } } }
  • et le thread main qui les lignes de caractères saisies à la console, classe chat.server.Main :
    package chat.server; public final class Main { ... public static void main(final String[] args) throws IOException { while (!Thread.interrupted()) { String consoleMsg = null; consoleMsg = bufin.readLine(); if (consoleMsg == null) { break; } GEN.debug("{}", () -> Log.computeServerLogMessage(server, ", new command line for server: " + consoleMsg)); server.treatConsoleInput(consoleMsg); } } }
  • avec la méthode chat.server.Server::treatConsoleInput qui analyse la chaîne de caractères et envoie un signal aux deux threads :
    package chat.server; public final class Server { ... public void treatConsoleInput(final String line) { Objects.requireNonNull(line, "argument line cannot be null"); GEN.debug("{}", () -> Log.computeServerLogMessage(this, ", new command line on console")); if (line.equals("quit")) { threadToRcvMsgs.interrupt(); // do not interrupt the main thread during the execution of a Scenario because // all the clients and all the servers are controlled by the same "main" thread if (!Scenario.isJUnitScenario()) { Thread.currentThread().interrupt(); } } assert invariant(); }

Enfin, les threads pouvant être interrompus, il faut en tenir compte dans le traitement des exceptions IOException lors des lectures de messages. Par exemple, dans la méthode FullDuplexMsgWorker::readMessage, lors du traitement des exceptions de type IOException, si le thread a été interrompu, alors le canal est considéré comme fermé :

package chat.common; public class FullDuplexMsgWorker { public ReadMessageStatus readMessage() { ... if (readState == ReadMessageStatus.READHEADERSTARTED) { if (inBuffers[0].position() < inBuffers[0].capacity()) { try { recvSize = rwChan.read(inBuffers[0]); } catch (IOException e) { if (Thread.interrupted()) { return ReadMessageStatus.CHANNELCLOSED; } ... } } ... } ... if (readState == ReadMessageStatus.READDATASTARTED) { if (inBuffers[1].position() < inBuffers[1].capacity()) { try { recvSize = rwChan.read(inBuffers[1]); ... } catch (IOException e) { if (Thread.interrupted()) { return ReadMessageStatus.CHANNELCLOSED; } ... } ... } return readState; } }

Architecture répartie et diagrammes de séquence de l'application de tchat

L'étape précédente à étudier des parties spécifiques de l'application pour présenter quelques concepts, patrons de conception, et idiomes qui sont insérés dans le code. Dans cette section, nous revenons à l'architecture répartie avec des serveurs et des clients en action. Pour ces explications, nous utilisons entre autres la modélisation avec UML.

Architecture de l'exemple d'exécution

L'architecture que vous avez utilisée au début de cette page pour les tests peut être visualisée de la manière suivante. Dans la figure qui suit, les cercles sont des processus, les restangles dans les cercles sont les espaces mémoire partagés par les threads du processus, et autour des rectangles, ce sont les threads. La figure montre que chaque client et chaque serveurs possède deux threads, le premier pour lire les commandes au clavier et afficher les messages à la console, et le second pour communiquer avec les autres entités.

Diagrammes de classes des parties client et serveur

Voici le diagramme de classes de la « partie » client :

Voici le diagramme de classes de la « partie » serveur :

Diagramme de séquence de l'émission d'un message de tchat — partie de la séquence depuis la saisie à la console par l'utilisateur jusqu'à l'émission du message vers le serveur auquel le client est connecté

dans les diagrammes de séquence, nous sommes surtout intéressés par la séquence des appels. Donc, nous ne visualisons pas les barres d'activation des objets. En outre, nous sommes essentiellement intéressés par l'utilisation des classes que nous avons écrites. C'est pourquoi nous ignorons les classes qui ne sont pas dans le code de l'ossature (SelectionKey, Optional, etc.). Par ailleurs, nous sommes principalement intéressés par les noms des méthodes exécutées. Par conséquent, nous n'indiquons que les principaux arguments et mettons par convention la chaîne de caractères « ... » pour remplacer les arguments qui nous intéressent moins. Enfin, nous sommes avant tout intéressés par les appels de méthodes. Ainsi, nous ne modélisons pas les retours d'appel.

dans l'ensemble des diagrammes de séquence, nous considérons que le mécanisme d'interception n'est pas activé, ou qu'aucun intercepteur client ou serveur n'est enregistré.

Voici le diagramme de séquence de l'émission d'un message de tchat — partie de la séquence depuis la saisie à la console par l'utilisateur jusqu'à l'émission du message vers le serveur auquel le client est connecté :

Diagramme de séquence de la réception d'un message de tchat par le serveur — partie de la séquence depuis la réception du message jusqu'à la transmission vers les serveurs voisins et les clients locaux

Tout message, c'est-à-dire tout objet dont la classe a pour classe parente AbstractMessage, contient l'idendité de l'émetteur ainsi qu'un chemin des serveurs que le message a visités. Lorsque le message provient d'un client, le chemin est par définition de longueur -1. Lorsque le chemin provient d'un serveur voisin, le chemin est par construction de longueur ≥ 1, avec > 1 lorsque le message est passé par plusieurs serveurs.

Par ailleurs, un message de tchat, c'est-à-dire de type MessageChat, contient un numéro de séquence.

Voici le diagramme de séquence de la réception d'un message de tchat par le serveur — partie de la réception du message jusqu'à la transmission vers les serveurs voisins et les clients locaux :

Diagramme de séquence de la réception d'un message de tchat par le client — partie de la séquence depuis la réception du message jusqu'à l'affichage dans la console

nous simplifions ce diagramme de séquence en ne modélisant pas les fragments opt des filtrages.

Voici le diagramme de séquence de la réception d'un message de tchat par le client — partie de la séquence depuis la réception du message jusqu'à l'affichage dans la console :

Programmation réseau dans l'application de tchat

Pour terminer cette étude de l'ossature de départ, nous parcourons les parties de code concernant la programmation réseau avec JAVA NIO.

Connexions JAVA NIO

Toutes les communications entre les processus clients ou serveurs sont réalisées avec JAVA NIO dans des objets de la classe FullDuplexMsgWorker, que vous avez étudiée dans les précédents TP pour gérer un lien bidirectionnel TCP.

Du côté du client, la classe chat.client.ReadMessagesFromNetwork étend la classe FullDuplexMsgWorker pour la gestion des communications du client avec le serveur auquel il est attaché. Le client Client utilise une délégation vers la classe ReadMessagesFromNetwork via l'attribut Client::runnableToRcvMsgs pour les communications (émission et réception de messages).

Du côté du serveur, la classe Server utilise aussi une délégation pour la réception des messages : via la classe chat.server.ReadMessagesFromNetwork avec l'appel à Selector::select dans la méthode ReadMessagesFromNetwork::run.

Le serveur rassemble les informations sur les clients (locaux) et les serveurs (voisins ou non) dans l'attribut Server::reachableEntities de type Map<Integer, RoutingInformation>, avec la classe RoutingInformation qui contient l'attribut worker de type FullDuplexMsgWorker :

  • pour les clients locaux, le SelectionKey correspond au worker vers le client local, et la longueur du chemin est égale à -1 ;
  • pour les serveurs voisins, le SelectionKey correspond au worker vers le serveur voisin, et la longueur du chemin est égale à 1 ;
  • pour les serveurs qui ne sont pas des voisins, le worker est le worker du serveur voisin qui permet de faire le premier saut dans le chemin le plus court vers le serveur destination, et la longueur du chemin est strictement supérieure à 1.

Structure des messages et gestion des cycles dans la topologie des serveurs

L'approche choisie pour gérer les cycles dans la topologie des serveurs est d'ajouter à tout message la séquence des serveurs déjà « visités ». Ainsi, l'attribut AbstractMessage::path est une liste d'entiers, c'est-à-dire une liste d'identités de serveurs. Dans la méthode Server::sendToAllNeighbouringServersExceptOne, un message à « diffuser aux serveurs voisins » (excepté un) n'est pas transmis vers les serveurs dont l'identité est déjà dans la valeur de path : condition !msg.getPath().contains(e.getKey()) dans le filtre du Stream. Rappelons que les serveurs voisins sont ceux qui vérifient la condition e.getValue().getLengthOfThePath() == 1 du même filtre.

Lorsque les serveurs sont créés, un algorithme réparti est exécuté qui construit sur chaque serveur la connaissance complète de la topologie du réseau. Cette connaissance est mémorisée dans la structure de données Server::reachableEntities de type Map<Integer, RoutingInformation>. La clef du dictionnaire est une identité (serveur ou client), et la valeur est un objet de type RoutingInformation, qui décrit la route vers cette entité.

Primitives de communication disponibles dans le serveur

La classe Server contient les méthodes d'envoi de message suivantes :

  • sendToAServer : elle permet d'envoyer un message à un serveur donné qui n'a pas déjà « vu passer » le message. La condition « qui n'a pas déjà "vu passer" le message » exprime le traitement des cycles dans le réseau des serveurs : il faut éviter les boucles infinies. Lorsque le destinataire n'est pas un serveur voisin, l'attribut RoutingInformation::lengthOfThePath possède une valeur supérieure à 1. La méthode utilise les informations de routage pour identifier le voisin du prochain saut : l'identité de ce serveur est contenu dans l'attribut RoutingInformation::identityNeighbouringServer ;
  • sendToAllNeighbouringServersExceptOne : comme déjà rencontré dans les explications ci-avant, cette méthode transmet le message en argument à tous les serveurs voisins qui n'ont pas déjà « vu passer » le message. Les serveurs voisins sont ceux dont l'attribut RoutingInformation::lengthOfThePath est égal à 1. Par ailleurs, le premier argument exceptId sert à exclure un voisin pour exprimer par exemple « envoyer le message à tous les voisins, excepté celui duquel le message a été reçu (en plus de ceux qui ont déjà "vu passer" le message) » ;
  • sendToAllLocalClientsExceptOne : elle permet d'envoyer un message à tous les clients locaux qui n'ont pas déjà « vu passer » le message. Les clients locaux sont ceux dont l'attribut RoutingInformation::lengthOfThePath est égal à -1. L'argument exceptId sert à exclure un client local pour exprimer par exemple « envoyer le message à tous les clients locaux, excepté celui duquel le message a été reçu ».

Calcul des identités des processus

L'identité des serveurs est fournie en argument du main ou dans le constructeur de la classe Server.

L'identité des clients est construite par les serveurs auxquels ils se connectent. Dans la méthode Server::acceptNewClient, dans l'expression « identity * OFFSET_ID_CLIENT + numberOfClientsSinceBeginning », l'attribut identity est l'identité du serveur d'accès auquel le client est connecté, la constante OFFSET_ID_CLIENT vaut 100, et l'attribut numberOfClientsSinceBeginning sert à compter les ouvertures de connexion des clients. Par exemple, pour le serveur d'identité 1, les premier et deuxième clients auront comme identité les valeurs 1 * 100 + 0 = 100 et 1 * 100 + 1 = 101, respectivement; et pour le serveur d'identité 2, les premier et deuxième clients auront comme identité les valeurs 2 * 100 + 0 = 200 et 2 * 100 + 1 = 201, respectivement. Vous pouvez retrouver ces valeurs dans les affichages des messages de tchat lors de l'exécution de la classe de test TestScenarioStartingFramework.

À titre d'exemple, dans la classe de test TestScenarioStartingFramework, l'instruction Log.setLevel(Log.CHAT, Level.INFO) permet de voir le journal de l'affichage des émissions et des réceptions avec les identités des clients et des serveurs :

0 [main] INFO chat - Client 100 (client 0 of server 1) sending chat message: message 0 from c0 4 [main] INFO chat - Client 101 (client 1 of server 1) sending chat message: message 1 from c1 4 [main] INFO chat - Client 300 (client 0 of server 3) sending chat message: message 2 from c2 10 [main] INFO chat - Client 400 (client 0 of server 4) sending chat message: message 3 from c3 10 [main] INFO chat - Client 401 (client 1 of server 4) sending chat message: message 4 from c4 ...62 [Thread-12] INFO chat - Client 600 (client 0 of server 6) receives message 5 from c5 62 [Thread-12] INFO chat - Client 600 (client 0 of server 6) receives message 3 from c3 63 [Thread-12] INFO chat - Client 600 (client 0 of server 6) receives message 1 from c1 63 [Thread-12] INFO chat - Client 600 (client 0 of server 6) receives message 2 from c2 65 [Thread-10] INFO chat - Client 401 (client 1 of server 4) receives message 6 from c6 70 [Thread-9] INFO chat - Client 400 (client 0 of server 4) receives message 6 from c6

Algorithme de découverte de la topologie

Dans les scénarios de test que nous avons écrits et dans ceux que vous écrirez, la phase de création et de connexion des serveurs est suivie d'une petite attente. Cette petite attente est programmée en utilisant le canevas logiciel Awaitility. Par exemple, dans la classe de test TestScenarioStartingFramework :

Awaitility.await().until(() -> s1.isStarted() && s1.isThreadToRcvMsgsAlive() && s2.isStarted() && s3.isStarted() && s4.isStarted() && s5.isStarted() && s6.isStarted());
Ainsi, l'attente se termine dès que les six serveurs sont démarrés, et est au plus de 5 secondes (cf. la configuration dans la méthode de classe setUp()).

Algorithmiquement, pendant cette attente, un algorithme réparti de découverte de la topologie est exécuté de telle façon que chacun des serveurs connaîsse le chemin le plus court vers chacun des autres serveurs du système. Concrètement, à la fin du constructeur Server::Server, le serveur émet un message IDENTITY_MESSAGE vers tous ses voisins ; une nouvelle instance de l'algorithme de découverte de la topologie débute. Lorsqu'un serveur s reçoit un message en provenance d'un serveur voisin, quelque soit le type du message, s analyse le chemin de ce message dans la méthode Server::parsePathOfMsgToUpdateRoutingInformation. Lorsque l'identité d'un nouveau serveur r est découverte, c'est-à-dire qu'aucune information de routage n'est disponible pour émettre vers r, ou lorsqu'un chemin plus court est découvert vers un serveur r déjà connu, alors le serveur s émet un message IDENTITY_MESSAGE vers r, donc en utilisant un nouveau chemin vers r.

Le traitement de la réception d'un message IDENTITY_MESSAGE est contenu dans la méthode receiveMessageIdentity. Notez que la méthode parsePathOfMsgToUpdateRoutingInformation est exécutée avant l'entrée dans la méthode receiveMessageIdentity. Par conséquent, la table de routage est déjà mise à jour lors de l'entrée dans la méthode receiveMessageIdentity.

Enfin, la méthode receiveMessageIdentity consiste à annoncer une nouvelle information de routage (nouveau serveur ou nouveau chemin) aux serveurs voisins.

Nous laissons à votre sagacité l'écriture en pseudo-code de l'algorithme ainsi que ses propriétés et sa preuve.