Continuing a great journey with awesome Play Framework and Scala language, I would like to share yet another interesting implementation of real-time charting: this time by using lightweight server-side events instead of full-duplex WebSockets technology described previously in this post. Indeed, if you don't need a bidirectional communication but only server push, server-side events look as a very natural fit. And if you are using Play Framework, it's really easy to do as well.
Let's try to cover the same use case so it will be fair to compare both implementations: we have couple of hosts and we would like to watch CPU usage on each one in real-time (on a chart). Let's start by creating a simple Play Framework application (choosing Scala as a primary language):
play new play-sse-example
Now, when the layout of our application is ready, our next step is to create some starting web page (using Play Framework's type safe template engine) and name it as views/dashboard.scala.html. Here is how it looks like:
@(title: String, hosts: List[Host]) <!DOCTYPE html> <html> <head> <title>@title</title> <link rel="stylesheet" media="screen" href="@routes.Assets.at("stylesheets/main.css")"> <link rel="shortcut icon" type="image/png" href="@routes.Assets.at("images/favicon.png")"> <script src="@routes.Assets.at("javascripts/jquery-1.9.0.min.js")" type="text/javascript"></script> <script src="@routes.Assets.at("javascripts/highcharts.js")" type="text/javascript"></script> </head> <body> <div id="hosts"> <ul class="hosts"> @hosts.map { host => <li> <a href="#" onclick="javascript:show( '@host.id' )">@host.name</a> </li> } </ul> </div> <div id="content"> </div> </body> </html> <script type="text/javascript"> function show( hostid ) { $('#content').trigger('unload'); $("#content").load( "/host/" + hostid, function( response, status, xhr ) { if (status == "error") { $("#content").html( "Sorry but there was an error:" + xhr.status + " " + xhr.statusText); } } ) } </script>
The template looks exactly the same as in WebSockets example, except one single line, the purpose of this one will be explained just a bit later.
$('#content').trigger('unload');
The result of this web page is a simple list of hosts. Whenever user clicks on a host link, the host-specific view will be fetched from the server (using AJAX) and displayed. Next template is the most interesting one, views/host.scala.html, and contains a lot of important details:
@(host: Host)( implicit request: RequestHeader ) <div id="content"> <div id="chart"></div> <script type="text/javascript"> var charts = [] charts[ '@host.id' ] = new Highcharts.Chart({ chart: { renderTo: 'chart', defaultSeriesType: 'spline' }, xAxis: { type: 'datetime' }, series: [{ name: "CPU", data: [] } ] }); </script> </div> <script type="text/javascript"> if( !!window.EventSource ) { var event = new EventSource("@routes.Application.stats( host.id )"); event.addEventListener('message', function( event ) { var datapoint = jQuery.parseJSON( event.data ); var chart = charts[ '@host.id' ]; chart.series[ 0 ].addPoint({ x: datapoint.cpu.timestamp, y: datapoint.cpu.load }, true, chart.series[ 0 ].data.length >= 50 ); } ); $('#content').bind('unload',function() { event.close(); }); } </script>
The core UI component is a simple chart, built using Highcharts library. The script block at the bottom tries to create an EventSource object which is an implementation of server-side events on browser side. If browser supports server-side events, the respective connection to server-side endpoint will be created and chart will be updated on every message received from the server ('message' listener). It's a good time to explain the purpose of this construct (and it's counterpart $('#content').trigger('unload') mentioned above):
$('#content').bind('unload',function() { event.close(); });
Whenever user clicks on different hosts, the previous event stream should be closed and new one should be created. Not doing so leads to more and more event streams to be created, flooding browser with more and more event listeners. To overcome this, we bind an unload method to a div element with id content and call it all the time when user clicks on a host. By doing that, we close event stream all the time before opening a new one. Enough UI, let's move on to back-end.
The routing table and mostly all the code stay the same, except only two small method changes, Statistics.attach and Application.stats. Let's take a look how server push of host's CPU statistics using server-side events is implemented on controller side (and mapped to /stats/:id URL):
def stats( id: String ) = Action { request => Hosts.hosts.find( _.id == id ) match { case Some( host ) => Async { Statistics.attach( host ).map { enumerator => Ok.stream( enumerator &> EventSource() ).as( "text/event-stream") } } case None => NoContent } }
Very short piece of code which does a lot of things. After finding the respective host by its id, we "attaching" to it by receiving the Enumerator instance: the continuous flow of CPU statistics data. The Ok.stream( enumerator &> EventSource() ).as( "text/event-stream") will transform this continuous flow of statistics data to stream of events which client is able to consume using server-side events.
To finish with server-side changes, let's take a look how "attaching" to host's statistics flow looks like:
def attach( host: Host ): Future[ Enumerator[ JsValue ] ] = { ( actor( host.id ) ? Connect( host ) ).map { case Connected( enumerator ) => enumerator } }
It's as simple as returning the Enumerator, and because we are using Akka actors, it becomes a bit more tricky with Future and asynchronous invocations. And, that's it!
In action our simple application looks like this (using Mozilla Firefox), having only Host 1 and Host 2 as an example:
Very nice and simple, and yet again, thanks a lot to Play Framework guys and the community. Complete source code is available on GitHub.
3 comments:
Hi Andriy,
OMsignal has a Scala development position where you could use many of the technologies you mentioned in your blog..
If you're interested let me know: cip at omsignal.com
Regards
Ciprian.
Hi Ciprian,
Thanks a lot for this very interesting position, got it through linkedin as well. Though I am not on a market, it's something I would like to think about.
Thank you very much.
Best Regards,
Andriy Redko
Post a Comment