Cher lecteur, chère lectrice
J’espère que tu passes de bonnes vacances.
Ici à Paris il fait un temps de merde, je pense bien à toi et il me tarde que tu rentres.
Nicolas
—————–
Ah la bonne blague.
Vous découvrez le titre « Realtime Web Application » et déjà vous pensez « Nicolas a encore trouvé un nouveau buzzword« .
Vous avez raison. Retournez à votre grille de Sudoku en surveillant Junior en slip de bain « Cars » sur la plage. Ce qui va suivre est hautement « buzzword » et devrait faire grincer les rhumatismes des « Architectes », surtout au niveau du muscle cérébro-spinal, autrement appelé « ouverture d’esprit » par le commun des mortels.
Aujourd’hui il est possible de construire des applications web temps réelles, capables d’envoyer de petits messages vers un navigateur. Une des technologies qui émerge est particulièrement intéressante : Server-sent event. Pour que cela soit vraiment complet, il faut aussi que votre framework web vous permettre d’utiliser une programmation orientée événement. Nous allons voir comment mettre en oeuvre ces 2 technologies, avec un exemple complet à assembler soit-même à la fin, comme au bon vieux temps de PIF Gadget.
Server-sent Events
Vous avez certainement entendu parler d’Ajax et de Comet. Nous allons voir avec un exemple complet, codé et déployé sur Heroku en une journée, comment cela fonctionne. Au passage je vais aussi vous parler de Play2, l’un des rares frameworks webs à proposer un modèle de programmation évenementiel. Cela vient du monde Haskell et c’est un petit bijou. S’il y a un cas d’usage de Play2 qu’il faut retenir : ça roxe du poney pour coder une application web temps réel.
Tout d’abord un peu d’explications sur Server-Sent Events (SSE). Standardisé et plutôt bien supporté par les navigateurs webs, cette technologie est moins populaire que les WebSockets, alors qu’elle est plus simple. SSE permet à une application côté navigateur de souscrire à un tuyau afin de recevoir des événements. L’application en Javascript déclare des callbacks, et lorsque le serveur envoie un événement, il est alors possible de traiter ce message simplement.
SSE est plus puissant que le Polling HTTP ou que le système de long-polling, autrement appelé Comet. Le polling est le plus vieux système qui permet à un navigateur de récupérer régulièrement des mises à jour du serveur. Cependant, lorsque vous avez de nombreux clients webs connectés sur un serveur, cela coûte un peu cher au serveur. Chaque message retourné par le serveur est accompagné d’un header HTTP, tout ceci coûte de la mémoire, du réseau et reste assez limité. L’avantage du polling : il fonctionne partout.
Le Long Polling, autrement appelé Comet, est un système basé sur une requête GET qui reste ouverte entre le navigateur et le serveur. Lorsque votre navigateur appelle une ressource type comet, le serveur ne fermera pas la connexion tant qu’il n’aura pas de données. Vous le voyez sur certains navigateurs lorsque l’indicateur de chargement de la page ne s’arrête pas. A chaque donnée envoyée, le serveur referme bien la connexion et le navigateur renégocie alors une nouvelle requête. La seule limitation c’est que du côté client, c’est un peu moyen. L’implémentation du côté serveur consiste à envoyer des paquets de javascript dans une iframe côté client, et que le code JS soit donc interprété par le navigateur pour mettre à jour la page.
Server-Sent Events est une technologie simple, similaire à Comet. Cependant c’est une technologie qui permet de faire mieux que ce qui se fait avec du XMLHttpRequest ou une iframe, car là c’est le navigateur lui-même qui va gérer cette connexion. Et qui sera ensuite celui qui notifiera votre partie cliente lorsqu’un message arrive.
Using this API rather than emulating it using
XMLHttpRequest
or aniframe
allows the user agent to make better use of network resources in cases where the user agent implementor and the network operator are able to coordinate in advance. Amongst other benefits, this can result in significant savings in battery life on portable devices. This is discussed further in the section below on connectionless push (source: W3.org)
Le serveur web enverra vers le navigateur des petits bouts de données, avec le mime type « text/event-stream ». Un des points intéressants, c’est que l’enveloppe est toute petite, il n’y a pas d’overhead lié à HTTP comme lorsque l’on fait du HTTP Polling.
Côté client, le navigateur est chargé d’écouter et d’appeler des callbacks Javascript selon le type d’événement. Le code est vraiment plus simple et facile à mettre en oeuvre.
Imaginons que notre application play2 envoie ce message:
data: YHOO data: +2 data: 10
Côté navigateur, il suffit alors de déclarer un peu de Javascript :
var stocks = new EventSource("http://stocks.example.com/play2/ticks/YHOO"); stocks.onmessage = function (event) { var data = event.data.split('\n'); updateStocks(data[0], data[1], data[2]); };
Pourquoi ne pas utiliser de Web sockets ?
Il y a une première réponse très simple : SSE est unidirectionnel. Contrairement à Web sockets qui permet de faire du client-serveur, SSE est plus simple et permet simplement de pousser vers le navigateur client de la donnée. SSE s’appuie sur HTTP et contrairement aux WebSockets, il passe un peu près partout au niveau firewall applicatif. Donc si votre application a besoin de notifications en temps réel, c’est peut-être une bonne solution.
Mise en oeuvre de SSE
Côté serveur, c’est assez simple. Il faut envoyer des paquets de data, en utilisant « text/event-stream » pour ce qui est du mime-type. Vous pouvez spécifier le type d’événement avec « event » afin que le client JS puisse ensuite déclencher une mise à jour de votre page.
event: airfare\n data: {"msg": "Paris Bordeaux 142 EUR"}\n\n event: user\n data: {"username": "nicolas", "currentLocation": "paris", "weather":"merdique"}\n\n
Du côté navigateur, il faut déclarer un objet EventSource, et ensuite déclarer des handlers. Tout ce qui est en dessous est du Javascript qui s’exécute du côté navigateur.
if (!!window.EventSource) { var source = new EventSource('http://localhost:9000/stream');
source.addEventListener('airfare', function(e) { var data = JSON.parse(e.data); $("#airfare").html(data.msg); }, false); source.addEventListener('user', function(e) { var data = JSON.parse(e.data); console.log('User login:' + data.username); }, false);
}
Mise en oeuvre d’un stream avec Play2
Nous venons de voir comment utiliser la technologie Server-sent event pour récupérer des événements et ensuite effectuer un traitement du côté client. Voyons maintenant comment créer des streams avec Play2.
Tout d’abord, un des points forts de Play2, c’est ce nouveau modèle de programmation orienté événementiel. C’est l’un des points forts de ce framework, qui en fait l’un des seuls dans le monde Java/Scala à proposer un modèle complet de programmation, qui vient du monde Haskell. Tous les frameworks Webs Java/Groovy/Scala sont capables de faire du WebSocket, ou au moins de répondre à du long polling HTTP. Le souci c’est que très souvent le modèle de programmation du côté serveur n’existe pas. Le développeur se retrouve à coder une fonction avec exactement le même modèle que celui utilisé pour les requêtes HTTP classiques.
Et ça, c’est pas bien.
Du polling avec derrière une base MySQL… laissez moi deviner… que se passe-t-il si je balance 10 000 requêtes ? Surtout, que se passe-t-il lorsqu’il n’y a pas de mise à jour et donc, de nouveaux messages à envoyer ?
Le modèle classique a un avantage : il est simple, un développeur peut coder en quelques heures une solution honorable. Vous ajouterez certainement un peu de cache afin de soulager votre application, mais tout ceci reste assez basique.
Qu’est-ce qu’un modèle de programmation temps réel ?
Il s’agit d’un ensemble qui permet de créer, composer, filtrer et même trier des événements, pour ensuite les envoyer vers le client. Un modèle orienté Realtime Web Development propose un cadre technique qui vous permet de définir des tuyaux vers des services, d’établir des règles de filtrage et d’agrégation, et le tout dans le but de générer de la donnée.
Je vais vous donner un exemple.
Imaginez un service capable de récupérer le prix d’un billet d’avion, le prix d’une location de voiture et le nombre de places restantes pour la finale du 100m olympique. Pour coder cela, vous disposez de 4 fournisseurs de donnés. Nous en avons 2 pour la partie avion, un pour l’hôtel et un pour les billets olympiques.
Comment résoudre le problème suivant :
- se connecter à airfare_provider1 et lancer recherche PARIS-LONDON - se connecter à airfare_provider2 si airfare_provider1 ne répond pas en 30s - se connecter à hotel_provider et lancer recherche LONDON - commencer à retourner le résultat au navigateur client si hotel ou avion trouvé - lorsque (airfare_provider1|airfare_provider2) retourne resultat alors verifier champ "place disponible" - commencer à retourner le résultat au navigateur client - si "place disponible>1" alors récupérer le nom de l'aéroport arrivée - se connecter à car_rental_company et envoyer une recherche pour l'aeroport trouvé - continuer à retourner un resultat au navigateur - si avion trouvé ET hotel trouvé ET voiture trouvé, alors arrêter le stream - si temps de recherche > 1mn alors arrêter le stream
Ce problème est intéressant car nous voyons que nous utilisons plusieurs services, et que notre logique de retour vers le client dépend du résultat. Ici, typiquement la recherche d’une location de voiture doit s’effectuer lorsque l’on connait l’aéroport d’arrivée.
Play2 propose un modèle inspiré du monde Haskell. Sadek Drobi, CTO Zenexity et auteur de cette partie l’explique très clairement dans cet article (attention le code se base sur Play 2.1.x et pas sur Play 2.0.3 comme moi) :
- An
Iteratee[E,A]
is an immutable interface that represents a consumer, it consumes chunks of data each of typeE
and eventually produces a computed value of typeA
. For example,Iteratee[String,Int]
is an iteratee that consumes chunks of strings and eventually produces anInt
(that could be, for instance, the count of characters in the passed chunks).An iteratee can choose to terminate before theEOF
is sent from the stream, or it can wait forEOF
before it terminates, returning the computedA
value.You can compose differentIteratee
s together: which provides an opportunity for partitioning consuming logic into different parts. - An
Enumerator[E]
represents a stream that is pushing chunks of data of typeE
. For example, anEnumerator[String]
is a stream of strings.Enumerator
s can be composed one after the other, or interleaved concurrently providing means of streams management. - An
Enumeratee[From,To]
is an adapter from a stream ofFrom
s to a stream ofTo
s. Note that anEnumeratee
can rechunk differently, add or remove chunks or parts of them.Enumeratee
s (as we will see) are instrumental for stream manipulation - There are also some convenience methods for creating different kinds of
Enumerator
sIteratee
s andEnumeratee
s – useful in various scenarios.
Un Iteratee est un consomateur. Un Enumerator est un producteur et un Enumeratee est un adapteur qui permet de transformer un stream.
Par exemple, un Enumeratee de prix d’avion pourrait ressembler à cela en Scala :
val airfareStream(requestId:String): Enumerator[Airfare] = Enumerator.fromCallback[Airfare] { Promise.timeout( airfareService(requestId) , 1000) }
Ici, toutes les secondes nous allons demander à airfareService de nous donner son avancement et éventuellement son contenu, sous la forme d’un objet Airfare. Lorsque nous effectuons notre recherche, nous récupérons le meilleur vol pour un parcours donnée, ainsi que les alternatives possibles. Imaginez ici que le code va commencer à retourner vers le navigateur notre résultat de recherche partiel, sans attendre d’avoir trouvé les vols alternatifs. Le client dans le navigateur verra apparaître petit à petit le résultat de sa recherche. Cela améliore l’expérience utilisateur, et nous permet d’avoir une interface plus sympa.
Par contre, je ne peux pas retourner directement d’objet Airfare vers le client. Ce qui serait bien, c’est de le transformer en JSON. Pour faire cela, je vais utiliser un Enumeratee :
val toJson: Enumeratee[Airfare, JsValue] = Enumeratee.mapInput[Airfare] { case other => { other.map(e => Json.toJson(e)) } }
Un Enumeratee permet aussi d’effectuer de la vraie logique. Disons que je ne souhaite garder que les Airfare pour lesquels j’ai un prix supérieur à zéro. Cela serait assez simple :
case class Airfare(flight:String, price:Double, eventType:String="air")
val toJson: Enumeratee[Airfare, JsValue] = Enumeratee.mapInput[Airfare] { case airfare@Airfare(_,zePrice) if zePrice > 0 => { airfare.map(e => Json.toJson(e)) } }
Les Enumerators produisent des flux de contenu, ne retourne rien s’il n’y a pas de données à consommer. C’est aussi un gros plus : votre client web ne reçoit que des mises à jour lorsqu’il se passe quelque chose. Sinon rien.
Nous avons donc créé un airfareStream qui produit des événements et un filtre pour ne conserver que les prix supérieur à zéro. Voyons comment maintenant déclarer une action du côté contrôleur.
Déclaration d’un stream dans un contrôleur Play2
Je vais tout d’abord déclarer une fonction dans mon controller :
def stream(requestId: String) = Action { Ok.feed(airfareStream(requestId) &> toJson ><> EventSource()).as("text/event-stream") }
Ah ça y est vous avez vu des choses bizarres et ça pique les yeux. Cette ligne avec ces 2 symboles au premier abord doit vous laisser perplexe. C’est vrai qu’un bon gros fichier XML de Spring c’est plus simple, je comprends.
Si je transforme la ligne en utilisant les infix notation, vous comprendrez mieux :
Ok.feed(airfareStream(requestId).&>(asJson.><>(EventSource()))).as("text/event-stream")
Et si je vous dis que &> est un alias pour la méthode « through » et que ><> est un alias pour « compose » vous pourrez alors aussi écrire ceci
Ok.feed(airfareStream(requestId).through(asJson.compose(EventSource()))).as("text/event-stream")
Montre moi la ficelle de ton Stream
Bon pour terminer je vais vous faire rêver un peu et vous montrer un exemple plus complet, donc le code est sur Github. Ci-dessous le code complet, dans lequel je déclare 3 streams. Pour l’un des streams, j’ai scrappé un site en temps réel pour vous montrer l’utilisation de l’API WS de Play.
package models import play.libs.WS import play.api.libs.json.JsValue import play.api.libs.json.Json._ import scala.Some import play.api.libs.EventSource import org.apache.commons.lang.StringUtils // Define a generic event, trait ZapEvent { def event: String // event is a specific Server sent event attribute def price: String // a price holds the currency } // This is an airfare price update case class AirfareMessage(price: String) extends ZapEvent { override def event = "airfare" } // This is an hotel price update case class HotelMessage(price: String) extends ZapEvent { override def event = "hotel" } // Defines the Enumerator for various kind of ZapEvent object Streams { import scala.util.Random import play.api.libs.iteratee._ import play.api.libs.concurrent._ // Please note that in Play 2.1.x fromCallback will be rename to generateM val airfareStream: Enumerator[ZapEvent] = Enumerator.fromCallback[ZapEvent] { () => Promise.timeout(Some(AirfareMessage(Random.nextInt(500) + 100 + " EUR")), Random.nextInt(3000)) } val hotelStream: Enumerator[ZapEvent] = Enumerator.fromCallback[ZapEvent] { () => Promise.timeout(Some(HotelMessage(Random.nextInt(500) + 100 + " EUR")), Random.nextInt(1500)) } val airfareEDreams: Enumerator[String] = Enumerator.fromCallback[String] { () => { play.api.libs.ws.WS.url("http://www.edreams.fr/engine/ItinerarySearch/search").post(Map( "buyPath" -> Seq("58"), "auxOrBt" -> Seq("0"), "searchMainProductTypeName" -> Seq("FLIGHT"), "departureLocation" -> Seq("PAR"), "arrivalLocation" -> Seq("BOD"), "departureDate" -> Seq("03082012"), "departureTime" -> Seq("0000"), "returnTime" -> Seq("0000"), "cabinClass" -> Seq(""), "tripTypeName" -> Seq("ROUND_TRIP"), "returnDate" -> Seq("05082012"), "filterDirectFlights" -> Seq(""), "mainAirportsOnly" -> Seq("false"), "numAdults" -> Seq("2"), "numChilds" -> Seq("0"), "numInfants" -> Seq("0"), "ctry" -> Seq("FR"), "utm_source" -> Seq("shareflights"), "utm_medium" -> Seq("web2"), "utm_content" -> Seq(""), "utm_campaign" -> Seq(""), "AirportsType" -> Seq(""), "departureCity" -> Seq(""), "arrivalCity" -> Seq(""), "onlyTrain" -> Seq("") ) ).map { content => content.status match { case 200 => Some(content.body) case error: Int => Some("Error " + error) } } } } val filterPrice: Enumeratee[String, ZapEvent] = Enumeratee.map[String] { content => val tableHTML = content.substring(content.indexOf("<div class=\"singleItinerayPrice defaultWhiteText centerAlign\" style='font-size:24px;'>"), content.indexOf("<label id=\"desgloseLabel0")) val clean1=tableHTML.replaceAll("<div class=\"singleItinerayPrice defaultWhiteText centerAlign\" style='font-size:24px;'>","").replaceAll(" ","").replaceAll("\n","") val clean2=clean1.substring(0,clean1.indexOf("</div>")) AirfareMessage(clean2) } // Adapter from a stream of ZapEvent to a stream of JsValue, to generate Json content. // event is a specific keywork in the Server sent events specification. // See also http://dev.w3.org/html5/eventsource/ val asJson: Enumeratee[ZapEvent, JsValue] = Enumeratee.map[ZapEvent] { zapEvent => play.Logger.info("asJson> " + zapEvent) toJson(Map("event" -> toJson(zapEvent.event), "price" -> toJson(zapEvent.price))) } val events: Enumerator[ZapEvent] = { airfareEDreams.&>(filterPrice) >- hotelStream } }
Déployer sur Heroku
Heroku et Play2 : super simple. Mon fils de 6 ans sait utiliser Heroku et il commence à bien maîtriser Scala.
Après avoir placé mon code sur Github, j’ai simplement tapé les 2 commandes ci-dessous pour créer une application puis la déployer sur Heroku en 4mn, tout en passant de la crème solaire à mon fils.
nicolas@host> heroku create play2-serverside-sample nicolas@host> git push heroku ... ... nicolas@host> put creme_solaire
et c’est tout !
http://play2-serverside-sample.herokuapp.com/
Conclusion
Je vous laisse terminer votre Sudoku tranquille sur la plage, on reparlera d’applications temps réels d’ici quelques jours.
Le code source complet qui vous permettra d’expérimenter vous même est sur Github sur https://github.com/nicmarti/play2-serverside-sample. C’est du Scala, je trouve que c’est plus simple que Java, moins verbeux et plus puissant.
Java c’était bien lorsque j’étais Architecte Machin. Maintenant je suis Développeur Web.
Muscle céphalo-rachidien, ouverture d’esprit. Curiosité et surtout savoir se remettre en question je pense.
Passez de bonnes fin de vacances.
Nicolas
Super sympa comme article ! L’avant-dernier paragraphe, c’est pour vérifier si on a bien tout lu ? 🙂
Excellent, je suis justement entrain de faire un petit proto pour mixer différents flux twitter et les pousser en temps réeel avec sur Comet avec des Enumerator, Iteratee et tout ça…
L’API est pas évidente quand on commence mais au final c’est vraiment puissant et ça forme bien l’esprit
Merci pour l’article. Vraiment interessant. Surtout que je m’exerce là dessus avec en surcroit quelques Actors Akka, pour faire de l’analytics. Pour le coup je préfère cela à Socket.IO ou au Long Polling. Facile à implémenter.
Merci pour cet article très intéressant !
Dommage que IE n’accepte pas les Server-sent Events….:-(
Ah zut, moi qui pensait connaître un super Architecte Machin, et pas un simple developpeur web 🙂
C’est marrant, l’ouverture d’esprit dont tu parles ici me fait tout à fait penser au genre de réaction auxquelles j’ai été confronté lorsque je présentais Scala il y a 3/4 ans à des user group plus ou moins privés, et souvent remplis de d’architecte Java truc machin – et bizarrement, plus il y avait de qualificatif autour de « architecte » (genre « Immensément reconnu comme leader de pensée »), et plus la réaction à Scala était mauvaise.
En tout cas, c’est un article très intéressant, et ca m’incite encore plus à regarder Play! pour de vrai (avec Akka, et ZeroMQ, et tiens, je vois que Scala-IO commence à avoir des choses intéressantes aussi[1]…)
Cheers,
[1] http://daily-scala.blogspot.fr/2012/08/scala-io-core-long-traversable.html
Je n’ai pas tout à fait compris un truc – c’est encore le matin dans ma tête en ce moment , ya pas mal de brume.. Mais SSE est un protocole au dessus de Http? Comment on passe d’un modèle stateless à statefull?
Si ton serveur 1 plante, est relancé est-ce qu’un serveur 2 prendre le relai?
Si je suis un client mobile et que je passe sous un pont, la connexion continue?
D’après le protocole (HTTP) , oui, mais est-ce bien le cas la vraie vie avec des vrais gens et des mobiles avec des réseaux normaux c’est à dire pas fiables?
Sinon… super article!! On en mangerait. (Va falloir que je regarde sérieusement play un de ces 4 . ça m’a l’air très adapté pour faire des api web et assez souple, voire versatile)
@Kast: non ce n’est pas un protocole, c’est un moyen technique pour que le navigateur s’occupe de gérer la connexion vers le serveur (HTTP classique ou HTTPS) au lieu de devoir utiliser soit XmlHttpRequest, soit une iframe et de devoir gérer toi meme cela.
Lorsque le service est down, le navigateur tentera même de se reconnecter automatiquement vers le serveur web.
Bon sujet.
Feindre la nature déconnectée d’HTTP peut se réveler tres pratique et élégant.
C’est même aussi pratique que de contourner la nature sans état avec des fonctionnalités statefull niveau serveur applicatif (sick).
Il semble qu’il y ait 2 poids 2 mesures concernant l’ouverture d esprit n’est-ce pas ?
Pour ma part je préconiserai, selon besoin, l’utilisation de l’une ou l’autre ou les 2 mais je n’ai pas ce pouvoir, je ne suis pas archi, simple developpeur.
Note d’humour, le touilleur ne devrait il pas se rebaptiser le playeur? 🙂