Статьи

Графики в реальном времени с Play Framework и Scala: высочайшая производительность на JVM для Web

Будучи хардкорным бэк-разработчиком, всякий раз, когда я думаю о создании веб-приложения с некоторым пользовательским интерфейсом на платформе JVM, я боюсь И тому есть причины: иметь опыт работы с  JSFLiferayGrails , … Я больше не хочу идти по этому пути. Но если возникает необходимость, есть ли выбор? Я нашел один, который я считаю удивительным:  Play Framework .

Play Framework,  созданный на основе JVM,  позволяет  без особых усилий создавать веб-приложения с использованием  Java  или  Scala . Это дает ценные и отличительные отличия: статическая компиляция (даже для шаблонов страниц), простота начала и краткость (подробнее об этом  здесь ).

Чтобы продемонстрировать, насколько великолепен  Play Framework  , я хотел бы поделиться своим опытом разработки простого веб-приложения. Предположим, у нас есть несколько хостов, и мы хотели бы наблюдать за загрузкой ЦП на каждом из них в режиме реального времени (на графике). Когда кто-то слышит «в реальном времени», это может означать разные вещи, но в контексте нашего приложения это означает: использование  WebSockets  для передачи данных с сервера на клиент. Хотя  Play Framework  поддерживает чистый  Java  API, я буду использовать немного  Scala,  поскольку он делает код очень компактным и понятным.

Давайте начнем! После загрузки  Play Framework  (последняя версия на момент написания статьи была 2.1.1), давайте создадим наше приложение, набрав

play new play-websockets-example

и выбрав  Scala  в качестве основного языка. Здесь нет чудес: это довольно стандартный способ, верно?

Когда наше приложение будет готово, следующим шагом будет создание начальной веб-страницы. Play Framework  использует собственный шаблонный движок, основанный на  Scala , имеет несколько чрезвычайно простых правил и с ним очень легко начать работу. Вот пример  views / dashboard.scala.html :

@(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 src="@routes.Assets.at("javascripts/highcharts.js")" type="text/javascript">
  </head>
    
  <body>
    <div id="hosts">
      <ul class="hosts">
        @hosts.map { host =>
        <li>               
          <a href="#" onclick="javascript:show( '@host.id' )"><b>@host.name</b></a>
        </li>
        }
      </ul>
    </div>
  
    <div id="content">
    </div>
  </body>
</html>

<script type="text/javascript">
function show( hostid ) {
  $("#content").load( "/host/" + hostid,
    function( response, status, xhr ) {
      if (status == "error") {
        $("#content").html( "Sorry but there was an error:" + xhr.status + " " + xhr.statusText);
      }
    }
  )
}
</script>

Помимо нескольких интересных конструкций (которые очень хорошо описаны  здесь ), он выглядит как обычный HTML с небольшим количеством JavaScript. Результатом этой веб-страницы является простой список хостов в браузере. Всякий раз, когда пользователь нажимает на конкретный хост, с сервера извлекается другое представление (с использованием старого приятеля  AJAX ) и отображается справа от хоста. Вот второй (и последний) шаблон,  views / host.scala.html :

@(host: Host)( implicit request: RequestHeader )

<div id="content">
  <div id="chart">
  <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">
var socket = new WebSocket("@routes.Application.stats( host.id ).webSocketURL()")
socket.onmessage = 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 );
}
</script>

Он выглядит скорее как фрагмент, а не как полная HTML-страница, которая имеет только диаграмму и открывает  соединение WebSockets со слушателем. С огромной помощью  Highcharts  и  jQuery программирование на JavaScript никогда не было таким легким для конечных разработчиков, как сейчас. На данный момент, часть пользовательского интерфейса полностью завершена. Давайте перейдем к задней стороне.

Во-первых, давайте определим таблицу маршрутизации, которая включает в себя только три URL-адреса и по умолчанию находится в  conf / routs :

GET     /                           controllers.Application.index
GET     /host/:id                   controllers.Application.host( id: String )
GET     /stats/:id                  controllers.Application.stats( id: String )

Определив виды и маршруты, пришло время заполнить последнюю и самую интересную часть — контроллеры, которые склеивают все части (фактически, только один контроллер,  controllers / Application.scala ). Вот фрагмент кода, который отображает   действие индекса на представление, созданное в  views / dashboard.scala.html , это так просто:

def index = Action {
  Ok( views.html.dashboard( "Dashboard", Hosts.hosts() ) )
}

Интерпретация этого действия может звучать так: вернуть успешный код ответа и отобразить шаблон views / dashboard.scala.html  с двумя параметрами,  title  и  hosts , в качестве тела ответа. Действие для обработки / host /: id  выглядит примерно так же:

def host( id: String ) = Action { implicit request =>
  Hosts.hosts.find( _.id == id ) match {
    case Some( host ) => Ok( views.html.host( host ) )
    case None => NoContent
  }    
}

А вот   объект Hosts, определенный в  models / Hosts.scala . Для простоты список хостов жестко запрограммирован:

package models

case class Host( id: String, name: String )

object Hosts {  
  def hosts(): List[ Host ] = {
    return List( new Host( "h1", "Host 1" ), new Host( "h2", "Host 2" ) )
  } 
}

Скучная часть закончена, давайте перейдем к последней, но не менее важной реализации: серверная загрузка статистики ЦП хоста с помощью  WebSockets . Как вы можете видеть,   URL / stats /: id уже сопоставлен с действием контроллера, поэтому давайте посмотрим на его реализацию:

def stats( id: String ) = WebSocket.async[JsValue] { request =>
  Hosts.hosts.find( _.id == id ) match {
    case Some( host ) => Statistics.attach( host )
    case None => {
      val enumerator = Enumerator
        .generateM[JsValue]( Promise.timeout( None, 1.second ) )
        .andThen( Enumerator.eof )
      Promise.pure( ( Iteratee.ignore[JsValue], enumerator ) )
    }
  }
}

Здесь не так уж много кода, но если вам интересно узнать о  WebSockets  в  Play Framework,  перейдите по  этой ссылке . Эта пара строк на первый взгляд может показаться немного странной, но как только вы прочитаете  документацию  и поймете основные принципы дизайна, лежащие в основе  Play Framework , она станет гораздо более знакомой и дружелюбной. Объект  статистики  — это тот, кто выполняет реальную работу, давайте посмотрим на код:

package models

import scala.concurrent.Future
import scala.concurrent.duration.DurationInt

import akka.actor.ActorRef
import akka.actor.Props
import akka.pattern.ask
import akka.util.Timeout
import play.api.Play.current
import play.api.libs.concurrent.Akka
import play.api.libs.concurrent.Execution.Implicits.defaultContext
import play.api.libs.iteratee.Enumerator
import play.api.libs.iteratee.Iteratee
import play.api.libs.json.JsValue

case class Refresh()
case class Connect( host: Host )
case class Connected( enumerator: Enumerator[ JsValue ] )

object Statistics {
  implicit val timeout = Timeout( 5 second )
  var actors: Map[ String, ActorRef ] = Map()
  
  def actor( id: String ) = actors.synchronized {
    actors.find( _._1 == id ).map( _._2 ) match {
      case Some( actor ) => actor      
      case None => {
        val actor = Akka.system.actorOf( Props( new StatisticsActor(id) ), name = s"host-$id" )   
        Akka.system.scheduler.schedule( 0.seconds, 3.second, actor, Refresh )
        actors += ( id -> actor )
        actor
      }
    }
  }
 
  def attach( host: Host ): Future[ ( Iteratee[ JsValue, _ ], Enumerator[ JsValue ] ) ] = {
    ( actor( host.id ) ? Connect( host ) ).map {      
      case Connected( enumerator ) => ( Iteratee.ignore[JsValue], enumerator )
    }
  }
}

Как всегда, благодаря   лаконичности Scala , не слишком много кода, но много чего происходит. Поскольку у нас могут быть сотни хостов, было бы разумно выделить каждому хосту свой рабочий (а не поток) или, точнее, собственный  актер . Для этого мы будем использовать другую удивительную библиотеку под названием  Akka . Приведенный выше фрагмент кода просто создает  актер  для хоста или использует существующий из реестра уже созданных  актеров . Обратите внимание, что реализация довольно упрощена и оставляет важные детали. Мысли в правильном направлении будут использовать  супервизоры  и другие передовые концепции вместо синхронизированного блока. Также стоит отметить, что мы хотели бы сделать наш Актер  запланированное задание: мы просим актерскую систему отправлять актеру сообщение  Обновить  каждые 3 секунды. Это означает, что графики будут обновляться новыми значениями каждые три секунды.

Так что , когда  актер  создан для хозяина, мы посылаем ему сообщение  Connect с  уведомлением о том , что новое соединение устанавливается. Когда  получено ответное сообщение  Connected , мы возвращаемся из метода, и в этот момент соединение через  WebSockets  должно быть установлено. Обратите внимание, что мы намеренно игнорируем любые входные данные от клиента, используя  Iteratee.ignore [JsValue] .

А вот и   реализация StatisticsActor :

package models

import java.util.Date

import scala.util.Random

import akka.actor.Actor
import play.api.libs.iteratee.Concurrent
import play.api.libs.json.JsNumber
import play.api.libs.json.JsObject
import play.api.libs.json.JsString
import play.api.libs.json.JsValue

class StatisticsActor( hostid: String ) extends Actor {
  val ( enumerator, channel ) = Concurrent.broadcast[JsValue]
  
  def receive = {
    case Connect( host ) => sender ! Connected( enumerator )       
    case Refresh => broadcast( new Date().getTime(), hostid )
  }
  
  def broadcast( timestamp: Long, id: String ) {
    val msg = JsObject( 
      Seq( 
        "id" -> JsString( id ),
        "cpu" -> JsObject( 
          Seq( 
            ( "timestamp" -> JsNumber( timestamp ) ), 
            ( "load" -> JsNumber( Random.nextInt( 100 ) ) ) 
          ) 
        )
      )
    )
     
    channel.push( msg )
  }
}

Статистика ЦП генерируется случайным образом, и  субъект  просто передает ее каждые 3 секунды как простой объект JSON. На стороне клиента код JavaScript анализирует этот JSON и обновляет диаграмму. Вот как это выглядит для двух хостов,  Host 1  и  Host 2  в  Mozilla Firefox :

В заключение я лично очень рад тому, что я сделал до сих пор с  Play Framework . Потребовалось всего пару часов, чтобы начать работу, и еще пару часов, чтобы все заработало, как и ожидалось. Отчеты об ошибках и цикл обратной связи от запущенного приложения абсолютно потрясающие, большое спасибо   ребятам из Play Framework и сообществу вокруг него. Мне еще есть чему поучиться, но это стоит того.

Пожалуйста, найдите полный исходный код на  GitHub .