Доброго времени суток, уважаемые хабравчане.

Мы, погромпрограммисты, очень часто сталкиваемся с одной и той же проблемой при изучении нового языка X или фреймворка Y — что писать после вступительного туториала Yet Another Hello World? Что-нибудь, что сможет показать какие-то преимущества и недостатки X/Y, но при этом не заняло бы много времени.

Мы с товарищами часто задавались подобным вопросом. В итоге родилась простая мысль — напиши RSS читалку. Тут тебе и работа с сетью, и XML парсер, и БД можно подключить, поглядеть на шаблонизатор. Да мало ли.

Итак, здесь начинается увлекательное путешествие в стек Play Framework 2.2 + Scala + MongoDB на бэкэнде и AngularJS + CoffeeScript на фронтенде.

TL;DR
Весь проект вместился в ~250-300 строк на Scala с документацией и ~150 строк на CS. Ну и немного HTML.
Код доступен на 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.
Этим занимается NewsController

object 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 асинхронен и с потоками работать в принципе вообще не надо. Но когда нам необходимо срочно посчитать число π обратиться к БД, считать данные из файла или выполнить иную медленную I/O процедуру, на помощь приходит 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.