Réseau séquentiel en Python

Programmer un jeu est un excellent exercice pour apprendre un langage informatique, progresser en algorithmique et manipuler des interfaces graphiques. Pour peu que l’on ait une bon concept multijoueur, on peut avoir envie de faire en sorte que plusieurs personnes interagissent depuis des ordinateurs différents. Je ne savais pas du tout comment faire, mais j’ai trouvé de la documentation dans le guide pratique des sockets en Python de Python.org et dans l’article Les sockets du Python du site pour l’enseignement de l’informatique du lycée Blaise Pascal de Clermont-Ferrand.

Structure serveur et client

Script du serveur

Avec un ordinateur sur lequel Python est installé, on peut écrire un fichier serveur.py comme ci-dessous :

import socket # chargement de la bibliothèque
# création de l’interface de connexion (ou connecteur)
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as connecteur_ouvert:
	connecteur_ouvert.bind(("", 5999)) # définition des paramètres du connecteur
	connecteur_ouvert.settimeout(600) # délai d’attente par défaut en secondes
	connecteur_ouvert.listen() # démarrage du connecteur
	# récupération de la connexion d’un client avec son adresse IP
	connexion, adresse = connecteur_ouvert.accept()
	# présentation du client
	print("Connexion de", adresse[0], "sur le port", adresse[1])
	with connexion:
		connexion.send(b"Comment vous appelez-vous ?") # envoi d’un message au client
		recu = connexion.recv(1024) # récupération de la réponse du client
		nom = recu.decode() # décodage de la réponse
		# encodage du retour au client
		connexion.send(bytes(f"Bonjour {nom} !", "utf-8"))

Le connecteur a pour seule fonction d’obtenir une connexion avec un client. C’est cette connexion qui assure ensuite l’échange des données entre le serveur et le client. La structure with permet de fermer automatiquement le connecteur à la fin du programme, même en cas d’erreur dans l’exécution du bloc.

Les paramètres du connecteurs sont une adresse IP et un port logiciel. L’adresse peut être gardée vide pour autoriser tout client du réseau à accéder au serveur mais on peut la préciser si on souhaite par exemple réserver la connexion à un client spécifique (et disposant d’une adresse fixe). Le port est codé par un entier entre 0 et 65535, mais pour ce genre d’application les valeurs utilisables se situent entre 1024 et 49151. Il est préférable d’éviter les valeurs listées parmi les ports enregistrés à l’IANA.

Une fois que le connecteur a démarré son écoute, il va attendre une connexion par un client jusqu’à ce que le délai imposé soit atteint. Si aucun client ne se présente et qu’aucun délai d’attente n’a été fixé en amont, cette attente se prolongera jusqu’à ce que le serveur soit arrêté (par exemple en interrompant la fenêtre d’exécution). Le délai peut également être fixé par défaut pour toutes les nouvelles interfaces de connexion avec la fonction socket.setdefaulttimeout.

Dans le cas où un client se connecte au serveur, la méthode accept fournit un objet représentant la connexion et l’adresse IP du client. Avec cette connexion, on peut par exemple envoyer un message au client ou en récupérer de sa part (avec le même délai fixé par le connecteur ou par modification de la connexion avec la même méthode). Ces messages doivent être formulés ou encodés au format binaire et décodés de même pour apparaitre comme une chaine de caractères.

L’argument obligatoire de la méthode recv est un entier qui décrit la taille du tampon, c’est-à-dire la taille maximale du message à récupérer en une seule fois. On choisit en général une petite puissance de 2. Il n’est pas pertinent de prévoir beaucoup plus grand, car la transmission de l’intégralité du message n’est pas garantie du premier coup, même s’il est de taille inférieure à celle du tampon. On verra plus bas comment reconstituer un message envoyé en plusieurs fois.

Script du client

Sur le même ordinateur que le serveur, on peut installer aussi un script de client.

import socket # chargement de la bibliothèque
# création de la connexion au serveur
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as connexion:
	connexion.connect(("localhost", 5999)) # connexion au serveur
	message = connexion.recv(1024) # récupération du message d’accueil du serveur
	# affichage des paramètres du serveur puis du message
	print("Message du serveur", connexion.getpeername())
	print(message.decode())
	reponse = input("> ") # saisie de la réponse au clavier
	connexion.send(bytes(reponse, "utf-8")) # envoi de la réponse au serveur
	print(connexion.recv(1024).decode()) # affichage du retour

Remarquons tout d’abord que cette connexion est créée exactement comme le connecteur du serveur. La différence se situe dans l’instruction suivante, avec la méthode connect au lieu de bind.

Puisque le client et le serveur sont lancés sur le même ordinateur, on peut chercher la connexion avec l’adresse localhost sans avoir besoin de connaitre l’adresse IP. Mais les affichages des deux scripts vont nous permettre d’obtenir cette adresse et de vérifier qu’elle est pour l’instant la même côté serveur et côté client. Il s’agit normalement de l’adresse locale 127.0.0.1 correspondant à localhost.

En revanche, le port utilisé par le serveur n’est pas le même que celui utilisé par le client. Ce dernier change d’ailleurs à chaque connexion.

On constate aussi que les scripts des deux parties sont parfaitement coordonnés : le client attend que le serveur lui adresse un message en premier, puis c’est au tour du client d’envoyer un message au serveur qui lui envoie enfin un retour. En l’absence d’un tel protocole, certains messages pourraient être envoyés sans être lus, ou au contraire l’une des deux parties pourrait attendre longtemps un message qui ne vient pas. On verra plus bas comment concevoir des protocoles plus souples qui assurent l’efficacité de la communication sans contraindre autant sa durée et l’alternance des envois.

Sur un réseau local

Pour créer un client sur un autre ordinateur que le serveur, on a besoin d’identifier le serveur sur le réseau. Au sein d’un réseau domestique relié par un boitier passerelle (box internet), les adresses IP peuvent être récupérées sur l’interface d’administration du boitier (normalement, à l’adresse 192.168.1.1). On peut aussi trouver le nom d’hôte en deux lignes de commande avec Python depuis l’ordinateur qui fera office de serveur :

import socket
socket.gethostname()
'monnomdemachine'

Ce nom de machine peut ensuite remplacer la mention localhost dans le script du client.

Pour que le serveur soit accessible depuis un réseau plus large (potentiellement par n’importe quel internaute), il faut ouvrir ce serveur en modifiant les paramètres de son boitier internet ou disposer d’un serveur dédié ou partagé hébergé par un fournisseur. La discussion de ces aspects sort du cadre du présent article, mais si vous savez le faire, il suffira de préciser l’adresse du serveur dans le script client pour obtenir le même niveau d’efficacité.

Problème de la transmission partielle

Lorsqu’on a été habitué à ce qu’une instruction déclenche une erreur si elle n’a pas été complètement effectuée, le traitement des communications par connexion entre serveur et client peut être un peu déroutant. Par exemple, si on envoie un message d’une dizaine d’octets et que le destinataire a prévu sa réception avec une borne de 1024 octets, on pourrait s’attendre à ce que le message soit transmis dans son intégralité. Or ce n’est pas forcément le cas, et sans pour autant déclencher une erreur ni d’un côté ni de l’autre.

Du côté de l’émetteur, la valeur de retour de la fonction send indique quand même le nombre de bytes effectivement transmis. Il est donc possible de savoir si le message est complètement arrivé ou non. Cela est même mis à profit dans la méthode sendall qui réalise des envois successifs jusqu’à ce que le nombre de bytes transmis égale le nombre de bytes du message initial.

Du côté du recepteur en revanche, on sait combien de bytes ont été reçus, mais pas combien auraient dû être transmis. Or l’attente d’éventuels nouveaux paquets peut bloquer l’action du programme. Il existe plusieurs solutions assez différentes à ce problème.

Contrôles de fin de transmission

Messages de taille calibrée
Il suffit de récupérer des paquets jusqu’à ce que cette taille soit atteinte. Cette solution est efficace lorsque les actions sont restreintes à une courte liste avec un codage simple : morpion, échecs, Puissance 4…
Terminateur et délimiteurs
Si un byte particulier n’est pas utilisé au sein du message, sa lecture peut signaler la fin du message. Des délimiteurs employés uniquement par paires (un ouvrant et un fermant, comme des parenthèses) peuvent aussi encadrer le message sans ambiguïté. Ces solutions sont pratiques pour des actions exprimées dans un langage courant.
Préfixe indicateur de longueur
En utilisant quelques bytes en nombre fixé au début du message pour spécifier sa taille, on s’assure que le récepteur lise le préfixe en entier puis continue sa réception jusqu’à ce que la taille du message soit atteinte. Avec un préfixe de deux octets (donc deux bytes a priori), cette taille peut aller jusqu’à plus de 64 ko, ce qui est déjà confortable. Cette solution est préférable lorsque le message peut contenir des bytes de valeur arbitraire en grand nombre (image et son, données numériques…)

Ces différentes procédures de contrôle peuvent être codées avec les fonctions suivantes.

def receptioncalibree(connexion, calibre, tailletampon):
	"""Cette fonction récupère via la /connexion/
	un message binaire de longueur fixe /calibre/
	par paquets de taille maximale /tailletampon/."""
	a = bytearray() # accumulateur binaire initialement vide
	while len(a) < calibre: # boucle pour remplir l’accumulateur
		t = min(tailletampon, calibre-len(a)) # taille attendue
		r = connexion.recv(t) # réception du paquet suivant

		a.extend(r) # accumulation
	return a
	
def receptionterminante(connexion, terminateur, tailletampon):
	a = bytearray()
	while True: # boucle infinie, sortie sur test
		r = connexion.recv(tailletampon) # réception d’un paquet

		if r.endswith(terminateur): # fin du message
			a.extend(r[:-1]) # accumulation sans le terminateur
			break # sortie de boucle
		# sinon, le message n’est pas terminé
		a.extend(r) # accumulation du paquet
	return a
	
def receptiondelimitee(connexion, ouvrant, fermant, tailletampon):
	a = bytearray()
	profondeur = 0 # nombre de délimiteurs ouverts et pas encore fermés
	while True:
		r = connexion.recv(tailletampon)

		a.extend(r)
		profondeur += r.count(ouvrant)-r.count(fermant)
		if profondeur == 0:
			 break # fin du message
	return a[1:-1]
	
def receptionprefixee(connexion, tailleprefixe, tailletampon):
	a = bytearray()
	while len(a) < tailleprefixe: # boucle de lecture du préfixe
		r = connexion.recv(tailletampon)

		a.extend(r)
	# calcul de la taille du message avec des bytes de poids décroissant
	for i in range(tailleprefixe):
		taille = taille*256 + a[i]
	a = a[tailleprefixe:] # élimination du préfixe
	while len(a) < taille: # boucle de lecture du message
		r = connexion.recv(min(tailletampon, taille-len(a)))

		a.extend(r)
	return a

Gestion des erreurs de format

Ces quatre fonctions permettent bien de récupérer les messages envoyés lorsqu’ils respectent le format attendu, mais en matière de communication entre ordinateurs, il faut toujours s’attendre au pire. D’abord, on définit une erreur spécifique pour signaler un message non conforme. Ensuite on met en place une fonction pour récupérer un paquet à la fois et vérifier qu’il est non vide dans une limite de temps. Enfin, on joint à chaque fonction de réception une fonction d’émission qui contrôle le format des messages émis.

import time
	
class ErreurFormat(Exception):
	"""Message reçu non conforme au format prévu par le serveur."""
	pass # pas d’action particulière
	
def reception(connexion, tailletampon, ultimatum):
	# calcul du délai d’attente
	if ultimatum is not None:
		connexion.settimeout(ultimatum-time.time())
	r = connexion.recv(tailletampon)
	if not r: # connexion coupée
		raise ConnectionAbortedError
	return r
	
def emissioncalibree(connexion, message, calibre):
	if len(message) != calibre: # taille différente de celle attendue
		raise ErreurFormat # erreur levée et fin de l’exécution de la fonction

	connexion.sendall(message) # sinon, envoi du message
	
def receptioncalibree(connexion, calibre, tailletampon, ultimatum):
	a = bytearray()
	while len(a) < calibre:
		t = min(tailletampon, calibre-len(a))
		r = reception(connexion, t, ultimatum)
		a.extend(r)
	return a
	
def emissionterminante(connexion, message, terminateur=3):
	# pas de terminateur dans le message qu’il est censé terminer !
	if terminateur in message:
		raise ErreurFormat
	connexion.sendall(message+bytes([terminateur])
	
def receptionterminante(connexion, terminateur=3, tailletampon, ultimatum):
	a = bytearray()
	while True:
		r = reception(connexion, tailletampon, ultimatum)
		i = r.find(terminateur) # recherche du terminateur dans le paquet
		if i > -1: # terminateur trouvé
			if i < len(r)-1: # avant la fin
				raise ErreurFormat
			else: # terminateur positionné à la fin
				a.extend(r[:-1]) # accumulation paquet sans le terminateur
				break # sortie de boucle
		a.extend(r)
	return a
	
def emissiondelimitee(connexion, message, delimiteurs=(2,3)):
	ouvrant, fermant = delimiteurs
	profondeur = 0
	for c in message: # parcours des éléments du message
		if c == ouvrant: # ouverture d’un délimiteur
			profondeur += 1 # augmentation de la profondeur
		elif c == fermant: # fermeture d’un délimiteur
			profondeur -= 1 # diminution de la profondeur
			if profondeur < 0: # un délimiteur fermant en trop
				raise ErreurFormat
	if profondeur: # au moins un délimiteur fermant qui manque
		raise ErreurFormat
	# envoi du message avec les délimiteurs à chaque extrémité
	connexion.sendall(bytes([ouvrant]) + message + bytes([fermant]))
	
def receptiondelimitee(connexion, delimiteurs=(2,3), tailletampon, ultimatum):
	ouvrant, fermant = delimiteurs
	r = reception(connexion, tailletampon, ultimatum)
	if r[0] != ouvrant: # délimiteur ouvrant manquant au début
		raise ErreurFormat
	a = bytearray()
	profondeur = 0
	while True:
		for i in range(len(r)):
			if r[i] == ouvrant:
				profondeur += 1
			elif r[i] == fermant:
				profondeur -= 1
				if profondeur == 0:
					if i+1 == len(r):
						break # fin du message, sortie de boucle
					# sinon dernier délimiteur fermé avant la fin
					raise ErreurFormat
		a.extend(r)
		r = reception(connexion, tailletampon, ultimatum)
	return a[1:-1]
	
def emissionprefixee(connexion, message, tailleprefixe):
	prefixe = bytearray()
	taille = len(message)
	for i in range(tailleprefixe): # calcul du préfixe en base 256
		prefixe.append(taille % 256)
		taille = taille // 256
	if taille: # taille restante signe que len(message)≥256^tailleprefixe
		raise OverflowError
	# concaténation avec préfixe réordonné par poids décroissants
	connexion.sendall(prefixe[::-1] + message)
	
def receptionprefixee(connexion, tailleprefixe, tailletampon, ultimatum):
	a = bytearray()
	while len(a) < tailleprefixe:
		r = reception(connexion, tailletampon, ultimatum)
		a.extend(r)
	# reconstitution taille du message
	for i in range(tailleprefixe):
		taille = taille*256 + a[i]
	a = a[tailleprefixe:]
	while len(a) < taille:
		t = min(tailletampon, taille-len(a))
		r = reception(connexion, t, ultimatum)
		a.extend(r)
	if len(a) > taille:
		raise ErreurFormat
	return a

Protocole

Les jeux présentent une très grande diversité de modes de fonctionnement. De façon très générale, chaque joueur prend des décisions qui affectent l’état de jeu (placement de pions, pose de cartes, communications orales ou écrites…) et il reçoit des informations de la part des autres joueurs et de divers éléments de jeu (pioche de cartes, dés…). L’aspect qui nous intéresse ici est l’ordre de jeu, c’est-à-dire la détermination des joueurs qui peuvent intervenir dans un état de jeu donné.

Dans un jeu séquentiel comme les échecs, le tarot ou le Yahtzee, à chaque moment du jeu un seul joueur a la main, c’est-à-dire qu’il peut accomplir une ou plusieurs actions avant de passer la main au joueur suivant, sans que l’état de jeu n’évolue entre deux actions. Le jeu peut donc s’organiser en récupérant successivement les actions des joueurs, y compris une éventuelle décision de passer au joueur suivant. Même dans le cas d’un jeu simultané comme pierre-feuille-ciseaux, les choix des joueurs peuvent être récupérés séquentiellement avant d’être communiqués globalement.

Au contraire, les jeux en temps réel évoluent entre les actions de joueurs, et dans certains cas autorisent simultanément plusieurs joueurs à réaliser une action (même si une seule action est traitée à la fois). On trouve ainsi beaucoup de jeux à deviner et les jeux de rapidité physique.

Temps discret

On considère une instance de jeu comme un objet Python service muni de deux méthodes.

import socket
import time
class ErreurFormat(Exception):
	"""Message reçu non conforme au format prévu par le serveur."""
	pass # pas d’action particulière
	
class Client: # création d’une classe d’objets
	def __init__(self, connexion, adresse, delai=600, tailletampon=1024):
		self.connexion = connexion
		self.adresse = adresse
	
class Serveur:
	def __init__(self, service, delai=60, tailletampon=1024):
		self.service = service
		self.delai = delai
		self.tailletampon = tailletampon
		self.emission = self._emission
		self.reception = self._reception
		self.ultimatum = None
	def _emission(self, connexion, message):
		connexion.sendall(message)
	def _reception(self, connexion, taille = None):
		# calcul du délai d’attente
		if self.ultimatum is not None:
			connexion.settimeout(self.ultimatum-time.time())
		r = connexion.recv(self.tailletampon if taille is None \
			else min(self.tailletampon, taille))
		if not r: # connexion coupée
			raise ConnectionAbortedError
		return r
	def _emissioncalibree(self, connexion, message):
		if len(message) != self.calibre:
			raise ErreurFormat
		connexion.sendall(message)
	def _receptioncalibree(self, connexion):
		a = bytearray()
		while len(a) < self.calibre:
			r = self._reception(connexion, self.calibre-len(a))
			a.extend(r)
		return a
	def _emissionterminante(self, connexion, message):
		if self.terminateur in message:
			raise ErreurFormat
		connexion.sendall(message+bytes([self.terminateur])
	def _receptionterminante(self, connexion):
		a = bytearray()
		while True:
			r = self._reception(connexion)
			i = r.find(self.terminateur)
			if i > -1:
				if i < len(r)-1:
					raise ErreurFormat
				a.extend(r[:-1])
				break
			a.extend(r)
		return a
	def _emissiondelimitee(self, connexion, message):
		profondeur = 0
		for c in message:
			if c == self.ouvrant:
				profondeur += 1
			elif c == self.fermant:
				profondeur -= 1
				if profondeur < 0:
					raise ErreurFormat
		if profondeur:
			raise ErreurFormat
		connexion.sendall(bytes([self.ouvrant])+message+bytes([self.fermant]))
	def _receptiondelimitee(self, connexion):
		r = self._reception(connexion)
		if r[0] != self.ouvrant:
			raise ErreurFormat
		a = bytearray()
		profondeur = 0
		while True:
			for i in range(len(r)):
				if r[i] == self.ouvrant:
					profondeur += 1
				elif r[i] == self.fermant:
					profondeur -= 1
					if profondeur == 0:
						if i+1 == len(r):
							break
						raise ErreurFormat
			a.extend(r)
			r = self._reception(connexion)
		return a[1:-1]
	def _emissionprefixee(self, connexion, message):
		prefixe = bytearray()
		taille = len(message)
		for i in range(self.calibre):
			prefixe.append(taille % 256)
		taille = taille // 256
		if taille:
			raise OverflowError
		connexion.sendall(prefixe[::-1] + message)
	def _receptionprefixee(self, connexion):
		a = bytearray()
		while len(a) < self.calibre:
			r = self._reception(connexion)
			a.extend(r)
		for i in range(self.calibre):
			taille = taille*256 + a[i]
		a = a[tailleprefixe:]
		while len(a) < taille:
			r = self._reception(connexion, taille-len(a))
			a.extend(r)
		if len(a) > taille:
			raise ErreurFormat
		return a
	def demarre(self, attente, port=5999)
		with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as connecteur:
			connecteur.bind(("", port))
			connecteur.listen()
			clients = []
			try:
				for i in range(nbclients):
					if self.delai:
						connecteur.settimeout(self.delai)
					connexion, adresse = connecteur.accept()
					print("Connexion de", adresse[0], "sur le port", adresse[1])
					clients.append(Client(connexion, adresse))
			except TimeoutError:
				pass
			while True:
				actifs, informations = self.service.tour()
				if actifs:
					if informations:
						for i in actifs:
							if self.delai:
								self.ultimatum = time.time()+self.delai
							self.emission(clients[i].connexion, informations)
					else:
						resultats = {}
						for i in actifs:
							try:
								if self.delai:
									self.ultimatum = time.time()+self.delai
								resultats[i] = self.reception(clients[i].connexion)
							except:
								pass
						self.service.realisation(resultats)
				elif informations:
						self.emission(clients[i].connexion, informations)
					else:
						break
				for i in actifs:
					clients[i].connexion.close()

Temps continu

Pour autoriser simultanément plusieurs joueurs à intervenir dans le jeu, mais aussi pour mieux gérer les éventuelles déconnexions et reconnexions pendant la partie, il vaut mieux mettre en place une communication par fils. Ce sera l’objet d’un prochain article.