Apprenez à programmer en Python

Apprenez à programmer en Python

Mis à jour le vendredi 20 juin 2014

Vaste sujet que le réseau ! Si je devais faire une présentation détaillée, ou même parler des réseaux en général, il me faudrait bien plus d'un chapitre rien que pour la théorie.

Dans ce chapitre, nous allons donc apprendre à faire communiquer deux applications grâce aux sockets, des objets qui permettent de connecter un client à un serveur et de transmettre des données de l'un à l'autre.

Si cela ne vous met pas l'eau à la bouche…

Brève présentation du réseau

Comme je l'ai dit plus haut, le réseau est un sujet bien trop vaste pour que je le présente en un unique chapitre. On va s'attacher ici à comprendre comment faire communiquer deux applications, qui peuvent être sur la même machine mais aussi sur des machines distantes. Dans ce cas, elles se connectent grâce au réseau local ou à Internet.

Il existe plusieurs protocoles de communication en réseau. Si vous voulez, c'est un peu comme la communication orale : pour que les échanges se passent correctement, les deux (ou plus) parties en présence doivent parler la même langue. Nous allons ici parler du protocole TCP.

Le protocole TCP

L'acronyme de ce protocole signifie Transmission Control Protocol, soit « protocole de contrôle de transmission ». Concrètement, il permet de connecter deux applications et de leur faire échanger des informations.

Ce protocole est dit « orienté connexion », c'est-à-dire que les applications sont connectées pour communiquer et que l'on peut être sûr, quand on envoie une information au travers du réseau, qu'elle a bien été réceptionnée par l'autre application. Si la connexion est rompue pour une raison quelconque, les applications doivent rétablir la connexion pour communiquer de nouveau.

Cela vous paraît peut-être évident mais le protocole UDP (User Datagram Protocol), par exemple, envoie des informations au travers du réseau sans se soucier de savoir si elles seront bien réceptionnées par la cible. Ce protocole n'est pas connecté, une application envoie quelque chose au travers du réseau en spécifiant une cible. Il suffit alors de prier très fort pour que le message soit réceptionné correctement !

Plus sérieusement, ce type de protocole est utile si vous avez besoin de faire transiter beaucoup d'informations au travers du réseau mais qu'une petite perte occasionnelle d'informations n'est pas très handicapante. On trouve ce type de protocole dans des jeux graphiques en réseau, le serveur envoyant très fréquemment des informations au client pour qu'il actualise sa fenêtre. Cela fait beaucoup à transmettre mais ce n'est pas dramatique s'il y a une petite perte d'informations de temps à autre puisque, quelques millisecondes plus tard, le serveur renverra de nouveau les informations.

En attendant, c'est le protocole TCP qui nous intéresse. Il est un peu plus lent que le protocole UDP mais plus sûr et, pour la quantité d'informations que nous allons transmettre, il est préférable d'être sûr des informations transmises plutôt que de la vitesse de transmission.

Clients et serveur

Dans l'architecture que nous allons voir dans ce chapitre, on trouve en général un serveur et plusieurs clients. Le serveur, c'est une machine qui va traiter les requêtes du client.

Si vous accédez par exemple à OpenClassrooms, c'est parce que votre navigateur, faisant office de client, se connecte au serveur d'OpenClassrooms. Il lui envoie un message en lui demandant la page que vous souhaitez afficher et le serveur d'OpenClassrooms, dans sa grande bonté, envoie la page demandée au client.

Cette architecture est très fréquente, même si ce n'est pas la seule envisageable.

Dans les exemples que nous allons voir, nous allons créer deux applications : l'application serveur et l'application client. Le serveur écoute donc en attendant des connexions et les clients se connectent au serveur.

Les différentes étapes

Nos applications vont fonctionner selon un schéma assez similaire. Voici dans l'ordre les étapes du client et du serveur. Les étapes sont très simplifiées, la plupart des serveurs peuvent communiquer avec plusieurs clients mais nous ne verrons pas cela tout de suite.

Le serveur :

  1. attend une connexion de la part du client ;

  2. accepte la connexion quand le client se connecte ;

  3. échange des informations avec le client ;

  4. ferme la connexion.

Le client :

  1. se connecte au serveur ;

  2. échange des informations avec le serveur ;

  3. ferme la connexion.

Comme on l'a vu, le serveur peut dialoguer avec plusieurs clients : c'est tout l'intérêt. Si le serveur d'OpenClassrooms ne pouvait dialoguer qu'avec un seul client à la fois, il faudrait attendre votre tour, peut-être assez longtemps, avant d'avoir accès à vos pages. Et, sans serveur pouvant dialoguer avec plusieurs clients, les jeux en réseau ou les logiciels de messagerie instantanée seraient bien plus complexes.

Établir une connexion

Pour que le client se connecte au serveur, il nous faut deux informations :

  • Le nom d'hôte (host name en anglais), qui identifie une machine sur Internet ou sur un réseau local. Les noms d'hôtes permettent de représenter des adresses IP de façon plus claire (on a un nom comme google.fr, plus facile à retenir que l'adresse IP correspondante 74.125.224.84).

  • Un numéro de port, qui est souvent propre au type d'information que l'on va échanger. Si on demande une connexion web, le navigateur va en général interroger le port 80 si c'est en http ou le port 443 si c'est en connexion sécurisée (https). Le numéro de port est compris entre 0 et 65535 (il y en a donc un certain nombre !) et les numéros entre 0 et 1023 sont réservés par le système. On peut les utiliser, mais ce n'est pas une très bonne idée.

Pour résumer, quand votre navigateur tente d'accéder à OpenClassrooms, il établit une connexion avec le serveur dont le nom d'hôte est fr.openclassrooms.com sur le port 80. Dans ce chapitre, nous allons plus volontiers travailler avec des noms d'hôtes qu'avec des adresses IP.

Les sockets

Comme on va le voir, les sockets sont des objets qui permettent d'ouvrir une connexion avec une machine locale ou distante et d'échanger avec elle.

Ces objets sont définis dans le module socket et nous allons maintenant voir comment ils fonctionnent.

Les sockets

Commençons donc, dans la joie et la bonne humeur, par importer notre module socket.

import socket

Nous allons d'abord créer notre serveur puis, en parallèle, un client. Nous allons faire communiquer les deux. Pour l'instant, nous nous occupons du serveur.

Construire notre socket

Nous allons pour cela faire appel au constructeur socket. Dans le cas d'une connexion TCP, il prend les deux paramètres suivants, dans l'ordre :

  • socket.AF_INET : la famille d'adresses, ici ce sont des adresses Internet ;

  • socket.SOCK_STREAM : le type du socket, SOCK_STREAM pour le protocole TCP.

>>> connexion_principale = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
>>>

Connecter le socket

Ensuite, nous connectons notre socket. Pour une connexion serveur, qui va attendre des connexions de clients, on utilise la méthode bind. Elle prend un paramètre : le tuple (nom_hote, port).

Attends un peu, je croyais que c'était notre client qui se connectait à notre serveur, pas l'inverse…

Oui mais, pour que notre serveur écoute sur un port, il faut le configurer en conséquence. Donc, dans notre cas, le nom de l'hôte sera vide et le port sera celui que vous voulez, entre 1024 et 65535.

>>> connexion_principale.bind(('', 12800))
>>>

Faire écouter notre socket

Bien. Notre socket est prêt à écouter sur le port 12800 mais il n'écoute pas encore. On va avant tout lui préciser le nombre maximum de connexions qu'il peut recevoir sur ce port sans les accepter. On utilise pour cela la méthode listen. On lui passe généralement 5 en paramètre.

Cela veut dire que notre serveur ne pourra dialoguer qu'avec 5 clients maximum ?

Non. Cela veut dire que si 5 clients se connectent et que le serveur n'accepte aucune de ces connexions, aucun autre client ne pourra se connecter. Mais généralement, très peu de temps après que le client ait demandé la connexion, le serveur l'accepte. Vous pouvez donc avoir bien plus de clients connectés, ne vous en faites pas.

>>> connexion_principale.listen(5)
>>>

Accepter une connexion venant du client

Enfin, dernière étape, on va accepter une connexion. Aucune connexion ne s'est encore présentée mais la méthode accept que nous allons utiliser va bloquer le programme tant qu'aucun client ne s'est connecté.

Il est important de noter que la méthode accept renvoie deux informations :

  • le socket connecté qui vient de se créer, celui qui va nous permettre de dialoguer avec notre client tout juste connecté ;

  • un tuple représentant l'adresse IP et le port de connexion du client.

Le port de connexion du client… ce n'est pas le même que celui du serveur ?

Non car votre client, en ouvrant une connexion, passe par un port dit « de sortie » qui va être choisi par le système parmi les ports disponibles. Pour schématiser, quand un client se connecte à un serveur, il emprunte un port (une forme de porte, si vous voulez) puis établit la connexion sur le port du serveur. Il y a donc deux ports dans notre histoire mais celui qu'utilise le client pour ouvrir sa connexion ne va pas nous intéresser.

>>> connexion_avec_client, infos_connexion = connexion_principale.accept()

Cette méthode, comme vous le voyez, bloque le programme. Elle attend qu'un client se connecte. Laissons cette fenêtre Python ouverte et, à présent, ouvrons-en une nouvelle pour construire notre client.

Création du client

Commencez par construire votre socket de la même façon :

>>> import socket
>>> connexion_avec_serveur = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
>>>

Connecter le client

Pour se connecter à un serveur, on va utiliser la méthode connect. Elle prend en paramètre un tuple, comme bind, contenant le nom d'hôte et le numéro du port identifiant le serveur auquel on veut se connecter.

Le numéro du port sur lequel on veut se connecter, vous le connaissez : c'est 12800. Vu que nos deux applications Python sont sur la même machine, le nom d'hôte va être localhost (c'est-à-dire la machine locale).

>>> connexion_avec_serveur.connect(('localhost', 12800))
>>>

Et voilà, notre serveur et notre client sont connectés !

Si vous retournez dans la console Python abritant le serveur, vous pouvez constater que la méthode accept ne bloque plus, puisqu'elle vient d'accepter la connexion demandée par le client. Vous pouvez donc de nouveau saisir du code côté serveur :

>>> print(infos_connexion)
('127.0.0.1', 2901)
>>>

La première information, c'est l'adresse IP du client. Ici, elle vaut 127.0.0.1 c'est-à-dire l'IP de l'ordinateur local. Dites-vous que l'hôte localhost redirige vers l'IP 127.0.0.1.

Le second est le port de sortie du client, qui ne nous intéresse pas ici.

Faire communiquer nos sockets

Bon, maintenant, comment faire communiquer nos sockets ? Eh bien, en utilisant les méthodes send pour envoyer et recv pour recevoir.

Donc côté serveur :

>>> connexion_avec_client.send(b"Je viens d'accepter la connexion")
32
>>>

La méthode send vous renvoie le nombre de caractères envoyés.

Maintenant, côté client, on va réceptionner le message que l'on vient d'envoyer. La méthode recv prend en paramètre le nombre de caractères à lire. Généralement, on lui passe la valeur 1024. Si le message est plus grand que 1024 caractères, on récupérera le reste après.

Dans la fenêtre Python côté client, donc :

>>> msg_recu = connexion_avec_serveur.recv(1024)
>>> msg_recu
b"Je viens d'accepter la connexion"
>>>

Magique, non ? Vraiment pas ? Songez que ce petit mécanisme peut servir à faire communiquer des applications entre elles non seulement sur la machine locale, mais aussi sur des machines distantes et reliées par Internet.

Le client peut également envoyer des informations au serveur et le serveur peut les réceptionner, tout cela grâce aux méthodes send et recv que nous venons de voir.

Fermer la connexion

Pour fermer la connexion, il faut appeler la méthode close de notre socket.

Côté serveur :

>>> connexion_avec_client.close()
>>>

Et côté client :

>>> connexion_avec_serveur.close()
>>>

Voilà ! Je vais récapituler en vous présentant dans l'ordre un petit serveur et un client que nous pouvons utiliser. Et pour finir, je vous montrerai une façon d'optimiser un peu notre serveur en lui permettant de gérer plusieurs clients à la fois.

Le serveur

Pour éviter les confusions, je vous remets ici le code du serveur, légèrement amélioré. Il n'accepte qu'un seul client (nous verrons plus bas comment en accepter plusieurs) et il tourne jusqu'à recevoir du client le message fin.

À chaque fois que le serveur reçoit un message, il envoie en retour le message '5 / 5'.

import socket

hote = ''
port = 12800

connexion_principale = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
connexion_principale.bind((hote, port))
connexion_principale.listen(5)
print("Le serveur écoute à présent sur le port {}".format(port))

connexion_avec_client, infos_connexion = connexion_principale.accept()

msg_recu = b""
while msg_recu != b"fin":
    msg_recu = connexion_avec_client.recv(1024)
    # L'instruction ci-dessous peut lever une exception si le message
    # Réceptionné comporte des accents
    print(msg_recu.decode())
    connexion_avec_client.send(b"5 / 5")

print("Fermeture de la connexion")
connexion_avec_client.close()
connexion_principale.close()

Voilà pour le serveur. Il est minimal, vous en conviendrez, mais il est fonctionnel. Nous verrons un peu plus loin comment l'améliorer.

Le client

Là encore, je vous propose le code du client pouvant interagir avec notre serveur.

Il va tenter de se connecter sur le port 12800 de la machine locale. Il demande à l'utilisateur de saisir quelque chose au clavier et envoie ce quelque chose au serveur, puis attend sa réponse.

import socket

hote = "localhost"
port = 12800

connexion_avec_serveur = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
connexion_avec_serveur.connect((hote, port))
print("Connexion établie avec le serveur sur le port {}".format(port))

msg_a_envoyer = b""
while msg_a_envoyer != b"fin":
    msg_a_envoyer = input("> ")
    # Peut planter si vous tapez des caractères spéciaux
    msg_a_envoyer = msg_a_envoyer.encode()
    # On envoie le message
    connexion_avec_serveur.send(msg_a_envoyer)
    msg_recu = connexion_avec_serveur.recv(1024)
    print(msg_recu.decode()) # Là encore, peut planter s'il y a des accents

print("Fermeture de la connexion")
connexion_avec_serveur.close()

Que font les méthodes encode et decode ?

encode est une méthode de str. Elle peut prendre en paramètre un nom d'encodage et permet de passer un str en chaîne bytes. C'est, comme vous le savez, ce type de chaîne que send accepte. En fait, encodeencode la chaîne str en fonction d'un encodage précis (par défaut, Utf-8).

decode, à l'inverse, est une méthode de bytes. Elle aussi peut prendre en paramètre un encodage et elle renvoie une chaîne str décodée grâce à l'encodage (par défaut Utf-8).

Si l'encodage de votre console est différent d'Utf-8 (ce sera souvent le cas sur Windows), des erreurs peuvent se produire si les messages que vous encodez ou décodez comportent des accents.

Voilà, nous avons vu un serveur et un client, tous deux très simples. Maintenant, voyons quelque chose de plus élaboré !

Un serveur plus élaboré

Quel sont les problèmes de notre serveur ?

Si vous y réfléchissez, il y en a pas mal !

  • D'abord, notre serveur ne peut accepter qu'un seul client. Si d'autres clients veulent se connecter, ils ne peuvent pas.

  • Ensuite, on part toujours du principe qu'on attend le message d'un client et qu'on lui renvoie immédiatement après réception. Mais ce n'est pas toujours le cas : parfois vous envoyez un message au client alors qu'il ne vous a rien envoyé, parfois vous recevez des informations de sa part alors que vous ne lui avez rien envoyé.

  • Prenez un logiciel de messagerie instantanée : est-ce que, pour dialoguer, vous êtes obligés d'attendre que votre interlocuteur vous réponde ? Ce n'est pas « j'envoie un message, il me répond, je lui réponds, il me répond »… Parfois, souvent même, vous enverrez deux messages à la suite, peut-être même trois, ou l'inverse, qui sait ? Bref, on doit pouvoir envoyer plusieurs messages au client et réceptionner plusieurs messages dans un ordre inconnu. Avec notre technique, c'est impossible (faites le test si vous voulez).

En outre, les erreurs sont assez mal gérées, vous en conviendrez.

Le module select

Le module select va nous permettre une chose très intéressante, à savoir interroger plusieurs clients dans l'attente d'un message à réceptionner, sans paralyser notre programme.

Pour schématiser, select va écouter sur une liste de clients et retourner au bout d'un temps précisé. Ce que renvoie select, c'est la liste des clients qui ont un message à réceptionner. Il suffit de parcourir ces clients, de lire les messages en attente (grâce à recv) et le tour est joué.

Sur Linux, select peut être utilisé sur autre chose que des sockets mais, cette fonctionnalité n'étant pas portable, je ne fais que la mentionner ici.

En théorie

La fonction qui nous intéresse porte le même nom que le module associé, select. Elle prend trois ou quatre arguments et en renvoie trois. C'est maintenant qu'il faut être attentif :

Les arguments que prend la fonction sont :

  • rlist : la liste des sockets en attente d'être lus ;

  • wlist : la liste des sockets en attente d'être écrits ;

  • xlist : la liste des sockets en attente d'une erreur (je ne m'attarderai pas sur cette liste) ;

  • timeout : le délai pendant lequel la fonction attend avant de retourner. Si vous précisez en timeout 0, la fonction retourne immédiatement. Si ce paramètre n'est pas précisé, la fonction retourne dès qu'un des sockets change d'état (est prêt à être lu s'il est dans rlist par exemple) mais pas avant.

Concrètement, nous allons surtout nous intéresser au premier et au quatrième paramètre. En effet, wlist et xlist ne nous intéresseront pas présentement.

Ce qu'on veut, c'est mettre des sockets dans une liste et que select les surveille, en retournant dès qu'un socket est prêt à être lu. Comme cela notre programme ne bloque pas et il peut recevoir des messages de plusieurs clients dans un ordre complètement inconnu.

Maintenant, concernant le timeout : comme je vous l'ai dit, si vous ne le précisez pas, select bloque jusqu'au moment où l'un des sockets que nous écoutons est prêt à être lu, dans notre cas. Si vous précisez un timeout de 0, select retournera tout de suite. Sinon, select retournera au bout du temps que vous indiquez en secondes, ou plus tôt si un socket est prêt à être lu.

En gros, si vous précisez un timeout de 1, la fonction va bloquer pendant une seconde maximum. Mais si un des sockets en écoute est prêt à être lu dans l'intervalle (c'est-à-dire si un des clients envoie un message au serveur), la fonction retourne prématurément.

select renvoie trois listes, là encore rlist, wlist et xlist, sauf qu'il ne s'agit pas des listes fournies en entrée mais uniquement des sockets « à lire » dans le cas de rlist.

Ce n'est pas clair ? Considérez cette ligne (ne l'essayez pas encore ) :

rlist, wlist, xlist = select.select(clients_connectes, [], [], 2)

Cette instruction va écouter les sockets contenus dans la liste clients_connectes. Elle retournera au plus tard dans 2 secondes. Mais elle retournera plus tôt si un client envoie un message. La liste des clients ayant envoyé un message se retrouve dans notre variable rlist. On la parcourt ensuite et on peut appeler recv sur chacun des sockets.

Si ce n'est pas plus clair, rassurez-vous : nous allons voir select en action un peu plus bas. Vous pouvez également aller jeter un coup d'œil à la documentation du module select.

select en action

Nous allons un peu travailler sur notre serveur. Vous pouvez garder le même client de test.

Le but va être de créer un serveur pouvant accepter plusieurs clients, réceptionner leurs messages et leur envoyer une confirmation à chaque réception. L'exercice ne change pas beaucoup mais on va utiliser select pour travailler avec plusieurs clients.

J'ai parlé de select pour écouter plusieurs clients connectés mais cette fonction va également nous permettre de savoir si un (ou plusieurs) clients sont connectés au serveur. Si vous vous souvenez, la méthode accept est aussi une fonction bloquante. On va du reste l'utiliser de la même façon qu'un peu plus haut.

Je crois vous avoir donné assez d'informations théoriques. Le code doit parler maintenant :

import socket
import select

hote = ''
port = 12800

connexion_principale = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
connexion_principale.bind((hote, port))
connexion_principale.listen(5)
print("Le serveur écoute à présent sur le port {}".format(port))

serveur_lance = True
clients_connectes = []
while serveur_lance:
    # On va vérifier que de nouveaux clients ne demandent pas à se connecter
    # Pour cela, on écoute la connexion_principale en lecture
    # On attend maximum 50ms
    connexions_demandees, wlist, xlist = select.select([connexion_principale],
        [], [], 0.05)
    
    for connexion in connexions_demandees:
        connexion_avec_client, infos_connexion = connexion.accept()
        # On ajoute le socket connecté à la liste des clients
        clients_connectes.append(connexion_avec_client)
    
    # Maintenant, on écoute la liste des clients connectés
    # Les clients renvoyés par select sont ceux devant être lus (recv)
    # On attend là encore 50ms maximum
    # On enferme l'appel à select.select dans un bloc try
    # En effet, si la liste de clients connectés est vide, une exception
    # Peut être levée
    clients_a_lire = []
    try:
        clients_a_lire, wlist, xlist = select.select(clients_connectes,
                [], [], 0.05)
    except select.error:
        pass
    else:
        # On parcourt la liste des clients à lire
        for client in clients_a_lire:
            # Client est de type socket
            msg_recu = client.recv(1024)
            # Peut planter si le message contient des caractères spéciaux
            msg_recu = msg_recu.decode()
            print("Reçu {}".format(msg_recu))
            client.send(b"5 / 5")
            if msg_recu == "fin":
                serveur_lance = False

print("Fermeture des connexions")
for client in clients_connectes:
    client.close()

connexion_principale.close()

C'est plus long hein ? C'est inévitable, cependant.

Maintenant notre serveur peut accepter des connexions de plus d'un client, vous pouvez faire le test. En outre, il ne se bloque pas dans l'attente d'un message, du moins pas plus de 50 millisecondes.

Je pense que les commentaires sont assez précis pour vous permettre d'aller plus loin. Ceci n'est naturellement pas encore une version complète mais, grâce à cette base, vous devriez pouvoir facilement arriver à quelque chose. Pourquoi ne pas faire un mini tchat ?

Les déconnexions fortuites ne sont pas gérées non plus. Mais vous avez assez d'éléments pour faire des tests et améliorer notre serveur si cela vous tente.

Et encore plus

Je vous l'ai dit, le réseau est un vaste sujet et, même en se restreignant au sujet que j'ai choisi, il y aurait beaucoup d'autres choses à vous montrer. Je ne peux tout simplement pas remplacer la documentation et donc, si vous voulez en apprendre plus, je vous invite à jeter un coup d'œil à la page du module socket, de select et de socketserver.

Le dernier module, socketserver, propose une alternative pour monter vos applications serveur. Il en existe d'autres, dans tous les cas : vous pouvez utiliser des sockets non bloquants (c'est-à-dire qui ne bloquent pas le programme quand vous utilisez leur méthode accept ou recv) ou des threads pour exécuter différentes portions de votre programme en parallèle. Mais je vous laisse vous documenter sur ces sujets s'ils vous intéressent !

En résumé

  • Dans la structure réseau que nous avons vue, on trouve un serveur pouvant dialoguer avec plusieurs clients.

  • Pour créer une connexion côté serveur ou client, on utilise le module socket et la classe socket de ce module.

  • Pour se connecter à un serveur, le socket client utilise la méthode connect.

  • Pour écouter sur un port précis, le serveur utilise d'abord la méthode bind puis la méthode listen.

  • Pour s'échanger des informations, les sockets client et serveur utilisent les méthodes send et recv.

  • Pour fermer une connexion, le socket serveur ou client utilise la méthode close.

  • Le module select peut être utile si l'on souhaite créer un serveur pouvant gérer plusieurs connexions simultanément ; toutefois, il en existe d'autres.

L'auteur

Découvrez aussi ce cours en...

Exemple de certificat de réussite
Exemple de certificat de réussite