Chapitre 18 : Communications à travers un réseau▲
Le développement extraordinaire de l'internet a amplement démontré que les ordinateurs peuvent
être des outils de communication très efficaces. Dans ce chapitre, nous allons expérimenter la plus
simple des techniques d'interconnexion de deux programmes, qui leur permette de s'échanger des
informations par l'intermédiaire d'un réseau.
Pour ce qui va suivre, nous supposerons donc que vous collaborez avec un ou plusieurs de vos
condisciples, et que vos postes de travail Python sont connectés à un réseau local dont les
communications utilisent le protocole TCP/IP. Le système d'exploitation n'a pas d'importance : vous
pouvez par exemple installer l'un des scripts Python décrits ci-après sur un poste de travail
fonctionnant sous Linux, et le faire dialoguer avec un autre script mis en oeuvre sur un poste de
travail confié aux bons soins d'un système d'exploitation différent, tel que MacOS ou Windows.
Vous pouvez également expérimenter ce qui suit sur une seule et même machine, en mettant les
différents scripts en oeuvre dans des fenêtres indépendantes.
18.1. Les sockets▲
Le premier exercice qui va vous être proposé consistera à établir une communication entre deux
machines seulement. L'une et l'autre pourront s'échanger des messages à tour de rôle, mais vous
constaterez cependant que leurs configurations ne sont pas symétriques. Le script installé sur l'une
de ces machines jouera en effet le rôle d'un logiciel serveur, alors que l'autre se comportera comme
un logiciel client.
Le logiciel serveur fonctionne en continu, sur une machine dont l'identité est bien définie sur le
réseau grâce à une adresse IP spécifique73. Il guette en permanence l'arrivée de requêtes expédiées
par les clients potentiels en direction de cette adresse, par l'intermédiaire d'un port de
communication bien déterminé. Pour ce faire, le script correspondant doit mettre en oeuvre un objet
logiciel associé à ce port, que l'on appelle un socket.
Au départ d'une autre machine, le logiciel client tente d'établir la connexion en émettant une
requête appropriée. Cette requête est un message qui est confié au réseau, un peu comme on confie
une lettre à la Poste. Le réseau pourrait en effet acheminer la requête vers n'importe quelle autre
machine, mais une seule est visée : pour que la destination visée puisse être atteinte, la requête
contient dans son en-tête l'indication de l'adresse IP et du port de communication destinataires.
Lorsque la connexion est établie avec le serveur, le client lui assigne lui-même l'un de ses
propres ports de communication. A partir de ce moment, on peut considérer qu'un canal privilégié
relie les deux machines, comme si on les avait connectées l'une à l'autre par l'intermédiaire d'un fil
(les deux ports de communication respectifs jouant le rôle des deux extrémités de ce fil). L'échange
d'informations proprement dit peut commencer.
Pour pouvoir utiliser les ports de communication réseau, les programmes font appel à un
ensemble de procédures et de fonctions du système d'exploitation, par l'intermédiaire d'objets
interfaces que l'on appelle des sockets. Ceux-ci peuvent mettre en oeuvre deux techniques de
communication différentes et complémentaires : celle des paquets (que l'on appelle aussi des
datagrammes), très largement utilisée sur l'internet, et celle de la connexion continue, ou stream
socket, qui est un peu plus simple.
73 Une machine particulière peut également être désignée par un nom plus explicite, mais à la condition qu'un mécanisme ait été mis en place sur le réseau (DNS) pour traduire automatiquement ce nom en adresse IP. Veuillez consulter votre cours sur les systèmes d'exploitation et les réseaux pour en savoir davantage.
18.2. Construction d'un serveur élémentaire▲
Pour nos premières expériences, nous allons utiliser la technique des stream sockets.
Celle-ci est en effet parfaitement appropriée lorsqu'il s'agit de faire communiquer des ordinateurs
interconnectés par l'intermédiaire d'un réseau local. C'est une technique particulièrement aisée à
mettre en oeuvre, et elle permet un débit élevé pour l'échange de données.
L'autre technologie (celle des paquets) serait préférable pour les communications expédiées via
l'internet, en raison de sa plus grande fiabilité (les mêmes paquets peuvent atteindre leur destination
par différents chemins, être émis ou ré-émis en plusieurs exemplaires si cela se révèle nécessaire
pour corriger les erreurs de transmission), mais sa mise en oeuvre est un peu plus complexe. Nous
ne l'étudierons pas dans ce cours.
Le script ci-dessous met en place un serveur capable de communiquer avec un seul client. Nous
verrons un peu plus loin ce qu'il faut lui ajouter afin qu'il puisse prendre en charge en parallèle les
connexions de plusieurs clients.
1
. #
Définition
d'un
serveur
réseau
rudimentaire
2
. #
Ce
serveur
attend
la
connexion
d'un
client,
pour
entamer
un
dialogue
avec
lui
3
.
4
. import
socket, sys
5
.
6
. HOST =
'
192
.
168
.
14
.
152
'
7
. PORT =
50000
8
.
9
. #
1)
création
du
socket
:
10
.mySocket =
socket.socket
(socket.AF_INET, socket.SOCK_STREAM)
11
.
12
.#
2)
liaison
du
socket
à
une
adresse
précise
:
13
.try
:
14
. mySocket.bind
((HOST, PORT))
15
.except
socket.error:
16
. print
"
La
liaison
du
socket
à
l
'
adresse
choisie
a
échoué
.
"
17
. sys.exit
()
18
.
19
.while
1
:
20
. #
3)
Attente
de
la
requête
de
connexion
d'un
client
:
21
. print
"
Serveur
prêt
,
en
attente
de
requêtes
.
.
.
"
22
. mySocket.listen
(5
)
23
.
24
. #
4)
Etablissement
de
la
connexion
:
25
. connexion, adresse =
mySocket.accept
()
26
. print
"
Client
connecté
,
adresse
IP
%s
,
port
%s
"
%
(adresse[0
], adresse[1
])
27
.
28
. #
5)
Dialogue
avec
le
client
:
29
. connexion.send
("
Vous
êtes
connecté
au
serveur
Marcel
.
Envoyez
vos
messages
.
"
)
30
. msgClient =
connexion.recv
(1024
)
31
. while
1
:
32
. print
"
C
>
"
, msgClient
33
. if
msgClient.upper
() =
=
"
FIN
"
or
msgClient =
=
"
"
:
34
. break
35
. msgServeur =
raw_input
("
S
>
"
)
36
. connexion.send
(msgServeur)
37
. msgClient =
connexion.recv
(1024
)
38
.
39
. #
6)
Fermeture
de
la
connexion
:
40
. connexion.send
("
Au
revoir
!
"
)
41
. print
"
Connexion
interrompue
.
"
42
. connexion.close
()
43
.
44
. ch =
raw_input
("
<
R
>
ecommencer
<
T
>
erminer
?
"
)
45
. if
ch.upper
() =
=
'
T
'
:
46
. break
Commentaires :
- Ligne 4 : Le module socket contient toutes les fonctions et les classes nécessaires pour construire
des programmes communiquants. Comme nous allons le voir dans les lignes suivantes,
l'établissement de la communication comporte six étapes.
- Lignes 6 & 7 : Ces deux variables définissent l'identité du serveur, telle qu'on l'intégrera au
socket. HOST doit contenir une chaîne de caractères indiquant l'adresse IP du serveur sous la
forme décimale habituelle, ou encore le nom DNS de ce même serveur (mais à la condition qu'un
mécanisme de résolution des noms ait été mis en place sur le réseau). PORT doit contenir un
entier, à savoir le numéro d'un port qui ne soit pas déjà utilisé pour un autre usage, et de
préférence une valeur supérieure à 1024 (Cfr. votre cours sur les services réseau).
- Lignes 9 & 10 : Première étape du mécanisme d'interconnexion. On instancie un objet de la
classe socket(), en précisant deux options qui indiquent le type d'adresses choisi (nous utiliserons
des adresses de type « internet ») ainsi que la technologie de transmission (datagrammes ou
connexion continue (stream) : nous avons décidé d'utiliser cette dernière).
- Lignes 12 à 17 : Seconde étape. On tente d'établir la liaison entre le socket et le port de
communication. Si cette liaison ne peut être établie (port de communication occupé, par
exemple, ou nom de machine incorrect), le programme se termine sur un message d'erreur.
Remarque : la méthode bind() du socket attend un argument du type tuple, raison pour laquelle nous devons enfermer nos deux variables dans une double paire de parenthèses. - Ligne 19 : Notre programme serveur étant destiné à fonctionner en permanence dans l'attente des
requêtes de clients potentiels, nous le lançons dans une boucle sans fin.
- Lignes 20 à 22 : Troisième étape. Le socket étant relié à un port de communication, il peut à
présent se préparer à recevoir les requêtes envoyées par les clients. C'est le rôle de la méthode
listen(). L'argument qu'on lui transmet indique le nombre maximum de connexions à accepter en
parallèle. Nous verrons plus loin comment gérer celles-ci.
- Lignes 24 à 26 : Quatrième étape. Lorsqu'on fait appel à sa méthode accept(), le socket attend
indéfiniment qu'une requête se présente. Le script est donc interrompu à cet endroit, un peu
comme il le serait si nous faisions appel à une fonction input() pour attendre une entrée clavier.
Si une requête est réceptionnée, la méthode accept() renvoie un tuple de deux éléments : le
premier est la référence d'un nouvel objet de la classe socket()74, qui sera la véritable interface de
communication entre le client et le serveur, et le second un autre tuple contenant les coordonnées
de ce client (son adresse IP et le n° de port qu'il utilise lui-même).
- Lignes 28 à 30 : Cinquième étape. La communication proprement dite est établie. Les méthodes
send() et recv() du socket servent évidemment à l'émission et à la réception des messages, qui
doivent être de simples chaînes de caractères.
Remarques : la méthode send() renvoie le nombre d'octets expédiés. L'appel de la méthode recv
() doit comporter un argument entier indiquant le nombre maximum d'octets à réceptionner en
une fois (Les octets surnuméraires sont mis en attente dans un tampon. Ils sont transmis lorsque
la même méthode recv() est appelée à nouveau).
- Lignes 31 à 37 : Cette nouvelle boucle sans fin maintient le dialogue jusqu'à ce que le client
décide d'envoyer le mot « fin » ou une simple chaîne vide. Les écrans des deux machines
afficheront chacune l'évolution de ce dialogue.
- Lignes 39 à 42 : Sixième étape. Fermeture de la connexion.
74 Nous verrons plus loin l'utilité de créer ainsi un nouvel objet socket pour prendre en charge la communication, plutôt que d'utiliser celui qui a déjà créé à la ligne 10. En bref, si nous voulons que notre serveur puisse prendre en charge simultanément les connexions de plusieurs clients, il nous faudra disposer d'un socket distinct pour chacun d'eux, indépendamment du premier que l'on laissera fonctionner en permanence pour réceptionner les requêtes qui continuent à arriver en provenance de nouveaux clients.
18.3. Construction d'un client rudimentaire▲
Le script ci-dessous définit un logiciel client complémentaire du serveur décrit dans les pages précédentes. On notera sa grande simplicité.
1
. #
Définition
d'un
client
réseau
rudimentaire
2
. #
Ce
client
dialogue
avec
un
serveur
ad
hoc
3
.
4
. import
socket, sys
5
.
6
. HOST =
'
192
.
168
.
14
.
152
'
7
. PORT =
50000
8
.
9
. #
1)
création
du
socket
:
10
.mySocket =
socket.socket
(socket.AF_INET, socket.SOCK_STREAM)
11
.
12
.#
2)
envoi
d'une
requête
de
connexion
au
serveur
:
13
.try
:
14
. mySocket.connect
((HOST, PORT))
15
.except
socket.error:
16
. print
"
La
connexion
a
échoué
.
"
17
. sys.exit
()
18
.print
"
Connexion
établie
avec
le
serveur
.
"
19
.
20
.#
3)
Dialogue
avec
le
serveur
:
21
.msgServeur =
mySocket.recv
(1024
)
22
.
23
.while
1
:
24
. if
msgServeur.upper
() =
=
"
FIN
"
or
msgServeur =
=
"
"
:
25
. break
26
. print
"
S
>
"
, msgServeur
27
. msgClient =
raw_input
("
C
>
"
)
28
. mySocket.send
(msgClient)
29
. msgServeur =
mySocket.recv
(1024
)
30
.
31
.#
4)
Fermeture
de
la
connexion
:
32
.print
"
Connexion
interrompue
.
"
33
.mySocket.close
()
Commentaires :
- Le début du script est similaire à celui du serveur. L'adresse IP et le port de communication
doivent être ceux du serveur.
- Lignes 12 à 18 : On ne crée cette fois qu'un seul objet socket, dont on utilise la méthode connect
() pour envoyer la requête de connexion.
- Lignes 20 à 33 : Une fois la connexion établie, on peut dialoguer avec le serveur en utilisant les méthodes send() et recv() déjà décrites plus haut pour celui-ci.
18.4. Gestion de plusieurs tâches en parallèle à l'aide des threads▲
Le système de communication que nous avons élaboré dans les pages précédentes est vraiment
très rudimentaire : d'une part il ne met en relation que deux machines seulement, et d'autre part il
limite la liberté d'expression des deux interlocuteurs. Ceux-ci ne peuvent en effet envoyer des
messages que chacun à leur tour. Par exemple, lorsque l'un d'eux vient d'émettre un message, son
système reste bloqué tant que son partenaire ne lui a pas envoyé une réponse. Lorsqu'il vient de
recevoir une telle réponse, son système reste incapable d'en réceptionner une autre, tant qu'il n'a pas
entré lui-même un nouveau message, ... et ainsi de suite.
Tous ces problèmes proviennent du fait que nos scripts habituels ne peuvent s'occuper que d'une
seule chose à la fois. Lorsque le flux d'instructions rencontre une fonction input(), par exemple, il
ne se passe plus rien tant que l'utilisateur n'a pas introduit la donnée attendue. Et même si cette
attente dure très longtemps, il n'est habituellement pas possible que le programme effectue d'autres
tâches pendant ce temps. Ceci n'est toutefois vrai qu'au sein d'un seul et même programme : vous
savez certainement que vous pouvez exécuter d'autres applications entretemps sur votre ordinateur,
car les systèmes d'exploitation modernes sont « multi-tâches ».
Les pages qui suivent sont destinées à vous expliquer comment vous pouvez introduire cette
fonctionnalité multi-tâches dans vos programmes, afin que vous puissiez développer de véritables
applications réseau, capables de communiquer simultanément avec plusieurs partenaires.
Veuillez à présent considérer le script de la page précédente. Sa fonctionnalité essentielle réside
dans la boucle while des lignes 23 à 29. Or, cette boucle s'interrompt à deux endroits :
- à la ligne 27, pour attendre les entrées clavier de l'utilisateur (fonction raw_input()) ;
- à la ligne 29, pour attendre l'arrivée d'un message réseau.
Ces deux attentes sont donc successives, alors qu'il serait bien plus intéressant qu'elles soient
simultanées. Si c'était le cas, l'utilisateur pourrait expédier des messages à tout moment, sans devoir
attendre à chaque fois la réaction de son partenaire. Il pourrait également recevoir n'importe quel
nombre de messages, sans l'obligation d'avoir à répondre à chacun d'eux pour recevoir les autres.
Nous pouvons arriver à ce résultat si nous apprenons à gérer plusieurs séquences d'instructions
en parallèle au sein d'un même programme. Mais comment cela est-il possible ?
Au cours de l'histoire de l'informatique, plusieurs techniques ont été mises au point pour partager
le temps de travail d'un processeur entre différentes tâches, de telle manière que celles-ci paraissent
être effectuées en même temps (alors qu'en réalité le processeur s'occupe d'un petit bout de chacune
d'elles à tour de rôle). Ces techniques sont implémentées dans le système d'exploitation, et il n'est
pas nécessaire de les détailler ici, même s'il est possible d'accéder à chacune d'elles avec Python.
Dans les pages suivantes, nous allons apprendre à utiliser celle de ces techniques qui est à la fois
la plus facile à mettre en oeuvre, et la seule qui soit véritablement portable (elle est en effet
supportée par tous les grands systèmes d'exploitation) : on l'appelle la technique des processus
légers ou threads75.
Dans un programme d'ordinateur, les threads sont des flux d'instructions qui sont menés en
parallèle (quasi-simultanément), tout en partageant le même espace de noms global.
En fait, le flux d'instructions de n'importe quel programme Python suit toujours au moins un
thread : le thread principal. À partir de celui-ci, d'autres threads « enfants » peuvent être amorcés,
qui seront exécutés en parallèle. Chaque thread enfant se termine et disparaît sans autre forme de
procès lorsque toutes les instructions qu'il contient ont été exécutées. Par contre, lorsque le thread
principal se termine, il faut parfois s'assurer que tous ses threads enfants « meurent » avec lui.
75 Dans un système d'exploitation de type Unix (comme Linux), les différents threads d'un même programme font partie d'un seul processus. Il est également possible de gérer différents processus à l'aide d'un même script Python (opération fork), mais l'explication de cette technique dépasse largement le cadre de ce cours.
18.5. Client gérant l'émission et la réception simultanées▲
Nous allons maintenant mettre en pratique la technique des threads pour construire un système
de « chat »76 simplifié. Ce système sera constitué d'un seul serveur et d'un nombre quelconque de
clients. Contrairement à ce qui se passait dans notre premier exercice, personne n'utilisera le serveur
lui-même pour communiquer, mais lorsque celui-ci aura été mis en route, plusieurs clients pourront
s'y connecter et commencer à s'échanger des messages.
Chaque client enverra tous ses messages au serveur, mais celui-ci les ré-expédiera
immédiatement à tous les autres clients connectés, de telle sorte que chacun puisse voir l'ensemble
du trafic. Chacun pourra à tout moment envoyer ses messages, et recevoir ceux des autres, dans
n'importe quel ordre, la réception et l'émission étant gérées simultanément, dans des threads
séparés.
Le script ci-après définit le programme client. Le serveur sera décrit un peu plus loin. Vous
constaterez que la partie principale du script (ligne 38 et suivantes) est similaire à celle de l'exemple
précédent. Seule la partie « Dialogue avec le serveur » a été remplacée. Au lieu d'une boucle while,
vous y trouvez à présent les instructions de création de deux objets threads (aux lignes 49 et 50),
dont on démarre la fonctionnalité aux deux lignes suivantes. Ces objets threads sont crées par
dérivation, à partir de la classe Thread() du module threading. Ils s'occuperont indépendamment
de la réception et le l'émission des messages. Les deux threads « enfants » sont ainsi parfaitement
encapsulés dans des objets distincts, ce qui facilite la compréhension du mécanisme.
1
. #
Définition
d'un
client
réseau
gérant
en
parallèle
l'émission
2
. #
et
la
réception
des
messages
(utilisation
de
2
THREADS).
3
.
4
. host =
'
192
.
168
.
0
.
235
'
5
. port =
40000
6
.
7
. import
socket, sys, threading
8
.
9
. class
ThreadReception
(threading.Thread):
10
. """
objet
thread
gérant
la
réception
des
messages
"""
11
. def
__init__
(self, conn):
12
. threading.Thread.__init__
(self)
13
. self.connexion =
conn #
réf.
du
socket
de
connexion
14
.
15
. def
run
(self):
16
. while
1
:
17
. message_recu =
self.connexion.recv
(1024
)
18
. print
"
*
"
+
message_recu +
"
*
"
19
. if
message_recu =
=
'
'
or
message_recu.upper
() =
=
"
FIN
"
:
20
. break
21
. #
Le
thread
<réception>
se
termine
ici.
22
. #
On
force
la
fermeture
du
thread
<émission>
:
23
. th_E._Thread__stop
()
24
. print
"
Client
arrêté
.
Connexion
interrompue
.
"
25
. self.connexion.close
()
26
.
27
.class
ThreadEmission
(threading.Thread):
28
. """
objet
thread
gérant
l
'
émission
des
messages
"""
29
. def
__init__
(self, conn):
30
. threading.Thread.__init__
(self)
31
. self.connexion =
conn #
réf.
du
socket
de
connexion
32
.
33
. def
run
(self):
34
. while
1
:
35
. message_emis =
raw_input
()
36
. self.connexion.send
(message_emis)
37
.
38
.#
Programme
principal
-
Établissement
de
la
connexion
:
39
.connexion =
socket.socket
(socket.AF_INET, socket.SOCK_STREAM)
40
.try
:
41
. connexion.connect
((host, port))
42
.except
socket.error:
43
. print
"
La
connexion
a
échoué
.
"
44
. sys.exit
()
45
.print
"
Connexion
établie
avec
le
serveur
.
"
46
.
47
.#
Dialogue
avec
le
serveur
:
on
lance
deux
threads
pour
gérer
48
.#
indépendamment
l'émission
et
la
réception
des
messages
:
49
.th_E =
ThreadEmission
(connexion)
50
.th_R =
ThreadReception
(connexion)
51
.th_E.start
()
52
.th_R.start
()
Commentaires :
- Remarque générale : Dans cet exemple, nous avons décidé de créer deux objets threads
indépendants du thread principal, afin de bien mettre en évidence les mécanismes. Notre
programme utilise donc trois threads en tout, alors que le lecteur attentif aura remarqué que deux
pourraient suffire. En effet : le thread principal ne sert en définitive qu'à lancer les deux autres !
Il n'y a cependant aucun intérêt à limiter le nombre de threads. Au contraire : à partir du moment
où l'on décide d'utiliser cette technique, il faut en profiter pour compartimenter l'application en
unités bien distinctes.
- Ligne 7 : Le module threading contient la définition de toute une série de classes intéressantes
pour gérer les threads. Nous n'utiliserons ici que la seule classe Thread(), mais une autre sera
exploitée plus loin (la classe Lock()), lorsque nous devrons nous préoccuper de problèmes de
synchronisation entre différents threads concurrents.
- Lignes 9 à 25 : Les classes dérivées de la classe Thread() contiendront essentiellement une
méthode run(). C'est dans celle-ci que l'on placera la portion de programme spécifiquement
confiée au thread. Il s'agira souvent d'une boucle répétitive, comme ici. Vous pouvez
parfaitement considérer le contenu de cette méthode comme un script indépendant, qui s'exécute
en parallèle avec les autres composants de votre application. Lorsque ce code a été complètement
exécuté, le thread se referme.
- Lignes 16 à 20 : Cette boucle gère la réception des messages. À chaque itération, le flux
d'instructions s'interrompt à la ligne 17 dans l'attente d'un nouveau message, mais le reste du
programme n'est pas figé pour autant : les autres threads continuent leur travail indépendamment.
- Ligne 19 : La sortie de boucle est provoquée par la réception d'un message 'fin' (en majuscules
ou en minuscules), ou encore d'un message vide (c'est notamment le cas si la connexion est
coupée par le partenaire). Quelques instructions de « nettoyage » sont alors exécutées, et puis le
thread se termine.
- Ligne 23 : Lorsque la réception des messages est terminée, nous souhaitons que le reste du
programme se termine lui aussi. Il nous faut donc forcer la fermeture de l'autre objet thread, celui
que nous avons mis en place pour gérer l'émission des messages. Cette fermeture forcée peut être
obtenue à l'aide de la méthode _Thread__stop()77.
- Lignes 27 à 36 : Cette classe définit donc un autre objet thread, qui contient cette fois une boucle
de répétition perpétuelle. Il ne se pourra donc se terminer que contraint et forcé par méthode
décrite au paragraphe précédent. À chaque itération de cette boucle, le flux d'instructions
s'interrompt à la ligne 35 dans l'attente d'une entrée clavier, mais cela n'empêche en aucune
manière les autres threads de faire leur travail.
- Lignes 38 à 45 : Ces lignes sont reprises à l'identique des scripts précédents.
- Lignes 47 à 52 : Instanciation et démarrage des deux objets threads « enfants ». Veuillez noter qu'il est recommandé de provoquer ce démarrage en invoquant la méthode intégrée start(), plutôt qu'en faisant appel directement à la méthode run() que vous aurez définie vous-même. Sachez également que vous ne pouvez invoquer start() qu'une seule fois (une fois arrêté, un objet thread ne peut pas être redémarré).
76 Le « chat » est l'occupation qui consiste à « papoter » par l'intermédiaire d'ordinateurs. Les canadiens francophones
ont proposé le terme de clavardage pour désigner ce « bavardage par claviers interposés ».
77 Que les puristes veuillent bien me pardonner : j'admets volontiers que cette astuce pour forcer l'arrêt d'un thread
n'est pas vraiment recommandable. Je me suis autorisé ce raccourci afin de ne pas trop alourdir ce texte, qui se veut
seulement une initiation. Le lecteur exigeant pourra approfondir cette question en consultant l'un ou l'autre des
ouvrages de référence mentionnés dans la bibliographie (voir page 8)
18.6. Serveur gérant les connexions de plusieurs clients en parallèle▲
Le script ci-après crée un serveur capable de prendre en charge les connexions d'un certain
nombre de clients du même type que ce que nous avons décrit dans les pages précédentes.
Ce serveur n'est pas utilisé lui-même pour communiquer : ce sont les clients qui communiquent
les uns avec les autres, par l'intermédiaire du serveur. Celui-ci joue donc le rôle d'un relais : il
accepte les connexions des clients, puis attend l'arrivée de leurs messages. Lorsqu'un message arrive
en provenance d'un client particulier, le serveur le ré-expédie à tous les autres, en lui ajoutant au
passage une chaîne d'identification spécifique du client émetteur, afin que chacun puisse voir tous
les messages, et savoir de qui ils proviennent.
1
. #
Définition
d'un
serveur
réseau
gérant
un
système
de
CHAT
simplifié.
2
. #
Utilise
les
threads
pour
gérer
les
connexions
clientes
en
parallèle.
3
.
4
. HOST =
'
192
.
168
.
0
.
235
'
5
. PORT =
40000
6
.
7
. import
socket, sys, threading
8
.
9
. class
ThreadClient
(threading.Thread):
10
. '''
dérivation
d
'
un
objet
thread
pour
gérer
la
connexion
avec
un
client
'''
11
. def
__init__
(self, conn):
12
. threading.Thread.__init__
(self)
13
. self.connexion =
conn
14
.
15
. def
run
(self):
16
. #
Dialogue
avec
le
client
:
17
. nom =
self.getName
() #
Chaque
thread
possède
un
nom
18
. while
1
:
19
. msgClient =
self.connexion.recv
(1024
)
20
. if
msgClient.upper
() =
=
"
FIN
"
or
msgClient =
=
"
"
:
21
. break
22
. message =
"
%s
>
%s
"
%
(nom, msgClient)
23
. print
message
24
. #
Faire
suivre
le
message
à
tous
les
autres
clients
:
25
. for
cle in
conn_client:
26
. if
cle !
=
nom: #
ne
pas
le
renvoyer
à
l'émetteur
27
. conn_client[cle].send
(message)
28
.
29
. #
Fermeture
de
la
connexion
:
30
. self.connexion.close
() #
couper
la
connexion
côté
serveur
31
. del
conn_client[nom] #
supprimer
son
entrée
dans
le
dictionnaire
32
. print
"
Client
%s
déconnecté
.
"
%
nom
33
. #
Le
thread
se
termine
ici
34
.
35
.#
Initialisation
du
serveur
-
Mise
en
place
du
socket
:
36
.mySocket =
socket.socket
(socket.AF_INET, socket.SOCK_STREAM)
37
.try
:
38
. mySocket.bind
((HOST, PORT))
39
.except
socket.error:
40
. print
"
La
liaison
du
socket
à
l
'
adresse
choisie
a
échoué
.
"
41
. sys.exit
()
42
.print
"
Serveur
prêt
,
en
attente
de
requêtes
.
.
.
"
43
.mySocket.listen
(5
)
44
.
45
.#
Attente
et
prise
en
charge
des
connexions
demandées
par
les
clients
:
46
.conn_client =
{} #
dictionnaire
des
connexions
clients
47
.while
1
:
48
. connexion, adresse =
mySocket.accept
()
49
. #
Créer
un
nouvel
objet
thread
pour
gérer
la
connexion
:
50
. th =
ThreadClient
(connexion)
51
. th.start
()
52
. #
Mémoriser
la
connexion
dans
le
dictionnaire
:
53
. it =
th.getName
() #
identifiant
du
thread
54
. conn_client[it] =
connexion
55
. print
"
Client
%s
connecté
,
adresse
IP
%s
,
port
%s
.
"
%
\
56
. (it, adresse[0
], adresse[1
])
57
. #
Dialogue
avec
le
client
:
58
. connexion.send
("
Vous
êtes
connecté
.
Envoyez
vos
messages
.
"
)
Commentaires :
- Lignes 35 à 43 : L'initialisation de ce serveur est identique à celle du serveur rudimentaire décrit
au début du présent chapitre.
- Ligne 46 : Les références des différentes connexions doivent être mémorisées. Nous pourrions
les placer dans une liste, mais il est plus judicieux de les placer dans un dictionnaire, pour deux
raisons : La première est que nous devrons pouvoir ajouter ou enlever ces références dans
n'importe quel ordre, puisque les clients se connecteront et se déconnecteront à leur guise. La
seconde est que nous pouvons disposer aisément d'un identifiant unique pour chaque connexion,
lequel pourra servir de clé d'accès dans un dictionnaire. Cet identifiant nous sera en effet fourni
automatiquement par La classe Thread().
- Lignes 47 à 51 : Le programme commence ici une boucle de répétition perpétuelle, qui va
constamment attendre l'arrivée de nouvelles connexions. Pour chacune de celles-ci, un nouvel
objet ThreadClient() est créé, lequel pourra s'occuper d'elle indépendamment de toutes les
autres.
- Lignes 52 à 54 : Obtention d'un identifiant unique à l'aide de la méthode getName(). Nous
pouvons profiter ici du fait que Python attribue automatiquement un nom unique à chaque
nouveau thread : ce nom convient bien comme identifiant (ou clé) pour retrouver la connexion
correspondante dans notre dictionnaire. Vous pourrez constater qu'il s'agit d'une chaîne de
caractères, de la forme : « Thread-N » (N étant le numéro d'ordre du thread).
- Lignes 15 à 17 : Gardez bien à l'esprit qu'il se créera autant d'objets ThreadClient() que de
connexions, et que tous ces objets fonctionneront en parallèle. La méthode getName() peut alors
être utilisée au sein de l'un quelconque de ces objets pour retrouver son identité particulière.
Nous utiliserons cette information pour distinguer la connexion courante de toutes les autres
(voir ligne 26).
- Lignes 18 à 23 : L'utilité du thread est de réceptionner tous les messages provenant d'un client
particulier. Il faut donc pour cela une boucle de répétition perpétuelle, qui ne s'interrompra qu'à
la réception du message spécifique : « fin », ou encore à la réception d'un message vide (cas où
la connexion est coupée par le partenaire).
- Lignes 24 à 27 : Chaque message reçu d'un client doit être ré-expédié à tous les autres. Nous
utilisons ici une boucle for pour parcourir l'ensemble des clés du dictionnaire des connexions,
lesquelles nous permettent ensuite de retrouver les connexions elles-mêmes. Un simple test (à la
ligne 26) nous évite de ré-expédier le message au client dont il provient.
- Ligne 31 : Lorsque nous fermons un socket de connexion, il est préférable de supprimer sa référence dans le dictionnaire, puisque cette référence ne peut plus servir. Et nous pouvons faire cela sans précaution particulière, car les éléments d'un dictionnaire ne sont pas ordonnés (nous pouvons en ajouter ou en enlever dans n'importe quel ordre).
18.7. Jeu des bombardes, version réseau▲
Au chapitre 15, nous avons commenté le développement d'un petit jeu de combat dans lequel des joueurs s'affrontaient à l'aide de bombardes. L'intérêt de ce jeu reste toutefois fort limité, tant qu'il se pratique sur un seul et même ordinateur. Nous allons donc le perfectionner, en y intégrant les techniques que nous venons d'apprendre. Comme le système de « chat » décrit dans les pages précédentes, l'application complète se composera désormais de deux programmes distincts : un logiciel serveur qui ne sera mis en fonctionnement que sur une seule machine, et un logiciel client qui pourra être lancé sur toute une série d'autres. Du fait du caractère portable de Python, il vous sera même possible d'organiser des combats de bombardes entre ordinateurs gérés par des systèmes d'exploitation différents (MacOS <> Linux <> Windows !).
18.7.1. Programme serveur : vue d'ensemble▲
Les programmes serveur et client exploitent la même base logicielle, elle-même largement
récupérée de ce qui avait déjà été mis au point tout au long du chapitre 15. Nous admettrons donc
pour la suite de cet exposé que les deux versions précédentes du jeu ont été sauvegardées dans les
fichiers-modules canon03.py et canon04.py, installés dans le répertoire courant. Nous pouvons en
effet réutiliser une bonne partie du code qu'ils contiennent, en nous servant judicieusement de
l'importation et de l'héritage de classes.
Du module canon04, nous allons réutiliser la classe Canon() telle quelle, aussi bien pour le
logiciel serveur que pour le logiciel client. De ce même module, nous importerons également la
classe AppBombardes(), dont nous ferons dériver la classe maîtresse de notre application serveur :
AppServeur(). Vous constaterez plus loin que celle-ci produira elle-même la sous-classe
AppClient(), toujours par héritage.
Du module canon03, nous récupérerons la classe Pupitre() dont nous tirerons une version plus
adaptée au « contrôle à distance ».
Enfin, deux nouvelles classes viendront s'ajouter aux précédentes, chacune spécialisée dans la
création d'un objet thread : la classe ThreadClients(), dont une instance surveillera en permanence
le socket destiné à réceptionner les demandes de connexion de nouveaux clients, et la classe
ThreadConnexion(), qui servira à créer autant d'objets sockets que nécessaire pour assurer le
dialogue avec chacun des clients déjà connectés.
Ces nouvelles classes seront inspirées de celles que nous avions développées pour notre serveur
de « chat » dans les pages précédentes. La principale différence par rapport à celui-ci est que nous
devrons activer un thread spécifique pour le code qui gère l'attente et la prise en charge des
connexions clientes, afin que l'application principale puisse faire autre chose pendant ce temps.
A partir de là, notre plus gros travail consistera à développer un protocole de communication
pour le dialogue entre le serveur et ses clients. De quoi est-il question ? Tout simplement de définir
la teneur des messages que vont s'échanger les machines connectées. Rassurez-vous : la mise au
point de ce « langage » peut être progressive. On commence par établir un dialogue de base, puis on
y ajoute petit à petit un « vocabulaire » plus étendu.
L'essentiel de ce travail peut être accompli en s 'aidant du logiciel client développé
précédemment pour le système de « chat ». On se sert de celui-ci pour envoyer des « ordres » au
serveur en cours de développement, et on corrige celui-ci jusqu'à ce qu'il « obéisse » : en clair, les
procédures que l'on met en place progressivement sur le serveur sont testées au fur et à mesure, en
réponse aux messages correspondants émis « à la main » à partir du client.
18.7.2. Protocole de communication▲
Il va de soi que le protocole décrit ci-après est tout à fait arbitraire. Il serait parfaitement possible
de choisir d'autres conventions complètement différentes. Vous pouvez bien évidemment critiquer
les choix effectués, et vous souhaiterez peut-être même les remplacer par d'autres, plus efficients ou
plus simples.
Vous savez déjà que les messages échangés sont de simples chaînes de caractères. Prévoyant que
certains de ces messages devront transmettre plusieurs informations à la fois, nous avons décidé que
chacun d'eux pourrait comporter plusieurs champs, que nous séparerons à l'aide de virgules. Lors de
la réception de l'un quelconque de ces messages, nous pourrons alors aisément récupérer tous ses
composants dans une liste, à l'aide de la méthode intégrée split().
Voici un exemple de dialogue type, tel qu'il peut être suivi du côté d'un client. Les messages
entre astérisques sont ceux qui sont reçus du serveur ; les autres sont ceux qui sont émis par le client
lui-même :
1
. *
serveur OK*
2
. client OK
3
. *
canons,Thread-
3
;104
;228
;1
;dark red,Thread-
2
;454
;166
;-
1
;dark blue,*
4
. OK
5
. *
nouveau_canon,Thread-
4
,481
,245
,-
1
,dark green,le_vôtre*
6
. orienter,25
,
7
. feu
8
. *
mouvement_de,Thread-
4
,549
,280
,*
9
. feu
10
.*
mouvement_de,Thread-
4
,504
,278
,*
11
.*
scores,Thread-
4
;1
,Thread-
3
;-
1
,Thread-
2
;0
,*
12
.*
angle,Thread-
2
,23
,*
13
.*
angle,Thread-
2
,20
,*
14
.*
tir_de,Thread-
2
,*
15
.*
mouvement_de,Thread-
2
,407
,191
,*
16
.*
départ_de,Thread-
2
*
17
.*
nouveau_canon,Thread-
5
,502
,276
,-
1
,dark green*
Lorsqu'un nouveau client démarre, il envoie une requête de connexion au serveur, lequel lui
expédie en retour le message : « serveur OK ». À la réception de ce dernier, le client répond alors en
envoyant lui-même : « client OK ». Ce premier échange de politesses n'est pas absolument
indispensable, mais il permet de vérifier que la communication passe bien dans les deux sens. Étant
donc averti que le client est prêt à travailler, le serveur lui expédie alors une description des canons
déjà présents dans le jeu (éventuellement aucun) : identifiant, emplacement sur le canevas,
orientation et couleur (ligne 3).
En réponse à l'accusé de réception du client (ligne 4), le serveur installe un nouveau canon dans
l'espace de jeu, puis il signale les caractéristiques de cette installation non seulement au client qui l'a
provoquée, mais également à tous les autres clients connectés. Le message expédié au nouveau
client comporte cependant une différence (car c'est lui le propriétaire de ce nouveau canon) : en plus
des caractéristiques du canon, qui sont fournies à tout le monde, il comporte un champ
supplémentaire contenant simplement « le_vôtre » (comparez par exemple la ligne 5 avec la ligne
17, laquelle signale la connexion d'un autre joueur). Cette indication supplémentaire permet au
client propriétaire du canon de distinguer parmi plusieurs messages similaires éventuels, celui qui
contient l'identifiant unique que lui a attribué le serveur.
Les messages des lignes 6 et 7 sont des commandes envoyées par le client (réglage de la hausse
et commande de tir). Dans la version précédente du jeu, nous avions déjà convenu que les canons se
déplaceraient quelque peu (et au hasard) après chaque tir. Le serveur effectue donc cette opération,
et s'empresse ensuite d'en faire connaître le résultat à tous les clients connectés. Le message reçu du
serveur à la ligne 8 est donc l'indication d'un tel déplacement (les coordonnées fournies sont les
coordonnées résultantes pour le canon concerné).
La ligne 11 reproduit le type de message expédié par le serveur lorsqu'une cible a été touchée.
Les nouveaux scores de tous les joueurs sont ainsi communiqués à tous les clients.
Les messages serveur des lignes 12, 13 et 14 indiquent les actions entreprises par un autre joueur
(réglage de hausse suivi d'un tir). Cette fois encore, le canon concerné est déplacé au hasard après
qu'il ait tiré (ligne 15).
Lignes 16 et 17 : lorsque l'un des clients coupe sa connexion, le serveur en avertit tous les autres,
afin que le canon correspondant disparaisse de l'espace de jeu sur tous les postes. À l'inverse, de
nouveaux clients peuvent se connecter à tout moment pour participer au jeu.
Remarques complémentaires :
Le premier champ de chaque message indique sa teneur. Les messages envoyés par le client sont
très simples : ils correspondent aux différentes actions entreprises par le joueur (modifications de
l'angle de tir et commandes de feu). Ceux qui sont envoyés par le serveur sont un peu plus
complexes. La plupart d'entre eux sont expédiés à tous les clients connectés, afin de les tenir
informés du déroulement du jeu. En conséquence, ces messages doivent mentionner l'identifiant du
joueur qui a commandé une action ou qui est concerné par un changement quelconque. Nous avons
vu plus haut que ces identifiants sont des noms générés automatiquement par le gestionnaire de
threads du serveur, chaque fois qu'un nouveau client se connecte.
Certains messages concernant l'ensemble du jeu contiennent plusieurs informations par champ.
Dans ce cas, les différents « sous-champs » sont séparés par des points-virgules (lignes 3 et 11).
18.7.3. Programme serveur : première partie▲
Vous trouverez dans les pages qui suivent le script complet du programme serveur. Nous vous le présentons en trois morceaux successifs afin de rapprocher les commentaires du code correspondant, mais la numérotation de ses lignes est continue. Bien qu'il soit déjà relativement long et complexe, vous estimerez probablement qu'il mérite d'être encore perfectionné, notamment au niveau de la présentation générale. Nous vous laisserons le soin d'y ajouter vous-même tous les compléments qui vous sembleront utiles (par exemple, une proposition de choisir les coordonnées de la machine hôte au démarrage, une barre de menus, etc.) :
1
. #
######################################################
2
. #
Jeu
des
bombardes
-
partie
serveur
#
3
. #
(C)
Gérard
Swinnen,
Liège
(Belgique)-
Juillet
2004
#
4
. #
Licence
:
GPL
#
5
. #
Avant
d'exécuter
ce
script,
vérifiez
que
l'adresse
#
6
. #
IP
ci-dessous
soit
bien
celle
de
la
machine
hôte.
#
7
. #
Vous
pouvez
choisir
un
numéro
de
port
différent,
ou
#
8
. #
changer
les
dimensions
de
l'espace
de
jeu.
#
9
. #
Dans
tous
les
cas,
vérifiez
que
les
mêmes
choix
ont
#
10
.#
été
effectués
pour
chacun
des
scripts
clients.
#
11
.#
######################################################
12
.
13
.host, port =
'
192
.
168
.
0
.
235
'
, 35000
14
.largeur, hauteur =
700
, 400
#
dimensions
de
l'espace
de
jeu
15
.
16
.from
Tkinter import
*
17
.import
socket, sys, threading, time
18
.import
canon03
19
.from
canon04 import
Canon, AppBombardes
20
.
21
.class
Pupitre
(canon03.Pupitre):
22
. """
Pupitre
de
pointage
amélioré
"""
23
. def
__init__
(self, boss, canon):
24
. canon03.Pupitre.__init__
(self, boss, canon)
25
.
26
. def
tirer
(self):
27
. "
déclencher
le
tir
du
canon
associé
"
28
. self.appli.tir_canon
(self.canon.id
)
29
.
30
. def
orienter
(self, angle):
31
. "
ajuster
la
hausse
du
canon
associé
"
32
. self.appli.orienter_canon
(self.canon.id
, angle)
33
.
34
. def
valeur_score
(self, sc =
None
):
35
. "
imposer
un
nouveau
score
<
sc
>
,
ou
lire
le
score
existant
"
36
. if
sc =
=
None
:
37
. return
self.score
38
. else
:
39
. self.score =
sc
40
. self.points.config
(text =
'
%s
'
%
self.score)
41
.
42
. def
inactiver
(self):
43
. "
désactiver
le
bouton
de
tir
et
le
système
de
réglage
d
'
angle
"
44
. self.bTir.config
(state =
DISABLED)
45
. self.regl.config
(state =
DISABLED)
46
.
47
. def
activer
(self):
48
. "
activer
le
bouton
de
tir
et
le
système
de
réglage
d
'
angle
"
49
. self.bTir.config
(state =
NORMAL)
50
. self.regl.config
(state =
NORMAL)
51
.
52
. def
reglage
(self, angle):
53
. "
changer
la
position
du
curseur
de
réglage
"
54
. self.regl.config
(state =
NORMAL)
55
. self.regl.set
(angle)
56
. self.regl.config
(state =
DISABLED)
La classe Pupitre() est construite par dérivation de la classe de même nom importée du modune
canon03. Elle hérite donc toutes les caractéristiques de celle-ci, mais nous devons surcharger78 ses
méthodes tirer() et orienter() :
Dans la version monoposte du logiciel, en effet, chacun des pupitres pouvait commander
directement l'objet canon correspondant. Dans cette version réseau, par contre, ce sont les clients
qui contrôlent à distance le fonctionnement des canons. Par conséquent, les pupitres qui
apparaissent dans la fenêtre du serveur ne peuvent être que de simples répétiteurs des manoeuvres
effectuées par les joueurs sur chaque client. Le bouton de tir et le curseur de réglage de la hausse
sont donc désactivés, mais les indications fournies obéissent aux injonctions qui leur sont adressées
par l'application principale.
Cette nouvelle classe Pupitre() sera également utilisée telle quelle dans chaque exemplaire du
programme client. Dans la fenêtre de celui-ci comme dans celle du serveur, tous les pupitres seront
affichés comme des répétiteurs, mais l'un d'entre eux cependant sera complètement fonctionnel :
celui qui correspond au canon du joueur.
Toutes ces raisons expliquent également l'apparition des nouvelles méthodes : activer(),
desactiver(), reglage() et valeur_score(), qui seront elles aussi invoquées par l'application
principale, en réponse aux messages-instructions échangés entre le serveur et ses clients.
La classe ThreadConnexion() ci-dessous sert à instancier la série d'objets threads qui
s'occuperont en parallèle de toutes les connexions lancées par les clients. Sa méthode run() contient
la fonctionnalité centrale du serveur, à savoir la boucle d'instructions qui gère la réception des
messages provenant d'un client particulier, lesquels entraînent chacun toute une cascade de
réactions. Vous y trouverez la mise en oeuvre concrète du protocole de communication décrit dans
les pages précédentes (certains messages étant cependant générés par les méthodes
depl_aleat_canon() et goal() de la classe AppServeur() décrite plus loin).
58
. class
ThreadConnexion
(threading.Thread):
59
. """
objet
thread
gestionnaire
d
'
une
connexion
client
"""
60
. def
__init__
(self, boss, conn):
61
. threading.Thread.__init__
(self)
62
. self.connexion =
conn #
réf.
du
socket
de
connexion
63
. self.app =
boss #
réf.
de
la
fenêtre
application
64
.
65
. def
run
(self):
66
. "
actions
entreprises
en
réponse
aux
messages
reçus
du
client
"
67
. nom =
self.getName
() #
id.
du
client
=
nom
du
thread
68
. while
1
:
69
. msgClient =
self.connexion.recv
(1024
)
70
. print
"
*
*
%s
*
*
de
%s
"
%
(msgClient, nom)
71
. deb =
msgClient.split
('
,
'
)[0
]
72
. if
deb =
=
"
fin
"
or
deb =
=
"
"
:
73
. self.app.enlever_canon
(nom)
74
. #
signaler
le
départ
de
ce
canon
aux
autres
clients
:
75
. self.app.verrou.acquire
()
76
. for
cli in
self.app.conn_client:
77
. if
cli !
=
nom:
78
. message =
"
départ_de
,
%s
"
%
nom
79
. self.app.conn_client[cli].send
(message)
80
. self.app.verrou.release
()
81
. #
fermer
le
présent
thread
:
82
. break
83
. elif
deb =
=
"
client
OK
"
:
84
. #
signaler
au
nouveau
client
les
canons
déjà
enregistrés
:
85
. msg =
"
canons
,
"
86
. for
g in
self.app.guns:
87
. gun =
self.app.guns[g]
88
. msg =
msg +
"
%s
;
%s
;
%s
;
%s
;
%s
,
"
%
\
89
. (gun.id
, gun.x1, gun.y1, gun.sens, gun.coul)
90
. self.app.verrou.acquire
()
91
. self.connexion.send
(msg)
92
. #
attendre
un
accusé
de
réception
('OK')
:
93
. self.connexion.recv
(100
)
94
. self.app.verrou.release
()
95
. #
ajouter
un
canon
dans
l'espace
de
jeu
serveur.
96
. #
la
méthode
invoquée
renvoie
les
caract.
du
canon
créé
:
97
. x, y, sens, coul =
self.app.ajouter_canon
(nom)
98
. #
signaler
les
caract.
de
ce
nouveau
canon
à
tous
les
99
. #
clients
déjà
connectés
:
100
. self.app.verrou.acquire
()
101
. for
cli in
self.app.conn_client:
102
. msg =
"
nouveau_canon
,
%s
,
%s
,
%s
,
%s
,
%s
"
%
\
103
. (nom, x, y, sens, coul)
104
. #
pour
le
nouveau
client,
ajouter
un
champ
indiquant
105
. #
que
le
message
concerne
son
propre
canon
:
106
. if
cli =
=
nom:
107
. msg =
msg +
"
,
le_vôtre
"
108
. self.app.conn_client[cli].send
(msg)
109
. self.app.verrou.release
()
110
. elif
deb =
=
'
feu
'
:
111
. self.app.tir_canon
(nom)
112
. #
Signaler
ce
tir
à
tous
les
autres
clients
:
113
. self.app.verrou.acquire
()
114
. for
cli in
self.app.conn_client:
115
. if
cli !
=
nom:
116
. message =
"
tir_de
,
%s
,
"
%
nom
117
. self.app.conn_client[cli].send
(message)
118
. self.app.verrou.release
()
119
. elif
deb =
=
"
orienter
"
:
120
. t =
msgClient.split
('
,
'
)
121
. #
on
peut
avoir
reçu
plusieurs
angles.
utiliser
le
dernier:
122
. self.app.orienter_canon
(nom, t[-
2
])
123
. #
Signaler
ce
changement
à
tous
les
autres
clients
:
124
. self.app.verrou.acquire
()
125
. for
cli in
self.app.conn_client:
126
. if
cli !
=
nom:
127
. #
virgule
terminale,
car
messages
parfois
groupés
:
128
. message =
"
angle
,
%s
,
%s
,
"
%
(nom, t[-
2
])
129
. self.app.conn_client[cli].send
(message)
130
. self.app.verrou.release
()
131
.
132
. #
Fermeture
de
la
connexion
:
133
. self.connexion.close
() #
couper
la
connexion
134
. del
self.app.conn_client[nom] #
suppr.
sa
réf.
dans
le
dictionn.
135
. self.app.afficher
("
Client
%s
déconnecté
.
\n
"
%
nom)
136
. #
Le
thread
se
termine
ici
78 Rappel : dans une classe dérivée, vous pouvez définir une nouvelle méthode avec le même nom qu'une méthode de la classe parente, afin de modifier sa fonctionnalité dans la classe dérivée. Cela s'appelle surcharger cette méthode (voir aussi page 167).
18.7.4. Synchronisation de threads concurrents à l'aide de « verrous » (thread locks)▲
Au cours de votre examen du code ci-dessus, vous aurez certainement remarqué la structure
particulière des blocs d'instructions par lesquelles le serveur expédie un même message à tous ses
clients. Considérez par exemple les lignes 74 à 80 :
La ligne 75 active la méthode acquire() d'un objet « verrou » qui a été créé par le constructeur de
l'application principale (voir plus loin). Cet objet est une instance de la classe Lock(), laquelle fait
partie du module threading que nous avons importé en début de script. Les lignes suivantes
(76 à 79) provoquent l'envoi d'un message à tous les clients connectés (sauf un). Ensuite, l'objet
« verrou » est à nouveau sollicité, cette fois pour sa méthode release().
A quoi cet objet « verrou » peut-il donc bien servir ? Puisqu'il est produit par une classe du
module threading, vous pouvez deviner que son utilité concerne les threads. En fait, de tels objets
« verrous » servent à synchroniser les threads concurrents. De quoi s'agit-il ?
Vous savez que le serveur démarre un thread différent pour chacun des clients qui se connecte.
Ensuite, tous ces threads fonctionnent en parallèle. Il existe donc un risque que de temps à autre,
deux ou plusieurs de ces threads essaient d'utiliser une ressource commune en même temps.
Dans les lignes de code que nous venons de discuter, par exemple, nous avons affaire à un thread
qui souhaite exploiter quasiment toutes les connexions présentes pour poster un message. Il est donc
parfaitement possible que pendant ce temps, un autre thread tente d'exploiter lui aussi l'une ou
l'autre de ces connexions, ce qui risque de provoquer un dysfonctionnement (en l'occurrence, la
superposition chaotique de plusieurs messages).
Un tel problème de concurrence entre threads peut être résolu par l'utilisation d'un objet-verrou
(thread lock). Un tel objet n'est créé qu'en un seul exemplaire, dans un espace de noms accessible à
tous les threads concurrents. Il se caractérise essentiellement par le fait qu'il se trouve toujours dans
l'un ou l'autre de deux états : soit verrouillé, soit déverrouillé. Son état initial est l'état déverrouillé.
Utilisation :
Lorsqu'un thread quelconque s'apprête à accéder à une ressource commune, il active d'abord la
méthode acquire() du verrou. Si celui-ci était dans l'état déverrouillé, il se verrouille, et le thread
demandeur peut alors utiliser la ressource commune, en toute tranquillité. Lorsqu'il aura fini
d'utiliser la ressource, il s'empressera cependant d'activer la méthode release() du verrou, ce qui le
fera repasser dans l'état déverrouillé.
En effet : Si un autre thread concurrent active lui aussi la méthode acquire() du verrou, alors que
celui-ci est dans l'état verrouillé, la méthode « ne rend pas la main », provoquant le blocage de ce
thread, lequel suspend donc son activité jusqu'à ce que le verrou repasse dans l'état déverrouillé.
Ceci l'empêche donc d'accéder à la ressource commune durant tout le temps où un autre thread s'en
sert. Lorsque le verrou est déverrouillé, l'un des threads en attente (il peut en effet y en avoir
plusieurs) reprend alors son activité, et ainsi de suite.
L'objet verrou mémorise les références des threads bloqués, de manière à n'en débloquer qu'un
seul à la fois lorsque sa méthode release() est invoquée. Il faut donc toujours veiller à ce que
chaque thread qui active la méthode acquire() du verrou avant d'accéder à une ressource, active
également sa méthode release() peu après.
Pour autant que tous les threads concurrents respectent la même procédure, cette technique
simple empêche donc qu'une ressource commune soit exploitée en même temps par plusieurs
d'entre eux. On dira dans ce cas que les threads ont été synchronisés.
18.7.5. Programme serveur : suite et fin▲
Les deux classes ci-dessous complètent le script serveur. Le code implémenté dans la classe
ThreadClients() est assez similaire à celui que nous avions développé précédemment pour le corps
d'application du logiciel de « Chat ». Dans le cas présent, toutefois, nous le plaçons dans une classe
dérivée de Thread(), parce que devons faire fonctionner ce code dans un thread indépendant de
celui de l'application principale. Celui-ci est en effet déjà complètement accaparé par la boucle
mainloop() de l'interface graphique79.
La classe AppServeur() dérive de la classe AppBombardes() du module canon04. Nous lui
avons ajouté un ensemble de méthodes complémentaires destinées à exécuter toutes les opérations
qui résulteront du dialogue entamé avec les clients. Nous avons déjà signalé plus haut que les
clients instancieront chacun une version dérivée de cette classe (afin de profiter des mêmes
définitions de base pour la fenêtre, le canevas, etc.).
138
.class
ThreadClients
(threading.Thread):
139
. """
objet
thread
gérant
la
connexion
de
nouveaux
clients
"""
140
. def
__init__
(self, boss, connex):
141
. threading.Thread.__init__
(self)
142
. self.boss =
boss #
réf.
de
la
fenêtre
application
143
. self.connex =
connex #
réf.
du
socket
initial
144
.
145
. def
run
(self):
146
. "
attente
et
prise
en
charge
de
nouvelles
connexions
clientes
"
147
. txt =
"
Serveur
prêt
,
en
attente
de
requêtes
.
.
.
\n
"
148
. self.boss.afficher
(txt)
149
. self.connex.listen
(5
)
150
. #
Gestion
des
connexions
demandées
par
les
clients
:
151
. while
1
:
152
. nouv_conn, adresse =
self.connex.accept
()
153
. #
Créer
un
nouvel
objet
thread
pour
gérer
la
connexion
:
154
. th =
ThreadConnexion
(self.boss, nouv_conn)
155
. th.start
()
156
. it =
th.getName
() #
identifiant
unique
du
thread
157
. #
Mémoriser
la
connexion
dans
le
dictionnaire
:
158
. self.boss.enregistrer_connexion
(nouv_conn, it)
159
. #
Afficher
:
160
. txt =
"
Client
%s
connecté
,
adresse
IP
%s
,
port
%s
.
\n
"
%
\
161
. (it, adresse[0
], adresse[1
])
162
. self.boss.afficher
(txt)
163
. #
Commencer
le
dialogue
avec
le
client
:
164
. nouv_conn.send
("
serveur
OK
"
)
165
.
166
.class
AppServeur
(AppBombardes):
167
. """
fenêtre
principale
de
l
'
application
(
serveur
ou
client
)
"""
168
. def
__init__
(self, host, port, larg_c, haut_c):
169
. self.host, self.port =
host, port
170
. AppBombardes.__init__
(self, larg_c, haut_c)
171
. self.active =
1
#
témoin
d'activité
172
. #
veiller
à
quitter
proprement
si
l'on
referme
la
fenêtre
:
173
. self.bind
('
<
Destroy
>
'
,self.fermer_threads)
174
.
175
. def
specificites
(self):
176
. "
préparer
les
objets
spécifiques
de
la
partie
serveur
"
177
. self.master.title
('
<
<
<
Serveur
pour
le
jeu
des
bombardes
>
>
>
'
)
178
.
179
. #
widget
Text,
associé
à
une
barre
de
défilement
:
180
. st =
Frame
(self)
181
. self.avis =
Text
(st, width =
65
, height =
5
)
182
. self.avis.pack
(side =
LEFT)
183
. scroll =
Scrollbar
(st, command =
self.avis.yview)
184
. self.avis.configure
(yscrollcommand =
scroll.set
)
185
. scroll.pack
(side =
RIGHT, fill =
Y)
186
. st.pack
()
187
.
188
. #
partie
serveur
réseau
:
189
. self.conn_client =
{} #
dictionn.
des
connexions
clients
190
. self.verrou =
threading.Lock
() #
verrou
pour
synchroniser
threads
191
. #
Initialisation
du
serveur
-
Mise
en
place
du
socket
:
192
. connexion =
socket.socket
(socket.AF_INET, socket.SOCK_STREAM)
193
. try
:
194
. connexion.bind
((self.host, self.port))
195
. except
socket.error:
196
. txt =
"
La
liaison
du
socket
à
l
'
hôte
%s
,
port
%s
a
échoué
.
\n
"
%
\
197
. (self.host, self.port)
198
. self.avis.insert
(END, txt)
199
. self.accueil =
None
200
. else
:
201
. #
démarrage
du
thread
guettant
la
connexion
des
clients
:
202
. self.accueil =
ThreadClients
(self, connexion)
203
. self.accueil.start
()
204
.
205
. def
depl_aleat_canon
(self, id
):
206
. "
déplacer
aléatoirement
le
canon
<
id
>
"
207
. x, y =
AppBombardes.depl_aleat_canon
(self, id
)
208
. #
signaler
ces
nouvelles
coord.
à
tous
les
clients
:
209
. self.verrou.acquire
()
210
. for
cli in
self.conn_client:
211
. message =
"
mouvement_de
,
%s
,
%s
,
%s
,
"
%
(id
, x, y)
212
. self.conn_client[cli].send
(message)
213
. self.verrou.release
()
214
.
215
. def
goal
(self, i, j):
216
. "
le
canon
<
i
>
signale
qu
'
il
a
atteint
l
'
adversaire
<
j
>
"
217
. AppBombardes.goal
(self, i, j)
218
. #
Signaler
les
nouveaux
scores
à
tous
les
clients
:
219
. self.verrou.acquire
()
220
. for
cli in
self.conn_client:
221
. msg =
'
scores
,
'
222
. for
id
in
self.pupi:
223
. sc =
self.pupi[id
].valeur_score
()
224
. msg =
msg +
"
%s
;
%s
,
"
%
(id
, sc)
225
. self.conn_client[cli].send
(msg)
226
. time.sleep
(.5
) #
pour
mieux
séparer
les
messages
227
. self.verrou.release
()
228
.
229
. def
ajouter_canon
(self, id
):
230
. "
instancier
un
canon
et
un
pupitre
de
nom
<
id
>
dans
2
dictionn
.
"
231
. #
on
alternera
ceux
des
2
camps
:
232
. n =
len
(self.guns)
233
. if
n %
2
=
=
0
:
234
. sens =
-
1
235
. else
:
236
. sens =
1
237
. x, y =
self.coord_aleat
(sens)
238
. coul =
('
dark
blue
'
, '
dark
red
'
, '
dark
green
'
, '
purple
'
,
239
. '
dark
cyan
'
, '
red
'
, '
cyan
'
, '
orange
'
, '
blue
'
, '
violet
'
)[n]
240
. self.guns[id
] =
Canon
(self.jeu, id
, x, y, sens, coul)
241
. self.pupi[id
] =
Pupitre
(self, self.guns[id
])
242
. self.pupi[id
].inactiver
()
243
. return
(x, y, sens, coul)
244
.
245
. def
enlever_canon
(self, id
):
246
. "
retirer
le
canon
et
le
pupitre
dont
l
'
identifiant
est
<
id
>
"
247
. if
self.active =
=
0
: #
la
fenêtre
a
été
refermée
248
. return
249
. self.guns[id
].effacer
()
250
. del
self.guns[id
]
251
. self.pupi[id
].destroy
()
252
. del
self.pupi[id
]
253
.
254
. def
orienter_canon
(self, id
, angle):
255
. "
régler
la
hausse
du
canon
<
id
>
à
la
valeur
<
angle
>
"
256
. self.guns[id
].orienter
(angle)
257
. self.pupi[id
].reglage
(angle)
258
.
259
. def
tir_canon
(self, id
):
260
. "
déclencher
le
tir
du
canon
<
id
>
"
261
. self.guns[id
].feu
()
262
.
263
. def
enregistrer_connexion
(self, conn, it):
264
. "
Mémoriser
la
connexion
dans
un
dictionnaire
"
265
. self.conn_client[it] =
conn
266
.
267
. def
afficher
(self, txt):
268
. "
afficher
un
message
dans
la
zone
de
texte
"
269
. self.avis.insert
(END, txt)
270
.
271
. def
fermer_threads
(self, evt):
272
. "
couper
les
connexions
existantes
et
fermer
les
threads
"
273
. #
couper
les
connexions
établies
avec
tous
les
clients
:
274
. for
id
in
self.conn_client:
275
. self.conn_client[id
].send
('
fin
'
)
276
. #
forcer
la
terminaison
du
thread
serveur
qui
attend
les
requêtes
:
277
. if
self.accueil !
=
None
:
278
. self.accueil._Thread__stop
()
279
. self.active =
0
#
empêcher
accès
ultérieurs
à
Tk
280
.
281
.if
__name__
=
=
'
__main__
'
:
282
. AppServeur
(host, port, largeur, hauteur).mainloop
()
Commentaires :
- Ligne 173 : Il vous arrivera de temps à autre de vouloir « intercepter » l'ordre de fermeture de
l'application que l'utilisateur déclenche en quittant votre programme, par exemple parce que vous
voulez forcer la sauvegarde de données importantes dans un fichier, ou fermer aussi d'autres
fenêtres, etc. Il suffit pour ce faire de détecter l'événement <Destroy>, comme nous le faisons ici
pour forcer la terminaison de tous les threads actifs.
- Lignes 179 à 186 : Au passage, voici comment vous pouvez associer une barre de défilement
(widget Scrollbar) à un widget Text (vous pouvez faire de même avec un widget Canvas), sans
faire appel à la bibliothèque Pmw80.
- Ligne 190 : Instanciation de l'obet « verrou » permettant de synchroniser les threads.
- Lignes 202, 203 : Instanciation de l'objet thread qui attendra en permanence les demandes de
connexion des clients potentiels.
- Lignes 205 à 213, 215 à 227 : Ces méthodes surchargent les méthodes de même nom héritées de
leur classe parente. Elles commencent par invoquer celles-ci pour effectuer le même travail
(lignes 207, 217), puis ajoutent leur fonctionnalité propre, laquelle consiste à signaler à tout le
monde ce qui vient de se passer.
- Lignes 229 à 243 : Cette méthode instancie un nouveau poste de tir, chaque fois qu'un nouveau client se connecte. Les canons sont placés alternativement dans le camp de droite et dans celui de gauche, procédure qui pourrait bien évidemment être améliorée. La liste des couleurs prévues limite le nombre de clients à 10, ce qui devrait suffire.
79 Nous détaillerons cette question quelques pages plus loin, car elle ouvre quelques perspectives intéressantes.
Voir : Optimiser les animations à l'aide des threads, page 300.
80 Voir : Python Mega Widgets, page 207.
18.7.6. Programme client▲
Le script correspondant au logiciel client est reproduit ci-après. Comme celui qui correspond au
serveur, il est relativement court, parce qu'il utilise lui aussi l'importation de modules et l'héritage de
classes. Le script serveur doit avoir été sauvegardé dans un fichier-module nommé
canon_serveur.py. Ce fichier doit être placé dans le répertoire courant, de même que les fichiersmodules
canon03.py et canon04.py qu'il utilise lui-même.
De ces modules ainsi importés, le présent script utilise les classes Canon() et Pupitre() à
l'identique, ainsi qu'une forme dérivée de la classe AppServeur(). Dans cette dernière, de
nombreuses méthodes ont été surchargées, afin d'adapter leur fonctionnalité. Considérez par
exemple les méthodes goal() et depl_aleat_canon(), dont la variante surchargée ne fait plus rien du
tout (instruction pass), parce que le calcul des scores et le repositionnement des canons après
chaque tir ne peuvent être effectués que sur le serveur seulement.
C'est dans la méthode run() de la classe ThreadSocket() (lignes 86 à 126) que se trouve le code
traitant les messages échangés avec le serveur. Nous y avons d'ailleurs laissé une instruction print
(à la ligne 88) afin que les messages reçus du serveur apparaissent sur la sortie standard. Si vous
réalisez vous-même une forme plus définitive de ce jeu, vous pourrez bien évidemment supprimer
cette instruction.
1
. #
######################################################
2
. #
Jeu
des
bombardes
-
partie
cliente
#
3
. #
(C)
Gérard
Swinnen,
Liège
(Belgique)
-
Juillet
2004
#
4
. #
Licence
:
GPL
#
5
. #
Avant
d'exécuter
ce
script,
vérifiez
que
l'adresse,
#
6
. #
le
numéro
de
port
et
les
dimensions
de
l'espace
de
#
7
. #
jeu
indiquées
ci-dessous
correspondent
exactement
#
8
. #
à
ce
qui
a
été
défini
pour
le
serveur.
#
9
. #
######################################################
10
.
11
. from
Tkinter import
*
12
. import
socket, sys, threading, time
13
. from
canon_serveur import
Canon, Pupitre, AppServeur
14
.
15
. host, port =
'
192
.
168
.
0
.
235
'
, 35000
16
. largeur, hauteur =
700
, 400
#
dimensions
de
l'espace
de
jeu
17
.
18
. class
AppClient
(AppServeur):
19
. def
__init__
(self, host, port, larg_c, haut_c):
20
. AppServeur.__init__
(self, host, port, larg_c, haut_c)
21
.
22
. def
specificites
(self):
23
. "
préparer
les
objets
spécifiques
de
la
partie
client
"
24
. self.master.title
('
<
<
<
Jeu
des
bombardes
>
>
>
'
)
25
. self.connex =
ThreadSocket
(self, self.host, self.port)
26
. self.connex.start
()
27
. self.id
=
None
28
.
29
. def
ajouter_canon
(self, id
, x, y, sens, coul):
30
. "
instancier
1
canon
et
1
pupitre
de
nom
<
id
>
dans
2
dictionnaires
"
31
. self.guns[id
] =
Canon
(self.jeu, id
, int
(x),int
(y),int
(sens), coul)
32
. self.pupi[id
] =
Pupitre
(self, self.guns[id
])
33
. self.pupi[id
].inactiver
()
34
.
35
. def
activer_pupitre_personnel
(self, id
):
36
. self.id
=
id
#
identifiant
reçu
du
serveur
37
. self.pupi[id
].activer
()
38
.
39
. def
tir_canon
(self, id
):
40
. r =
self.guns[id
].feu
() #
renvoie
False
si
enrayé
41
. if
r and
id
=
=
self.id
:
42
. self.connex.signaler_tir
()
43
.
44
. def
imposer_score
(self, id
, sc):
45
. self.pupi[id
].valeur_score
(int
(sc))
46
.
47
. def
deplacer_canon
(self, id
, x, y):
48
. "
note
:
les
valeurs
de
x
et
y
sont
reçues
en
tant
que
chaînes
"
49
. self.guns[id
].deplacer
(int
(x), int
(y))
50
.
51
. def
orienter_canon
(self, id
, angle):
52
. "
régler
la
hausse
du
canon
<
id
>
à
la
valeur
<
angle
>
"
53
. self.guns[id
].orienter
(angle)
54
. if
id
=
=
self.id
:
55
. self.connex.signaler_angle
(angle)
56
. else
:
57
. self.pupi[id
].reglage
(angle)
58
.
59
. def
fermer_threads
(self, evt):
60
. "
couper
les
connexions
existantes
et
refermer
les
threads
"
61
. self.connex.terminer
()
62
. self.active =
0
#
empêcher
accès
ultérieurs
à
Tk
63
.
64
. def
depl_aleat_canon
(self, id
):
65
. pass
#
=>
méthode
inopérante
66
.
67
. def
goal
(self, a, b):
68
. pass
#
=>
méthode
inopérante
69
.
70
.
71
. class
ThreadSocket
(threading.Thread):
72
. """
objet
thread
gérant
l
'
échange
de
messages
avec
le
serveur
"""
73
. def
__init__
(self, boss, host, port):
74
. threading.Thread.__init__
(self)
75
. self.app =
boss #
réf.
de
la
fenêtre
application
76
. #
Mise
en
place
du
socket
-
connexion
avec
le
serveur
:
77
. self.connexion =
socket.socket
(socket.AF_INET, socket.SOCK_STREAM)
78
. try
:
79
. self.connexion.connect
((host, port))
80
. except
socket.error:
81
. print
"
La
connexion
a
échoué
.
"
82
. sys.exit
()
83
. print
"
Connexion
établie
avec
le
serveur
.
"
84
.
85
. def
run
(self):
86
. while
1
:
87
. msg_recu =
self.connexion.recv
(1024
)
88
. print
"
*
%s
*
"
%
msg_recu
89
. #
le
message
reçu
est
d'abord
converti
en
une
liste
:
90
. t =
msg_recu.split
('
,
'
)
91
. if
t[0
] =
=
"
"
or
t[0
] =
=
"
fin
"
:
92
. #
fermer
le
présent
thread
:
93
. break
94
. elif
t[0
] =
=
"
serveur
OK
"
:
95
. self.connexion.send
("
client
OK
"
)
96
. elif
t[0
] =
=
"
canons
"
:
97
. self.connexion.send
("
OK
"
) #
accusé
de
réception
98
. #
éliminons
le
1er
et
le
dernier
élément
de
la
liste.
99
. #
ceux
qui
restent
sont
eux-mêmes
des
listes
:
100
. lc =
t[1
:-
1
]
101
. #
chacune
est
la
description
complète
d'un
canon
:
102
. for
g in
lc:
103
. s =
g.split
('
;
'
)
104
. self.app.ajouter_canon
(s[0
], s[1
], s[2
], s[3
], s[4
])
105
. elif
t[0
] =
=
"
nouveau_canon
"
:
106
. self.app.ajouter_canon
(t[1
], t[2
], t[3
], t[4
], t[5
])
107
. if
len
(t) >
6
:
108
. self.app.activer_pupitre_personnel
(t[1
])
109
. elif
t[0
] =
=
'
angle
'
:
110
. #
il
se
peut
que
l'on
ait
reçu
plusieurs
infos
regroupées.
111
. #
on
ne
considère
alors
que
la
première
:
112
. self.app.orienter_canon
(t[1
], t[2
])
113
. elif
t[0
] =
=
"
tir_de
"
:
114
. self.app.tir_canon
(t[1
])
115
. elif
t[0
] =
=
"
scores
"
:
116
. #
éliminons
le
1er
et
le
dernier
élément
de
la
liste.
117
. #
ceux
qui
restent
sont
eux-mêmes
des
listes
:
118
. lc =
t[1
:-
1
]
119
. #
chaque
élément
est
la
description
d'un
score
:
120
. for
g in
lc:
121
. s =
g.split
('
;
'
)
122
. self.app.imposer_score
(s[0
], s[1
])
123
. elif
t[0
] =
=
"
mouvement_de
"
:
124
. self.app.deplacer_canon
(t[1
],t[2
],t[3
])
125
. elif
t[0
] =
=
"
départ_de
"
:
126
. self.app.enlever_canon
(t[1
])
127
.
128
. #
Le
thread
<réception>
se
termine
ici.
129
. print
"
Client
arrêté
.
Connexion
interrompue
.
"
130
. self.connexion.close
()
131
.
132
. def
signaler_tir
(self):
133
. self.connexion.send
('
feu
'
)
134
.
135
. def
signaler_angle
(self, angle):
136
. self.connexion.send
('
orienter
,
%s
,
'
%
angle)
137
.
138
. def
terminer
(self):
139
. self.connexion.send
('
fin
'
)
140
.
141
.#
Programme
principal
:
142
.if
__name__
=
=
'
__main__
'
:
143
. AppClient
(host, port, largeur, hauteur).mainloop
()
Commentaires :
- Lignes 15, 16 : Vous pouvez vous-même perfectionner ce script en lui ajoutant un formulaire qui
demandera ces valeurs à l'utilisateur au cours du démarrage.
- Lignes 19 à 27 : Le constructeur de la classe parente se termine en invoquant la méthode
specificites(). On peut donc placer dans celle-ci ce qui doit être construit différemment dans le
serveur et dans les clients. (Le serveur instancie notamment un widget text qui n'est pas repris
dans les clients ; l'un et l'autre démarrent des objets threads différents pour gérer les connexions).
- Lignes 39 à 42 : Cette méthode est invoquée chaque fois que l'utilisateur enfonce le bouton de
tir. Le canon ne peut cependant pas effectuer des tirs en rafale. Par conséquent, aucun nouveau
tir ne peut être accepté tant que l'obus précédent n'a pas terminé sa trajectoire. C'est la valeur
« vraie » ou « fausse » renvoyée par la méthode feu() de l'objet canon qui indique si le tir a été
accepté ou non. On utilise cette valeur pour ne signaler au serveur (et donc aux autres clients)
que les tirs qui ont effectivement eu lieu.
- Lignes 105 à 108 : Un nouveau canon doit être ajouté dans l'espace de jeu de chacun (c'est-à-dire dans le canevas du serveur, et dans le canevas de tous les clients connectés), chaque fois qu'un nouveau client se connecte. Le serveur envoie donc à ce moment un même message à tous les clients pour les informer de la présence de ce nouveau partenaire. Mais le message envoyé à celui-ci en particulier comporte un champ supplémentaire (lequel contient simplement la chaîne « le_vôtre »), afin que ce partenaire sache que ce message concerne son propre canon, et qu'il puisse donc activer le pupitre correspondant, tout en mémorisant l'identifiant qui lui a été attribué par le serveur (voir également les lignes 35 à 37).
Conclusions et perspectives :
Cette application vous a été présentée dans un but didactique. Nous y avons délibérément
simplifié un certain nombre de problèmes. Par exemple, si vous testez vous-même ces logiciels,
vous constaterez que les messages échangés sont souvent rassemblés en « paquets », ce qui
nécessiterait d'affiner les algorithmes mis en place pour les interpréter. De même, nous avons à
peine esquissé le mécanisme fondamental du jeu : répartition des joueurs dans les deux camps,
destruction des canons touchés, obstacles divers, etc. Il vous reste bien des pistes à explorer !
(18) Exercices :
18.1. Simplifiez le script correspondant au client de « chat » décrit à la page 283, en supprimant
l'un des deux objets threads. Arrangez-vous par exemple pour traiter l'émission de messages
au niveau du thread principal.
18.2. Modifiez le jeu des bombardes (version monoposte) du chapitre 15 (voir pages 227 et
suivantes), en ne gardant qu'un seul canon et un seul pupitre de pointage. Ajoutez-y une
cible mobile, dont le mouvement sera géré par un objet thread indépendant (de manière à
bien séparer les portions de code qui contrôlent l'animation de la cible et celle du boulet).
18.8. Utilisation de threads pour optimiser les animations▲
Le dernier exercice proposé à la fin de la section précédente nous suggère une méthodologie de
développements d'applications qui peut se révéler particulièrement intéressante, dans le cas de jeux
vidéo impliquant plusieurs animations simultanées.
En effet : si vous programmez les différents éléments animés d'un jeu comme des objets
indépendants fonctionnant chacun sur son propre thread, alors non seulement vous vous simplifiez
la tâche et vous améliorez la lisibilité de votre script, mais encore vous augmentez la vitesse
d'exécution et donc la fluidité de ces animations. Pour arriver à ce résultat, vous devrez abandonner
la technique de temporisation que vous avez exploitée jusqu'ici, mais celle que vous allez utiliser à
sa place est finalement plus simple !
18.8.1. Temporisation des animations à l'aide de after()▲
Dans toutes les animations que nous avons décrites jusqu'à présent, le « moteur » était constitué
à chaque fois par une fonction contenant la méthode after(), laquelle est associée d'office à tous les
widgets Tkinter. Vous savez que cette méthode permet d'introduire une temporisation dans le
déroulement de votre programme : un chronomètre interne est activé, de telle sorte qu'après un
intervalle de temps convenu, le système invoque automatiquement une fonction quelconque. En
général, c'est la fonction contenant after() qui est elle-même invoquée : on réalise ainsi une boucle
récursive, dans laquelle il reste à programmer les déplacements des divers objets graphiques.
Vous devez bien comprendre que pendant l'écoulement de l'intervalle de temps programmé à
l'aide de la méthode after(), votre application n'est pas du tout « figée ». Vous pouvez par exemple
pendant ce temps : cliquer sur un bouton, redimensionner la fenêtre, effectuer une entrée clavier,
etc. Comment cela est-il rendu possible ?
Nous avons mentionné déjà à plusieurs reprises le fait que les applications graphiques modernes
comportent toujours une sorte de moteur qui « tourne » continuellement en tâche de fond : ce
dispositif se met en route lorsque vous activez la méthode mainloop() de votre fenêtre principale.
Comme son nom l'indique fort bien, cette méthode met en oeuvre une boucle répétitive perpétuelle,
du même type que les boucles while que vous connaissez bien. De nombreux mécanismes sont
intégrés à ce « moteur ». L'un d'entre eux consiste à réceptionner tous les événements qui se
produisent, et à les signaler ensuite à l'aide de messages appropriés aux programmes qui en font la
demande (voir : Programmes pilotés par des événements, page 85), d'autres contrôlent les actions à
effectuer au niveau de l'affichage, etc. Lorsque vous faites appel à la méthode after() d'un widget,
vous utilisez en fait un mécanisme de chronométrage qui est intégré lui aussi à mainloop(), et c'est
donc ce gestionnaire central qui déclenche l'appel de fonction que vous souhaitez, après un certain
intervalle de temps.
La technique d'animation utilisant la méthode after() est la seule possible pour une application
fonctionnant toute entière sur un seul thread, parce que c'est la boucle mainloop() qui dirige
l'ensemble du comportement d'une telle application de manière absolue. C'est notamment elle qui se
charge de redessiner tout ou partie de la fenêtre chaque fois que cela s'avère nécessaire. Pour cette
raison, vous ne pouvez pas imaginer de construire un moteur d'animation qui redéfinirait les
coordonnées d'un objet graphique à l'intérieur d'une simple boucle while, par exemple, parce que
pendant tout ce temps l'exécution de mainloop() resterait suspendue, ce qui aurait pour
conséquence que pendant tout ce temps aucun objet graphique ne serait redessiné (en particulier
celui que vous souhaitez mettre en mouvement !). En fait, toute l'application apparaîtrait figée, aussi
longtemps que la boucle while ne serait pas interrompue.
Puisqu'elle est la seule possible, c'est donc cette technique que nous avons utilisée jusqu'à présent
dans tous nos exemples d'applications mono-thread. Elle comporte cependant un inconvénient
gênant : du fait du grand nombre d'opérations prises en charge à chaque itération de la boucle
mainloop(), la temporisation que l'on peut programmer à l'aide de after() ne peut pas être très
courte. Par exemple, elle ne peut guère descendre en dessous de 15 ms sur un PC typique
(processeur de type Pentium IV, f = 1,5 GHz). Vous devez tenir compte de cette limitation si vous
souhaitez développer des animations rapides.
Un autre inconvénient lié à l'utilisation de la méthode after() réside dans la structure de la boucle
d'animation (à savoir une fonction ou une méthode « récursive », c'est-à-dire qui s'appelle ellemême)
: il n'est pas toujours simple en effet de bien maîtriser ce genre de construction logique, en
particulier si l'on souhaite programmer l'animation de plusieurs objets graphiques indépendants,
dont le nombre ou les mouvements doivent varier au cours du temps.
18.8.2. Temporisation des animations à l'aide de time.sleep()▲
Vous pouvez ignorer les limitations de la méthode after() évoquées ci-dessus, si vous en confiez
l'animation de vos objets graphiques à des threads indépendants. En procédant ainsi, vous vous
libérez de la tutelle de mainloop(), et il vous est permis alors de construire des procédures
d'animation sur la base de structures de boucles plus « classiques », utilisant l'instruction while ou
l'instruction for par exemple.
Au coeur de chacune de ces boucles, vous devez cependant toujours veiller à insérer une
temporisation pendant laquelle vous « rendez la main » au système d'exploitation (afin qu'il puisse
s'occuper des autres threads). Pour ce faire, vous ferez appel à la fonction sleep() du module time.
Cette fonction permet de suspendre l'exécution du thread courant pendant un certain intervalle de
temps, pendant lequel les autres threads et applications continuent à fonctionner. La temporisation
ainsi produite ne dépend pas de mainloop(), et par conséquent, elle peut être beaucoup plus courte
que celle que vous autorise la méthode after().
Attention : cela ne signifie pas que le rafraîchissement de l'écran sera lui-même plus rapide, car
ce rafraîchissement continue à être assuré par mainloop(). Vous pourrez cependant accélérer
fortement les différents mécanismes que vous installez vous-même dans vos procédures
d'animation. Dans un logiciel de jeu, par exemple, il est fréquent d'avoir à comparer périodiquement
les positions de deux mobiles (tels qu' un projectile et une cible), afin de pouvoir entreprendre une
action lorsqu'ils se rejoignent (explosion, ajout de points à un score, etc.). Avec la technique
d'animation décrite ici, vous pouvez effectuer beaucoup plus souvent ces comparaisons et donc
espérer un résultat plus précis. De même, vous pouvez augmenter le nombre de points pris en
considération pour le calcul d'une trajectoire en temps réel, et donc affiner celle-ci.
Remarque : Lorsque vous utilisez la méthode after(), vous devez lui indiquer la temporisation
souhaitée en millisecondes, sous la forme d'un argument entier. Lorsque vous faites appel à la
fonction sleep(), par contre, l'argument que vous transmettez doit être exprimé en secondes, sous la
forme d'un réel (float). Vous pouvez cependant utiliser des très petites valeurs (0.0003 par ex.).
18.8.3. Exemple concret▲
Le petit script reproduit ci-dessous illustre la mise en oeuvre de cette technique, dans un exemple volontairement minimaliste. Il s'agit d'une petite application graphique dans laquelle une figure se déplace en cercle à l'intérieur d'un canevas. Son « moteur » mainloop() est lancé comme d'habitude sur le thread principal. Le constructeur de l'application instancie un canevas contenant le dessin d'un cercle, un bouton et un objet thread. C'est cet objet thread qui assure l'animation du dessin, mais sans faire appel à la méthode after() d'un widget. Il utilise plutôt une simple boucle while très classique, installée dans sa méthode run().
1
. from
Tkinter import
*
2
. from
math import
sin, cos
3
. import
time, threading
4
.
5
. class
App
(Frame):
6
. def
__init__
(self):
7
. Frame.__init__
(self)
8
. self.pack
()
9
. can =
Canvas
(self, width =
400
, height =
400
,
10
. bg =
'
ivory
'
, bd =
3
, relief =
SUNKEN)
11
. can.pack
(padx =
5
, pady =
5
)
12
. cercle =
can.create_oval
(185
, 355
, 215
, 385
, fill =
'
red
'
)
13
. tb =
Thread_balle
(can, cercle)
14
. Button
(self, text =
'
Marche
'
, command =
tb.start).pack
(side =
LEFT)
15
. #
Button(self,
text
='Arrêt',
command
=tb.stop).pack(side
=RIGHT)
16
. #
arrêter
l'autre
thread
si
l'on
ferme
la
fenêtre
:
17
. self.bind
('
<
Destroy
>
'
, tb.stop)
18
.
19
.class
Thread_balle
(threading.Thread):
20
. def
__init__
(self, canevas, dessin):
21
. threading.Thread.__init__
(self)
22
. self.can, self.dessin =
canevas, dessin
23
. self.anim =
1
24
.
25
. def
run
(self):
26
. a =
0
.0
27
. while
self.anim =
=
1
:
28
. a +
=
.01
29
. x, y =
200
+
170
*
sin
(a), 200
+
170
*
cos
(a)
30
. self.can.coords
(self.dessin, x-
15
, y-
15
, x+
15
, y+
15
)
31
. time.sleep
(0
.010
)
32
.
33
. def
stop
(self, evt =
0
):
34
. self.anim =
0
35
.
36
.App
().mainloop
()
Commentaires :
- Lignes 13 & 14 : Afin de simplifier notre exemple au maximum, nous créons l'objet thread
chargé de l'animation, directement dans le constructeur de l'application principale. Cet objet
thread ne démarrera cependant que lorsque l'utilisateur aura cliqué sur le bouton « Marche », qui
active sa méthode start() (rappelons ici que c'est cette méthode intégrée qui lancera elle-même la
méthode run() où nous avons installé notre boucle d'animation).
- Ligne 15 : Vous ne pouvez par redémarrer un thread qui s'est terminé. De ce fait, vous ne pouvez
lancer cette animation qu'une seule fois (tout au moins sous la forme présentée ici). Pour vous en
convaincre, activez la ligne n° 15 en enlevant le caractère # situé au début (et qui fait que Python
considère qu'il s'agit d'un simple commentaire) : lorsque l'animation est lancée, un clic de souris
sur le bouton ainsi mis en place provoque la sortie de la boucle while des lignes 27-31, ce qui
termine la méthode run(). L'animation s'arrête, mais le thread qui la gérait s'est terminé lui aussi.
Si vous essayez de le relancer à l'aide du bouton « Marche », vous n'obtenez rien d'autre qu'un
message d'erreur.
- Lignes 26 à 31 : Pour simuler un mouvement circulaire uniforme, il suffit de faire varier
continuellement la valeur d'un angle a. Le sinus et le cosinus de cet angle permettent alors de
calculer les coordonnées x et y du point de la circonférence qui correspond à cet angle81.
À chaque itération, l'angle ne varie que d'un centième de radian seulement (environ 0,6°), et il faudra donc 628 itérations pour que le mobile effectue un tour complet. La temporisation choisie pour ces itérations se trouve à la ligne 31 : 10 millisecondes. Vous pouvez accélérer le mouvement en diminuant cette valeur, mais vous ne pourrez guère descendre en dessous de 1 milliseconde (0.001 s), ce qui n'est déjà pas si mal.
81 Vous pouvez trouver quelques explications complémentaires à ce sujet, à la page 230.