Scalatra – это легковесный высокопроизводительный web-фреймворк, близкий к Sinatra, что может значительно облегчить вам жизнь при переходе с Ruby на Scala. В этой статье я хочу восполнить пробел в отсутствии мануалов на русском языке по этому интересному фреймворку на примере создания простого приложения с возможностью аутентификации.

Установка


Официальная документация предлагает создать проект при помощи giter8 из заранее подготовленного шаблона. Однако если Вы хотите обойтись без лишних инструментов, можно просто создать sbt проект, следующим образом:

project\plugins.sbt

addSbtPlugin("com.earldouglas"  % "xsbt-web-plugin" % "1.1.0")

Этот плагин позволит вам запускать веб-сервис при помощи специальной sbt команды:

$ sbt
> container:start

build.sbt

val scalatraVersion = "2.4.0-RC2-2"

resolvers += "Scalaz Bintray Repo" at "https://dl.bintray.com/scalaz/releases"

lazy val root = (project in file(".")).settings(
  organization := "com.example",
  name := "scalatra-auth-example",
  version := "0.1.0-SNAPSHOT",
  scalaVersion := "2.11.6",
  scalacOptions ++= Seq("-unchecked", "-deprecation", "-feature"),
  libraryDependencies ++= Seq(
    "org.scalatra" %% "scalatra-auth" % scalatraVersion,
    "org.scalatra" %% "scalatra" % scalatraVersion,
    "org.scalatra" %% "scalatra-json" % scalatraVersion,
    "org.scalatra" %% "scalatra-specs2" % scalatraVersion % "test",
    "org.json4s" %% "json4s-jackson" % "3.3.0.RC2",
    "javax.servlet" % "javax.servlet-api" % "3.1.0" % "provided"
  )
).settings(jetty(): _*)

Назначение добавляемых библиотек можно понять из их названия, если Вам не нужен json или аутентификация — можете смело убрать лишнее.

Маршрутизация


Чтобы сервис начал отвечать на запросы, нужно для начала указать, какие контроллеры будут отвечать на запросы. Создадим для этого вот такой файл:

src\main\webapp\WEB-INF\web.xml

<?xml version="1.0" encoding="UTF-8"?>
<web-app version="2.5" xmlns="http://java.sun.com/xml/ns/javaee"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://java.sun.com/xml/ns/javaee http://java.sun.com/xml/ns/javaee/web-app_2_5.xsd">
    <servlet>
        <servlet-name>user</servlet-name>
        <servlet-class>
            org.scalatra.example.UserController
        </servlet-class>
    </servlet>
    <servlet-mapping>
        <servlet-name>user</servlet-name>
        <url-pattern>/user/*</url-pattern>
    </servlet-mapping>
</web-app>

Если вы испытываете отвращение к xml, можно то же самое описать компактнее таким образом:

src/main/scala/ScalatraBootstrap.scala

import org.scalatra.example._
import org.scalatra._
import javax.servlet.ServletContext

class ScalatraBootstrap extends LifeCycle {
  override def init(context: ServletContext) {
    context.mount(new UserController, "/user")
  }
}

Тут мы определили, что org.scalatra.example.UserController будет отвечать на запросы, начинающиеся с пути yoursite.example/user. Посмотрим, как устроен этот файл:

src\main\scala\org\scalatra\example\UserController.scala

package org.scalatra.example

import org.json4s.{DefaultFormats, Formats}
import org.scalatra._
import org.scalatra.json.JacksonJsonSupport

import scala.util.{Failure, Success, Try}

class UserController extends ScalatraServlet with AuthenticationSupport with JacksonJsonSupport {

  protected implicit lazy val jsonFormats: Formats = DefaultFormats

  before() {
    contentType = formats("json")
    basicAuth()
  }

  get("/") {
    DB.getAllUsers
  }

  get("/:id") {
    Try {
      params("id").toInt
    } match {
      case Success(id) => DB.getUserById(id)
      case Failure(ex) => pass()
    }
  }

}

Разберем этот код подробнее. Для начала все контроллеры в Scalatra должны наследоваться от ScalatraServlet. Чтобы определить пути, на по которым будет отвечать сервлет, нужно добавить блок get, post, put или delete (в зависимости от типа запроса), например:

  get("/") { /*...*/  }

будет отвечать на запросы к yoursite.example/user. Если какие-то из параметров являются частью URL, необходимо описать ваши параметры примерно так:

  get("/:id") { params("id")  }

В результате внутри блока get можно использовать параметр id при помощи метода params(). Аналогично можно получить и остальные параметры запроса. Если Вы извращенец хотите передать несколько параметров с одинаковым именем, например /user/52?foo=uno&bar=dos&baz=three&foo=anotherfoo (обратите внимание, что тут 2 раза встречается параметр foo), можно использовать функцию multiParams(), который позволяет единообразно обрабатывать параметры, например:

  multiParams("id") // => Seq("52")
  multiParams("foo") // => Seq("uno", "anotherfoo")
  multiParams("unknown") // => an empty Seq

Отмечу, что в UserController используется метод pass(). Он позволяет пропустить обработку по данному маршруту и перейти к следующим маршрутам (хотя в данном случае, больше нет обработчиков, под который попадает данный путь). Если требуется прервать обработку запроса и показать пользователю страницу с ошибкой следует использовать метод halt(), который умеет принимать различные параметры, например код возврата и текст ошибки.
Еще одна возможность, предоставляемая фреймворком — задать пред- и пост-обработчики, например, написав:

  before() {
    contentType = formats("json")
    basicAuth()
  }

можно задать тип ответа (в данном случае json) и затребовать у пользователя аутентификацию (об аутентификации и работе с json речь пойдет в следующих разделах).

Более подробную информацию про маршрутизацию можно найти в официальной документации.

Работа с БД


В предыдущем разделе в качестве ответа контроллера используются объекты, получаемые из класса BD. Однако в Scalatra нет встроенного фреймворка для работы с базой данных, в связи с чем я оставил лишь имитацию работы с БД.

src\main\scala\org\scalatra\example\DB.scala

package org.scalatra.example

import org.scalatra.example.models.User

object DB {

  private var users = List(
    User(1, "scalatra", "scalatra"),
    User(2, "admin", "admin"))

  def getAllUsers: List[User] = users

  def getUserById(id: Int): Option[User] = users.find(_.id == id)

  def getUserByLogin(login: String): Option[User] = users.find(_.login == login)
}

src\main\scala\org\scalatra\example\models\User.scala

package org.scalatra.example.models

case class User(id: Int, login:String, password: String)

Однако, не думайте, что с этим есть какие-либо сложности — в официальной документациии описано, как подружить Scalatra с наиболее популярными базами данных и ORM: Slick, MongoDB, Squeryl, Riak.

Json


Обратите внимание, что контроллер возвращает напрямую case class User, а точнее даже Option[User] и List[User]. По умолчанию Scalatra преобразует возвращаемое значение в строку и использует ее в качестве ответа на запрос, т.е., например, ответ на запрос /user будет таким:

List(User(1,scalatra,scalatra), User(2,admin,admin)).

Для того, чтобы сервлет начал работать с json, необходимо:
  • Подмешать к нему трейт JacksonJsonSupport
  • Указать формат преобразования к json. Scalatra использует json4s для работы с json, что позволяет создавать кастомные правила преобразования в json и обратно. В нашем случае будет достаточно формата по умолчанию:

     protected implicit lazy val jsonFormats: Formats = DefaultFormats
    
  • Добавить заголовок с тип возвращаемого значения:

    contentType = formats("json")

После выполнения этих простых действий ответ на тот же запрос /user станет таким:

[{"id":1,"login":"scalatra","password":"scalatra"},{"id":2,"login":"admin","password":"admin"}]


Аутентификация


Напоследок, хотелось бы коснуться такой темы, как аутентификация пользователей. Для этого предлагается использовать Scentry фреймворк, который представляет из себя портированный на Scala фреймворк Warden, что также может облегчить жизнь людям, знакомым с Ruby.
Если внимательно посмотреть на класс UserController, можно обнаружить, что аутентификация в нем уже реализована. Для этого к классу подмешан трейт AuthenticationSupport и в before() фильтре вызван метод basicAuth(). Взглянем на реализацию AuthenticationSupport.

src\main\scala\org\scalatra\example\AuthenticationSupport.scala

package org.scalatra.example

import org.scalatra.auth.strategy.{BasicAuthStrategy, BasicAuthSupport}
import org.scalatra.auth.{ScentrySupport, ScentryConfig}
import org.scalatra.example.models.User
import org.scalatra.ScalatraBase
import javax.servlet.http.{HttpServletResponse, HttpServletRequest}


class OurBasicAuthStrategy(protected override val app: ScalatraBase, realm: String) extends BasicAuthStrategy[User](app, realm) {

  protected def validate(userName: String, password: String)(implicit request: HttpServletRequest, response: HttpServletResponse): Option[User] = {
    DB.getUserByLogin(userName).filter(_.password == password)
  }

  protected def getUserId(user: User)(implicit request: HttpServletRequest, response: HttpServletResponse): String = user.id.toString
}

trait AuthenticationSupport extends ScentrySupport[User] with BasicAuthSupport[User] {
  self: ScalatraBase =>

  val realm = "Scalatra Basic Auth Example"

  protected def fromSession = {
    case id: String => DB.getUserById(id.toInt).get
  }

  protected def toSession = {
    case usr: User => usr.id.toString
  }

  protected val scentryConfig = new ScentryConfig {}.asInstanceOf[ScentryConfiguration]


  override protected def configureScentry() = {
    scentry.unauthenticated {
      scentry.strategies("Basic").unauthenticated()
    }
  }

  override protected def registerAuthStrategies() = {
    scentry.register("Basic", app => new OurBasicAuthStrategy(app, realm))
  }

}

Первое, что нужно сделать — это определить стратегию аутентификации — класс, реализующий интерфейс ScentryStrategy. В данном случае мы использовали заготовку BasicAuthStrategy[User] реализующий некоторые стандартные методы. После этого нам осталось определить 2 метода — validate(), который в случае успешного логина должен возвращать Some[User], либо None в случае неверных данных и getUserId(), который должен возвращать строку для дальнейшего ее добавления в заголовки ответа.

Следующее, что нужно сделать — это объединить OurBasicAuthStrategy и ScentrySupport в трейт AuthenticationSupport, который мы и будем подмешивать к контроллеру. В нем мы зарегистрировали наше стратегию аутентификации и реализовали (наиболее простым способом) способы получения объекта пользователя из сессии и, наоборот, добавления его id в сессию.

В результате, если незалогинившийся пользователь зайдет на страницу, за обработку которой отвечает UserController, ему сначала нужно будет ввести логин и пароль.

Заключение


В данной статье были показаны лишь некоторые, выборочные возможности Scalatra. Хотя данный фреймворк не пользуется большой популярностью в русскоязычном сообществе, широкий набор реализованной функциональности и простота освоения делают его весьма перспективным для написания как небольших веб-сервисов, так и крупных сайтов.

Если после прочтения статьи у вас остались какие-либо вопросы, готов ответить на них в комментариях, либо в следующих статьях.

Весь исходный код доступен на гитхабе.
Удачного изучения!

Комментарии (9)


  1. aparamonov
    14.07.2015 18:57

        Try {
          params("id").toInt
        } match {
          case Success(id) => DB.getUserById(id)
          case Failure(ex) => pass()
        }
    

    Кошмарный бойлерплейт. В Play достаточно объявить аргумент метода — и он будет считаться параметром запроса. А проверки на валидность реализованы на уровне фреймворка.

    Mutability в контроллере тоже доставило. :(


    1. pozharko Автор
      14.07.2015 19:06

      Безусловно, лучше при объявлении маршрута написать регулярку, которая будет пропускать только Int значения, но хотелось продемонстрировать возможность pass().
      Про mutability в контроллере — поясните, пожалуйста.


      1. aparamonov
        14.07.2015 19:16

            contentType = formats("json")
            basicAuth()
        


        По мне очевидно, что первая строчка устанавливает какую-то переменную в недрах фреймворка, а вторая ну просто должна тоже что-то установить — в противном случае как фреймворк поймет, что надо проверять аутентификацию?

        Регулярку писать — тоже плохой тон — оно нечитаемо в сложных случаях.


        1. pozharko Автор
          14.07.2015 19:26

          Вторая строка ничего не меняет, она вызывает halt(403) в случае если аутентификация не прошла. А эти самые halt, если не ошибаюсь, реализованы через исключения…
          Пожалуй, в Play мне тоже маршрутизация побольше нравится, но после Spray это уже кажется вполне рабочим вариантом).


  1. vaniaPooh
    14.07.2015 20:31

    Пробовал его, года два назад. Приложение до сих пор стоит в продакшне. Сам фреймворк нормальный, но смущает несколько вещей:
    1) Редко обновляется (последний релиз больше чем полгода назад).
    2) Много боли и геморроя с sbt
    3) Шаблонизатор по-умолчанию Scalate имеет очень неприятные баги и не совсем интуитивен. Кроме того шаблоны нужно компилировать, а это дополнительное время.
    4) Для базы данных пробовал Slick — интересный фреймворк, но на тот момент он был сыроват и не везде интуитивен.
    В общем Scala — это как раз один из тех случаев, когда распространению крутого языка мешает его чрезмерная крутость (т.е. сложность) и отсутствие сопоставимой хотя бы с Java инфраструктуры разработки.


    1. crmMaster
      14.07.2015 20:52

      Скала сложна только первый год, а потом ничего, втягиваешься.

      А что подразумевается под «инфраструктурой разработки» — не совсем понятно. IDE для скалы давно есть, а библиотеки можно и от явы использовать — в этом плане скала как раз уделывает не только всякие-там-пыхапы, но и вполне себе активно развивающиеся Ruby и Javascript


      1. vaniaPooh
        15.07.2015 16:44

        Скала не так уж сложна, но вот, например, добивает подход к написанию библиотек на имплиситах. Сложно разбираться в коде. Макросы, используемые в коде sbt — это вообще что-то запредельное. Очень бесит то, что нужно собирать библиотеки с поддержкой разных версий Scala (почему 2.10 несовместима с 2.11 ?!!!). В общем местами кажется, что ребята идут не по правильному пути.


        1. senia
          16.07.2015 00:04

          почему 2.10 несовместима с 2.11 ?!!!

          Для этого есть огромное количество причин. Самая простая и наглядная: если в 2.11 в стандартной библиотеке добавили хотя бы 1 не абстрактный метод в 1 трейт, то весь код уже требует перекомпиляции — Java 7 не поддерживает не абстрактные методы в интерфейсах.

          Но они очень стараются найти этот правильный путь. Например проект TASTY.


    1. pozharko Автор
      14.07.2015 21:32

      1. Релиз 2.3.1 был в конце марта, сейчас активно готовится релиз 2.4.0 (https://github.com/scalatra/scalatra/releases). Примеры, как раз, с 2.4.0-RC2.
      2. Sbt очень активно развивается, попробуйте еще раз. Боль все еще есть, но уже, имхо, не хуже всяких там maven/grandle/ant.
      3. Есть еще инструкция по настройке twirl, использующемся в Play. Скорее соглашусь, меня Scalate тоже не слишком впечатлил.
      4. Как раз думал в следующей статье написать про Slick. Если есть еще интересующие вопросы — пишите, постараюсь разобрать.