Les WebSockets en HTML5 - serveur en Python et client(s) sous Firefox

N.B.: la version du code ici présenté est obsolète et minimaliste. Réalisée en 2013, j'ai depuis repris mon code source, et je l'ai notamment fait évolué dans le cadre d'un projet client de domotique KNX, bien plus complexe.

Considérez donc ce qui suit comme une archive, dont j'ai gardé l'architecture de base côté serveur. Je n'ai pas mis la nouvelle version en ligne, tout simplement parce que «libre» n'est pas «gratuit», et que je n'ai aucune raison de brader mon savoir-faire&,nbsp;!

Introduction

Avec des besoins en mobilité de plus en plus flagrants, il n'est pas rare, dans un projet web, de rechercher l'équivalent d'un "mode PUSH" - c'est-à-dire une connexion "permanente" entre le client (le navigateur) et le serveur (le site internet consulté).

Malheureusement, le protocole HTTP usuel, utilisé pour la consultation des pages, est de par sa nature même un protocole déconnecté, quand aux technologies basées sur AJAX, elles ne font que détourner un appel du navigateur pour s'accaparer les données en transit - la limitation technique est donc fondamentalement la même.

Bref, pour qui voulait faire du mode PUSH dans son navigateur, comme sur un téléphone portable, il n'y avait jusqu'à présent aucune solution fiable. Mais HTML5 est arrivé ! (sans se presser - faut pas déconner quand même...), et nous voilà donc maintenant avec des WebSockets bidirectionnelles, qui permettent la connexion permanente recherchée !

But de ce tutorial

Votre mission, si vous l'acceptez, sera d'émuler un client de messagerie instantanée !

A noter enfin, que la librairie fournie a été testée sous Firefox et Iceweasel, mais qu'elle ne fonctionne pas sous Chromium (et par extension sous Chrome). Elle utilise la version 13 du protocole, la dernière en date en cette fin 2012 - cf. http://tools.ietf.org/html/rfc6455.

>> TÉLÉCHARGER <<

Côté navigateur (clients)

Capture du client
WebSocket Server - 1.0 -

Nous avons ici un champ texte qui nous permettra d'envoyer un message au serveur, un boutton Send pour l'envoi, et un boutton Close pour fermer la connexion.

Enfin, les messages envoyés et reçus s'affichent dans une petite division sous le formulaire.


<!DOCTYPE html>
<html lang="fr" xmlns="http://www.w3.org/1999/xhtml">
	<head>
		<title>WebSocket Test</title>
		<meta charset="UTF-8" />
		<script type="text/javascript">

			var _WS = {
				// for distant tests
				// uri: 'ws://echo.websocket.org/',
				// for python WSMain.py
				uri: 'ws://localhost:9999/',
				ws: null,
				init : function (e) {
					_WS.s = new WebSocket(_WS.uri);
					_WS.s.onopen = function (e) { _WS.onOpen(e); };
					_WS.s.onclose = function (e) { _WS.onClose(e); };
					_WS.s.onmessage = function (e) { _WS.onMessage(e); };
					_WS.s.onerror = function (e) { _WS.onError(e); };
				},
				onOpen: function () {
					_WS.write('CONNECTED');
				},
				onClose: function () {
					_WS.write('DISCONNECTED');
				},
				onMessage: function (e) {
					_WS.write('<span style="color: blue;">RESPONSE: ' + e.data+'</span>');
				},
				onError: function (e) {
					_WS.write('<span style="color: red;">ERROR:</span> ' + e.data);
				},
				write: function (message) {
	    			var p = document.createElement('p');
				    p.style.wordWrap = 'break-word';
				    p.innerHTML = message.toString();
				    document.getElementById('output').appendChild(p);
				},
				send: function (message) {
					if (!message.length) {
						alert('Empty message not allowed !');
					} else {
						_WS.write('SEND: ' + message);
						_WS.s.send(message);
					}
				},
				close: function () {
					_WS.write('GOODBYE !');
					_WS.s.close();
				}
			};

			window.addEventListener('load', _WS.init, false);

		</script>
	</head>

	<body>
		<h2>WebSocket Test</h2>
		<input type="text" id="input" name="input" value="" />
		&nbsp;
		<input type="button" value="Send"  onclick="_WS.send(document.getElementById('input').value);"/>
		&nbsp;
		<input type="button" value="Close"  onclick="_WS.close();"/>
		<br/>
		<div id="output" style="max-height:300px;overflow:auto"></div>
	</body>
</html>

Comme on peut le constater, Firefox implémente les WebSockets via 4 gestionnaires d'évènements simples [onopen(), onclose(), onmessage() et onerror()], et 2 fonctions [send() pour envoyer des données, et close() pour fermer la connexion].

Il est intéressant de noter également l'URI d'appel (ws://localhost:9999/), ici non sécurisée (il faudrait remplacer ws:// pas wss://), et le fait que notre navigateur attend son homologue sur le port 9999 de la machine locale, ou nous allons trouvé notre serveur Python ci-après.

Côté serveur (Python)

J'ai préféré ici scinder les classes dans plusieurs fichiers, et laisser des instructions de débugage dans le code. Les puristes bondiront, mais ce n'est qu'un tutorial !

Schéma de base de notre application :

  WSMain (Startup)   incoming datas
    |                       |
    v                       v
WSServer (listen) => WSClient (thread) -> WSDecoder (one per WSClient)
    ^                       ^                  |
    |                       |                  |
    |                       |                  |
    |                       |                  v
    |                       |        	 WSController (one per WSClient)
    |                    unicast               |
    |                       |                  |
    |                       |                  |
    |                       |                  v
    |-------multicast-------|------------- WSEncoder

La partie serveur se démarre en console via python WSMain.py, et s'interrompt à tout moment via CTRL+C.

# python WSMain.py
Server started !
Press Ctrl+C to quit
New client host/address: ('127.0.0.1', 39785)
Total Clients: 1
--- HANDSHAKE ---
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Origin: http://localhost:8917
Sec-WebSocket-Location: ws://localhost:9999
Sec-WebSocket-Accept: W0BICO/8+E7sJI7gqcPTF1/7Too=
Sec-WebSocket-Version: 13


-----------------

Le programme principal WSMain.py appelle la méthode WSServer.start() qui suit.

WSServer.py

Le script WSServer.py gère les sockets à la demande. La liste des clients est stockée à chaque instant dans self.clients, et fait office de référence pour savoir qui est connecté à un instant donné.


import socket, threading, string, time

from WSClient import *

## WebSocket Server Class
#
#  Contains the main thread

class WSServer:

	def __init__(self):
		self.clients = []
		self.s = ''
		self.listening = False

	## Start server
	#  @param host WebSocket server or ip to join. Default is localhost.
	#  @param host port to join. Default is 9999.
	#  @param maxclients Max clients which can connect at the same time. Default is 20.

	def start(self, host='localhost', port=9999, maxclients=20):
		self.s = socket.socket()
		self.s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
		self.s.bind((host,port))
		self.s.listen(1)
		print 'Server started !'
		print 'Press Ctrl+C to quit'
		self.listening = True
		while self.listening:
			conn, addr = self.s.accept()
			print 'New client host/address:', addr
			if len(self.clients) == maxclients:
				print 'Too much clients - connection refused:', repr(conn)
				conn.close()
			else:
				_WSClient = WSClient(self)
				self.clients.append(_WSClient)
				print 'Total Clients:', str(len(self.clients))
				threading.Thread(target = _WSClient.handle, args = (conn,addr)).start()

	## Send a multicast frame
	#  @param bytes Bytes to send.

	def send(self, bytes):
		print '-- SEND MULTICAST ---', repr(bytes)
		for _WSClient in self.clients:
			_WSClient.send(bytes)
		print 'multicast send finished'

	## Stop all WSClients

	def stop(self):
		self.listening = False
		while (len(self.clients)):
			self.clients.pop()._WSController.kill()
		self.s.close()
		print '--- THAT''S ALL FOLKS ! ---'

	## Remove a WSClient from clients list
	#  @param _WSClient WSClient object to remove from the clients list

	def remove(self,_WSClient):
		if _WSClient in self.clients:
			print 'client left:', repr(_WSClient.conn)
			self.clients.remove(_WSClient)


Pour renvoyer les messages à tous les clients connectés, on utilise la méthode WSServer.send(). La méthode WSServer.stop() "tue" (kill) les clients connectés un par un. La méthode WSServer.remove() s'occupe d'enlever un client de la liste, mais sans toucher à la connexion physique ; c'est notamment utile pour court-circuiter immédiatement un client en envoi.

A chaque nouveau client qui arrive, on créé un nouvel object WSClient, auquel on passe les arguments de la connexion. On remarquera enfin le threading.Thread() de Python, qui permet de créer un Thread à la volée, et démontre aussi toute la simplicité et la puissance de ce langage.

Etudions maintenant l'objet WSClient.

WSClient.py

Cette partie cliente mémorise en permanence l'état de la connexion (OPENING, OPEN, CLOSING, CLOSED), et permet donc de réagir en conséquence aux changements.

La fonction WSclient.handle() s'occupe d'abord de la poignée de main (ou «handshake») entre client et serveur. Dans le protocole WebSockets, le client qui arrive impose une clé (Sec-WebSocket-Key) dans le header de sa requête. Cette clé doit être décodée d'une certaine façon, et le résultat doit être renvoyé au client (via Sec-WebSocket-Accept) pour valider la connexion. Une fois la poignée de main terminée, chaque partie peut commencer à envoyer des données à l'autre.

C'est donc la fonction WSclient.handle() qui reçoit et décode les données reçues via la classe WSDecoder, que nous ne détaillerons pas ici (cf. code source dans le fichier à télécharger). WSDecoder reprend simplement les paramètres de la RFC (Request For Comments) pour décoder correctement les données. A noter qu'en cas d'erreur de décodage (données non valides par exemple), on ferme tout de suite et correctement la liaison.

Si les données sont valides, la méthode renvoie deux variables :

  • ctrl pour la partie contrôle
  • data pour la partie données

Ces deux variables sont ensuite passées à la méthode WSController.run() que nous étudierons plus loin.


import threading, hashlib, base64
import WSSettings

from WSDecoder import *
from WSController import *

## WebSocket Client Class
#
#  Socket control for a given client.

class WSClient:

	# WSClient connection status

	CONNECTION_STATUS = {
		'CONNECTING': 0x0,
		'OPEN': 0x1,
		'CLOSING': 0x2,
		'CLOSED': 0x3
	}

	## Constructor
	#  @param _WSServer WebSocket Server object attached to client.

	def __init__(self,_WSServer):
		self._WSServer = _WSServer
		self.conn = ''
		self.addr = ''
		self.setStatus('CLOSED')
		self._WSController = WSController(self)

	## Set current connection status
	#  @param status Status of the socket. Can be 'CONNECTING', 'OPEN', 'CLOSING' or 'CLOSED'.

	def setStatus(self, status=''):
		if (status in self.CONNECTION_STATUS):
			self.status = self.CONNECTION_STATUS[status]

	## Test current connection status
	#  @param status Status of the socket. Can be 'CONNECTING', 'OPEN', 'CLOSING' or 'CLOSED'.

	def hasStatus(self, status):
		if (status in self.CONNECTION_STATUS):
			return self.status == self.CONNECTION_STATUS[status]
		return False

	## Real socket bytes reception
	#  @param bufsize Buffer size to return.

	def receive(self, bufsize):
		bytes = self.conn.recv(bufsize)
		if not bytes:
			print 'Client left', repr(self.conn)
			self._WSServer.remove(self)
			self.close()
			return ''
		return bytes

	## Try to read an amount bytes
	#  @param bufsize Buffer size to fill.

	def read(self, bufsize):
		remaining = bufsize
		bytes = ''
		while remaining and self.hasStatus('OPEN'):
			bytes += self.receive(remaining)
			remaining = bufsize - len(bytes)
		return bytes

	## Read data until line return (used by handshake)

	def readlineheader(self):
		line = []
		while self.hasStatus('CONNECTING') and len(line)<1024:
			c = self.receive(1)
			line.append(c)
			#print 'readlineheader: ', line
			if c == "\n":
				break
		return "".join(line)

	## Send handshake according to RFC

	def hanshake(self):
		headers = {}
		# Ignore first line with GET
		line = self.readlineheader()
		while self.hasStatus('CONNECTING'):
			if len(headers)>64:
				raise ValueError('Header too long.')
			line = self.readlineheader()
			if not self.hasStatus('CONNECTING'):
				raise ValueError('Client left.')
			if len(line) == 0 or len(line) == 1024:
				raise ValueError('Invalid line in header.')
			if line == '\r\n':
				break
			# take care with strip !
			# >>> import string;string.whitespace
			# '\t\n\x0b\x0c\r '
			line = line.strip()
			# take care with split !
			# >>> a='key1:value1:key2:value2';a.split(':',1)
			# ['key1', 'value1:key2:value2']
			kv = line.split(':', 1)
			if len(kv) == 2:
				key, value = kv
				k = key.strip().lower()
				v = value.strip()
				headers[k] = v
			else:
				raise ValueError('Invalid header key/value.')
		#print headers

		if not len(headers):
			raise ValueError('Reading headers failed.')
		if not 'sec-websocket-version' in headers:
			raise ValueError('Missing parameter "Sec-WebSocket-Version".')
		if not 'sec-websocket-key' in headers:
			raise ValueError('Missing parameter "Sec-WebSocket-Key".')
		if not 'host' in headers:
			raise ValueError('Missing parameter "Host".')
		if not 'origin' in headers:
			raise ValueError('Missing parameter "Origin".')

		if (int(headers['sec-websocket-version']) != WSSettings.VERSION):
			raise ValueError('Wrong protocol version %s.' % WSSettings.VERSION)

		accept = base64.b64encode(hashlib.sha1(headers['sec-websocket-key'] + '258EAFA5-E914-47DA-95CA-C5AB0DC85B11').digest())

		bytes = ('HTTP/1.1 101 Switching Protocols\r\n'
			'Upgrade: websocket\r\n'
			'Connection: Upgrade\r\n'
			'Sec-WebSocket-Origin: %s\r\n'
			'Sec-WebSocket-Location: ws://%s\r\n'
			'Sec-WebSocket-Accept: %s\r\n'
			'Sec-WebSocket-Version: %s\r\n'
			'\r\n') % (headers['origin'], headers['host'], accept, headers['sec-websocket-version'])

		print '--- HANDSHAKE ---'
		print bytes
		print '-----------------'
		self.send(bytes)

	## Handle incoming datas
	#  @param conn Socket of WebSocket client (from WSServer).
	#  @param addr Adress of WebSocket client (from WSServer).

	def handle(self, conn, addr):
		self.conn = conn
		self.addr = addr
		self.setStatus('CONNECTING')
		try:
			self.hanshake()
		except ValueError as error:
			self._WSServer.remove(self)
			self.close()
			raise ValueError('Client rejected: ' + str(error))
		else:
			_WSDecoder = WSDecoder()
			self.setStatus('OPEN')
			while self.hasStatus('OPEN'):
				try:
					ctrl, data = _WSDecoder.decode(self)
				except ValueError as (closing_code, message):
					if self.hasStatus('OPEN'): # context can change...
						self._WSController.kill(closing_code,'WSDecoder::'+str(message))
					break
				else:
					print '--- INCOMING DATAS ---'
					self._WSController.run(ctrl, data)

	## Send an unicast frame
	#  @param bytes Bytes to send.

	def send(self,bytes):
		if not self.hasStatus('CLOSED'):
			print '--- SEND UNICAST ---', repr(self.conn), repr(bytes), '[', str(len(bytes)), ']'
			lock = threading.Lock()
			lock.acquire()
			self.conn.send(bytes)
			lock.release()
			print 'unicast send finished'

	## Close connexion (don't forget to remove client from WebSocket Server first !)

	def close(self):
		print '--- CLOSE (WSCLIENT) ---', repr(self.conn)
		if not self.hasStatus('CLOSED'):
			self.setStatus('CLOSED')
			self.conn.close()

N.B. : la lecture des données binaires entrantes, via les méthodes WSClient.receive(), WSClient.read() et WSClient.readlineheader(), s'effectue en attendant un nombre défini d'octets, et non pas un bloc, comme on le voit trop souvent dans certains exemples sur le net. Cette façon de procéder est beaucoup plus propre, et beaucoup plus sécurisée à l'usage.

On s'aperçoit aussi que l'envoi de données du serveur vers les clients se fait toujours et uniquement via la méthode WSClient.send(), afin de garantir un maximum de sécurité. La méthode WSClient.close() est le pendant de WSServer.remove(), sauf qu'ici, on ferme la connexion physique, sans se soucier de la liste des clients actifs stockée dans la variable WSServer.clients.

Cette étape ayant permis de décoder les données, il ne nous reste plus qu'à les envoyer au controlleur.

WSController.py

C'est ici le coeur de notre application, puisque c'est cette classe qui va contrôler ce que nous ferons de nos données. Plus exactement, c'est la méthode WSController.run() qui va utiliser la variable «ctrl» pour définir la réponse au client. On peut classer ces réponses en deux types :

  • les actions de contrôle qui agissent directement sur l'état de connexion : WSController.ping(), WSController.pong() ou WSController.close()
  • les actions sur les données (texte ou binaire).

Ici, nous recevons des données de type «texte» depuis l'un des clients, et nous nous contentons simplement de renvoyer ce texte à tout le monde, via la méthode WSServer::send() vue plus haut. On rajoute un petit WSController.ping() pour le fun, histoire de recevoir un pong() dans les gencives (et oui : le web, c'est dangereux !).

Il va de soit que les méthodes ping/pong sont plus sérieusement utilisées dans une application pour vérifier la présence du client à intervalle régulier, et fermer la connexion après quelques tentatives infructueuses.

import WSSettings

from WSEncoder import *

## WebSocket Controller Class
#
#  Here we decide what to do with indoming ctrl/data frames.

class WSController:

	# OPCODE USED
	# 1000: NORMAL_CLOSURE
	# 1011: UNEXPECTED_CONDITION_ENCOUTERED_ON_SERVER

	## Constructor

	def __init__(self,_WSClient):
		self._WSClient = _WSClient

	## Pop n bytes
	#  @param bytes Bytes to shift.
	#  @param n Number if bytes to shift.

	def array_shift(self, bytes, n):
		out = ''
		for num in range(0,n):
			out += bytes[num]
		return out, bytes[n:]

	## Handle incoming datas
	#  @param ctrl Control dictionnary for data.
	#  @param data Decoded data, text or binary.

	def run(self, ctrl, data):

		print '--- CONTROLLER ---', repr(self._WSClient.conn)
		_WSEncoder = WSEncoder()

		# CONTROLS

		if ctrl['opcode'] == 0x9: # PING
			print '--- PING FRAME ---', repr(self._WSClient.conn)
			try:
				bytes = _WSEncoder.pong('Application data')
			except ValueError as error:
				self._WSClient._WSServer.remove(self._WSClient)
				self.kill(1011, 'WSEncoder error: ' + str(error))
			else:
				self._WSClient.send(bytes)

		if ctrl['opcode'] == 0xA: # PONG
			print '--- PONG FRAME ---', repr(self._WSClient.conn)
			if len(data):
				print 'Pong frame datas:', str(data)

		if ctrl['opcode'] == 0x8: # CLOSE
			print '--- CLOSE FRAME ---', repr(self._WSClient.conn)
			self._WSClient._WSServer.remove(self._WSClient)
			# closing was initiated by server
			if self._WSClient.hasStatus('CLOSING'):
				self._WSClient.close()
			# closing was initiated by client
			if self._WSClient.hasStatus('OPEN'):
				self._WSClient.setStatus('CLOSING')
				self.kill(1000, 'Goodbye !')
			# the two first bytes MUST contains the exit code, follow optionnaly with text data not shown to clients
			if len(data) >= 2:
				code, data = self.array_shift(data,2)
				status = ''
				if code in WSSettings.CLOSING_CODES:
					print 'Closing frame code:', code
				if len(data):
					print 'Closing frame data:', data

		# DATAS

		if ctrl['opcode'] == 0x1: # TEXT
			print '--- TEXT FRAME ---', repr(self._WSClient.conn)
			if len(data):
				try:
					bytes = _WSEncoder.text(data)
				except ValueError as error:
					self._WSClient._WSServer.remove(self._WSClient)
					self.kill(1011, 'WSEncoder error: ' + str(error))
				else:
					#  send incoming message to all clients
					self._WSClient._WSServer.send(bytes)
					# test ping/pong
					self.ping()

		if ctrl['opcode'] == 0x0: # CONTINUATION
			print '--- CONTINUATION FRAME ---', repr(self._WSClient.conn)
			pass

		if ctrl['opcode'] == 0x2: # BINARY
			print '--- BINARY FRAME ---', repr(self._WSClient.conn)
			pass

	## Send a ping

	def ping(self):
		print '--- PING (CONTROLLER) ---'
		if self._WSClient.hasStatus('OPEN'):
			_WSEncoder = WSEncoder()
			try:
				bytes = _WSEncoder.ping('Application data')
			except ValueError as error:
				self._WSClient._WSServer.remove(self._WSClient)
				self.kill(1011, 'WSEncoder error: ' + str(error))
			else:
				self._WSClient.send(bytes)

	## Force to close the connection
	#  @param code Closing code according to RFC. Default is 1000 (NORMAL_CLOSURE).
	#  @param error Error message to append on closing frame. Default is empty.

	def kill(self, code=1000, error=''):
		print '--- KILL (CONTROLLER)  ---', repr(self._WSClient.conn)
		if not self._WSClient.hasStatus('CLOSED'):
			_WSEncoder = WSEncoder()
			data = struct.pack('!H', code)
			if len(error):
				data += error
			print '--- KILL FRAME ---', code, error, repr(self._WSClient.conn)
			try:
				bytes = _WSEncoder.close(data)
			except ValueError as error:
				self._WSClient.close()
			else:
				self._WSClient.send(bytes)
				self._WSClient.close()

A noter l'utilisation massive de la classe WSEncoder, qui encode les données à envoyer. Pour faciliter les choses, on dispose de petits raccourcis comme les méthodes WSEncoder.text(), WSEncoder.binary(), WSEncoder.ping(), WSEncoder.pong(), WSEncoder.close(). Là encore, nous ne détaillerons pas cette classe, livrée avec le code source de l'application.

Autre petite chose à savoir : la méthode WSController.kill() renvoie deux octets qui spécifient le status de fermeture, accompagné éventuellement d'un message d'information, lequel ne doit pas être affiché côté client.

En conclusion

Le protocole WebSockets est finalement assez simple à comprendre et à maîtriser. Il ouvre la voie au mode PUSH pour les données web, et vient combler un manque latent du protocole HTTP.

Les codes sources ici présentés sont volontairement simplistes, et ne prennent pas en compte les données fragmentées par exemple. En outre, il est toujours dangereux de confier le traitement des données au contrôleur, lequel risque de bloquer la lecture dans une application temps réel.

Quoiqu'il en soit, Python démontre ici toute sa puissance, et j'avoue personnellement apprécier de plus en plus ce langage face à PHP, C++ et Java. En s'appuyant sur la seule indentation du code source pour délimiter les blocs, des données entrantes Python simplifie en effet énormément la vie du développeur, en lui évitant les traditionnelles accolades et points virgules oubliés ou mal placés.


N'hésitez évidemment pas à me contacter pour toute amélioration sur ce code ou toute adaptation quand à son utilisation dans vos projets.