Luca Saiu: page officielle du cours « Applications Informatiques »

Ce cours consiste en un projet de programmation en réseau.

Cette page est la page web officielle du cours.

Projet

Le projet est à réaliser en binôme, en ne soumettant que du code écrit personnellement par les deux étudiants, ou soumis par moi.

Évaluation

Envoyez-moi votre travail par mail à la fin de chaque séance. Vous allez être évalués sur la base de votre travail au laboratoire, et à la maison aussi. Décrivez (rapidement) votre façon de tester le code. Si vous utilisez des programmes temporaires je veux les voir: aucun problème s'ils sont lourds, dans ce cas. Le code de test, qui n'est pas partie du projet proprement dit, ne doit pas être nécessairement joli.

Les retards seront pénalisés. Par contre, vous avez le droit de travailler en avance sur les phases que les autres n'ont pas encore commencé, et être particulièrement rapides vous amendera des bonus sur la note finale.

Vous avez le droit de faire plus par rapport à ce qui est demandé. Toute extension raisonnable sera bien appréciée et notée positivement, si vous avez satisfait l'énoncé aussi.



Énoncé

Ce projet est structuré en phases. Si un groupe n'arrive pas à compléter une phase après un délai raisonnable, je vais lui fournir une solution implémentée par moi, à utiliser dans les phases suivantes.

La structuration en phases est conçue comme un aide : elle va vous diriger à écrire le code dans un ordre raisonnable pour pouvoir bien le tester avant son utilisation dans les phases suivantes.

Si vous avez des idées alternatives et vous avez des bonnes raisons pour ne pas respecter la spécification, contactez moi par avance. Par contre, vous êtes libres d'implémenter la spécification avec des ajouts. Vous avez toujours le droit d'ajouter des fonctions auxiliaires, non documentées ici, pour simplifier votre vie.

On utilise, obligatoirement, Python version 3 (pas Python version 2).


Phase -1: binômes

Le projet est à développer en binôme.
Si vous êtes en nombre impair il pourra y avoir un, et un seul, trinôme, sujet à mon approuvation.

En tout cas, chaque composant d'un groupe, soit-il en binôme ou en trinôme, est responsable de tout le code soumis. « Ce code n'a pas été écrit par moi » n'est pas une excuse acceptable.

Tâche -1.0 : Envoyez-moi, par mail, un message par binôme, indiquant les noms et les adresses mail des composants.


Préliminaires: RPC

Une façon commune d'utiliser un réseau suit un motif dit requête/réponse. Un client envoie à un certain moment une requête à un serveur sur un canal fiable, en général en transmettant aussi des paramètres ; le serveur fait son travail, puis envoie une réponse au même client, en général en transmettant aussi un résultat.

Pendant le travail du serveur, le client reste en attente.

Requête/réponse

Du point de vue du client ce modèle d'interaction est très similaire à un appel de fonction (appelée aussi procédure : ici j'utilise les deux mots comme synonymes) à distance. C'est possible, en fait, d'implémenter cette interaction en utilisant une fonction/procédure Python qui (après une initialisation globale à exécuter juste une fois) cache complètement la présence du serveur et les deux communications:
Le client appellera une fonction, en donnant des objets Python comme paramètres, et en recevant un objet Python comme résultat. Le fait que le « travail » de la fonction soit exécuté sur une autre machine est invisible.

On parle en anglais de Remote Procedure Call ou RPC.

Votre tâche est la réalisation d'un système de RPC en Python permettant de définir facilement des fonctions arbitraires appelées à distance. Vous allez aussi réaliser une simple application réseau en utilisant votre système RPC.

Votre système de RPC sera un outil de programmation, réutilisable pour n'importe quelle application basée sur des RPCs, écrite par vous ou par des autres.

Vous trouvez plus en bas dans cette page un exemple complet, client et serveur, utilisant des RPCs, qui marchera en utilisant les modules que vous devez écrire, si vous aurez respecté la spécification.


Phase 0: recv_anything

Tâche 0.0 : Préparez rapidement un client et un serveur TCP, en Python, qui communiquent entre eux, les plus simples possible. Ils seront utiles pour tester le code.

La fonction prédéfinie socket.recv est désagréable à utiliser, parce que il faut savoir exactement la taille du message qu'on s'attend. Il faut aussi appeler socket.recv dans une boucle pour être sûrs que le message reçu ne soit pas tronqué.

Notre premier module à écrire s'appellera easysocket. La première fonction demandée sera easysocket.recv_exactly, une version de socket.recv plus confortable à utiliser, elle-même utilisant socket.recv internement.

Tâche 0.1 : Définir la fonction easysocket.recv_exactly (socket, total_byte_no). Le premier paramètre est un socket TCP. Le deuxième paramètre est la taille du message attendu (on peut faire l'hypothèse que le message reçu aura exactement la taille donnée par qui appelle la fonction). easysocket.recv_exactly va recevoir exactement le nombre donné d'octets à partir du socket, et les renvoyer comme résultat en tant qu'on objet bytes.

Suggestions : Les fonctions prédéfinies dans le module socket ne travaillent pas avec des chaînes de caractères (str en Python), mais avec des objets de type bytes (pluriel). Comment on peut convertir entre str et bytes? Je suggère de définir des fonctions d'utilité nommées str_to_bytes et bytes_to_str. Les méthodes bytes.decode et str.encode sont utiles pour tester: vous pouvez les utiliser pour convertir entre chaînes de caractères et suites d'octets. Jouez avec str.encode et bytes.decode interactivement avec ipython3, pour les maîtriser. Tester en utilisant des chaînes de caractères est facile.
easysocket.recv_exactly (socket, total_byte_no) utilisera une boucle. Elle doit être simple : si votre définition, sans compter les commentaires, est plus longue qu'environs 10 lignes, elle est presque certainement incorrecte. Ma solution est de 7 lignes.
Il faut tester! Vous avez besoin d'écrire un serveur et/ou un client pour vérifier que votre easysocket.recv_exactly marche bien. Vous avez le droit, bien sûr, d'utiliser aussi un outil externe comme telnet ou netcat si vous voulez; mais vous devez trouver une façon d'exécuter votre code pour vous (et me) convaincre de sa correction.
Testez (aussi) des communications entre deux vraies machines distantes, en transmettant de gros messages. Il faut s'assurer que easysocket.recv_exactly marche correctement quand elle doit utiliser socket.recv plusieurs fois pour recevoir un message complet. Comment vérifiez-vous que ce soit le cas ? [Réponse : ajoutez temporairement un compteur incrémenté à chaque appel de socket.recv, et un appel de print ; montrez-moi le code.]


Phase 1: sockets confortables à utiliser

Nous voulons arriver, dans une phase suivante, à transmettre des objets Python arbitraires à travers des sockets TCP. Mais ce n'est pas encore le moment.

Pour le moment, enrichissons notre module easysocket pour travailler plus confortablement en transmettant des suites d'octets, c'est à dire des objets bytes.

Nous voulons aussi cacher le plus possible la complexité de l'ouverture des sockets. Notre module easysocket aura des fonctions plus simples et haut-niveau par rapport aux méthodes de socket.socket.

Tâche 1.0 : Définir easysocket.make_client_socket (host, port). Étant donné un nom de host (serveur) et un numéro de port, easysocket.make_client_socket renvoie un socket (un socket ordinaire Python de type socket.socket) déjà ouvert et prêt à communiquer avec le serveur par socket.sendall et easysocket.recv_exactly.

Tâche 1.1 : Définir easysocket.make_server_socket (port). Étant donné un port (sans restrictions sur les adresses IP), la fonction renvoie un serveur prêt à accepter des connexions à partir des clients. Il faut utiliser socket.bind et socket.listen.

À ce point nous pouvons définir des fonctions confortables, dans notre module easysocket, pour envoyer et recevoir des objets bytes de taille arbitraire.

Le point est que l'expéditeur et le destinataire ne doivent pas « se mettre d'accord » pour établir le nombre d'octets à transmettre/recevoir : plus simplement, l'expéditeur enverra un objet de type bytes ; le destinataire le recevra, tout entier, sans jamais avoir des octets en plus ou en moins.

Nous travaillons encore dans le module easysocket.

Tâche 1.2a : Définir une fonction easysocket.send (socket, payload_as_bytes) acceptant un socket et le message en tant qu'un objet bytes ; la fonction transmet la suite d'octés donnée par l'appeleur (plus les données auxiliaires dont vous aurez besoin), et ne renvoie aucun résultat. Dans « l'autre côté du socket » le destinataire utilisera toujours votre easysocket.receive pour recevoir le message envoyé par easysocket.send.

Au même temps il faut penser à la prochaine tâche :

Tâche 1.2b : Définir une fonction easysocket.receive (socket) acceptant juste un socket. La fonction easysocket.receive (socket) recevra un message envoyé par easysocket.send, et renverra comme résultat le payload, en tant qu'un objet bytes.

Suggestions : easysocket.send et easysocket.receive doivent être développées et testées ensemble : elle doivent être compatibles entre elles (et juste entre elles).
Encore une fois, les méthodes bytes.decode et str.encode sont utiles pour tester, et peut-être pas juste pour tester.
Dans easysocket.send il faut utiliser socket.sendall, et pas socket.send.

Une solution proposée : Pour chaque message, transmettre (1) la taille du payload, en octets, en tant qu'une suite d'octés de taille fixe, puis (2) le payload. Nos fonctions easysocket.send et easysocket.receive utiliseront toujours des messages débutants par ce nombre sur les sockets. Ces tailles sont, bien sûr, invisibles aux utilisateurs de easysocket.send et easysocket.receive: la taille est un détail interne du protocole, observable par exemple avec wireshark, mais caché par nos fonctions.
La méthode ljust de la classe str sera utile pour ajouter autant d'espaces que nécessaire à une chaîne de caractères pour obtenir une autre chaîne de taille fixe. Par exemple 'abc'.jlust (3) donne 'abc' (trois caractères), et 'abc'.jlust (5) donne 'abc  ' (cinq caractères).


Phase 2: protocole basé sur les objets Python

Grâce à easysocket.send, easysocket.receive et aux fonctions prédéfinies de Python, passer d'une communication par suites d'octets à une communication par objets Python arbitraire est incroyablement facile.

Il faut utiliser le module prédéfini pickle, dont je vais parler. Les seules fonction utiles pour ce projet sont les deux plus simples, pickle.loads et pickle.dumps.

Nous allons implémenter un nouveau module, très simple, nommé protocol. Bien sûr, protocol utilisera easysocket.

Tâche 2.0a : Définir protocol.send (socket, object), acceptant un socket et un objet Python. La fonction transmettra l'objet (encodé) sur le socket, sans renvoyer aucun résultat. Dans « l'autre côté du socket » le destinataire utilisera protocol.receive.

Au même temps :

Tâche 2.0b : Définir protocol.receive (socket), acceptant un socket. La fonction recevra un objet (encodé) sur le socket, et renverra comme résultat sa version décodée. Dans « l'autre côté du socket » l'envoyeur a utilisé protocol.send.

Suggestions : Jouez avec pickle.loads et pickle.dumps interactivement, dans ipython3, pour comprendre bien comment on les utilise.
Encore une fois, ces deux fonctions doivent être compatibles entre elles, et sont à développer au même temps.
Ces tâches sont extrêmement simples. Dans ma solution les corps de protocol.send et protocol.receive sont, chacun, de deux lignes.


Fonctions de première classe, et programmation d'ordre supérieur

Dans la prochaine phase nous allons faire de la (simple) programmation d'ordre supérieur: c'est à dire, nous allons utiliser des fonctions travaillant sur des autres fonctions.

Les fonctions en Python (et dans des autres langages aussi) sont des objets comme les autres : on dit que les fonctions sont de première classe—rien à voir avec les classes Python : ce signifie juste que les fonctions sont utilisables comme des objets arbitraires, avec la même « dignité », par exemple, des entiers, des listes, des dictionnaires. En fait on peut aussi stocker des fonctions dans des listes ou des dictionnaires. On peut combiner des objets contenant des autres objets d'une façon arbitraire, et les fonctions sont juste un type d'object, parmi les autres.

En particulier, vous pouvez stocker une fonction dans une variable, la passer comme paramètre à une autre fonction, la renvoyer comme résultat d'une autre fonction.

import math

# Je garde une fonction dans une variable.
foo = math.cos

# J'appelle « la variable », ou mieux la valeur de la
# variable ; cette valeur est une fonction.  C'est exactement
# la fonction originale : math.cos est simplement un nom
# d'un objet-fonction, et foo est un autre nom du même objet.
foo (0)

Je vais montrer rapidement les fonctions anonymes et la syntaxe lambda au tableau.

On peut penser à la dérivée comme à une fonction d'ordre supérieur: la dérivée est une fonction qui accepte une autre fonction comme paramètre, et renvoie encore une autre fonction comme résultat.

Même si le types de son paramètre et de son résultat sont peut-être inusuels, l'opération dérivée est clairement une fonction elle-même : une « moulinette » qui accepte un paramètre, fait son travail et renvoie un résultat. En fait on peut définir facilement (une approximation de) l'opération de dérivation comme une fonction Python.

Un exemple :

# Au lieu de calculer la limite, je prends un h de taille « petite ».
def derivative (f):
  h = 0.000001
  return lambda x: (f (x + h) - f (x)) / h

import math

math.cos (0.0)  # Renvoie 1, le cosinus de 0

# Je garde le résultat de la dérivation de la fonction math.sin,
# qui est aussi une fonction, dans la variable mycos.
# La dérivée du sinus est le cosinus.
mycos = derivative (math.sin)

# Je peux appeler la variable, ou mieux la valeur de la variable;
# et cette valeur est une fonction.  Le résultat de l'appel est une
# bonne approximation de la « vraie » fonction cosinus.
mycos (0.0)   # Renvoie une approximation du cosinus de zéro

# Bien sûr ce n'est pas obligatoire de nommer le résultat de la
# dérivation.
# Je peux avoir une expression dont le résultat est une function,
# et l'appeler directement, sans stocker la fonction dans une variable.
#
# Ici j'appelle la dérivée du sinus en lui donnant zéro : c'est à dire,
# j'appelle le cosinus an lui donnant zéro.  C'est la même chose, mais
# sans utiliser le nom « cosinus ».
(derivative (math.sin)) (0)

Travailler avec des fonctions en tant qu'objets (d'une façon simple) sera nécessaire pour la prochaine phase.


Phase 3: Remote Procedure Call

Nous allons implémenter un nouveau module, rpc.


Phase 3: Remote Procedure Call, côté serveur

Notre module rpc contiendra une table associant une fonction à chaque nom de fonction « exportée » pour être appelée à distance. Ces fonctions exportées sont dites « RPs », en anglais Remote Procedures.

Il y aura une fonction dans rpc, à appeler sur le « serveur », ajoutant une fonction et son nom à la table des fonctions exportées.

Tâche 3.0 : Définir rpc.export_rp (name, function), acceptant une chaîne de caractères et une fonction. La fonction mettra à jour la table en ajoutant une association entre le nom de la fonction et la fonction.

Suggestion : Il y aura un dictionnaire global. Le clés seront les noms, les données associées seront les fonctions.

Nous avons besoin d'une fonction, côté serveur, qui étant donnée un socket ouvert vers le client, sert une requête.

Tâche 3.1 : Définir rpc.serve_request (socket), acceptant un socket vers le « client ». La fonction va recevoir des données à travers le socket : ces données seront le nom de la fonction exportée, et l'n-uplet de paramètres avec lesquels il faudra l'appeler.
rpc.serve_request trouvera la fonction exportée dans le dictionnaire, l'appellera pour obtenir le résultat, et transmettra le résultat au client sur le socket.

Suggestions : Le résultat de l'accès au dictionnaire est une fonction, qu'on peut appeler.
Regardez l'image en haut : le serveur doit recevoir une requête (contenant nom de fonction et paramètres), faire le travail, et renvoyer une réponse.
La fonctionnalité côté serveur et la fonctionnalité côté client doivent être compatibles pour pouvoir communiquer d'un côté à l'autre. En particulier, il y a plusieurs façons possible de passer le nom de la fonction est l'n-uplet des paramètres.

Tâche 3.2 : Définir rpc.serve_requests (socket) (requests, pluriel), acceptant un socket vers le client et appelant rpc.serve_request dans une boucle. En utilisant rpc.serve_requests on peut servir un nombre arbitraire de requêtes du même client, l'une après l'autre.

Suggestions : La boucle est-elle infinie ? Comment faut-il réagir aux exceptions ?


Phase 3: Remote Procedure Call, côté client

La première fonction à implémenter tourne sur le côté client, et permet d'appeler une fonction distante en donnant le nom de la fonction et un n-uplet de paramètres. Bien sûr, ici "rp" signifie remote procedure.

Tâche 3.3 : Définir rpc.call_rp (socket, name, parameters), acceptant un socket vers le « serveur », un nom de fonction distante, et un n-uplet de paramètres. La fonction transmettra la requête au serveur, recevra la réponse, et renverra la résultat de l'appel à distance comme résultat de rpc.call_rp.

Suggestions : La fonction nommée par le paramètre name (une chaîne de caractères) est distante : son travail, donc, n'est pas exécuté « ici », mais « de l'autre côté du socket ».
La communication se passe par objets Python ici : on n'a plus besoin de penser à des suites d'octets.
Regardez l'image en haut: il y a une phase d'envoi suivie par une phase de réception.
Il faut envoyer deux morceaux d'information : le nom et les paramètres. On peut soit envoyer deux messages, soit les joindre dans un seul objet Python, par exemple un n-uplet ou une liste.

À ce point vous pouvez déjà tester un appel à distance, même sans avoir encore rpc.rp. Si par exemple le côté serveur exporte une fonction nommée 'plus', de deux paramètres, et le client est connecté au serveur par un socket s, vous pouvez exécuter la fonction distante à partir du client par rpc.call_rp (s, 'plus', (1, 2)). Le résultat devrait être 3.

Suggestion : Faites-le. Vous devez vous convaincre que votre implémentation rpc.call_rp marche avant de développer rpc.rp.

Tâche 3.4 : Définir rpc.rp (socket, name), acceptant un socket vers le « serveur » et un nom de fonction distante. Le résultat est une « fonction distante », c'est à dire une fonction qui a le même comportement d'une fonction locale mais en fait utilise rpc.call_rp pour travailler à distance. La fonction renvoyée par rpc.rp accepte le paramètres et donne le résultat de la fonction distante.

Suggestions : rpc.rp est la seule fonction dans ce projet où vous avez besoin de lambda. Sa définition est simple (le corps dans ma solution est une ligne) : le vrai travail est fait par rpc.call_rp, et rpc.rp va simplement renvoyer une fonction anonyme utilisant rpc.call_rp.
Il faut utiliser une astérisque dans une spécification de paramètres formels. Où ?

Tâche 3.5 (optionnelle) : Si l'exécution d'un appel distant provoque une exception sur le serveur, le client ne reçoit jamais une réponse.
Est-ce possible de propager l'exception du serveur vers le client, de façon q'une exception distante soit perçue comme une exception locale ?

Suggestions : Cette tâche (optionnelle) est peut-être la plus difficile du projet au niveau conceptuel, même si la quantité du code nécessaire est petite.
Il faut modifier la façon de renvoyer le résultat du serveur au client. Dans ma solution j'utilise un n-uplet : ('success', RÉSULTAT) en cas de succès, ou ('error', EXCEPTION) en cas d'échec. rpc.call_rp peut regarder si l'appel a eu succès, et en cas affirmatif renvoyer juste le résultat ; et en cas négatif ne pas renvoyer, soulever la même exception soulevée sur le côté serveur avec raise.
Il y a plusieurs raisons possible pour un échec de fonction distante: erreur de transmission de la requête, nom de la fonction invalide, peut-être requête mal formée, erreur dans l'exécution de la fonction côté serveur, erreur de transmission de la réponse.
Le côté serveur a besoin de la syntaxe except Exception as VARIABLE, parce que on veut savoir exactement quelle exception a été levée : il faudra la reproduire dans l'autre côté.
Si vous reproduisez une exception sur le côté client, le serveur ne doit pas fermer la connexion. En général le client peut traiter l'exception avec try..except, continuer son exécution et faire des autres RPCs plus tard. En fait le client de mon exemple fait un RPC (le dernier) après l'échec d'une autre (l'avant-dernier). Le dernier ne doit pas échouer.


Phase 4: une application RPC

Tâche 4 : Écrivez une application chat multi-utilisateur, faite par un serveur et un client (un seul serveur gérant plusieurs clients au même temps), en utilisant les RPCs.

Votre application peut être simple, ou même très élaborée, en supportant par exemple plusieurs canaux et un langage de commandes, comme IRC.

Cette partie de la spécification est intentionnellement vague : il s'agit d'écrire une application réseau en utilisant des RPCs. Vous pouvez choisir les détails.

Suggestion : J'ai parlé de « côté client » et « côté serveur » du point de vue d'un RPC, où le « côté client » appelle une fonction distante, et le « côté serveur » l'exécute. Mais ce n'est pas du tout nécessaire d'avoir le « côté client » exécuté sur un programme client et le « côté serveur » sur un programme serveur. On peut avoir des fonction distantes dans les deux côtés, le programme client et le programme serveur faisant des RPCs dans les deux directions. Dans ce cas le programme serveur utilisera aussi rpc.rp (en donnant un socket vers un client, déjà connecté), et le programme client aura un thread gérant des requêtes provenant du serveur. Pour faire ça vous n'avez pas besoin de modifier le module rpc : les fonctions spécifiées en haut que vous avez déjà implémenté suffisent pour avoir des RPCs dans deux directions, et même pour des applications peer-to-peer. [Avez-vous besoin de sockets additionnels en ce cas?]


Ressources

Le répertoire code/ contient le code déjà publié, notamment pour les phases passées où j'ai publié ma solution. Tant que la solution d'une phase n'a pas été publiée, le sous-répertoire correspondant restera inaccessible.

Le sous-répertoire code/exemple/ contient un client et un serveur utilisables avec les modules décrits dans cet énoncé. Bien sûr il faut arriver à la fin du projet pour pouvoir tester ce code, mais il est utile même au début, en tant qu'exemple d'utilisation.



[hacker emblem]

Luca Saiu — IUT de Villetaneuse, Département Réseaux et Télécommunications
Mis à jour le 13 juillet 2019.