CSC 4509 – Algorithmique et communications des applications réparties

Portail informatique

Devoir maison: Étape 3

  • transmission d'objets JAVA à travers une connexion TCP ;
  • mise en place d'un service de RPC (Remote Procedure Call, appel de procédures à distance).

Cette étape est réalisable à partir de la séance 3 et devrait être réalisée avant la séance 8.

Prérequis pour la réalisation de cette étape :

Écriture des méthodes d'échange d'objet

Écriture des méthodes TcpSocket::sendObject() et TcpSocket::receiveObject()

L'application envoie les objets à travers la connexion TCP en utilisant la sérialisation. La taille du flux contenant un objet sérialisée n'est pas fixe, et pour pouvoir connaître la taille de la trame transportant cet objet, nous ajoutons un entête contenant cette taille sous la forme d'un entier.

Donc, envoyez un objet consiste à :

  • sérialiser l'objet ;
  • calculer la taille de l'objet sérialisé ;
  • envoyer un entier contenant cette taille ;
  • envoyer l'objet sérialisé.

Et, recevoir un objet consiste à :

  • recevoir un entier qui contient la taille de l'objet sérialisé ;
  • allouer un ByteBuffer faisant exactement cette taille ;
  • recevoir des octets du réseau jusqu'à remplir ce buffer ;
  • extraire les données de ce buffer et les désérialiser.

Complétez les dernières méthodes manquantes de la classe « tsp.csc4509.dm.tcp.TcpSocket », sendObject() et receiveObject(), en respectant cette logique (et en utilisant les méthodes déjà écrites dans cette classe).

Pensez à écrire les tests unitaires de ces deux méthodes.

Écriture des classes de l'application pour échanger des objets

Écrivez une classe d'application cliente qui envoie un objet (de votre choix) à une application serveur. Vérifiez que l'objet construit par le client a bien été reconstruit et est utilisable côté serveur.

Réalisation d'un mini protocole de RPC

Un RPC (Remote Procedure Call) est un appel d'une procédure à distance. Nous utilisons notre projet, qui sait maintenant échanger des objets, pour envoyer les données nécessaires à l'appel d'une procédure sur une autre machine. Cette machine exécute alors la procédure et renvoie au client le résultat dans un objet.

Compréhension du code des classes RPC

Téléchargez l'archive dm3.2.zip et décompressez la en vous plaçant à la racine de votre projet. Les paquetages « tsp.csc4509.dm.rpc », « tsp.csc4509.dm.mandelbrot » et « tsp.csc4509.dm.rpcparam » ainsi que diverses classes ont été ajoutés à votre projet. Exécutez la commande « mvn clean install » pour rétablir la cohérence de votre arborescence. Et, dans Eclipse, sélectionnez « Refresh » dans le menu contextuel de votre projet (ou utilisez la touche « F5 », puis sélectionnez dans le menu « Project > Clean... ».

Commençons par étudier les classes « tsp.csc4509.dm.rpc.RpcRequest », « tsp.csc4509.dm.rpc.RpcReply », et « tsp.csc4509.dm.rpc.RpcParam ». Pour les comprendre, nous suivons leur utilisation lors d'une requête RPC.

Notre projet sait échanger des objets entre un client et un serveur. Pour faire un RPC, nous échangeons deux types d'objets :

  • le client transmet au serveur un objet de la classe RpcRequest qui transporte les données et la méthode à exécuter dans un objet de la classe RpcParam ;
  • le serveur transmet au client un objet de la classe RpcReply qui transporte la réponse dans un objet de la classe du résultat attendu.

L'objet RpcRequest transporte aussi les informations pour que le serveur RPC puisse connaître le contenu exact de l'objet RpcParam pour son utilisation.

L'objet RpcReply transporte aussi les informations pour que le client RPC puisse connaître le type du résultat.

La figure 1 schématise la demande du RPC et la figure 2 schématise la réponse du RPC.

Fig 1 : la requête RPC.
La requête RPC
Fig 2 : la réponse du RPC.
La réponse du RPC

L'objet RpcParam qui est encapsulé dans la requête du RPC dépend de la procédure que l'on veut déporter. Les informations à fournir pour appeler une procédure peuvent être très variables suivant le calcul que l'on veut faire. Il n'est pas possible de prévoir à l'avance une classe qui réponde à tous les besoins. C'est pourquoi « tsp.csc4509.dm.rpc.RpcParam » n'est pas une classe, mais une interface. Cette interface oblige l'implémentation des deux méthodes suivantes :

  • Serializable compute() : c'est la méthode qui contient le code de la procédure à exécuter. Son résultat est un objet serialisable ;
  • Class<? extends Serializable> getResultClass() : cette méthode retourne la classe de l'objet retourné par la méthode compute(). Cela peut être n'importe quelle classe, tant qu'elle est sérialisable.

Il faut écrire cette classe pour chaque type de RPC que l'on veut offrir, et le code de cette classe doit être connu du serveur qui rend ce service.

La classe « tsp.csc4509.dm.rpcparam.AdditionParam » est un exemple de mise en œuvre de cette classe. Elle sert à déporter l'addition de deux entiers. Les données sont mémorisées grâce aux deux attributs « val1 » et « val2 ».

La classe « tsp.csc4509.dm.rpc.RpcRequest » contient un attribut « String id » qui sera expliqué plus tard. Pour l'instant, vous pouvez placer une chaîne de caractères vide dans cet attribut. Elle contient aussi deux constructeurs. Pour l'instant, vous ne devez utiliser que le premier qui initialise « param » avec la référence d'un objet créé.

La classe « tsp.csc4509.dm.rpc.RpcReply » contient un attribut « RpcStatus status » qui permet de savoir si le RPC s'est bien réalisé. Les valeurs possibles pour ce statut sont les objets de l'énumération « tsp.csc4509.dm.rpc.RpcStatus » :

  • RPC_REPLY_OK : l'exécution du RPC s'est bien passé, et le résultat peut être récupéré par la méthode getResult() de l'objet RpcReply ;
  • RPC_REPLY_BAD_REQUEST : le serveur n'a pas compris votre requête : le message reçu n'était pas un objet sérialisé, ou bien c'était un objet d'une classe inconnue pour le serveur ;
  • RPC_REPLY_ERROR : le serveur a compris votre requête et a tenté d'exécuter la méthode indiquée, mais l'exécution a provoqué une exception.

Écriture de la classe RpcClient

Écrivez le premier constructeur de la classe RpcClient. Ce constructeur réalise la partie client des figures 1 et 2 de la question 2.a ci-dessus :

  • connexion TCP au serveur « serverHost:serverPort » ;
  • construction d'un objet RpcRequest en utilisant les paramètres du constructeur ;
  • envoi de cet objet au serveur ;
  • réception de la réponse, qui est un objet de la classe RpcReply ;
  • fermeture de la connexion avec le serveur.

Écrivez la méthode RpcClient::getResult() qui permet d'obtenir la référence pointant sur le résultat du RCP si l'exécution s'est bien passée.

Écriture de la classe RpcServer

Écrivez le constructeur de la classe RpcServer. Ce constructeur réalise la partie serveur des figures 1 et 2 de la question 2.a ci-dessus :

  • création d'un serveur TCP écoutant sur le port passé en paramètre ;
  • pour tous les futurs clients qui vont se connecter :
    • accepter le client ;
    • recevoir un objet de ce client ;
    • vérifier que c'est un objet RpcRequest ;
    • appeler la procédure transportée dans le RpcParam ;
    • créer un objet RpcReply avec le résultat de cette procédure ;
    • envoyer cet objet RpcReply au client ;
    • assurer la fermeture de la connexion avec ce client.

Écriture des classes de l'application

Écrivez les classes « tsp.csc4509.dm.appli.AppliRpcClient » et « tsp.csc4509.dm.appli.AppliRpcServer » de façon à ce que le serveur réalise l'addition de deux Integer grâce à la classe « tsp.csc4509.dm.param.AdditionParam ». À ce stade du sujet, vous pouvez passer la chaîne vide ("") pour le paramètre rpcId du constructeur de la classe RpcRequest.

Ajoutez dans le répertoire scripts, les shell-scripts pour lancer ces applications.

Écriture d'une classe implémentant RpcParam

La classe « tsp.csc4509.dm.mandelbrot.Complexe » contient l'implémentation de quelques fonctions basiques sur les complexes. Écrivez une classe « tsp.csc4509.dm.param.MultiplicationComplexeParam » pour pouvoir réaliser la multiplication complexe en RPC. Surtout, ne réécrivez pas le code de la mutiplication dans votre classe, mais faites un appel à la méthode tsp.csc4509.dm.mandelbrot.Complexe::multiplier(). Notez que pour que ce RPC fonctionne, il faut que la classe tsp.csc4509.dm.mandelbrot.Complexe soit connue du client RPC et du serveur RPC. Comme les deux applications partagent le même projet, cela est fait automatiquement.

Ajoutez à votre application « tsp.csc4509.dm.appli.AppliRpcClient » un appel à ce RPC.

Ajout de RPC sans paramètre

Pour le service de RPC que l'on vient d'écrire, le client envoie le code de la procédure et les données pour son exécution. On aimerait que le serveur de RPC puisse nous fournir quelques services supplémentaires pour lesquels il ne serait pas utile d'envoyer des données au serveur. Voici quelques exemples :

  • quelle la liste des RPC implémentés sur le serveur?
  • quelle est la charge de la machine du serveur?
  • combien de cœurs possèdent la machine du serveur?

Dans tous ces cas, le client n'a pas besoin de fournir la moindre information, mis à part le nom de la demande. L'attribut « tsp.csc4509.dm.rpc.RpcRequest::id » transporte ce nom. Il est utilisé par le serveur pour savoir quelle méthode il doit exécuter. L'identificateur pour le service de l'exercice précédent est « "COMPUTE" ». Vous pouvez donc modifier votre classe « tsp.csc4509.dm.appli.AppliRpcClient » pour remplacer les identificateurs laissés vides par la chaîne de caractères "COMPUTE".

Le serveur doit gérer une liste de services. Nous utilisons une énumération pour gérer cette liste. Le premier brouillon de cette énumération donne :

Il faut maintenant associer la méthode à appeler pour chacun de ces services. Le meilleur endroit pour faire cette association est dans l'énumération elle-même. Dans le constructeur de l'énumération, nous associons à chaque valeur de l'énumération une référence vers une instance d'un objet qui contient la méthode à appeler. Pour cela, nous fournissons un constructeur qui a cette référence en paramètre. Le constructeur de l'énumération ressemblerait à ce qui suit :

Cette solution pose un problème : chaque service possède sa propre procédure et on ne peut pas mettre la même classe pour chaque service. La solution consiste alors à indiquer une interface plutôt qu'une classe. Chaque classe concrète implémente alors l'interface avec sa méthode qui lui est propre. Donc, en imaginant que « MaClasse1 » et « MaClasse2 » sont des classes qui implémentent l'interface « MonInterface », le code de l'énumération devient :

Cette solution fonctionne s'il est possible d'utiliser la même interface pour tous les cas de l'énumération. Mais, nous avons construit notre service avec deux principes différents : (1) certains RPC arrivent avec un paramètre, et (2) d'autres sans. Aussi, pour certains RPC, nous avons besoin d'une méthode avec un paramètre, et pour d'autres une méthode sans paramètre. Deux solutions sont possibles : soit écrire un code commun à tous les cas, en passant des références « null » quand il n'y a pas de valeur à fournir, soit prévoir deux constructeurs pour gérer les deux cas. Nous adoptons bien sûr la seconde solution.

Il ne reste plus qu'à définir les deux interfaces pour chacun des deux cas :

  • Pour les RPC sans paramètre, il faut définir une interface qui oblige l'écriture d'une méthode qui n'a pas de paramètre, mais qui fournit un résultat. JAVA fournit déjà des interfaces pour ce cas simples. L'interface pour notre cas est « java.util.function.Supplier<T> » qui demande d'implémenter la méthode T get(). L'interface « Supplier<T> » est donc une interface avec un type paramétré. La seule spécificité des résultats attendus pour ce cas de RPC est qu'il soit sérialisable. Donc nous utilisons l'interface « Supplier<Serializable> » en implémentant la méthode Serializable get() ;
  • Pour les RPC avec un paramètre, il faut définir une interface qui oblige l'écriture d'une méthode avec un seul paramètre, et qui fournit un résultat. Il s'agit de l'interface « java.util.function.Function<T,R> », avec « T » la classe du paramètre de la fonction et « R » la classe de sa valeur de retour. Cette interface demande d'implémenter la méthode R apply(T). Pour notre cas, le paramètre est du type RpcParam, et les résultats doivent être sérialisables, donc il s'agit de l'interface « Function<RpcParam,Serializable> » en fournissant la méthode Serializable apply(RpcParam).

Il nous faut maintenant écrire une nouvelle classe qui implémente l'une ou l'autre des deux interfaces pour chacun des cas de l'énumération. Nous procédons ainsi pour le premier cas pour illustrer cette façon de faire. Ensuite, pour tous les autres cas, nous réduirons le code écrit grâce aux expressions lambda.

Compréhension du code des classes RPC

Téléchargez l'archive dm3.3.zip et décompressez-la en vous plaçant à la racine de votre projet. Diverses classes ont été ajoutées à votre projet. Exécutez la commande « mvn clean install » pour rétablir la cohérence de votre arborescence, et opérez Refresh puis un Clean dans Eclipse.

Ouvrez l'énumération « tsp.csc4509.dm.rpc.RpcType ». Vers la fin de l'énumération, vous y verrez deux constructeurs :

  • le premier prend en charge les RPC sans paramètre. Il utilise deux paramètres. Le second est un objet d'une classe qui implémente l'interface « Supplier<Serializable> » ;
  • le second prend en charge les RPC avec un paramètre. Son seul paramètre est un objet d'une classe qui implémente l'interface « Function<RpcParam,Serializable> ».

Pour fournir la classe qui contient la méthode d'implémentation, il existe plusieurs façons de faire :

  • écrire une classe d'implémentation avec la méthode ;
  • construire l'objet avec une classe anonyme qui implémente la méthode ;
  • construire l'objet avec une expression lambda qui fournit le code de la méthode.

Ces trois cas sont illustrés dans l'énumération « tsp.csc4509.dm.rpc.RpcType ».

Pour la valeur "LIST", la classe d'implémentation est écrite : « tsp.csc4509.dm.rpc.RpcListSupplier ». On y trouve le code de la méthode get(). Elle retourne la liste de tous les énumérateurs de l'énumération sous la forme d'un tableau de chaîne de caractères. Et, dans l'énumération « tsp.csc4509.dm.rpc.RpcType », la construction du cas « LIST » construit un objet de cette classe avec l'instruction « new RpcListSupplier() ».

Pour le cas "AVERAGELOAD", il n'y a plus de classe d'implémentation, mais une classe anonyme. On appelle le « new » directement avec l'interface, mais en lui ajoutant le code de la méthode get().

Pour le cas "COMPUTELIST", le code est encore réduit : tout ce qui est implicite est retiré (le « new », le nom de l'interface, le nom de la méthode) pour ne garder que ce qui contient de l'information : la valeur des paramètres (vide pour notre cas) et le corps de la méthode. De plus, lorsque l'expression lambda se résume à une seule instruction, les accolades « { } » ne sont plus obligatoires et le return devient lui aussi implicite.

Cette expression revient donc à créer (« new ») un objet d'une classe qui implémente la méthode get():

Le cas "COMPUTE" utilise aussi une expression lambda. Mais, comme il utilise le second constructeur, l'expression lambda doit implémenter la méthode Serializable apply(RpcParam) de l'interface « Function<RpcParam,Serializable> ». Cette méthode possède un paramètre de type RpcParam, il faut donc déclarer ce paramètre dans l'expression :

Finir l'écriture la classe RpcClient

Écrivez la seconde version du constructeur de la classe RpcClient. Ce construteur est exactement identique au premier, sauf pour la construction de la requête. Il utilise le constructeur avec un seul paramètre RpcRequest(final String id), à la place du constructeur à trois paramètres.

Réécriture de la classe RpcServer

Écrivez une nouvelle version du constructeur de la classe RpcServer qui utilise l'énumération « tsp.csc4509.dm.rpc.RpcType » :

  • création d'un serveur TCP écoutant sur le port passé en paramètre ;
  • pour tous les futurs clients qui vont se connecter :
    • accepter le client ;
    • recevoir un objet de ce client ;
    • vérifier que c'est un objet de type RpcRequest ;
    • retrouver la valeur du RpcType à partir de l'« id » de cette requête. La requête ne contient pas directement la valeur du le RpcType. En effet, l'énumération RpcType appartient au code du serveur et le client n'a aucune raison de connaître cette liste a priori. Par convention, la chaîne de caractères contenu dans l'attribut « id » de la requête doit correspondre à la valeur de l'énumération. Pour transformer une chaîne de caractères en valeur de l'énumération, utilisez la méthode de classe Enum.valueOf() : « RpcType rpcType = Enum.valueOf(RpcType.class, idStr); » (où idStr est l'attribut id que vous avez extrait de la requête). Si un client envoie un « id » qui ne correspond à aucune valeur de l'énumération, cette instruction lève l'exception IllegalArgumentException. Il ne faut pas stopper le serveur à cause des requêtes qui lèvent une exception. Vous devez donc attraper toutes les exceptions et renvoyer au client une « RpcReply » avec le status fixé à RPC_REPLY_BAD_REQUEST ;
    • appeler la méthode qui a été associée au RpcType pour calculer le résultat du RPC ;
    • créer un objet RpcReply avec le résultat de cette procédure ;
    • envoyer cet objet RpcReply au client ;
    • fermer la connexion avec ce client.

Sécruriser le RpcServer (question optionnelle)

Actuellement, comme le code du client et du serveur partagent le même projet, il est possible de demander au serveur de déclencher un RPC avec paramètre (id="COMPUTE") pour toutes les classes RpcParam présentes. Un serveur sécurisé ne devrait pourvoir lancer que les procédures qui sont formellement enregistrées. L'énumération « tsp.csc4509.dm.rpc.RpcParamList » offre ce service. Essayez de la comprendre pour n'accepter que les procédures qui y sont listées, et pour renvoyer un objet « RpcReply » avec le status fixé à RPC_REPLY_BAD_REQUEST pour les autres.

Enchissez votre classe AppliRpcClient pour qu'elle demande au serveur la liste de ses RPC (requête avec l'id "LIST").

Ajout de nouveaux RPC

Le nombre de cœurs du serveur

Trouvez les instructions qui permettent de connaître le nombre de cœurs d'une machine et ajouter ce service au serveur.

Calcul d'une image de Mandelbrot

Dans le paquetage « tsp.csc4509.dm.graphique », se trouve une application graphique qui calcule une image de Mandelbrot. Vous n'avez pas pas besoin de la lire ou de la comprendre pour la modifier. Elle est actuellement entièrement autonome, et vous pouvez donc la lancer (classe d'application: tsp.csc4509.dm.graphique.AppliSwing aucun argument à fournir). Elle fera le calcul de l'image en prennant au hasard dans les coordonnées d'images prédéfinies (dans la classe tsp.csc4509.dm.graphique.ImagesPredefiniesSwing). Attention, pour certaines images il faut plusieurs milliards d'itérations de calcul, cela peut prendre un peu de temps!

Tout ce calcul est fait sur la ligne 50 de tsp.csc4509.dm.graphique.WindowSwing:

Ajoutez un nouveau service au serveur RPC pour pouvoir faire ce calcul sur le serveur. Et, modifiez l'application en remplaçant cette ligne par un appel à ce RPC. Si vous avez bien compris le mécanisme d'ajout d'un RPC, vous n'avez pas besoin d'aller lire la classe Mendelbrot, ni même de comprendre le code de l'application graphique, mis à par la ligne que vous devez remplacer. La ligne de calcul de l'image doit être remplacée par quatre lignes de code. N'oubliez pas d'ajouter ce nouveau service dans l'énumération tsp.csc4509.dm.rpc.RpcParamList.

La méthode WindowSwing() où vous devez placer votre appel au RpcClient possède déjà deux paramètres hostname et port pour retrouver l'adresse de votre serveur RPC. Ces deux paramètres sont initialisés avec les valeurs des deux premiers arguments de la classe d'application AppliSwing.

 


$Date: 2021-04-26 18:48:12 +0200 (lun. 26 avril 2021) $