PROJET AUTOBLOG


Sam & Max: Python, Django, Git et du cul

Site original : Sam & Max: Python, Django, Git et du cul

⇐ retour index

Mise à jour

Mise à jour de la base de données, veuillez patienter...

Petite démo pragmatique d’un usage de WAMP en Python

jeudi 26 juin 2014 à 09:27

Vu que dernièrement je vous ai bien gavé avec WAMP, ça mérite un tuto non ?

Il se trouve que l’équipe derrière WAMP a publié plus tôt que prévu une version de leurs libs contenant l’API flaskesque sur laquelle on bosse. L’idée est que même si on n’a pas encore les tests unitaires, on peut déjà jouer avec.

Maintenant il me fallait un projet sexy, histoire de donner envie. Donc j’ai fouillé dans ce qui se faisait côté temps réel (essentiellement du NodeJS et du Tornado, mais pas que) pour trouver l’inspiration.

Et j’ai trouvé un truc très sympa : un player vidéo piloté à distance.

En effet, n’est-il pas chiant de regarder une vidéo en ligne sur son ordi posé sur la commode pendant qu’on est enfoncé dans le canap ? Si on veut faire pause ou changer le son, il faut se lever, arg.

Les problèmes du tiers monde, c’est du pipi de chat à côté. Ils ont de la chance, eux, ils ne connaissent pas le streaming.

Voici donc le projet :

Une page avec un player HTML 5 et un QR code.

Capture d'écran de la démo, côté player

Pour simplifier la démo, on peut cliquer sur le QR code et avoir la télécommande dans un autre tab pour ceux qui n'ont pas de smartphone ou d'app de scan de QRCode.

Si on scanne le QR code avec son téléphone, il vous envoie sur une page avec une télécommande pour contrôler le player sans bouger votre cul :

Capture d'écrand de la démo, côté contrôles

Évidement, c'est basique. Je vais pas m'amuser à faire un produit complet juste pour un truc dont le code source ne sera même pas regardé par la plupart d'enter vous. Je vous connais, bandes de feignasses !

Et vous allez voir, c’est même pas dur à faire.

Mais d’abord :

La démo

Bon, y a plus de latence que je le voudrais, mais sur un projet sérieux, il y a moyen de faire ça mieux. Ici on veut faire simple pour expliquer le principe.

Et vous pouvez télécharger le code ici.

Pour comprendre ce qui va suivre, il va vous falloir les bases en prog Javascript et Python, ainsi que bien comprendre la notion de callback. Être à l’aise avec promises peut aider.

Et pour bien digérer ce paté, rien ne vaut un peu de son :

Le Chteumeuleu

Il va nous falloir deux pages Web, une pour le player video, et une pour la télécommande.

Le player :

<!DOCTYPE html>
<html>
<head>
   <title>Video</title>
   <meta charset='utf-8'>
   <!-- Chargement des dépendances : autobahn pour WAMP
   et qrcode pour générer le QR code. Bien entendu, je
   vous invite à ne pas les hotlinker dans vos projets,
   mais pour la démo c'est plus simple. -->
  <script src="https://autobahn.s3.amazonaws.com/autobahnjs/latest/autobahn.min.jgz"
         type="text/javascript"></script>
   <script src="http://davidshimjs.github.com/qrcodejs/qrcode.min.js"
           type="text/javascript"></script>
 
   <style type="text/css">
      /* Quelques styles histoire que ça soit pas
         TROP moche. J'inline, hein, on va pas
         se faire chier à faire un fichier CSS
         externe juste pour ça. */
      #vid {
         /* Taille de la video */
         width:427px;
         height:240px;
      }
      /* Centrage avec la méthode Rache */
      #container {
          width:427px;
          margin:auto;
      }
      #ctrllink {
          display:block;
          width:256px;
          margin:auto;
      }
   </style>
 
</head>
<body>
<div id="container">
  <p>
 
   <!-- J'utilise le lecteur video HTML5 car c'est le plus facile
        à faire. Je ne vais pas m'attarder sur comment ça
        marche, y a plein de tutos pas trop mauvais
        sur la question -->
    <video id="vid"
           class="video-js vjs-default-skin"
           controls preload="auto"
           poster="http://media.w3.org/2010/05/sintel/poster.png" >
 
      <!-- Encore une fois, je hotlink la video, mais ne faites
       pas ça à la maison les enfants. Surtout que les perfs du
      serveur du W3C sont merdiques et ça bufferise à mort. -->
       <source id='ogv'
         src="http://media.w3.org/2010/05/sintel/trailer.ogv"
         type='video/ogg'>
       <source id='mp4'
         src="http://media.w3.org/2010/05/sintel/trailer.mp4"
         type='video/mp4'>
       <source id='webm'
         src="http://media.w3.org/2010/05/sintel/trailer.webm"
         type='video/webm'>
    </video>
  </p>
  <!-- Un élément vide pour le QR code -->
  <p>
    <a id="ctrllink" href="#" target="_blank">
      <span id="qrcode"></span>
    </a>
  </p>
 </div>
 
</body>
</html>

Et la télécommande :

<!DOCTYPE html>
<html>
<head>
  <title>Télécommande</title>
  <meta charset='utf-8'>
  <script src="https://autobahn.s3.amazonaws.com/autobahnjs/latest/autobahn.min.jgz"
         type="text/javascript"></script>
  <!-- Zoom du viewport sur mobile pour éviter d'avoir
       à le faire à la main. -->
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <style type="text/css">
    #controls {
      width:350px;
      margin:auto;
    }
    #controls button {
      font-size: 1em;
    }
    #controls input {
      vertical-align:middle;
       width: 200px;
       height:20px;
   }
  </style>
</head>
<body>
  <p id="controls">
    <!-- Marrant de se dire qu'en 2000, le JS inline était
         considéré comme démoniaque, et maintenant avec
         angularjs et cie, c'est exactement ce que tout
         le monde fait...
         Bref, on attache le clic sur nos contrôles à des
         méthodes de notre objet qui va se charger de la
         logique. -->
 
    <button id="play" onclick="control.togglePlay()">Play</button>
    <input id="volume"
                    onchange="control.volume(this.value)"
                    type="range">
  </p>
</body>
</html>

Rien d’incroyable. C’est du HTML, un peu de CSS, on charge les dépendances en JS. Classique.

Vu qu’on utilise des ressources hotlinkées par souci de simplicité, il vous faudra être connecté à Internet.

Setup du serveur

On va travailler avec Python 2.7. Je sais, je sais, c’est pas idéal, mais pour le moment je n’ai fait l’API flaskesque que pour le backend twisted, qui est en 2.7. On ne peut pas tout faire. Est-ce que vous savez le temps que me prend juste la rédaction de ce tuto, sérieux ?

Bref, il nous faut avant tout un serveur HTTP pour servir nos fichiers HTML. Normalement crossbar peut le faire pour nous en prod, mais en dev, on va pas setuper crossbar, on va faire plus simple.

Donc, à la racine du projet, lancez la commande :

python -m SimpleHTTPServer

Vos pages Web seront ainsi servies en local sur le port 8000. Par exemple, pour afficher la page de video :

http:localhost:8000/index.html

Ensuite, faut installer de quoi faire du WAMP en Python avec pip :

pip install "autobahn[twisted]"

Pour simplifier le développement, notre app Python va lancer automatiquement un petit serveur WAMP, du coup pas besoin d’installer crossbar du tout pour cette démo.

Création de l’App WAMP côté serveur

Pour cette démo, le serveur n’a pas grand chose à faire. On pourrait en fait la faire sans aucun code serveur, mais ça va nous simplifier la vie.

En effet, on a deux problématiques que le serveur va résoudre facilement pour nous : créer un ID unique pour le player et récupérer l’IP sur le réseau local.

L’ID, c’est simplement que si plusieurs personnes lancent en même temps un player, on ne veut pas que les télécommandes puissent lancer un ordre à un autre player que le sien. On pourrait utiliser un timestamp, mais ils sont contiguës, n’importe quel script kiddies pourrait faire un script pour foutre la merde. On va donc créer un ID unique qui ne soit pas facilement prévisible. Javascript n’a rien pour faire ça en natif, et c’est un peu con de charger une lib de plus pour ça alors que Python peut le faire pour nous.

L’IP, c’est parce qu’il faut donner l’adresse de notre machine au téléphone qui va l’afficher sur la télécommande, puisque notre serveur tourne dessus. Sinon notre démo ne marchera pas. Bien sûr, en prod, on ne fera pas ça, mais en local, il faut faire avec les moyens du bord.

Cela veut dire aussi que le téléphone doit être sur le même réseau local pour que ça fonctionne. Donc mettez votre téléphone en Wifi, par en 3G.

Voilà ce que donne notre code WAMP côté serveur :

# -*- coding: utf-8 -*-
 
from autobahn.twisted.wamp import Application
 
import socket
import uuid
 
# Comme pour flask, l'objet app
# est ce qui lie tous les éléments
# de notre code ensemble. On lui donne
# un nom, ici "demo"
app = Application('demo')
# Bien que l'app va démarrer un serveur
# pour nous, l'app est bien un CLIENT
# du serveur WAMP. Le serveur démarré
# automatiquement n'est qu'une facilité
# pour le dev. En prod on utiliserait
# crossbar.
 
# Juste un conteneur pour y mettre notre IP
app._data = {}
 
# On déclare que cette fonction sera appelée
# quand l'app se sera connectée au serveur WAMP.
# Ceci permet de lancer du code juste après
# le app.run() que l'on voit en bas du fichier.
# '_' est une convention en Python pour dire
# "ce nom n'a aucune importance, c'est du code
# jetable qu'on utilisera une seule fois".
@app.signal('onjoined')
def _():
   # On récupère notre adresse IP sur le réseau local
   # C'est une astuce qui demande de se connecter et donc
   #  à une IP externe, on a besoin d'une connexion internet.
   s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
   s.connect(("8.8.8.8", 80))
   # On stocke l'adresse IP locale dans un conteneur
   # qui sera accessible partout ailleur.
   app._data['LOCAL_IP'] = s.getsockname()[0]
   s.close()
 
# On déclare que la fonction "ip()" est appelable
# via RCP. Ce qui veut dire que tout autre client
# WAMP peut obtenir le résultat de cette fonction.
# Donc on va pouvoir l'appeler depuis notre navigateur.
# Comme notre app s'appelle "demo" et notre fonction
# s'appelle "ip", un client pourra l'appeler en faisant
# "demo.ip".
@app.register()
def ip():
   # On ne fait que retourner l'IP locale. Rien de fou.
   return app._data['LOCAL_IP']
 
# Je voulais appeler cette fonction distante "uuid", mais ça
# override le module Python uuid. Ce n'est pas une bonne
# idée. Je l'appelle donc 'get_uuid' mais je déclare le
# namespace complet dans register(). Un client WAMP pourra donc
# bien l'appeler via "demo.uuid".
# Notez que ce namespace doit toujours s'écrire
# truc.machine.bidule. Pas truc/machin ou truc:machin.
# ou truc et bidule.MACHIN.
@app.register('demo.uuid')
def get_uuid():
   # Retourne un UUID, sans les tirets.
   # ex: b27f7e9360c04efabfae5ac21a8f4e3c
   return str(uuid.uuid4()).replace('-', '')
 
# On lance l'application. Ceci va lancer le serveur
# puis le client. On peut désactiver le lancement du
# serveur une fois qu'on met tout ça en prod.
if __name__ == '__main__':
    app.run(url="ws://0.0.0.0:8080/")
# On ne peut rien mettre comme code ici, il faut le
# mettre dans @app.signal('onjoined') si on veut
# entrer du code après que l'app soit lancée.

Et on lance notre app :

python app.py

Nous avons maintenant deux serveurs qui tournent : un serveur HTTP qui écoute sur le port 8000, et un serveur WAMP qui écoute sur le port 8080. En prod, crossbar peut servir à la fois HTTP et WAMP, donc pas besoin de lancer deux outils.

Le lecteur video

Il nous faut maintenant définir le comportement de notre lecteur video via Javascript. Il s’agit essentiellement de se connecter au serveur WAMP, et d’échanger des messages via RPC ou PUB/SUB :

  var player = {};
  var url;
  /* On va utiliser du pur JS histoire de pas mélanger
    des notions de jQuery dans le tas. Je ne vais
    PAS utiliser les best practices sinon vous allez
    être noyés dans des détails */
 
  /* Lancer le code une fois que la page est chargée */
  window.addEventListener("load", function(){
 
    /* Connexion au serveur WAMP. J'utilise
       les valeurs par défaut du serveur de
       dev. On ouvre explicitement la connection
       à la fin du script. */
    var connection = new autobahn.Connection({
       url: 'ws://' + window.location.hostname + ':8080/',
       realm: 'realm1'
    });
 
    /* Lancer ce code une fois que la connexion
       est réussie. Notez que je ne gère pas
       les erreurs dans dans une APP JS, c'est
       un puits sans fond. */
    connection.onopen = function (session) {
 
      /* Appel de la fonction ip() sur le serveur */
      session.call('demo.ip')
 
      /* Une fois qu'on a récupéré l'IP,
         on peut fabriquer l'URL de notre
         projet et on appelle la fonction
         get_uuid() du serveur */
      .then(function(ip){
        url = 'http://' + ip + ':8000';
        return session.call('demo.uuid');
      })
 
      /* Une fois qu'on a l'UUID, on peut commencer
         à gérer la partie télécommande */
      .then(function(uuid){
 
        /* Création du QR code avec le lien pointant
           sur la bonne URL. On met l'ID dans le hash. */
        var controlUrl = url + '/control.html#' + uuid;
        var codeDiv = document.getElementById("qrcode");
        new QRCode(codeDiv, controlUrl);
        var ctrllink = document.getElementById("ctrllink");
        ctrllink.href = controlUrl;
 
        /* Notre travail consiste essentiellement à
           manipuler cet élément */
        var video = document.getElementById("vid");
 
        /* On attache déclare 4 fonctions comme étant
           appelable à distance. Ces fonctions sont
           appelables en utilisant le nom composé
           de notre ID et de l'action qu'on souhaite
           faire. Ex:
           'b27f7e9360c04efabfae5ac21a8f4e3c.play'
           pour appeler "play" sur notre session. */
        session.register(uuid + '.play', function(){
           video.play();
        });
 
        session.register(uuid + '.pause', function(){
           video.pause();
        });
 
        session.register(uuid + '.volume', function(val){
           video.volume = val[0];
        });
 
        session.register(uuid + '.status', function(val){
          return {
            'playing': !video.paused,
            'volume': video.volume
          };
        });
 
 
 
       /* Quelqu'un peut très bien
           appuyer sur play directement sur cette page.
 
          Il faut donc réagir si l'utilisateur le fait,
          publier un événement via WAMP pour permettre
          à notre télécommande de se mettre à jour
          */
       video.addEventListener('play', function(){
         /* On publie un message indiquant que
            le player a recommencé à lire la vidéo.
            */
         session.publish(uuid + '.play');
       });
 
        video.addEventListener('pause', function(){
          session.publish(uuid + '.pause');
        });
 
        video.addEventListener('volumechange', function(){
          session.publish(uuid + '.volume', [video.volume]);
        });
 
     });
    };
 
    /* Ouverture de la connection une fois que tous les
       callbacks sont bien en place.*/
    connection.open();
  });

Code de la télécommande

La télécommande est notre 3eme client WAMP (on peut avoir des centaines de clients WAMP). Notre 1er est l’App Python, notre second est le player.

Son code a pour but d’envoyer des ordres au player HTML5, mais aussi de mettre à jour son UI si le player change d’état.

/* L'objet qui se charge de la logique de nos
   controles play/pause et changement de
   volume.
   Rien de fou, il change l'affichage
   du bouton et du slider selon qu'on
   est en pause/play et la valeur du
   volume.
   */
var control = {
   playing: false,
   setPlaying: function(val){
      control.playing = val;
      var button = window.document.getElementById('play');
      if (!val){
         button.innerHTML = 'Play'
      } else {
         button.innerHTML = 'Pause';
      }
   },
   setVolume: function(val){
      var slider = window.document.getElementById('volume');
      slider.value = val;
   }
};
window.onload = function(){
  var connection = new autobahn.Connection({
    url: 'ws://' + window.location.hostname + ':8080/',
    realm: 'realm1'
  });
 
  connection.onopen = function (session) {
 
    /* Récupération de l'ID dans le hash de l'URL */
    var uuid = window.location.hash.replace('#', '');
 
    /* Mise à jour des controles selon le status actuel
       du player grace à un appel RPC vers notre autre
       page. */
    session.call(uuid + '.status').then(function(status){
 
      control.setPlaying(status['playing']);
      control.setVolume(status['volume'])
 
      /* On attache l'appui sur les contrôles à
         un appel de la fonction play() sur le
         player distant. L'uuid nous permet
         de n'envoyer l'événement que sur le
         bon player. */
      control.togglePlay = function() {
        if (control.playing){
          session.call(uuid + '.pause');
          control.setPlaying(false);
        } else {
          session.call(uuid + '.play');
          control.setPlaying(true);
        }
      };
 
      control.volume = function(val){
        session.call(uuid + '.volume', [val / 100]);
      };
 
      /* On ajoute un callback sur les événements
         de changement de status du player. Si
         quelqu'un fait play/pause ou change le
         volume, on veut mettre à jour la page. */
      session.subscribe(uuid + '.play', function(){
        control.setPlaying(true);
      });
 
      session.subscribe(uuid + '.pause', function(){
        control.setPlaying(false);
      });
 
      session.subscribe(uuid + '.volume', function(val){
        control.setVolume(val[0] * 100);
      });
    });
  };
 
  connection.open();
};

En résumé

Voici à quoi ressemble le projet final :

.
├── app.py (client python))
├── control.html (télécommande)
├── index.html (player video)
Schéma de fonctionnement de la démo

Bien que l'app Python lance le serveur automatiquement et de manière invisible, c'est bien un composant à part.

Pour ce projet, on aura utilisé :

On n’aura pas utilisé crossbar, le serveur WAMP Python. On a utilisé un petit serveur de dev inclus dans autobahn. En prod, on utiliserait crossbar comme serveur WAMP.

Il y a pas mal de notions à prendre en compte.

D’abord, le RPC.

Cela permet à un client de dire “les autres clients peuvent appeler cette fonction à distance”. On l’utilise pour exposer ip() et get_uuid() sur notre serveur et notre javascript peut donc les appeler. Mais on l’utilise AUSSI pour qu’une des pages (le player) expose play(), pause() et volume() et que l’autre page (notre télécommande) puisse les utiliser.

La grosse différence, c’est que ip() peut être appelé par tous les clients en utilisant “demo.ip” alors que play() ne peut être appelé que par les clients qui connaissent l’ID du player, puisqu’il faut utiliser “<id>.play”.

Ensuite, il y a le PUB/SUB.

Cela permet à un client de dire “j’écoute tous les messages adressés à ce nom”. Et un autre client peut envoyer un message (on appelle ça aussi un événement, c’est pareil) sur ce nom, de telle sorte que tous les clients abonnés le reçoivent.

On l’utilise pour que notre télécommande dise “j’écoute tous les messages qui concernent les changements de status du player.” De l’autre côté, quand on clique sur un contrôle du player, on envoie un message précisant si le volume a changé, ou si on a appuyé sur play/pause. La télécommande peut ainsi mettre son UI à jour et refléter par exemple, la nouvelle valeur du volume.

Cela résume bien les usages principaux de ces deux outils :

Voici le workflow de notre projet :

Si vous virez tous les commentaires, vous verrez que le code est en fait vraiment court pour une application aussi complexe.

Encore une fois, il est possible de le faire sans WAMP, ce sera juste plus compliqué. Je vous invite à essayer de le faire pour vour rendre compte. Avec PHP, Ruby ou une app WSGI, c’est pas marrant du tout. Avec NodeJs, c’est plus simple, mais il faut quand même se taper la logique de gestion RPC et PUB/SUB à la main ou installer pas mal de libs en plus.

WAMP rend ce genre d’app triviale à écrire. Enfin triviale parce que là j’ignore tous les edge cases, évidemment. Pour un produit solide, il faut toujours suer un peu.

Les limites du truc

C’est du Python 2.7. Bientôt on pourra le faire avec asyncio et donc Python 3.4, mais malheureusement sans le serveur de dev.

Heureusement, Twisted est en cours de portage vers Python 3, et donc tout finira par marcher en 3.2+.

C’est du HTML5, mais bien entendu, rien ne vous empêche de faire ça avec du flash si ça vous amuse.

C’est du websocket, mais on peut utiliser un peu de flash pour simuler websocket pour les vieux navigateurs qui ne le supportent pas.

Non, la vraie limite c’est encore la jeunesse du projet : pas d’autoreload pour le serveur (super chiant de devoir le faire à la main à chaque fois qu’on modifie le code) et les erreurs côté serveur se lisent dans la console JS, et pas dans le terminal depuis lequel on a lancé le serveur. Plein de petits détails comme ça.

EDIT: Mise en prod

On m’a demandé à quoi ça ressemblerait si on mettais le truc en prod, alors je vais donner un exemple.

D’abord, on installe crossbar :

pip install crossbar

Ensuite, on le fait créer un dossier de configuration :

crossbar init

Ça va créer un dossier .crossbar avec un fichier config.json dedans.

On édite le fichier, de telle sorte qu’il ressemble à ça :

{
   "controller": {
   },
   "workers": [
      {
         "type": "router",
         "options": {
            "pythonpath": [".."]
         },
         "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": "static",
                     "directory": "../templates"
                  },
                  "ws": {
                     "type": "websocket"
                  }
               }
            }
         ]
      },
      {
         "type": "container",
         "options": {
            "pythonpath": [".."]
         },
         "components": [
            {
               "type": "class",
               "classname": "app.app",
               "realm": "realm1",
               "transport": {
                  "type": "websocket",
                  "endpoint": {
                     "type": "tcp",
                     "host": "127.0.0.1",
                     "port": 8080
                  },
                  "url": "ws://127.0.0.1/ws"
               }
            }
         ]
      }
   ]
}

Ceci n’est pas un tuto sur la mise en prod d’un projet WAMP avec crossbar, alors je ne vais pas rentrer dans les détails, mais en gros on change les ports HTTP et WAMP pour le port 8080, et on donne juste l’url /ws comme point d’entrée pour WAMP (on doit éditer le code source de la démo en conséquence). On sert les fichiers statiques en utilisant la section “transports” et notre app via la section “components”. Pour le realm, encore une fois, je skip, c’est un sujet à part, celui par défaut marche pour notre usage.

A partir de là il y a plusieurs solutions possibles :

Pour lancer la machine :

crossbar run

On met un petit script d’init pour le lancer au démarrage, et on est good.

flattr this!

Error happened! 0 - count(): Argument #1 ($value) must be of type Countable|array, null given In: /var/www/ecirtam.net/autoblogs/autoblogs/autoblog.php:428 http://ecirtam.net/autoblogs/autoblogs/sametmaxcom_a844ada43a979e3b1395ab9acb6afafb84340999/?Petite-d%C3%A9mo-pragmatique-d-un-usage-de-WAMP-en-Python #0 /var/www/ecirtam.net/autoblogs/autoblogs/autoblog.php(999): VroumVroum_Blog->update() #1 /var/www/ecirtam.net/autoblogs/autoblogs/sametmaxcom_a844ada43a979e3b1395ab9acb6afafb84340999/index.php(1): require_once('...') #2 {main}