Играем в RSS с PlayFramework 2.2 и Scala

в 8:14, , рубрики: AngularJS, coffeescript, playframework, rss, scala, Веб-разработка, функциональщина, метки: , , , , ,

Играем в RSS с PlayFramework 2.2 и Scala

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

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

Автор: ImLiar

Источник

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js