Будучи хардкорным бэк-разработчиком, всякий раз, когда я думаю о создании веб-приложения с некоторым пользовательским интерфейсом на платформе JVM, я боюсь. И тому есть причины: иметь опыт работы с JSF , Liferay , Grails ,… я больше не хочу идти по этому пути. Но если возникает необходимость, есть ли выбор? Я нашел один, который я считаю удивительным: Play Framework .
Play Framework, созданный на основе JVM, позволяет без особых усилий создавать веб-приложения с использованием Java или Scala . Это дает ценные и отличительные отличия: статическая компиляция (даже для шаблонов страниц), простота начала и краткость (подробнее об этом здесь ).
Чтобы продемонстрировать, насколько великолепен Play Framework , я хотел бы поделиться своим опытом разработки простого веб-приложения. Предположим, у нас есть несколько хостов, и мы хотели бы наблюдать за загрузкой ЦП на каждом из них в режиме реального времени (на графике). Когда кто-то слышит «в реальном времени», это может означать разные вещи, но в контексте нашего приложения это означает: использование WebSockets для передачи данных с сервера на клиент. Хотя Play Framework поддерживает чистый Java API, я буду использовать немного Scala, поскольку он делает код очень компактным и понятным.
Давайте начнем! После загрузки Play Framework (последняя версия на момент написания статьи была 2.1.1), давайте создадим наше приложение, набрав
1
|
play new play-websockets-example |
и выбрав Scala в качестве основного языка. Здесь нет чудес: это довольно стандартный способ, верно?
Когда наше приложение будет готово, следующим шагом будет создание начальной веб-страницы. Play Framework использует собственный шаблонный движок, основанный на Scala , имеет несколько чрезвычайно простых правил и с ним очень легко начать работу. Вот пример views / dashboard.scala.html :
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
|
@(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 :
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
|
@(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 :
1
2
3
|
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 , это так просто:
1
2
3
|
def index = Action { Ok( views.html.dashboard( "Dashboard" , Hosts.hosts() ) ) } |
Интерпретация этого действия может звучать так: вернуть успешный код ответа и отобразить шаблон views / dashboard.scala.html с двумя параметрами, title и hosts , в качестве тела ответа. Действие для обработки / host /: id выглядит примерно так же:
1
2
3
4
5
6
|
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 . Для простоты список хостов жестко запрограммирован:
1
2
3
4
5
6
7
8
9
|
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 уже сопоставлен с действием контроллера, поэтому давайте посмотрим на его реализацию:
01
02
03
04
05
06
07
08
09
10
11
|
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 , она будет выглядеть гораздо более знакомой и дружелюбной. Объект статистики — это тот, кто выполняет реальную работу, давайте посмотрим на код:
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
|
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 :
01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
|
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 .