Sam & Max » crossbar.io http://sametmax.com Du code, du cul Sat, 07 Nov 2015 10:56:13 +0000 en-US hourly 1 http://wordpress.org/?v=4.1 Un petit dashboard de monitoring avec Django et WAMP 7 http://sametmax.com/un-petit-dashboard-de-monitoring-avec-django-et-wamp/ http://sametmax.com/un-petit-dashboard-de-monitoring-avec-django-et-wamp/#comments Sat, 07 Feb 2015 10:58:43 +0000 http://sametmax.com/?p=15872 Cet article est écrit dans le cadre de ma collaboration avec Tavendo.

On a déjà vu que WAMP c’est cool, mais c’est asynchrone et nos frameworks Web chéris WSGI sont synchrones.

J’ai donné une solution de contournement avec la lib crochet qui permet de faire tourner du twisted de manière synchrone dans son projet.

Néanmoins, beaucoup sont, j’en suis certain, à la recherche d’un truc plus simple. En effet, le bénéfice le plus immédiat de WAMP sont les notifications en temps réel. Et pour ça, crossbar vient avec le HTTP PUSHER service : quelques lignes de JSON dans le fichier de config de crossbar et zou, on peut publier sur un topic WAMP avec une simple requête POST :

 "transports": [
    {
       "type": "web",
       "endpoint": {
          "type": "tcp",
          "port": 8080
       },
       "paths": {
          ...
          "notify": {
             "type": "pusher",
             "realm": "realm1",
             "role": "anonymous"
          }
       }
    }
 ]

Et derrière, pour publier un event sur le sujet “super_sujet”, on peut faire :

import requets
requests.post("http://ip_du_router/pusher",
                  json={
                      'topic': 'super_sujet'
                      'args': [queques, params, a, passer, si, on veut]
                  })

Ceci va envoyer une requête POST à un service de crossbar qui va transformer ça en véritable publish WAMP.

Histoire d’illustrer tout ça, je vais vous montrer comment construire un petit service de monitoring avec Crossbar.io et Django. Pour suivre le tuto vous aurez besoin :

  • De connaissances de base en JS.
  • De connaître le principe de WAMP.
  • De savoir installer des bibliothèques Python avec extensions sur votre machine. pip et virtualenv sont vos amis.
  • De connaître Django. Même si le concept peut s’appliquer à Flask, Pyramid, ou autre.

Premiers pas

Le but du jeu est d’avoir un petit client WAMP qu’on lance sur chaque machine qu’on veut monitorer. Celui-ci va, toutes les x secondes, récupérer l’usage CPU, RAM et disque et faire un publish WAMP.

Chaque machine possède un client WAMP

Chaque machine possède un client WAMP

A l’autre bout, on a un site Django qui a un modèle pour chaque machine monitorée, avec des valeurs pour dire si on est intéressé par le CPU, la RAM ou le disque et la valeur de x.

Une page affiche en temps réel tous les relevés pour toutes les machines. Si dans l’admin de Django on change un modèle, la page reflète ce changement.

Si je déclique "CPU" dans l'admin Django, les CPUs ne sont plus affichés

Si je déclique “CPU” dans l’admin Django, les CPUs ne sont plus affichés

On aura donc besoin de django (pip install Django, ça c’est pas trop dur), requests (pip install requests, jusqu’ici tout va bien), et psutil.

psutil est la lib Python qui va nous permettre de récupérer toutes le valeurs pour la RAM, le disque et le CPU. Elle utilise des extensions en C, il faut donc un compilateur et les headers Python. Sous Ubuntu, il faut donc faire :

sudo apt-get install gcc python-dev

Sous CentOS ça donne :

yum groupinstall "Development tools"
yum install python-devel

Sous Mac, les headers Python devraient être inclus, mais il vous faut aussi GCC. Si vous avez xcode, vous avez déjà un compilateur, sinon, il existe un installeur plus léger.

Sous windows, c’est un wheel donc rien à faire normalement.

Et reste plus qu’à pip install psutil.

Enfin il nous faudra, logique, installer crossbar. pip install crossbar, sachant que sous Windows vous aurez besoin de PyWin32 et comme toujours, d’avoir les dossiers C:\Python27\ and C:\Python27\Scripts dans votre PATH.

Le HTML

On a besoin que d’une page. Afin de rendre le tuto agnostique, je l’ai fait en pur JS, pas de jQuery, pas d’Angular. Donc c’est verbeux :)

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
 
    <!-- De quoi cacher un bloc facilement -->
    <style type="text/css">
        .hide {display:none;}
    </style>
 
    <!--
        La lib JS qui permet de parler WAMP .
 
        Ici je suppose qu'on utilise un navigateur qui support websocket.
        Il est possible de faire du fallback sur flash ou long poll, mais
        ce sont des dépendances en plus.
    -->
    <script src="https://autobahn.s3.amazonaws.com/autobahnjs/latest/autobahn.min.jgz"
           type="text/javascript"></script>
 
 
    <!-- Tout notre code client, inline pour faciliter votre lecture -->
    <script type="text/javascript">
 
      /* Connexion à notre serveur WAMP */
      window.addEventListener("load", function(){
        var connection = new autobahn.Connection({
           url: 'ws://127.0.0.1:8080/ws',
           realm: 'realm1'
        });
 
        /* Quand la connexion est ouverte, exécuter ce code */
        connection.onopen = function(session) {
 
          var clients = document.getElementById("clients");
 
          /* Quand on reçoit l'événement clientstats, lancer cette fonction */
          session.subscribe('clientstats', function(args){
            var stats = args[0];
            var serverNode = document.getElementById(stats.ip);
 
            /*
                 Créer un li contenant un h2 et un dl pour ce client si
                 il n'est pas encore dans la page.
            */
            if (!serverNode){
                serverNode = document.createElement("li");
                serverNode.id = stats.ip;
                serverNode.appendChild(document.createElement("h2"));
                serverNode.appendChild(document.createElement("dl"));
                serverNode.firstChild.innerHTML = stats.name + " (" + stats.ip + ")";
                clients.appendChild(serverNode);
 
                // Cacher les infos du serveur si il est désactivé.
                session.subscribe('clientconfig.' + stats.ip, function(args){
                    var config = args[0];
                    if (config.disabled){
                        var serverNode = document.getElementById(config.ip);
                        serverNode.className = "hide";
                    }
                });
 
            }
 
            // Remettre à zéro le contenu du li du serveur.
            serverNode.className = "";
            var dl = serverNode.lastChild;
            while (dl.hasChildNodes()) {
                dl.removeChild(dl.lastChild);
            }
 
            // Si on a des infos sur le CPU, les afficher
            if (stats.cpus){
                var cpus = document.createElement("dt");
                cpus.innerHTML = "CPUs:";
                dl.appendChild(cpus);
                for (var i = 0; i < stats.cpus.length; i++) {
                    var cpu = document.createElement("dd");
                    cpu.innerHTML = stats.cpus[i];
                    dl.appendChild(cpu);
                };
            }
 
            // Si on a des infos sur l'espace disque, les afficher
            if (stats.disks){
                var disks = document.createElement("dt");
                disks.innerHTML = "Disk usage:";
                dl.appendChild(disks);
                for (key in stats.disks) {
                    var disk = document.createElement("dd");
                    disk.innerHTML = "<strong>" + key + "</strong>: " + stats.disks[key];
                    dl.appendChild(disk);
                };
            }
 
            // Si on a des infos sur l'usage mémoire, les afficher.
            if (stats.memory){
                var memory = document.createElement("dt");
                memory.innerHTML = "Memory:";
                dl.appendChild(memory);
                var memVal = document.createElement("dd");
                memVal.innerHTML = stats.memory;
                dl.appendChild(memVal);
            }
 
          });
 
        };
 
        // Ouvrir la connexion avec le routeur WAMP.
        connection.open();
 
      });
    </script>
 
    <title> Monitoring</title>
</head>
<body>
    <h1> Monitoring </h1>
    <ul id="clients"></ul>
</body>
 
</html>

Comme vous pouvez le voir, c’est beaucoup de JS ordinaire et du DOM. Les seules parties spécifiques à WAMP sont :

var connection = new autobahn.Connection({
           url: 'ws://127.0.0.1:8080/ws',
           realm: 'realm1'
        });
connection.onopen = function(session) {
...
}
connection.open();

Pour se connecter au serveur.

Et :

session.subscribe('nom_du_sujet', function(args){
...
}

Pour réagir à la publication d’un sujet WAMP.

Le client de monitoring

C’est la partie qui va aller sur chaque machine qu’on veut surveiller.

# -*- coding: utf-8 -*-
 
from __future__ import division
 
import socket
 
import requests
import psutil
 
from autobahn.twisted.wamp import Application
from autobahn.twisted.util import sleep
 
from twisted.internet.defer import inlineCallbacks
 
def to_gib(bytes, factor=2**30, suffix="GiB"):
    """ Converti un nombre d'octets en gibioctets.
 
        Ex : 1073741824 octets = 1073741824/2**30 = 1GiO
    """
    return "%0.2f%s" % (bytes / factor, suffix)
 
def get_infos(filters={}):
    """ Retourne la valeur actuelle de l'usage CPU, mémoire et disque.
 
        Ces valeurs sont retournées sous la forme d'un dictionnaire :
 
            {
                'cpus': ['x%', 'y%', etc],
                'memory': "z%",
                'disk':{
                    '/partition/1': 'x/y (z%)',
                    '/partition/2': 'x/y (z%)',
                    etc
                }
            }
 
        Le paramètre filter est un dico de la forme :
 
            {'cpus': bool, 'memory':bool, 'disk':bool}
 
        Il est utilisé pour décider d'inclure ou non les résultats des mesures
        pour les 3 types de ressource.
 
    """
 
    results = {}
 
    if (filters.get('show_cpus', True)):
        results['cpus'] = tuple("%s%%" % x for x in psutil.cpu_percent(percpu=True))
 
    if (filters.get('show_memory', True)):
        memory = psutil.phymem_usage()
        results['memory'] = '{used}/{total} ({percent}%)'.format(
            used=to_gib(memory.active),
            total=to_gib(memory.total),
            percent=memory.percent
        )
 
    if (filters.get('show_disk', True)):
        disks = {}
        for device in psutil.disk_partitions():
            usage = psutil.disk_usage(device.mountpoint)
            disks[device.mountpoint] = '{used}/{total} ({percent}%)'.format(
                used=to_gib(usage.used),
                total=to_gib(usage.total),
                percent=usage.percent
            )
        results['disks'] = disks
 
    return results
 
# On créé le client WAMP.
app = Application('monitoring')
 
# Ceci est l'IP publique de ma machine puisque
# ce client doit pouvoir accéder à mon serveur
# depuis l'extérieur.
SERVER = '172.17.42.1'
 
# D'abord on utilise une astuce pour connaître l'IP publique de cette
# machine.
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
# On attache un dictionnaire à l'app, ainsi
# sa référence sera accessible partout.
app._params = {'name': socket.gethostname(), 'ip': s.getsockname()[0]}
s.close()
 
@app.signal('onjoined')
@inlineCallbacks
def called_on_joinded():
    """ Boucle envoyant l'état de cette machine avec WAMP toutes les x secondes.
 
        Cette fonction est exécutée quand le client "joins" le router, c'est
        à dire qu'il est connecté et authentifié, prêt à envoyer des messages
        WAMP.
    """
    # Ensuite on fait une requête post au serveur pour dire qu'on est
    # actif et récupérer les valeurs de configuration de notre client.
    app._params.update(requests.post('http://' + SERVER + ':8080/clients/',
                                    data={'ip': app._params['ip']}).json())
 
 
    # Puis on boucle indéfiniment
    while True:
        # Chaque tour de boucle, on récupère les infos de notre machine
        infos = {'ip': app._params['ip'], 'name': app._params['name']}
        infos.update(get_infos(app._params))
 
        # Si les stats sont a envoyer, on fait une publication WAMP.
        if not app._params['disabled']:
            app.session.publish('clientstats', infos)
 
        # Et on attend. Grâce à @inlineCallbacks, utiliser yield indique
        # qu'on ne bloque pas ici, donc pendant ce temps notre client
        # peut écouter les événements WAMP et y réagir.
        yield sleep(app._params['frequency'])
 
 
# On dit qu'on est intéressé par les événements concernant clientconfig
@app.subscribe('clientconfig.' + app._params['ip'])
def update_configuration(args):
    """ Met à jour la configuration du client quand Django nous le demande. """
    app._params.update(args)
 
# On démarre notre client.
if __name__ == '__main__':
    app.run(url="ws://%s:8080/ws" % SERVER)

Le plus gros du code est get_infos() qui n’a rien à voir avec WAMP. C’est nous, manipulant psutil pour obtenir les relevés de cette machine. Je ne recommande bien évidement pas de faire ça en prod : une grosse fonction monolithique qui prend un dico en param. Mais c’est pour une démo, et ça me permet de grouper les instructions qui vont ensemble pour faciliter votre compréhension.

La partie qui concerne WAMP :

app = Application('monitoring')
 
@app.signal('onjoined')
@inlineCallbacks
def called_on_joinded():
    ...
 
    while True:
 
        ...
        app.session.publish('clientstats', infos)
        ...
        yield sleep(app._params['frequency'])

app = Application('monitoring') créé un client WAMP, et @app.signal('onjoined') nous dit de lancer la fonction quand notre client est connecté et prêt à envoyer des événements. @inlineCallbacks est une spécificité de Twisted qui nous permet d’écrire du code asynchrone sans avoir à mettre des callback partout : à la place on met des yield.

Tout le boulot de notre client a lieu dans la boucle : app.session.publish('clientstats', infos) publie les nouvelles mesures de CPU/RAM/Disque via WAMP, puis attend un certain temps (yield sleep(app._params['frequency'])) avant de le faire à nouveau. L’attente n’est pas bloquante car elle se fait avec le sleep de Twisted.

N’oublions pas :

@app.subscribe('clientconfig.' + app._params['ip'])
def update_configuration(args):
    app._params.update(args)

La fonction update_configuration() sera appelée à chaque fois qu’une publication WAMP sera faite sur le sujet clientconfig.<ip_du_client>. Notre fonction ne fait que mettre à jour la configuration du client, qui est un dico de la forme :

    {'cpus': True,
    'memory': False,
    'disk': True,
    'disabled': False,
    'frequency': 1}

C’est ce dico qui est utilisé par get_infos() pour choisir quelles mesures récupérer, et aussi par sleep() pour savoir combien de secondes attendre avant la prochaine mesure.

La valeur initiale de ce dico est récupérée au lancement du client, en faisant :

app._params.update(requests.post('http://' + SERVER + ':8080/clients/',
                                    data={'ip': app._params['ip']}).json())

requests.post(url_du_serveur, data={'ip': app._params['ip']}).json() fait en effet une requête POST vers une URL de django qui nous allons voir plus loin, et qui retourne la configuration du client portant cette IP sous forme de JSON.

On utilise donc une fois HTTP pour obtenir les valeurs de départs, et ensuite WAMP pour les mises à jours des futures valeurs. WAMP et HTTP ne s’excluent pas : ils sont complémentaires.

Petite parenthèse sur :

SERVER = '172.17.42.1'
 
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.connect(("8.8.8.8", 80))
app._params = {'name': socket.gethostname(), 'ip': s.getsockname()[0]}
s.close()

D’une part, j’ai mis l’IP du serveur qui va contenir Crossbar.io et Django en dur car je suis, je pense que maintenant vous le savez, une grosse feignasse. Mais en prod, vous me faites un paramètre, on est d’accord ? Ensuite, il faut que j’identifie mon client, ce que je fais avec l’adresse IP. Il me faut donc son adresse IP externe, et je l’obtiens avec une astuce consistant à me connecter à l’IP 8.8.8.8 (les DNS google \o/) et en fermant la connexion juste derrière. Ce me permet de voir comment les autres machines me voit depuis l’extérieur.

Le site Django

Puisque le prérequis de l’article et de connaître Django, ça va pas être trop dur.

On créé son projet et son app :

django-admin startproject django_project
./manage.py startapp django_app

On se rajoute un petit modèle qui contient la configuration de chaque client (vous vous souvenez, le fameux dico) :

# -*- coding: utf-8 -*-
 
import requests
 
from django.db import models
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.forms.models import model_to_dict
 
 
class Client(models.Model):
    """ Configuration de notre client. """
 
    # Pour l'identifier.
    ip = models.GenericIPAddressField()
 
    # Quelles données envoyer à notre dashboard
    show_cpus = models.BooleanField(default=True)
    show_memory = models.BooleanField(default=True)
    show_disk = models.BooleanField(default=True)
 
    # Arrêter d'envoyer les données
    disabled = models.BooleanField(default=False)
 
    # Fréquence de rafraîchissement des données
    frequency = models.IntegerField(default=1)
 
    def __unicode__(self):
        return self.ip
 
 
@receiver(post_save, sender=Client, dispatch_uid="server_post_save")
def notify_server_config_changed(sender, instance, **kwargs):
    """ Notifie un client que sa configuration a changé.
 
        Cette fonction est lancée quand on sauvegarde un modèle Client,
        et fait une requête POST sur le bridge WAMP-HTTP, nous permettant
        de faire un publish depuis Django.
    """
    requests.post("http://127.0.0.1:8080/notify",
                  json={
                      'topic': 'clientconfig.' + instance.ip,
                      'args': [model_to_dict(instance)]
                  })

La partie modèle est connue. L’astuce est dans :

@receiver(post_save, sender=Client, dispatch_uid="server_post_save")
def notify_server_config_changed(sender, instance, **kwargs):
    requests.post("http://127.0.0.1:8080/notify",
                  json={
                      'topic': 'clientconfig.' + instance.ip,
                      'args': [model_to_dict(instance)]
                  })

On utilise ici les signaux Django, une fonctionnalité du framework qui nous permet de lancer une fonction quand quelque chose se passe. Ici on dit “lance cette fonction quand le modèle Client est modifié”.

Donc notify_server_config_changed va se lancer quand la config d’un client est modifiée, par exemple dans l’admin, et recevoir l’objet modifié via son paramètre instance.

On fait alors une petite requête POST sur http://127.0.0.1:8080/notify, l’URL sur laquelle on configurera plus loin notre service de push. En faisant une requête dessus, on va demander à Crossbar.io de transformer la requête HTTP en message publish WAMP, ici sur le sujet ‘clientconfig.<ip_du_client>’. On publie donc un message WAMP, depuis Django.

Ca marche depuis n’importe où, pas juste Django. Depuis le shell, depuis Flask, n’importe où on peut faire une requête HTTP vers le service de push de crossbar.

Ce message va être récupéré par notre client, où qu’il soit, puisqu’il est aussi connecté au routeur WAMP. Comme, je vous le rappelle, notre client fait ça :

@app.subscribe('clientconfig.' + app._params['ip'])
def update_configuration(args):
    app._params.update(args)

Il va recevoir ce message, et donc le contenu de 'args': [model_to_dict(instance)], c’est à dire la nouvelle configuration qu’on a changé en base de donnée. Il se met ainsi à jour immédiatement. La boucle est bouclée.

Comme on veut profiter de notre boucle toute bouclée, on rajoute le modèle dans l’admin :

from django.contrib import admin
 
# Register your models here.
 
from django_app.models import Client
 
admin.site.register(Client)

Ainsi, les configs des clients seront éditables dans l’admin, et quand on cliquera sur “save”, ça va lancer notre publish WAMP qui mettra à jour le bon client.

Le reste, c’est du fignolage. Une petite vue pour créer ou récupérer notre configuration de client au démarrage :

# -*- coding: utf-8 -*-
 
import json
 
from django.http import HttpResponse
from django_app.models import Client
from django.views.decorators.csrf import csrf_exempt
from django.forms.models import model_to_dict
 
 
@csrf_exempt
def clients(request):
    """ Récupère la config d'un client en base de donnée et lui envoie."""
    client, created = Client.objects.get_or_create(ip=request.POST['ip'])
    return HttpResponse(json.dumps(model_to_dict(client)), content_type='application/json')

On désactive la protection CSRF pour la démo, mais encore une fois, en prod, faites ça proprement, avec une jolie authentification pour protéger la vue, et tout, et tout.

Donc, cette vue récupère la configuration d’un client avec cette IP (la créant au besoin), et la retourne en JSON. Souvenez-vous, cela permet à notre client de faire :

    app._params.update(requests.post('http://' + SERVER + ':8080/clients/',
                                    data={'ip': app._params['ip']}).json())

Au démarrage et se déclarer dans la base de données, tout en récupérant sa config.

On branche tout ça via urls.py :

from django.conf.urls import patterns, include, url
from django.contrib import admin
from django.views.generic import TemplateView
 
urlpatterns = patterns('',
    url(r'^admin/', include(admin.site.urls)),
    url(r'^clients/', 'django_app.views.clients'),
    url(r'^$', TemplateView.as_view(template_name='dashboard.html')),
)

L’admin, notre vue toute fraiche, et de quoi servir le HTML du début de l’article.

Y plus qu’à :

./manage.py syncdb

Crossbar.io

Finalement, tout ce qu’il reste, c’est notre bon crossbar :

crossbar init

Ceci nous pond le dossier .crossbar dans lequel on a le fichier config.json qu’on édite pour qu’il ressemble à ça :

{
   "workers": [
      {
         "type": "router",
         "realms": [
            {
               "name": "realm1",
               "roles": [
                  {
                     "name": "anonymous",
                     "permissions": [
                        {
                           "uri": "*",
                           "publish": true,
                           "subscribe": true,
                           "call": true,
                           "register": true
                        }
                     ]
                  }
               ]
            }
         ],
         "transports": [
            {
               "type": "web",
               "endpoint": {
                  "type": "tcp",
                  "port": 8080
               },
               "paths": {
                  "/": {
                     "type": "wsgi",
                     "module": "django_project.wsgi",
                     "object": "application"
                  },
                  "ws": {
                     "type": "websocket"
                  },
                  "notify": {
                     "type": "pusher",
                     "realm": "realm1",
                     "role": "anonymous"
                  },
                  "static": {
                     "type": "static",
                     "directory": "../static"
                  }
               }
            }
         ]
      }
   ]
}

La partie du haut c’est un peu l’équivalent du chmod 777 de crossbar :

         "type": "router",
         "realms": [
            {
               "name": "realm1",
               "roles": [
                  {
                     "name": "anonymous",
                     "permissions": [
                        {
                           "uri": "*",
                           "publish": true,
                           "subscribe": true,
                           "call": true,
                           "register": true
                        }
                     ]
                  }
               ]
            }
         ],

“Met moi en place un router avec un accès nommé realm1 qui autorise à tous les anonymes de tout faire”. Un realm est une notion de sécurité dans Crossbar.io qui permet de cloisonner les clients connectés, nous on va tout mettre sur le même realm, c’est pour une démo je vous dis.

Ensuite on rajoute les transports pour chaque techno qui nous intéresse. On va tout regrouper sur le port 8080 car Twisted peut écouter en HTTP et Websocket sur le même port :

"transports": [
{
   "type": "web",
   "endpoint": {
      "type": "tcp",
      "port": 8080
   },

A la racine, on sert notre app Django :

  "/": {
     "type": "wsgi",
     "module": "django_project.wsgi",
     "object": "application"
  },

Car oui, crossbar peut servir votre app django en prod. Pas besoin de gunicorn. En fait même pas besoin d’nginx pour un site simple, car ça tient très bien la charge. On a juste à lui indiquer quelle variable (application) de quel fichier WSGI (django_project/wsgi.py) charger, et il s’occupe du reste.

Sur ‘/ws’, on écoute en Websocket :

"ws": {
 "type": "websocket"
},

WAMP passe par là, et c’est pour ça que nos clients se connectent en faisant app.run(url="ws://%s:8080/ws" % SERVER) et autobahn.Connection({url: 'ws://127.0.0.1:8080/ws', realm: 'realm1'});.

‘/notify’ va recevoir le bridge WAMP-HTTP :

"notify": {
     "type": "pusher",
     "realm": "realm1",
     "role": "anonymous"
  }

Tous les anonymes du realm1 peuvent l’utiliser. Grâce à ça, on a pu faire depuis notre signal Django :

    requests.post("http://127.0.0.1:8080/notify",
                  json={
                      'topic': 'clientconfig.' + instance.ip,
                      'args': [model_to_dict(instance)]
                  })

Et donc publier un message WAMP, via un POST HTTP.

Enfin, on sert les fichiers statiques Django avec Crossbar (oui, il fait aussi ça :):

 "static": {
    "type": "static",
    "directory": "../static"
}

N’oubliez pas le de spécifier STATIC_ROOT dans le fichier settings et lancer ./manage.py collecstatic.

Tout ça en place, on lance notre routeur :

export PYTHONPATH=/chemin/vers/votre/project
crossbar start

(Remplacer export par set sous Windows>

La modification de PYTHONPATH est nécessaire pour que crossbar trouve votre fichier WSGI.

On visite http:127.0.0.1:8080/, qui va charger notre template Django dashboard.html.

Chaque machine qui lance un client via python client.py va déclencher l’apparition des stats sur notre dashboard, qui seront mises à jour en temps réel.

Si on va sur http:127.0.0.1:8080/admin/ et qu’on change la config d’un client, notre client s’adapte, et notre dashboard se met à jour automatiquement.

Conclusion

Notre projet ressemble à ceci au final :

.
├── client.py
├── .crossbar
│   ├── config.json
├── db.sqlite3
├── django_app
│   ├── admin.py
│   ├── __init__.py
│   ├── models.py
│   ├── templates
│   │   └── dashboard.html
│   └── views.py
├── django_project
│   ├── __init__.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
├── static
└── manage.py

Vous pouvez récupérer le code ici.

Finalement, très peu de code WAMP : un peu dans le JS, un peu dans le client. Et la seule chose qui lie WAMP à Django est la config crossbar qui ajoute le service HTTP PUSHER et notre requête POST dans models.py

Cette technique n’est pas limitée à Django, et fonctionne bien pour toutes techno synchrones qui ne peut pas lancer un client WAMP directement en son sein. Pour le moment, le bridge HTTP-WAMP ne propose que PUB, pas de SUB, de pas de RPC. C’est déjà assez sympa pour avoir les notifications en temps réel un peu partout, et ça Tobias m’a dit qu’il ajoutera les autres actions dans un future proche.

En attendant, vous voyez le deal : on peut mélanger allègrement HTTP, WAMP, Python, JS, Client, Serveur, et monter sa petite architecture comme on le souhaite. Crossbar permet de démarrer du WSGI, mais aussi les clients WAMP sur la même machine et même n’importe quel process en ligne de commande (par exemple NodeJS) si besoin. C’est Mac Gyver ce truc.

On aurait pu écrire le client en Python 3 puisqu’il est sur une autre machine. Et en fait, si on lance Django en dehors de crossbar, aussi la partie Django en Python 3. Le code de crossbar n’est jamais modifié, on touche juste la configuration JSON.

Personnellement j’ai lancé plusieurs images dockers avec un client dedans à chaque fois, et c’est vraiment sympas de voir les machines se rajouter sur le dashboard en temps réel. On a une super sensation d’interactivité quand on change une valeur dans l’admin et qu’on voit le dashboard bouger.

]]>
http://sametmax.com/un-petit-dashboard-de-monitoring-avec-django-et-wamp/feed/ 7
Présentation de WAMP.ws, round 2 39 http://sametmax.com/presentation-de-wamp-round-2/ http://sametmax.com/presentation-de-wamp-round-2/#comments Sun, 21 Dec 2014 01:46:55 +0000 http://sametmax.com/?p=12969 mon travail sur WAMP, j'ai proposé à Tobias de commencer par une présentation générale de la stack techno sous forme de slide show. L'idée est de mettre à ça dans le header des sites de WAMP, crossbar.io et autobahn, afin que quand les gens arrivent dessus ils puissent rapidement voir de quoi on parle. Ou alors, si on est sur un forum, on peut linker vers les diapos pour donner un contexte.]]> Dans le cadre de mon travail sur WAMP, j’ai proposé à Tobias de commencer par une présentation générale de la stack techno sous forme de slide show.

L’idée est de mettre ça dans le header des sites de WAMP, crossbar.io et autobahn, afin que quand les gens arrivent dessus ils puissent rapidement voir de quoi on parle. Ou alors, si on est sur un forum, on peut linker vers les diapos pour donner un contexte.

Comme prévu, je fais une première version en français que je poste ici. Puis je vais récolter vos commentaires : qu’est-ce qu’on comprend pas, quelles informations manquent, qu’est-ce qui est flou, ambigüe, etc.

Ensuite je l’améliorerai lors de la traduction en anglais qui sera ensuite proposée à Tavendo.

]]>
http://sametmax.com/presentation-de-wamp-round-2/feed/ 39
Crossbar, le futur des applications Web Python ? 32 http://sametmax.com/crossbar-le-futur-des-applications-web-python/ http://sametmax.com/crossbar-le-futur-des-applications-web-python/#comments Sun, 25 May 2014 10:24:36 +0000 http://sametmax.com/?p=10329 crossbar.io depuis quelques temps maintenant, et je suis très, très étonné de ne pas plus en entendre parler dans la communauté Python.]]> Je suis crossbar.io depuis quelques temps maintenant, et je suis très, très étonné de ne pas plus en entendre parler dans la communauté Python.

Bon, en fait, à moitié étonné.

D’un côté, c’est une techno qui, à mon sens, représente ce vers quoi Python doit se diriger pour faire copain-copain avec Go/NodeJs et proposer une “killer feature” dans le monde des applications serveurs complexes.

De l’autre, hum, leur page d’accueil explique à quoi ça sert de cette manière :

Crossbar.io is an application router which implements the Web Application Messaging Protocol (WAMP). WAMP provides asynchronous Remote Procedure Calls and Publish & Subscribe (with WebSocket being one transport option) and allows to connect application components in distributed systems

Moui, moui, moui monseigneur, mais concrètement, là, hein, je peux faire quoi avec ?

C’est toujours le problème avec les gens intelligents (hein Cortex ?) : ils font des trucs super cool, et personne ne comprend à quoi ça sert parce qu’il ne sont pas foutus de l’expliquer.

Moi je suis un peu con, alors je vais profiter qu’on soit tous au même niveau pour vous faire passer le message.

J’étais persuadé d’avoir mis la musique habituelle… Je la remets :

Web Application Message Protocol

Je vous avais parlé d’autobahn dernièrement, un client WAMP qui embarque aussi un routeur basique. Crossbar est la partie serveur, un routeur WAMP plus sérieux.

Crossbar permet à tous les clients d’échanger des messages WAMP à travers lui. Bien entendu, un client WAMP peut parler au serveur Crossbar et inversement comme un client HTTP peut parler à un serveur Apache/Nginx et inversement. Mais plus que ça, les clients peuvent parler entre eux, de manière transparente et simple. Comme un client AMQP peut parler aux autres à travers un serveur RabbitMQ.

Cependant ça ne vous avance pas si vous ne savez pas ce qu’est WAMP ou à quoi ça sert. La charrue avec la peau de l’ours, tout ça.

WAMP est un protocole standardisé pour échanger des messages entre deux systèmes. Ce n’est pas particulièrement lié à Python, on peut parler WAMP dans n’importe quel langage.

Il fonctionne en effet, principalement, au dessus de Websocket, donc on peut l’utiliser directement dans le navigateur, dans Firefox, Chrome, Opera, Safari et même IE10, via une lib Javascript. Qui est en fait juste un gros wrapper autour de l’API websocket standardisant la manière d’envoyer des données. Il n’y a rien de magique derrière, pas de formats compliqués, pas de binaire, c’est vraiment juste des appels websocket contenant des données formatées en JSON avec une certaine convention. En ce sens il fait penser à SockJS et (feu) socket.io.

Seulement contrairement aux solutions type SocketJS, il n’est pas limité au navigateur. Il y a des libs pour l’utiliser dans un code serveur Python, C++ ou NodeJS, dans une app Android et même directement depuis les entrailles de Nginx ou d’une base de données Oracle (en SQL) avec certains routeurs.

Schéma général du fonctionnement de WAMP

Comme HTTP, WAMP est juste un moyen de faire parvenir des données d’un point A à un point B. Mais contrairement à HTTP, WAMP permet aux clients de parler entre eux, et pas juste au serveur.

Comprenez bien, ça veut dire qu’on peut envoyer et recevoir des données arbitraires, en temps réel, entre tous ces systèmes, sans se prendre la tête, et de manière transparente.

WAMP c’est donc comme, mais mieux que :

  • des requêtes HTTP, car c’est du push, asynchrone et temps réel;
  • des requêtes websocket via SocketJS car c’est un standard, qui fonctionne SUR et ENTRE plusieurs services côté serveurs malgré les différents langages;
  • des messages AMQP car ça marche dans le navigateur et ça se configure facilement.

Bien utilisé, Crossbar permet d’amener Python dans la cour de frameworks “temps réel” novateurs comme MeteorJS, et potentiellement les dépasser.

Car WAMP permet de faire deux choses. Simplement. Et bien.

1 – Du PUB/SUB

Donc de dire dans son code “appelle cette fonction quand cet événement arrive”. C’est comme les signaux de Django ou QT, mais ça marche à travers le réseau. On le fait souvent avec Redis ces temps-ci. Avec WAMP et Javascript, ça donne :

// connection au routeur WAMP (par exemple, crossbar.io)
ab.connect("ws://localhost:9000", function(session) {
    // je m'inscris à un événement
    session.subscribe('un_evenement_qui_vous_plait', function(topic, evt){
        // faire un truc avec les données reçues
        // à chaque fois que l'événement est envoyé
        // par exemple mettre la page à jour
    });
});
Schéma de fonctionnement du subscribe de WAMP

Des client SUBscribe à un événément. Un événément est un nom arbitrairement choisit par le programmeur, et qu’il va déclencher lui-même quand il pense qu’il se passe quelque chose d’important auquel il faut que le reste du signal réagisse.

Et ailleurs, dans une autre partie du fichier, ou même sur un autre navigateur Web :

ab.connect("ws://localhost:9000", function(session) {
    // création d'un événement auquel on attache des données
    session.publish('un_evenement_qui_vous_plait', ['des données']);
Schéma de fonctionnement de PUB avec WAMP

Le programmeur décide que quelque chose d’important arrive (création d’un contenu, login d’un utilisateur, notification), et PUBlish l’événement

Et oui, c’est tout. On se connecte à crossbar, et on discute. La fonction du subscribe sera alors appelée avec les données du publish. Même si il y a 3000 km entre les deux codes. Même si le code A est sur un navigateur et le B sur un autre, ou sur un serveur NodeJS, ou une app Android.

Ce qui fait peur au début, c’est qu’il y a TROP de flexibilité :

  • Je dois attendre quoi comme événements ?
  • Qu’est-ce que je passe comme données ?
  • Est-ce que c’est rapide ? Léger ?

Mais en fait c’est super simple : un événement c’est juste une action de votre application comme un élément (un post, un commentaire, un utilisateur…) ajouté, supprimé ou modifié. Finalement c’est le bon vieux CRUD, mais en temps réel, et en push, au lieu du pull. Vous choisissez un nom qui représente cette action, vous attachez des données à ce nom, voilà, c’est un événement que tous les abonnés peuvent recevoir.

Avec un bonus : ça marche sur le serveur aussi ! Votre code Python reçoit “ajouter un commentaire” comme événement ? Il peut ajouter le commentaire en base de données, envoyer un message à un service de cache ou à un autre site en NodeJS pour le mettre à jour, renvoyer un événement pour mettre à jour les pages Web et l’app Android, etc.

On peut passer n’importe quelles données qui peut se JSONiser. En gros n’importe quoi qu’on enverrait via HTTP. Donc des données très structurées, imbriquées et complexes comme des données géographiques, ou très simples comme des notifications

Avec PUB / SUB, WAMP remplace tout ce qu’on ferait normalement avec des appels AJAX dans le browser, et tout ce qu’on ferait avec des files de message côté serveur. Plus puissant encore, il permet de relier ces deux mondes.

Et même si on atteint pas les perfs de ZeroMQ (qui n’a pas de serveur central), c’est très performant et léger.

2 – Du RPC

Appeler une fonction située ailleurs que dans son code. C’est vieux comme le monde (si vous avez des souvenirs douloureux de CORBA et SOAP, levez la main), et c’est extrêmement pratique. Pour faire simple, continuons avec un exemple en Javascript, mais rappelez-vous que ça marche pareil en C++ ou Python :

ab.connect("ws://localhost:9000", function(session) {
   function une_fonction(a, b) {
      return a + b;
   }
   // on déclare que cette fonction est appelable à distance
   session.register('une_fonction', une_fonction);
});
Schéma expliquant register avec WAMP

RPC marche à l’envers de PUB/SUB. Un client expose du code, et un autre demande explicitement qu’il soit exécuté.

Côté appelant :

ab.connect("ws://localhost:9000", function(session) {
    // on appelle la fonction à distance, on récupère une
    // promise qui nous permet de travailler sur le résultat
   session.call('une_fonction', 2, 3).then(
      function (res) {
         console.log(res);
      }
   );
Schéma expliquant CALL en WAMP

Contrairement à PUB/SUB, RPC ne concerne que deux clients à la fois. Mais ça reste asynchrone. Le client demandeur n’attend pas le résultat de l’appel de la fonction. Il est signalé par le serveur quand il est prêt.

Pareil que pour le PUB/SUB, les gens ont du mal à voir l’utilité à cause du trop de flexibilité que ça apporte. Imaginez que votre projet soit maintenant éclaté en de nombreux petits services qui tournent et qui sont indépendants :

  • Un service pour le site Web.
  • Un service d’authentification.
  • Un service pour l’API.
  • Un service pour les tâches longues.
  • Un service de monitoring et administration technique.

Tous ces services peuvent ainsi communiquer entre eux via RPC, mais n’ont pas besoin d’être dans le même processus. On peut profiter pleinement de tous les cœurs de sa machine, on peut même les mettre sur des serveurs séparés.

Mieux, avoir un service bloquant ne pénalise pas tout le système. En effet, un problème avec les systèmes asynchrones en Python est que beaucoup de libs sont encore bloquantes (typiquement les ORMs). Avec ce genre d’architecture, on peut créer un service qui ne fait que les appels bloquant et laisser les autres services non bloquant l’appeler de manière asynchrone. Pendant qu’il bloque, le reste du système peut traiter d’autres requêtes.

Crossbar, plus qu’un routeur WAMP

L’idée des concepteurs de crossbar est de permettre de créer des systèmes avec des services composables qui communiquent entre eux plutôt que tout dans un gros processus central. Ils ne se sont donc pas arrêtés au routing.

Crossar est également un gestionnaire de processus, comme supervisor ou, plus légitimement, circus (Tarek, fait une pause, vient ici !) et sa communication ZeroMQ.

Il se configure avec un simple fichier JSON, et on peut y définir des classes Python qui seront lancées dans un processus séparé et pourront discuter avec les autres clients via WAMP :

{
   "processes": [
      { // premier processus
         "type": "worker",
         "modules": [
            {
               un_worker.Classe
            },
            {
               un_autre_worker.Classe
            }
         ]
      },
      {  // second processus
         "type": "worker",
         "modules": [
            {
               un_autre_worker_dans_un_autre_process.Classe
            }
         ]
      }
   ]
}

Mais si ça ne suffit pas, on peut également lancer des programmes extérieurs non Python dont crossbar va gérer le cycle de vie :

{
   "processes": [
      {
         "type": "guest",
         "executable": "/usr/bin/node",
         "arguments": ["votre_script.js"],
         "stdout": "log"
      }
   ]
}

Vous avez donc ainsi les deux atouts pour avoir une architecture découplée, scalable, exploitant plusieurs cœurs, et compensant en partie les bibliothèques bloquantes :

  • Un protocole flexible, simple, qui permet à tout le monde se parler entre eux (WAMP).
  • Une API qui permet soit de réagir à un changement (PUB/SUB), soit de demander une action (RPC).
  • Un programme qui gère cette communication, et le cycle de vie des composants qui parlent entre eux.

Cas concret

WAMP est typiquement le genre de techno qui ne permet PAS de faire quelque chose qu’on ne faisait pas avant. Ce n’est pas nouveau.

En revanche, WAMP permet de le faire mieux et plus facilement.

Prenez le cas d’un utilisateur qui se connecte sur un forum. Il va sur un formulaire, il poste ses identifiants, ça recharge la page, il est connecté. Si les autres utilisateurs rechargent leurs pages, ils verront un utilisateur de plus connecté.

Si on veut rendre ça plus dynamique, il faut utiliser de l’AJAX, et si on veut avoir une mise à jour presque en temps réel, il faut faire des requêtes Ajax régulières. Ce qui est assez bancal et demande beaucoup de travail manuel.

Certains sites modernes utilisent Websocket, et des serveurs asynchrones comme NodeJS, et un routeur PUB/SUB comme Redis, pour faire cela de manière rapide et plus facile. L’application est très réactive. Mais le système est hétéroclite. Et si on veut envoyer des messages entre des composants serveurs, ça demande encore quelque chose de différent.

WAMP unifie tout ça. Un coup de RPC pour le login pour effectuer l’action:

Schéma d'un exemple concret de RPC WAMP

Notez que le RPC marche de n’importe quel client à n’importe quel client. Il n’y a pas de sens obligatoire. Le login est un exemple simple mais on peut faire des choses bien plus complexes.

Et un coup de PUB/SUB pour prévenir tout le monde que quelque chose s’est passé :

Schéma d'exemple concret de PUB/SUB avec WAMP

Je n’ai mis que des clients légers ici, mais je vous rappelle qu’un client peut être un serveur NodeJS, une base de données, un code C++…

Bien entendu, on pourrait faire ça avec les technos existantes. C’est juste moins pratique.

Notez également que Crossbar encourage à avoir un service qui ne se charge que du login, sans avoir à faire une usine à gaz pour cela. Si demain votre service de login a besoin d’être sur un coeur/serveur/une VM séparé pour des raisons de perfs ou de sécurité, c’est possible. Crossbar encourage ce genre de design.

Voici où est le piège

Car évidement, il y en a toujours un, putain de métier à la con.

Et c’est la jeunesse du projet.

Le projet est stable, le code marche, et les sources sont propres.

Mais la doc, mon dieu la doc… Les exemples sont pas à jour, il y a deux versions qui se battent en duel, on sait pas trop quelle partie sert à quoi.

Et comme tout projet jeune, l’API n’a pas été assez étudiée. Or la partie Python est basée sur Twisted, sans polish. Twisted, c’est puissant, c’est solide, et c’est aussi une API dégueulasse.

Un exemple ? Comment écouter un événement :

# Des imports légers
from twisted.python import log
from twisted.internet.defer import inlineCallbacks
 
from autobahn.twisted.wamp import ApplicationSession
from autobahn.twisted.wamp import ApplicationRunner
 
# Une bonne classe bien subtile pour copier Java
class ListenForEvent(ApplicationSession):
 
    # Deux méthodes de boiler plate obligatoires
    # et parfaitement soulantes pour l'utilisateur
    # final. Cachez moi ça bordel !
    def __init__(self, config):
        ApplicationSession.__init__(self)
        self.config = config
 
    def onConnect(self):
        self.join(self.config.realm)
 
    # Bon, on se décide, soit on fait une classe avec des noms
    # de méthode conventionnés, soit on met des décorateurs, 
    # mais pas les deux, pitié !
    @inlineCallbacks
    def onJoin(self, details):
        callback = lambda x: log.msg("Received event %s" % x)
        yield self.subscribe(callback, 'un_evenement')
 
# Python doit lancer explicitement un event loop.
# Ca pourrait (devrait) aussi être embeded dans une
# sousclasse de ApplicationSession.
# /me prend un colt. BANG !
if __name__ == '__main__':
   runner = ApplicationRunner(endpoint="tcp:127.0.0.1:8080",
                              url="ws://localhost:8080/ws",
                              realm="realm1")
   runner.run(ListenForEvent)

C’est la raison pour laquelle je vous ai montré le code JS et pas Python pour vous vendre le truc. Sur sametmax.com, on aura tout vu :(

Voilà à quoi devrait ressembler le code Python si l’API était mature :

from autobahn.app import App
 
app = App(url="ws://localhost:8080/ws")
 
@event("un_evenement")
def handle(details):
    app.log("Received event %s" % x)
 
if __name__ == '__main__':
   app.run()

Chose que je vais proposer sur la mailing list (ils sont réactifs et sympas, vive les allemands !) dans pas longtemps. Et si ils n’ont pas le temps de le faire, il est possible que je m’y colle. Ca me fait mal aux yeux.

]]>
http://sametmax.com/crossbar-le-futur-des-applications-web-python/feed/ 32