Petite démo pragmatique d’un usage de WAMP en Python
jeudi 26 juin 2014 à 09:27Vu 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.
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 :
Et vous allez voir, c’est même pas dur à faire.
Mais d’abord :
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)
Pour ce projet, on aura utilisé :
- WAMP: le protocole qui permet de faire communiquer en temps réel des parties d’application via RPC et PUB/SUB.
- Autobahn.js: une lib pour créer des clients WAMP en javascript.
- Autobahn.py: une lib pour créer des clients WAMP en Python.
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 :
- RPC permet de donner un ordre ou récupérer une information.
- PUB/SUB permet de (se) tenir au courant d’un événement.
Voici le workflow de notre projet :
- On lance un serveur WAMP.
- On connecte des clients dessus (du code Python ou Js dans notre exemple).
- Les clients déclarent les fonctions qu’ils exposent en RPC et les événements qu’ils écoutent en PUB/SUB.
- Ensuite on réagit aux actions utilisateurs et on fait les appels RPC et les publications PUB/SUB en conséquence.
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 :
- On souhaite que crossbar gère tout lui-même. Dans ce cas il faut utiliser un outil pour binder le port 80 sur le port 8080. Ça évite de lancer crossbar en root.
- On peut mettre nginx devant (parce qu’on en a déjà un qui tourne, parce que c’est cool pour le load balancing, parce l’hébergeur le fournit) et dans ce cas, comme pour les autres config, il faut qu’il fasse un proxy_pass pour HTTP et Websocket sur les bonnes URLs.
- On est un gros bourrin, on met le port 80 dans le fichier de config, on lance le tout avec un sudo et on sert les fesses. Si un sys admin passe par là il vous lapidera, mais ça marche bien et facilement tant que personne n’essaye de pirater votre serveur :)
Pour lancer la machine :
crossbar run
On met un petit script d’init pour le lancer au démarrage, et on est good.