Dans ce tutoriel, je présente les bases des protocoles de communication TCP/IP et comment s’en servir pour programmer des applications client-serveur en Python.
Introduction
En ce moment, l’expression “programmer l’échange d’informations sur un réseau” signifie essentiellement “programmer l’échange d’informations via le protocole IP.” Peu importe la technologie réseautique sous-jacente, tous les réseaux généralement rencontrés sont chapeautés par ce Internet Protocol, aussi presque toutes les applications réseau qui ne sont pas basées sur IP servent à articuler les technologies qui mettent en oeuvre IP.
En soit, IP détermine la présence d’une machine sur un réseau et permet d’y véhiculer des données. L’échange de données en tant que tel est quant à lui mis en oeuvre via un protocole qui utilise les services d’IP et procurent au programmeur un ensemble de services supplémentaires. Dans ce tutoriel, nous nous intéresserons donc aussi au protocole TCP, appuyé sur IP, d’où la nomenclature TCP/IP (TCP over IP). Le protocole TCP garantit un certain nombre de propriétés sur les échanges de données entre les hôtes qu’il connecte. En particulier, il préserve l’ordre des messages (qu’on appelle paquets) et garantit leur livraison: si un envoi rapporte avoir envoyé N octets, ces N octets sont parvenus à l’autre bout de la connexion.
L’interface Python nécessaire à l’utilisation de TCP/IP est comprise dans le
module socket
. Dans ce tutoriel, nous allons d’abord jeter un coup d’oeil à
un exemple complet d’application client-serveur basée sur les sockets. Nous
nous pencherons ensuite sur la notion d’une adresse de service Internet.
Comprenant mieux ce concept, nous revisiterons les éléments de notre
exemple, soit le client et le serveur. Nous terminerons sur quelques notes sur
le design d’un protocole d’échange d’information entre le client et le
serveur.
Matériel
Le code discuté dans cet article est disponible sur ce dépôt Git. Les programmes de ce dépôt peuvent vraisemblablement être exécutés sur tout système d’exploitation à l’aide de Python 2.7. Il n’a cependant été testé que sur Linux, spécifiquement Ubuntu 12.04 et Debian Testing (LMDE). L’étudiant sérieux est cependant invité à retaper lui-même le code présenté dans ce tutoriel.
Un exemple complet
L’exemple très simple qui suit consiste en un serveur d’écho: le serveur
affiche sur son écran ce que le client lui envoie, puis le renvoie au client.
Lançons-nous: voici le code du serveur (echo_serveur.py
sur le dépôt
Git).
01 import socket
02 import sys
03
04 if len(sys.argv) < 3:
05 print "Usage: {} <adresse> <port>".format(__file__)
06 sys.exit(1)
07
08 serv = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
09 serv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
10 serv.bind((sys.argv[1], int(sys.argv[2])))
11 serv.listen(3)
12
13 fini = False
14 while not fini:
15 conn, pair = serv.accept()
16 n = 0
17 while True:
18 c = conn.recv(1)
19 if len(c) < 1:
20 break
21 sys.stdout.write(c)
22 conn.send(c)
23 if c == "#":
24 n += 1
25 else:
26 n = 0
27 if n >= 3:
28 fini = True
29 break
30 conn.close()
31
32 serv.close()
33
34 # EOF
Éléments à remarquer:
- Ligne 1: importer le module
socket
. - Lignes 8 à 10: le serveur s’arrange pour recevoir des connexions de clients.
- Ligne 9: le serveur fait en sorte qu’au cas où son exécution se terminait subitement, son port d’écoute des connexions soit instantanément libéré. Ainsi, si on doit interrompre l’exécution du serveur (avec Ctrl+C, par exemple), on pourra le redémarrer immédiatement sur le même port. Dans le cas contraire, il faudrait attendre quelques instants (environ 60 secondes) pour que le noyau du système d’exploitation libère le port.
- Ligne 14: le serveur attend qu’un client se connecte.
- Ligne 17: réception un à un des caractères envoyés par le client.
- Lignes 18, 19: si on ne reçoit plus de caractère, alors ce client s’est déconnecté; on passe au suivant.
- Ligne 20: écho des caractères sur l’écran.
- Ligne 21: écho des caractères au client.
- Lignes 15, 22 à 28: le serveur s’interrompt s’il reçoit
###
. - Ligne 29: on referme la connexion une fois qu’on ne reçoit plus de caractère ou lorsqu’on ferme le serveur.
- Ligne 30: le serveur termine la possibilité de s’y connecter avant de quitter.
À présent, le client (echo_client.py
dans le dépôt
Git):
01 import socket
02 import sys
03
04 if len(sys.argv) < 3:
05 print "Usage: {} <adresse> <port>".format(__file__)
06 sys.exit(1)
07
08 conn = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
09 conn.connect((sys.argv[1], int(sys.argv[2])))
10
11 fini = False
12 while not fini:
13 try:
14 ligne = raw_input()
15 except EOFError:
16 break
17 ligne += "\n"
18 n = len(ligne)
19 conn.send(ligne)
20 m = 0
21 while m < n:
22 try:
23 r = conn.recv(n - m)
24 except socket.error:
25 fini = True
26 break
27 sys.stdout.write(r)
28 m += len(r)
29
30 conn.close()
31
32 # EOF
Éléments à remarquer:
- Ligne 1: importer le module
socket
. - Lignes 8, 9: le client se connecte au serveur qui est censé déjà rouler.
- Ligne 14: le client accepte une ligne de texte…
- Ligne 19: qu’il envoit au serveur.
- Lignes 20-28: réception de l’écho renvoyé par le serveur.
- Lignes 24-26: si le serveur se ferme après avoir reçu
###
, la tentative de réception à la ligne 23 se soldera par le lancement d’une exception. - Lignes 15, 16: réciproquement, lorsque l’utilisateur termine la connexion
en fermant l’entrée standard (CTRL+D sur Linux), une exception
de type
EOFError
est générée. - On ferme la connexion une fois qu’on a terminé.
On peut exécuter le serveur et le client sur une même machine: il suffit d’utiliser l’interface IP réflexive, c’est-à-dire l’adresse IP spéciale 127.0.0.1 qui réfère à la machine elle-même.
-
Dans un terminal (appelons-le S), on exécute le serveur:
$ python echo_serveur.py 127.0.0.1 1234
-
Dans un second terminal (appelons-le C), on exécute le client:
$ python echo_client.py 127.0.0.1 1234
-
Tapons quelque chose dans C:
asdf asdf
On peut voir que S fait aussi écho de cette entrée.
-
Dans C, tapons CTRL+D: le client termine son exécution, mais on peut voir dans S que le serveur, quant à lui, ne s’est pas fermé.
-
Dans C, connectons une nouvelle instance du client en répétant l’étapes 2. Cette nouvelle instance parle à son tour au serveur, entrons cette fois
qwerty qwerty
Dans S, la sortie du serveur ressemble à
asdf qwerty
-
Exigeons maintenant la fermeture du serveur en tapant dans C
###
. Les deux programmes se terminent: le serveur car il a reçu la séquence de fermeture, le client parce que le serveur, en se fermant, coupe la connexion.
Cet exemple peut fonctionner aisément entre n’importe quelle paire de machines se trouvant toutes deux sur Internet. Pour se faciliter les choses, on peut se contenter de deux hôtes branchés sur un même réseau local IP (à défaut d’avoir deux ordinateurs adéquats, on peut utiliser des machines virtuelles). On démarre alors l’adresse réflexive 127.0.0.1 dans l’exemple ci-haut par l’adresse IP de l’hôte où le serveur est exécuté.
Ces exemples comportent quelques mystères que le reste de cet article cherchera à élucider.
Services Internet: adresses et ports
En démarrant le serveur et le client, nous avons entré deux informations chaque fois: un code bizarre composé de nombres séparés de points, ainsi qu’un deuxième nombre. À quoi tout cela correspond-t-il?
Tout hôte voulant faire partie d’un inter-réseau, de l’Internet quoi, doit avoir une interface réseau, à laquelle est assignée une adresse unique. Une interface correspond à une technologie de réseautique mise en oeuvre sur l’hôte: cartes Ethernet, antenne Wi-Fi 802.11, interface réflexive (loopback). Avec le protocole IP tel que généralement utilisé aujourd’hui, l’adresse assignée à chaque interface consiste en un nombre entier sur 32 bits, qu’on représente en affichant chaque octet qui le compose en base 10, séparés des autres par des points.
Par exemple, l’ordinateur sur lequel je rédige cet article porte deux interfaces: une interface Ethernet, à laquelle est assignée l’adresse 24.132.107.28, et son interface réflexive, qui porte l’adresse spéciale 127.0.0.1. L’interface réflexive n’est visible que depuis l’hôte lui-même: elle est utile pour rouler certains service Internet que je ne désire pas rendre public. Par exemple, je peux exécuter contre 127.0.0.1 un serveur web contre que j’utilise pour tester mes pages avant de les publier. Si plutôt j’exécute mon serveur web contre l’interface Ethernet, toutes les autres machines sur mon réseau local pourraient s’en faire servir des pages. L’adresse 0.0.0.0 est spéciale: si un serveur s’y attache, il peut alors recevoir des connexions via toutes les interfaces définies par l’hôte.
Donc, pour obtenir un service d’un serveur, je dois connaître son adresse. Cependant, un hôte peut procurer plusieurs services simultanément. Chacun de ces services est respectivement identifié à l’aide d’un numéro de port, un entier sur 16 bits. Plusieurs services d’application communs utilisent de manière conventionnelle des ports spécifiques. Par exemple, les serveurs web s’attachent au port 80 pour servir des pages et des fichiers par le protocole HTTP, et au port 443 pour servir par le protocole HTTPS (HTTP encrypté par SSL ou TLS). Les serveurs FTP utilisent le port 25. Les serveurs SSH, le port 21. Les serveurs DNS, le port 53. Tous les ports de 0 à 1023 sont ainsi assignés à un service ou un autre par convention: il s’agit des ports privilégiés, auxquels seuls les processus privilégiés (roulant avec privilèges administratifs) ont le droit de s’attacher. Les ports 1024 à 65535 peuvent être utilisés par toute application non privilégiée.
Donc, en résumé: une application client-serveur implantée par TCP/IP s’identifie à l’aide d’une adresse IP et d’un port. L’adresse est rattachée à une interface réseau branchée à Internet. Le port dénote l’application sur cette interface.
Communication TCP/IP par sockets
Le protocole TCP/IP cache les échanges non fiables et désordonnés derrière une couche d’abstraction, la connexion. Une fois une connexion établie entre deux hôtes, le protocole garantit la fiabilités des paquets d’information transmis. Le mécanisme par lequel la connexion est articulée est le socket, un objet permettant la transmission bidirectionnelle de blocs d’octets.
Le système de communication par sockets assigne à chaque hôte désirant communiquer un rôle: l’un est le serveur, l’autre est le client. En principe, le serveur se met en place et attend qu’un client désire s’y connecter: lorsqu’un client se connecte, le serveur accepte la connexion. Une fois cette salutation complétée, la connexion devient symétrique: tant le client et le serveur peuvent envoyer et recevoir des données à l’aide de son socket. Les conventions qui permettent à des hôtes de communiquer de manière cohérente définissent le protocole d’une application réseau. Concentrons-nous d’abord sur l’utilisation des sockets.
Connexion: clients
Un client doit d’abord créer un socket, puis simplement se connecter au serveur. Créer un socket:
import socket # Pour accéder à l'interface!
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
La constante socket.AF_INET
indique qu’il faut créer un socket pour une
connexion internet, par opposition aux quelques autres types de socket
disponibles, par exemple, pour la communication entre processus sur une même
machine. La constante socket.SOCK_STREAM
indique que ce socket doit
transmettre par le protocole TCP. Le dernier paramètre (0) indique le
protocole IP.
Il suffit maintenant de connecter le socket:
s.connect((addr, port))
Notez la syntaxe ici: en Python, une adresse Internet est notée par un tuple
composé d’une chaîne contenant l’adresse IP ou le nom de domaine de l’hôte
contre lequel se connecter, et du numéro de port. Une exception de type
socket.error
sera lancée si la connexion est impossible. Sinon, au retour de
cet appel, la connexion est établie.
Connexion: serveurs
Pour le serveur, la situation est légèrement plus compliquée. En effet le système des sockets suggère qu’un serveur puisse desservir plusieurs clients à la fois. En ce sens, le premier socket créé par le serveur ne sert qu’à accepter les connexions des divers clients. Chaque acceptation crée un nouveau socket qui est ensuite utilisé pour communiquer avec ce client spécifique.
D’abord créer le socket d’acceptation, comme pour le client:
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM, 0)
Le serveur doit ensuite attacher ce socket à une interface, afin qu’on sache à quelle adresse il écoute.
s.bind((addr, port)) # Attache à une interface, donc une adresse, spécifique.
s.bind(("0.0.0.0", port)) # Attache à toutes les interfaces.
# Peut recevoir des clients sur toutes les
# adresses de la machine.
À présent, le socket doit être mis en mode d’écoute:
s.listen(nb)
Le nombre nb
dénote la longueur de la file d’attente du serveur. Les clients
qui n’ont pas encore été acceptés sont logés dans cette file d’attente s’il
y reste de la place; sinon, ils sont rejetés avec suggestion que le serveur
est occupé.
Finalement, le serveur est prêt à attendre la connexion d’un client:
connection, adresse = s.accept()
Ceci bloque jusqu’à ce qu’un client se connecte. Un nouveau socket est créé
pour communiquer avec ce client et est retourné par accept
. Cette routine
renvoit aussi l’adresse du client qui s’est connecté (tuple adresse et port).
On peut de ce fait déterminer l’adresse IP du client, ainsi que le port auquel
il s’est attaché. On peut décider de ce port en utilisant bind
avant
connect
; sinon, connect
détermine automatiquement un port non utilisé.
Échange de données
Une fois la connexion établie, tant le client que le serveur peuvent envoyer et recevoir des données via le socket de connexion. Pour envoyer:
s.send(data)
Ici, data
est un bloc de données binaires, comme une chaîne de caractères.
send
renvoit le nombre d’octets effectivement envoyés: si le bloc donné est
excessivement grand, send
pourra n’en envoyer qu’un préfixe. On peut
invoquer send
à nouveau pour envoyer le reste. Le protocole TCP/IP garantit
que le nombre d’octets retourné par send
a été transmis sans erreur à
l’autre bout de la connexion.
Pour recevoir:
data = s.recv(n)
n
correspond au nombre d’octets attendus. Il est possible que le bloc de
données reçu comporte moins de n
octets. On peut alors invoquer recv
à
nouveau pour recevoir les octets manquants. Comment fait-on pour savoir
combien d’octets on attend? C’est une question de protocole.
Design d’un protocole de communication
Lorsque deux personnes entament une conversation, un certain nombre de conventions leur permettent d’établir un rapport et, ainsi, d’échanger efficacement l’information voulue. On se salue; on établit le ton de la conversation; on parle à tour de rôle; on utilise la ponctuation du langage pour savoir quand l’autre a fini de parler; et ainsi de suite. Pour les machines, c’est pareil.
Les conventions d’échange de données entre pairs d’une communication constituent le protocole de communication. Dans le cas de l’exemple du serveur d’écho ci-haut, un protocole simple a été établi:
- Une fois la connexion établie, le client parle en premier.
- Le client envoit une chaîne de caractères qui se termine par ASCII 10 (newline, \n).
- Le serveur renvoit cette même chaîne de caractères.
- Au lieu d’envoyer une chaîne, le client peut couper la connexion.
- Si la chaîne comporte ###, le serveur coupe la connexion.
Le code ci-haut n’implante pas parfaitement ce protocole, mais il en suit les éléments importants. Pouvez-vous remarquer les distinctions?
La plupart des protocoles de communication sont plus complexes que celui du
serveur d’écho. Il implique l’échange efficace d’objets de données complexes
et variables. Un aspect important de ces protocoles est la sérialisation, le
processus par lequel les objets à échanger sont convertis en blocs de données
transmissibles, puis reconvertis en objet une fois arrivés. Plusieurs modules
standards de Python peuvent nous assister pour résoudre nos problèmes de
sérialisation: pickle
, json
, etc.
Jetons cependant un coup d’oeil à un problème de sérialisation commun: la transmission des nombres. Disons qu’on veuille transmettre une séquence de nombres entiers: deux entiers signés sur 16 bits, un entier non signé sur 32 bits et un entier signé sur 32 bits. Comment faire?
Une approche naïve serait de transmettre pour chaque nombre une séquence de chiffres décimaux ou hexadécimaux. Pour les entiers sur 16 bits, la longueur de la représentation va de 1 à 5 octets si on représente en base 10, et de 1 à 4 octets si on représente en base 16. Pour les entiers 32 bits, cette représentation va de 1 à 10 octets en base 10, et de 1 à 8 octets à base 16. En outre, pour les entiers signés, il faut ajouter un bit pour déterminer l’information de signe, donc un octet pour porter les signes des trois entiers signés. Si on utilise une approche par sentinelle (utilisée pour séparer les nombres du message et terminer le message), et qu’on utilise la base 16 plus compacte, il faut de 9 à 29 octets pour représenter le message et il faut écrire des routines relativement complexes de sérialisation et de désérialisation. On peut aussi laisser tomber les sentinelles et plutôt utiliser une représentation fixe des nombres (en coussinant avec des zéros à gauche). On a alors 25 octets par message.
C’est du gaspillage, considérant qu’un entier sur 16 bits est
représenté en mémoire à l’aide d’à peine deux octets, et qu’un entier sur 32
bits, sur 4 octets, peu importe s’ils sont signés ou non. Cette représentation
binaire peut être obtenus grâce au module
struct
. Par exemple, on
entasse les nombres décrits ci-haut en une chaîne de 12 octets par la simple
instruction Python
import struct
msg = struct.pack("!H!H!i!I", hu1, hu2, int_s, int_u)
struct.pack
s’occupe même de convertir le sexe des entiers à transmettre.
Ainsi, si le client est une machine big-endian alors que le serveur est
little-endian, aucun souci de conversion (notons que l’approche naïve
ci-haut était aussi robuste en ce sens). Pour retrouver les nombres à partir
du message reçu:
hu1, hu2, int_s, int_u = struct.unpack("!H!H!i!I", msg)
Le message est sérialisé sur 12 octets dans tous les cas, ce qui est
légèrement supérieur au meilleur cas pour l’approche par sentinelles.
Cependant, l’algorithme de sérialisation dans le cas des sentinelles est
beaucoup plus complexe et prône à l’erreur d’implantation. L’algorithme très
simple basé sur struct
procure une taille de paquet 59% moindre
que l’approche naïve dans le pire cas et 33% pire dans le meilleur
cas. Un bon deal, selon mon humble opinion.
Conclusion
Ceci complète ce bref tutoriel sur la programmation TCP/IP avec le langage Python. En examinant à nouveau le serveur d’écho, les structures nécessaires à l’établissement de la connexion sont plus faciles à comprendre. On voit aussi comment le protocole de communication entre le client et le serveur détermine significativement comment le code est écrit. Cela souligne l’importance de bien réfléchir au protocole avant d’entreprendre l’implantation: un protocole compliqué engendre un programme compliqué, par conséquent difficile à maintenir.