Tutoriel: programmation TCP/IP sur Python
par Benoit, 2014-04-26

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:

  1. Ligne 1: importer le module socket.
  2. Lignes 8 à 10: le serveur s’arrange pour recevoir des connexions de clients.
  3. 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.
  4. Ligne 14: le serveur attend qu’un client se connecte.
  5. Ligne 17: réception un à un des caractères envoyés par le client.
  6. Lignes 18, 19: si on ne reçoit plus de caractère, alors ce client s’est déconnecté; on passe au suivant.
  7. Ligne 20: écho des caractères sur l’écran.
  8. Ligne 21: écho des caractères au client.
  9. Lignes 15, 22 à 28: le serveur s’interrompt s’il reçoit ###.
  10. Ligne 29: on referme la connexion une fois qu’on ne reçoit plus de caractère ou lorsqu’on ferme le serveur.
  11. 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:

  1. Ligne 1: importer le module socket.
  2. Lignes 8, 9: le client se connecte au serveur qui est censé déjà rouler.
  3. Ligne 14: le client accepte une ligne de texte…
  4. Ligne 19: qu’il envoit au serveur.
  5. Lignes 20-28: réception de l’écho renvoyé par le serveur.
  6. 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.
  7. 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.
  8. 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.

  1. Dans un terminal (appelons-le S), on exécute le serveur:

     $ python echo_serveur.py 127.0.0.1 1234
    
  2. Dans un second terminal (appelons-le C), on exécute le client:

     $ python echo_client.py 127.0.0.1 1234
    
  3. Tapons quelque chose dans C:

     asdf
     asdf
    

    On peut voir que S fait aussi écho de cette entrée.

  4. 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é.

  5. 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
    
  6. 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.

Commentaires