
Доброго времени суток, уважаемые хабравчане.
Мы,
Мы с товарищами часто задавались подобным вопросом. В итоге родилась простая мысль — напиши RSS читалку. Тут тебе и работа с сетью, и XML парсер, и БД можно подключить, поглядеть на шаблонизатор. Да мало ли.
Итак, здесь начинается увлекательное путешествие в стек Play Framework 2.2 + Scala + MongoDB на бэкэнде и AngularJS + CoffeeScript на фронтенде.
TL;DR
Весь проект вместился в ~250-300 строк на Scala с документацией и ~150 строк на CS. Ну и немного HTML.
Код доступен на Bitbucket
Код доступен на Bitbucket
И первой остановкой будет вопрос — почему Scala, а не Java? И почему Play, а не тот же Lift?
Ответы донельзя просты и субъективны.
Scala пред��ставляет более высокий уровень абстракции и меньше кода ради кода. Когда я увидел документацию по стандартному List с его 200 методами на все случаи жизни… Серьезно, попробуйте сами.
Что касается выбора фреймворка — незатейливый пример на Lift'e отдал мне страницу на локалхосте за ~150 мс, и это без использования БД. При этом на той же машине и той же JVM Play справился за ~5-10 мс. Не знаю, может звезды так сложились.
А еще в плее консолька милая.
Я упущу часть о том, как установить и начать работу с Play, так как все вполне подробно разжевано в официальной документации (вплоть до генерации проекта для любимой IDE), и мы отправимся дальше.
Путь запроса
Самый очевидный способ разобрать приложение — проследовать за запросом клиента.
Сегодня лучше пропустить черный ящик обработки запроса самим фреймворком, тем более, что построен он на Netty, а значить копать пришлось бы глубоко. Возможно до Китая.
Как каждая река начинается с ручейка, так и любое приложение в Play начинается с роутинга, который довольно наглядно описан в
conf/routes
# Routes # This file defines all application routes (Higher priority routes first) # ~~~~ # Get news GET /news controllers.NewsController.news(tag: String ?= "", pubDate:Int ?= (System.currentTimeMillis()/1000).toInt) # Parse news GET /parse controllers.NewsController.parseRSS # Get tags GET /tags controllers.TagsController.tags # Map static resources from the /public folder to the /assets URL path GET /assets/*file controllers.Assets.at(path="/public", file) # Home page GET / controllers.Application.index
Пометка на полях:
Отдельно хочется выделить то, что помимо самой возможности установки дефолтных значений для аргументов, передающихся в указанный метод, можно указывать выражения. Например — получение текущей временной метки.
К слову, роутинг в Play довольно функциональный, вплоть до регэкспов при обработке запроса.
Предъявите билет!
Как можно догадаться из заголовка — история продолжается вместе с контроллерами. В Play пользовательские контроллеры входят в пакет
controllers, используют трейт Controller и представляют из себя объекты, чьи методы принимают и отвечают на запросы пользователей в соответствии с роутингом.Так как приложение получает данные от сервера через AJAX, то контроллер для отрисовки главной страницы банален как квадрат и необходим только для загрузки HTML/CS/JS скриптов.
Не наберется и 20 строк
package controllers
import play.api.mvc._
/**
* playRSS entry point
*/
object Application extends Controller {
/**
* Main page. So it begins...
* @return
*/
def index = Action {
Ok(views.html.index())
}
}
Ok возвращает инстанс play.api.mvc.SimpleResult, который содержит в себе заголовки и тело страницы. Ответ от сервера будет равен, как могли догадаться особо внимательные, 200 OK.Однако
Если в 20 строк вмещается полноценный контроллер для всего приложения, то весьма вероятно, что вы пишете на руби.
Итак, что лучше всего отдавать клиенту на AJAX запрос для получения новостей? Прави��ьно, JSON.
Этим занимается
NewsControllerobject NewsController
package controllers
import play.api.mvc._
import scala.concurrent._
import models.News
import play.api.libs.concurrent.Execution.Implicits.defaultContext
import models.parsers.Parser
import com.mongodb.casbah.Imports._
object NewsController extends Controller {
/**
* Get news JSON
* @param tag optional tag filter
* @param pubDate optional pubDate filter for loading news before this UNIX timestamp
* @return
*/
def news(tag: String, pubDate: Int) = Action.async {
val futureNews = Future {
try {
News asJson News.allNews(tag, pubDate)
} catch {
case e: MongoException => throw e
}
}
futureNews.map {
news => Ok(news).as("application/json")
}.recover {
case e: MongoException => InternalServerError("{error: 'DB Error: " + e.getMessage + "'}").as("application/json")
}
}
/**
* Start new RSS parsing and return first N news
* @return
*/
def parseRSS = Action.async {
val futureParse = scala.concurrent.Future {
try {
Parser.downloadItems(News.addNews(_))
News asJson News.allNews()
} catch {
case e: Exception => throw e
}
}
futureParse.map(newsJson => Ok(newsJson).as("application/json")).recover {
case e: MongoException => InternalServerError("{error: 'DB Error: " + e.getMessage + "'}").as("application/json")
case e: Exception => InternalServerError("{error: 'Parse Error: " + e.getMessage + "'}").as("application/json")
}
}
}
Future. Async. Тут впервые становится интересно.Начнем с того, что Play асинхронен и с потоками работать в принципе вообще не надо. Но когда нам необходимо
Future, который позволяет асинхронно выполнить операцию, не блокируя при этом основной поток. Для выполнения Future использует отдельный контекст, поэтому беспокоиться о потоках не стоит.Поскольку функция теперь возвращает уже не
SimpleResult, а Future[SimpleResult], то используется метод async трейта ActionBuilder (который и использует объект Action)Пейзажы
Прервемся с этим асинхронным кошмаром и обратимся к милым нашему взгляду шаблонам. Play предоставляет возможность работать с обычным HTML. Обычным таким HTML со вставками Scala кода. Шаблон автоматически компилируется в скалкоисходники и является обычной функцией, куда можно передавать параметры или подключать (вызывать) другие шаблоны. К слову, многие невзлюбили новый шаблонизатор из-за относительно медленного времени компиляции того самого HTML в код. А мне норм.
index.scala.html
<!DOCTYPE html>
<html>
<head>
<title>
playRSS
</title>
<link rel="shortcut icon" href='@routes.Assets.at("images/favicon.png")' type="image/png">
<link rel="stylesheet" href="http://netdna.bootstrapcdn.com/bootstrap/3.0.0/css/bootstrap.min.css"/>
<link rel="stylesheet" href='@routes.Assets.at("stylesheets/main.css")'>
@helper.requireJs(core = routes.Assets.at("javascripts/require.js").url, module =
routes.Assets.at("javascripts/main").url)
</head>
<body>
<div class="container" id="container" ng-controller="MainCtrl">
<a href="/"><h1>playRSS</h1></a>
@control()
<div class="row">
<div class="col-lg-12">
@news()
</div>
</div>
</div>
</body>
</html>
Как видно из исходников — магии немного.
@helper подключает requireJS, поставляемый самим фреймворком, и указывает путь до main.js, где и инициализируется фронтенд. @news() и @control() — шаблоны news.scala.html и control.scala.html соответственно. Выполняем функцию и выводим результат внутри текущего шаблона. Мило.А еще
можно работать с циклами, if/else и т.п. Есть подробная документация
Гора Касбах
Продолжим, пожалуй, работой с БД. В моем случае был выбран Mongo.
Casbah — официальный драйвер для работы с MongoDB в скалке. Его преимущество — одновременная простота и функциональность. А основной недостаток будет рассмотрен в конце.
Подключается драйвер довольно незамысловато:
- Добавляем в
libraryDependenciesвнутри build.sbt строчку:
"org.mongodb" %% "casbah" % "2.6.3" - Добавляем в наш код:
import com.mongodb.casbah.Imports._ - Play при запуске проекта сам выкачает зависимости
- ???
- PROFIT
И немного о коде. Так как читалка у меня не несложная, был создан объект, который раздает нуждающимся коллекции из MongoDB. Право слово, городить DAO или DI пока просто излишне.
object Database
package models
import com.mongodb.casbah.Imports._
import play.api.Play
/**
* Simple object for DB connection
*/
object Database {
private val db = MongoClient(
Play.current.configuration.getString("mongo.host").get,
Play.current.configuration.getInt("mongo.port").get).
getDB(Play.current.configuration.getString("mongo.db").get)
/**
* Get collection by its name
* @param collectionName
* @return
*/
def collection(collectionName:String) = db(collectionName)
/**
* Clear collection by its name
* @param collectionName
* @return
*/
def clearCollection(collectionName:String) = db(collectionName).remove(MongoDBObject())
}
Пометка на полях:
В Scala объекты представляют из себя по факту синглтоны. Если включать режим зануды — создается и инстанцируется анонимный класс со статичными методами (в представлении Java/JVM). Так что наше соединение поднимется при создании объекта и будет доступно на протяжении всего рабочего цикла приложения.
Настало время продемонстрировать работу с базой на Scala и Casbah:
object News
/**
* Default news container
* @param id MongoID
* @param title
* @param link
* @param content
* @param tags Sequence of tags. Since categories could be joined into one
* @param pubDate
*/
case class News(val id: String = "0", val title: String, val link: String, val content: String, val tags: Seq[String], val pubDate: Long)
/**
* News object allows to operate with news in database. Companion object for News class
*/
object News {
....
/**
* Method to add news to database
* @param news filled News object
* @return
*/
def addNews(news: News) = {
val toInsert = MongoDBObject("title" -> news.title, "content" -> news.content, "link" -> news.link, "tags" -> news.tags, "pubDate" -> news.pubDate)
try {
col.insert(toInsert)
} catch {
case e: Exception =>
}
}
....
/**
* Get news from DB
* @param filter filter for find() method
* @param sort object for sorting. by default sorts by pubDate
* @param limit limit for news select. by default equals to newsLimit
* @return
*/
def getNews(filter: MongoDBObject, sort: MongoDBObject = MongoDBObject("pubDate" -> -1), limit: Int = newsLimit): Array[News] = {
try {
col.find(filter).
sort(sort).
limit(limit).
map((o: DBObject) => {
new News(
id = o.as[ObjectId]("_id").toString,
title = o.as[String]("title"),
link = o.as[String]("link"),
content = o.as[String]("content"),
tags = o.as[MongoDBList]("tags").map(_.toString),
pubDate = o.as[Long]("pubDate"))
}).toArray
} catch {
case e: MongoException => throw e
}
}
}
Знакомый всем, кто работал с MongoDB, API и тривиальное заполнение инстанса case class News. Пока все элементарно. Даже слишком.
Нужно что-то интереснее. Как насчет aggregation?
Вытаскивая теги
/**
* News tag container
* @param name
* @param total
*/
case class Tags(name: String, total: Int)
/**
* Tags object allows to operate with tags in DB
*/
object Tags {
/**
* News collection contains all tag info
*/
private val col: MongoCollection = Database.collection("news")
/**
* Get all tags as [{name: "", total: 0}] array of objects
* @return
*/
def allTags: Array[Tags] = {
val group = MongoDBObject("$group" -> MongoDBObject(
"_id" -> "$tags",
"total" -> MongoDBObject("$sum" -> 1)
))
val sort = MongoDBObject("$sort" -> MongoDBObject("total"-> -1))
try {
col.aggregate(group,sort).results.map((o: DBObject) => {
val name = o.as[MongoDBList]("_id").toSeq.mkString(", ")
val total = o.as[Int]("total")
Tags(name, total)
}).toArray
} catch {
case e: MongoException => throw e
}
}
}
.aggregate позволяет творить чудеса без mapReduce. И принцип работы в Scala такой же, как и из консоли. Эдакий pipeline-way, только через запятую. Сгруппировали по тегам, просуммировали одинаковые в total и отсортировали все это дело. Отлично.Кстати, Casbah — это цитадель
You're JSON-XMLed
Never gonna give you up
Never gonna let you down
Потому что для статически типизированного языка работа с XML/JSON в данном случае выглядит как розыгрыш. Подозрительно кратко.
И в самом деле, парсинг XML в Scala — услада для моих глаз (после массивных фабрик фабрик в Java).
XML Parser
package models.parsers
import scala.xml._
import models.News
import java.util.Locale
import java.text.{SimpleDateFormat, ParseException}
import java.text._
import play.api.Play
import collection.JavaConversions._
/**
* Simple XML parser
*/
object Parser {
/**
* RSS urls from application.conf
*/
val urls = try {
Play.current.configuration.getStringList("rss.urls").map(_.toList).getOrElse(List())
} catch {
case e: Throwable => List()
}
/**
* Download and parse XML, fill News object and pass it to callback
* @param cb
*/
def downloadItems(cb: (News) => Unit) = {
urls.foreach {
(url: String) =>
try {
parseItem(XML.load(url)).foreach(cb(_))
} catch {
case e: Exception => throw e
}
}
}
/**
* Parse standart RSS time
* @param s
* @return
*/
def parseDateTime(s: String): Long = {
try {
new SimpleDateFormat("EEE, dd MMM yyyy HH:mm:ss zzz", Locale.ENGLISH).parse(s).getTime / 1000
} catch {
case e: ParseException => 0
}
}
/**
* For all items in RSS parse its content and return list of News objects
* @param xml
* @return
*/
def parseItem(xml: Elem): List[News] = (xml \\ "item").map(buildNews(_)).toList
/**
* Fill and return News object
* @param node
* @return
*/
def buildNews(node: Node) = new News(
title = (node \\ "title").text,
link = (node \\ "link").text,
content = (node \\ "description").text,
pubDate = parseDateTime((node \\ "pubDate").text),
tags = Seq((node \\ "category").text))
}
Согласен
По началу методы с названием вида \ или \\ вгоняют в ступор. Однако в этом есть какой-то смысл, когда вспоминаешь BigInteger из Java.
А что там про JSON? Нативный JSON в Scala пока что субъективно никакой. Медленный и страшный.
В трудную минуту на помощь приходит Play и его Writes/Reads из пакета
play.api.libs.json. Кто-то знает интерфейс JsonSerializable из PHP 5.4? Так вот в Play все еще проще!JSON Writes
case class News(val id: String = "0", val title: String, val link: String, val content: String, val tags: Seq[String], val pubDate: Long)
/**
* News object allows to operate with news in database. Companion object for News class
*/
object News {
/**
* Play Magic
* @return
*/
implicit def newsWrites = Json.writes[News]
/**
* Converts array of news to json
* @param src Array of News instances
* @return JSON string
*/
def asJson(src: Array[News]) = {
Json.stringify(Json.toJson(src))
}
}
Однострочный метод
someObjectWrites в простых случаях сериализации снимает все вопросы. Неявные преобразования в Scala являются мощным и удобным инструментом, применяемым на практике.Но это совсем банальный случай. Когда хочется чего-то особенного или сложного, то на помощь приходят функциональщина и комбинаторы.
Через тернии к звездам
Пока пользователь скучает и ждет ответа на запрос, который был послан на сервер скриптом… Погодите. Еще же фронтенд.
Как и было обещано — использовался CoffeeScript и AngularJS. После того, как мы стали использовать эту связку в продакшене, количество болей чуть ниже спины при разработке пользовательских интерфейсов уменьшилось на 78,5% процентов. Как и количество кода.
Именно по этой причине я решил использовать в читалке эти стильные, модные и молодежные технологии. А еще потому, что выбранный мною фреймворк имеет на борту компиляторы CoffeeScript и LESS.
На самом деле, бывалые разработчики не узнают ничего нового и интересного, поэтому покажу только пару интересных приемов.
Часто необходимо обмениваться данными между контроллерами ангулара. И на какие только изощрения не идут некоторые господа (типа записи в localStorage)…
А ларчик просто открывается.
Достаточно создать сервис и внедрять его в нужные контроллеры
Объявляем
Отправляем
Получаем
define ["angular","ngInfinite"],(angular,infiniteScroll) ->
newsModule = angular.module("News", ['infinite-scroll'])
newsModule.factory 'broadcastService', ["$rootScope", ($rootScope) ->
broadcastService =
message: {},
broadcast: (sub, msg)->
if typeof msg == "number" then msg = {}
this.message[sub] = angular.copy msg
$rootScope.$broadcast(sub)
]
newsModule
Отправляем
define ["app/NewsModule"], (newsModule)->
newsModule.controller "PanelCtrl", ["$scope", "$http", "broadcastService", ($scope, $http, broadcastService)->
$scope.loadByTag = (tag) ->
if tag.active
tag.active = false
broadcastService.broadcast("loadAll",0)
else
broadcastService.broadcast("loadByTag",tag.name)
]
Получаем
define ["app/NewsModule","url"], (newsModule,urlParser)->
newsModule.controller "NewsCtrl", ["$scope", "$http", "broadcastService", ($scope, $http, broadcastService)->
#recieving message
$scope.$on "loadAll", ()->
$scope.after = 0
$scope.tag = false
$scope.busy = false
$scope.loadByTag()
]
В Angular
Сервисы являются синглтонами. Поэтому мы и можем гонять сообщения туда сюда, не плодя при этом инстансов.
Все, приехали
После столь сумбурного путешествия в недры и обратно стоит подвести итоги.
Преимущества и недостатки, фатальные и не очень, каждый для себя должен выделять сам. Мы все же используем инструмент там, где он подходит, а не культивируем карго, правда?
Мне понравилось:
- Краткость Scala перед Java. Для статически типизированного языка как-то мало кода, а ошибки вылавливаются в compile time. Что бы не говорили про тесты и т.п., а языки динамической типизации в этом плане уступают.
- Функциональность самого фреймворка, предоставляющего множество решений, но при этом не навязывающего определенную структуру и манеру написания кода.
Не понравилось:
- Все же hit refresh workflow работает туго из-за постоянной рекомпиляции измененных исходников. Не смертельно, но затормаживает полет мысли
- Виндопроблемы (\\ vs /) фреймворка вместе с JVM не позволяют полноценно погонять под Win системой скомпилированное и запущенное приложение. Только в dev режиме. А тут особо производительность не измерить. Благо под рукой есть несколько nix серверов
- Нет логирования в тестах. Якобы из-за диких утечек памяти в логгере. Ну это я уже придираюсь.
Так же при разработке стоит быть аккуратным при работе с блокирующими операциями, используя Future. Однако тут есть одно но. Не смотря на то, что блокироваться основной поток выполнения не будет, заблокируется другой. И хорошо, если у вас потоков хватит и конкурентных запросов будет не много. А вдруг? На этот случай разработчики Play рекомендуют использовать асинхронные по своей природе драйвера для тех же баз данных. ReactiveMongo вместо Casbah, например. Или хотя бы настраивать акторы и тред пулы. Но это уже совсем другая история…
Благодарю за внимание.
P.S.
Если этой писанины показалось мало — вот репозиторий на Bitbucket.
