HLab

Julien Hautefeuille

Websocket : Python et Gevent

L’objectif est de construire un système minimaliste de discussion en temps réel. Les technologies les plus prometteuses pour réaliser cette tâche sont les WebSockets. Les websockets permettent l’ouverture d’un canal bidirectionnel entre un serveur et un client. Les échanges sont plus rapides et les messages échangés sont de tailles réduites. Les gains en production sont évidents : amélioration des performances, allégement de la consommation de la bande passante. On utilisera ici la librairie Gevent_ pour encapsuler notre application websocket. Le code devra gérer l’echo en broadcast des messages, le buffering des messages et la surveillance des connexions cassées ou perdues.

Prototype de l’application

Exemple :

#!/usr/bin/python
#-*- coding : utf-8 -*-

# @author : julien@hautefeuille.eu
# @date : 06/18/2012
# @version : 0.5
# @install : gevent, gevent-websocket

import os
import datetime
import json
import gevent
from gevent.pywsgi import WSGIServer
import geventwebsocket
from gevent import monkey
monkey.patch_all()

class BroadcastServer(object):
    def __init__(self):
        self.buffer = []
        self.all_socket = set()
        self.fail_socket = set()
        path = os.path.dirname(geventwebsocket.__file__)
        agent = "gevent-websocket/%s" % (geventwebsocket.__version__)
        print "Running %s from %s" % (agent, path)
        self.server = WSGIServer(("0.0.0.0", 443), self.websocket_handler, 
        handler_class=geventwebsocket.WebSocketHandler)
        self.server.serve_forever()

    def websocket_handler(self, environ, start_response):
        websocket = environ.get('wsgi.websocket')
        session = environ.get('REMOTE_ADDR')
        annonce = '%s connected' % session

        if websocket is None: # Switch to standard http mode
            return self.http_handler(environ, start_response)

        websocket.send(json.dumps({'buffer': self.buffer}))
        self.broadcast_message(json.dumps({'annonce' : annonce}))
        websocket.send(json.dumps({'annonce' : "You are welcome"}))

        try:
            while True:
                time = datetime.datetime.now()          
                message = websocket.receive()           

                if message is None:
                    annonce = '%s deconnected' % session
                    self.broadcast_message(json.dumps({'annonce' : annonce}))
                    break

                premessage = json.dumps({'ip' : session, 
                'date' : str(time), 'message' : message})

                self.tracking_socket(websocket)         
                self.buffer.append(premessage)          
                self.cleaning_buffer()                  
                self.broadcast_message(premessage)      
                self.cleaning_socket()                  

            websocket.close()                           
        except geventwebsocket.WebSocketError, ex:
            print "%s : %s" % (ex.__class__.__name__, ex)

    def http_handler(self, environ, start_response):    
        if environ["PATH_INFO"] == "/":
            start_response("200 OK", [("Content-Type", "text/html")])
            return open('index.html').readlines()
        else:
            start_response("400 Bad Request", [])
            return ["WebSocket connection is expected !"]

    def broadcast_message(self, message):
        for s in self.all_socket: 
            try:
                s.send(message)
                print "Send to all"
                print message 
            except Exception:
                self.fail_socket.add(s)
                print "Failed sockets"
                print self.fail_socket
                continue

    def tracking_socket(self, socket):
        if socket not in self.all_socket:
            self.all_socket.add(socket)
            print "socket added"
            print self.all_socket

    def cleaning_socket(self):
        if self.fail_socket:
            for s in self.fail_socket:
                print "Trying to close socket"
                s.close()
                if s in self.all_socket:
                    self.all_socket.discard(s)
            self.fail_socket.clear()
            print "Socket remove"

    def cleaning_buffer(self):
        if len(self.buffer) > 10:
            del self.buffer[0]
            print "Buffer cleaned"

if __name__ == "__main__":
    app = BroadcastServer()

Le client en Javascript (jQuery)

Code Javascript :

<!DOCTYPE html>
<meta charset="utf-8" />
<html>
<head>
<script src="https://ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min.js"></script>
<script type="text/javascript">
    window.onload = function() {

    var ws_uri = "ws://192.168.0.33:443";
    ws = new WebSocket(ws_uri);

    ws.onmessage = function (event) {

        var result = jQuery.parseJSON(event.data);

        if (typeof(result.buffer) != 'undefined') { // buffer message
            $.each(result.buffer, function(i, object) {
                var buffer_data = jQuery.parseJSON(object);
                $('#affichage').append(buffer_data.ip + ' ' + buffer_data.date + ' ' + buffer_data.message + '*<br>');
            });
        };

        if (typeof(result.date) != 'undefined') { // New posted message
            $('#affichage').append(result.ip + ' ' + result.date + ' ' + result.message + '<br>');
        };

        if (typeof(result.annonce) != 'undefined') { // Annonce
            $('#affichage').append(result.annonce + '<br>');
        };

        console.log("Got echo : " + event.data);
    };

    ws.onopen = function (event) {
        $('#status').html('<b>Status : connection opened</b>')
        ws.send("Enters...")
        console.log('Web Socket State::' + 'OPEN');
    };

    ws.onclose = function (event) {
        var code = event.code;
        var reason = event.reason;
        var wasClean = event.wasClean;
        $('#status').html('<b>Status : connection losed</b>');
        console.log('Web Socket State::' + 'CLOSED ' + event.code + ' ' + event.reason + ' ' + event.wasClean);
    }; 

    ws.onerror = function(event) {
        $('#status').html('<b>Status connection error</b>');
        console.log('Web Socket State::' + 'ERROR');
    };

    send_area = function() {
        var r = $('#area').val();
        ws.send(r);
    };
}   
</script>
</head>
<title>ShootaWall</title>
<div id="zone">
<textarea rows="4" cols="50" id="area"></textarea> 
<button onclick='send_area();'>Send</button>
</div>
<div id="status">Not Connected</div>
<div id="affichage"></div>
<style>
#status { background: #ddd;}
</style>
</html>

Conclusion

J’ai apprécié la puissance et la simplicité de la librairie Gevent. Les personnes habituées au développement tradionnel d’applications web devront apprendre ou ré-apprendre à penser en terme de canaux bidirectionnels, ce n’est pas forcément naturel. Je pense qu’il pourrait être bénéfique d’utiliser des librairies dédiées à l’échange de messages de ce type (AutoBahn, ZeroMQ_). Il me semble que sur une application plus conséquente, l’échange par message peut rapidement devenir un casse-tête. J’aime la structuration de l’application aidée par Gevent. J’aime beaucoup moins l’idée d’un développement Javascript du côté client. A mon goût, le code peut devenir difficilement maintenable. Je ne suis néanmoins pas un développeur Javascript qui possède sans doute une méthodologie particulière de développement.

Je cherche à présent une solution élégante pour authentifier des utilisateurs sur une application websocket. Les websockets s’appuyant sur http, je devrais trouver mon bonheur dans les solutions existantes.