
Доброго времени суток, уважаемые хабравчане.
Мы,
Мы с товарищами часто задавались подобным вопросом. В итоге родилась простая мысль — напиши 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.
