Статьи

Разработка современных приложений с Scala: веб-приложения с Play Framework

Эта статья является частью нашего академического курса под названием « Разработка современных приложений с помощью Scala» .

В этом курсе мы предоставляем среду и набор инструментов, чтобы вы могли разрабатывать современные приложения Scala. Мы охватываем широкий спектр тем: от сборки SBT и реактивных приложений до тестирования и доступа к базе данных. С нашими простыми учебными пособиями вы сможете запустить и запустить собственные проекты за минимальное время. Проверьте это здесь !

1. Введение

Прошло много времени с тех пор, как Интернет стал доминирующей, универсально и глобально доступной платформой для множества различных приложений: веб-сайтов, веб-порталов или веб-API. Начинаясь как простой набор статических HTML-страниц, веб-приложения стремительно догоняли своих настольных аналогов, превращаясь в новый класс того, что мы привыкли называть многофункциональными интернет-приложениями (или просто RIA ). Однако большинство таких достижений было бы невозможно без эволюции (а в некоторых случаях и революции) веб-браузеров.

В этом разделе руководства мы поговорим о разработке богатых веб-сайтов и порталов (или, проще говоря, современных веб-приложений) с использованием языка программирования и экосистемы Scala .

2. MVC и сила паттернов

Существует множество способов приблизиться к проектированию и разработке веб-приложений. Тем не менее, пара шаблонов появилась из толпы и получила широкое распространение в сообществе разработчиков программного обеспечения.

Model-View-Controller (или MVC ) является одним из наиболее широко применяемых архитектурных шаблонов, используемых для разработки поддерживаемых приложений на основе пользовательского интерфейса. Это очень просто, но обеспечивает вполне достаточный уровень разделения интересов и обязанностей.

Шаблон MVC и его сотрудники

Шаблон MVC и его сотрудники

По сути, MVC очень хорошо описывает сотрудников и их роли. View использует Model для визуализации представления рабочего стола или веб-интерфейса для User . User взаимодействует с View , что может привести к обновлению (или поиску) модели посредством использования Controller . В свою очередь, действия Controller's над Model могут привести к обновлению представления. В некоторых случаях User может взаимодействовать с Controller напрямую, полностью обходя View .

Многие фреймворки, используемые в наши дни для разработки веб-приложений, разработаны на основе шаблона MVC или одного (или нескольких) его производных. В экосистеме Scala , Play Framework, несомненно, является наилучшим из доступных вариантов, и именно об этом мы и поговорим в этом разделе руководства.

3. Play Framework: приятный и продуктивный

Play Framework — это современный, готовый к работе, высокоскоростной, полнофункциональный веб-фреймворк, написанный на Scalaдоступным для Java API). Он спроектирован так, чтобы быть полностью асинхронным, легким и не сохраняющим состояние, и построен на основе Akka Toolkit , который мы подробно обсудили в предыдущем разделе учебника. Последняя выпущенная версия Play Framework на момент написания этой статьи — 2.5.9 .

Хотя Play Framework не ограничивается поддержкой только разработки веб-приложений, мы сосредоточимся в основном на этой стороне вещей, продолжая обсуждение веб-API в следующем разделе, посвященном именно этому.

4. Контроллеры, действия и маршруты

Play Framework полностью охватывает модель MVC и с самого начала представляет концепцию контроллеров . Следуя своим обязанностям, контроллеры могут генерировать некоторые действия , возвращая некоторые результаты, например:

1
2
3
4
5
6
@Singleton
class HealthController extends Controller {
  def check() = Action {
    Ok("OK")
  }
}

Обратите внимание, что по соглашению контроллеры хранятся в пакете controllers . Методы контроллера могут быть представлены непосредственно как конечные точки HTTP , используя семантику протокола HTTP . В Play Framework такое отображение называется route и все определения маршрутов помещаются в файл conf/routes route, например:

1
2
GET  /health        controllers.HealthController.check
GET  /assets/*file  controllers.Assets.versioned(path="/public", file: Asset)

Пусть простота этого примера не обманет вас, определения маршрута Play Framework могут включать произвольное количество довольно сложных параметров и шаблонов URI , как мы увидим позже в этом разделе.

Контроллеры в Play Framework расширяют черту Controller и могут содержать любое (разумное) количество методов, которые возвращают экземпляры Action . Все действия выполняются асинхронно, неблокирующим образом, и это очень важно помнить. Контроллеры должны по возможности избегать выполнения операций блокировки, а объект-компаньон Action предлагает удобное семейство асинхронных методов для бесшовной интеграции с потоками асинхронного выполнения, например:

01
02
03
04
05
06
07
08
09
10
@Singleton
class UserController @Inject() (val service: UserService) extends Controller {
  import play.api.libs.concurrent.Execution.Implicits.defaultContext
   
  def getUsers = Action.async {
    service.findAll().map { users =>
      Ok(views.html.users(users))
    }
  }
}

Помимо асинхронности, этот короткий фрагмент кода также показывает, как контроллеры представляют еще одного сотрудника MVC , view model . Давайте поговорим об этом на минуту.

5. Представления и шаблоны

Представления в Play Framework обычно основаны на обычной HTML- разметке, но поддерживаются Twirl , чрезвычайно мощным механизмом шаблонов на основе Scala . Под капотом шаблоны преобразуются в классы Scala вместе с сопутствующими объектами. Они могут иметь аргументы и скомпилированы как стандартный код Scala .

Давайте кратко рассмотрим, как можно передать и отобразить model в шаблоне представления Play Framework , введя класс case User :

1
2
case class User(id: Option[Int], email: String,
  firstName: Option[String], lastName: Option[String])

Если это выглядит знакомо, вы не ошибаетесь: это точно такой же класс наблюдений, который мы видели в разделе « Доступ к базе данных с помощью Slick » этого руководства. Итак, давайте создадим шаблон users.scala.html для распечатки списка пользователей в виде HTML- таблицы, которая по соглашению хранится в папке представлений (которая также служит именем пакета):

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@(users: Seq[model.User])
 
<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Manage Users</title>
  </head>
  <body>
    <div class="container">
      <div class="panel panel-default">
        <div class="panel-heading">Users</div>
          <table class="table">
            <thead>
              <tr>
                <th>Id</th>
                <th>First Name</th>
                <th>Last Name</th>
                <th>Email</th>
              </tr>
            </thead>
            @for(user <- users) {
              <tr>
                <td>@user.id</td>
                <td>@user.firstName</td>
                <td>@user.lastName</td>
              <td>@user.email</td>
          </tr>
        }
        </table>
      </div>
      </div>
    </body>
</html>

По большому счету, это просто необработанная HTML- разметка, которая так близка сердцу любого фронтенд-разработчика. Первая строка объявляет аргументы шаблона, @(users: Seq[model.User]) который является просто списком пользователей. Единственное место, где мы используем этот аргумент, это когда вы визуализируете строки таблицы с помощью выражений, подобных Scala :

1
2
3
4
5
6
7
8
@for(user <- users) {
  <tr>
    <td>@user.id</td>
    <td>@user.firstName</td>
    <td>@user.lastName</td>
    <td>@user.email</td>
  </tr>
}

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

пользователи стол

Таблица пользователей

Чтобы закрыть цикл, в разделе « Контроллеры, действия и маршруты » мы уже видели, как создавать экземпляры шаблонов и отправлять их в браузер с помощью действия контроллера:

1
2
3
4
5
def getUsers = Action.async {
  service.findAll().map { users =>
    Ok(views.html.users(users))
  }
}

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

1
2
3
4
5
6
7
8
val userForm = Form(
  mapping(
    "id" -> ignored[Option[Int]](None),
    "email" -> email.verifying("Maximum length is 512", _.size <= 512),
    "firstName" -> optional(text(maxLength = 64)),
    "lastName" -> optional(text(maxLength = 64))
  )(User.apply)(User.unapply)
)

Во-вторых, мы собираемся создать выделенную конечную точку в контроллере, чтобы добавить нового пользователя в результате отправки формы:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
def addUser = Action.async { implicit request =>
  userForm.bindFromRequest.fold(
    formWithErrors => {
      service.findAll().map { users =>
        BadRequest(views.html.users(users)(formWithErrors))
      }
    },
    user => {
      service.insert(user).map { user =>
        Redirect(routes.UserController.getUsers)
      } recoverWith {
        case _ => service.findAll().map { users =>
          BadRequest(views.html.users(users)(userForm
            .withGlobalError(s"Duplicate email address: ${user.email}")))
        }
      }
    }
  )
}

Обратите внимание, как userForm.bindFromRequest.fold за один раз выполняет userForm.bindFromRequest.fold параметров формы из запроса вместе с выполнением всех проверок валидации. Следующее, мы должны скопировать userForm в его HTML- презентацию, используя для этого шаблон представления:

1
2
3
4
5
6
@helper.form(action = routes.UserController.addUser) {
  @helper.inputText(userForm("email"), '_label -> "Email Address")
  @helper.inputText(userForm("firstName"), '_label -> "First Name")
  @helper.inputText(userForm("lastName"), '_label -> "Last Name")
  <button class="btn btn-default" type="submit">Add User</button>
}

Использование @helper упрощает построение многих форм, так как все соответствующие типы и ограничения проверки будут взяты из определения Form и намекаются пользователю. Но, как и везде в Play Framework , вы не обязаны использовать этот подход: вместо этого можно использовать любой JavaScript / CSS-фреймворк по вашему выбору. Вот краткий обзор того, как эта форма выглядит в браузере.

пользователь форма

Добавить форму пользователя

И в качестве последнего шага, таблица маршрутизации должна быть обновлена, чтобы также была указана эта новая конечная точка. К счастью, это всего лишь один лайнер:

1
POST  /  controllers.UserController.addUser

Обратите внимание, что леса Play Framework позаботятся обо всей проверочной части и сообщат о ней обратно, например, отправка формы с неверным адресом электронной почты не будет принята:

электронная почта-инвалид

Ошибка, если указан неверный адрес электронной почты

Конечно, Play Framework предоставляет достаточно гибкости, чтобы сообщать о других типах ошибок, не обязательно связанных с проверкой. Например, в контроллере мы использовали метод userForm.withGlobalError для сигнализации дублированного адреса электронной почты.

6. Композиции действий и фильтры

Часто возникает необходимость выполнить некоторые действия до или после вызова метода контроллера, возможно, даже изменить ответ. Примерами этого могут быть регистрация, проверки безопасности или поддержка совместного использования ресурсов между источниками (CORS).

В стандартной комплектации Play Framework предоставляет несколько способов проникновения в конвейер обработки: использование фильтров и композиций действий .

7. Доступ к базе данных

Любое более или менее реальное веб-приложение должно было бы управлять некоторыми данными, и во многих случаях известные хранилища данных отношений являются идеальным выбором. Play Framework предлагает превосходную интеграцию с парой библиотек на основе JDBC, но мы уже узнали много хорошего о Slick и, конечно, Play Framework легко интегрируется с Slick .

Чтобы сделать вещи еще проще и привычнее, мы собираемся повторно использовать ту же модель данных, которую мы создали в компоненте Database Access с Slick , без каких-либо изменений. Незначительное изменение, которое нам нужно сделать, UserRepository только UserRepository : внедрение DatabaseConfigProvider для конфигурации базы DatabaseConfigProvider по умолчанию и использование его метода provider.get[JdbcProfile] для получения соответствующего экземпляра JdbcProfile .

01
02
03
04
05
06
07
08
09
10
@Singleton
class UserRepository @Inject()  (val provider: DatabaseConfigProvider)
    extends HasDatabaseConfig[JdbcProfile] with UsersTable { 
  val dbConfig = provider.get[JdbcProfile]
   
  import dbConfig.driver.api._
  import scala.concurrent.ExecutionContext.Implicits.global
  
  ...     
}

И мы сделали. Play Framework позволяет управлять несколькими именованными экземплярами базы данных и настраивать их через файл application.conf . Для удобства веб-приложения с одной базой данных могут использовать специально обработанное приложение по default .

01
02
03
04
05
06
07
08
09
10
11
slick {
  dbs {
    default {
      driver="slick.driver.H2Driver$"     
      db {
        driver="org.h2.Driver"
        url="jdbc:h2:mem:users;DB_CLOSE_DELAY=-1"
      }
    }
  }
}

Одной из наиболее распространенных проблем, с которыми каждый раз сталкиваются разработчики приложений при работе с реляционными базами данных, является управление схемами. Модель данных со временем эволюционирует, как и база данных: часто добавляются новые таблицы, столбцы и индексы, удаляются неиспользуемые. Развитие базы данных — еще одна потрясающая функция, которую Play Framework предоставляет из коробки.

8. Использование Akka

Play Framework находится на базе Akka Toolkit, и в качестве таких актеров они являются первоклассными гражданами. Любое веб-приложение Play Framework имеет выделенную систему актеров, созданную непосредственно при запуске приложения (и автоматически перезапускается при перезапуске приложения).

Платформа Play Framework полностью охватывает реактивную парадигму с самого начала и является одним из первых разработчиков внедрения Akka Streams . Более того, Play Framework предоставляет довольно много полезных служебных классов для объединения Akka Streams с технологиями, специфичными для веб-приложений, о которых мы поговорим.

9. WebSockets

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

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

1
case class UserAdded(user: User)

Используя поток событий Akka , о котором мы говорили в предыдущей части урока, мы можем подписаться на это событие, создав отдельного актера, назовем его UsersWebSocketActor .

01
02
03
04
05
06
07
08
09
10
11
12
13
class UsersWebSocketActor(val out: ActorRef) extends Actor with ActorLogging {
  override def preStart() = {
    context.system.eventStream.subscribe(self, classOf[UserAdded])
  }
   
  override def postStop() = {
    context.system.eventStream.unsubscribe(self)
  }
   
  def receive() = {
    case UserAdded(user) => out ! user
  }
}

Это выглядит исключительно просто, но таинственное упоминание актера. Давайте посмотрим, откуда это. Play Framework всегда имел превосходную поддержку WebSockets , однако более тесная интеграция с Akka Streams сделала его намного лучше . Вот конечная точка WebSockets для передачи клиенту уведомлений о новых пользователях.

1
2
3
def usersWs = WebSocket.accept[User, User] { request =>
  ActorFlow.actorRef(out => Props(new UsersWebSocketActor(out)))
}

Это всего лишь несколько строк кода для такой сложной функции, действительно удивительно! Обратите внимание, что ссылка out actor по существу представляет собой сторону веб-клиента и предоставляется Play Framework из коробки. Изменения в таблице маршрутизации также минимальны.

1
GET  /notifications/users  controllers.NotificationController.usersWs

На стороне браузера это стандартный фрагмент кода JavaScript, который можно вставить прямо в шаблон представления.

1
2
3
4
5
6
7
8
9
<script type="text/javascript">
  var socket = new WebSocket(
    "@routes.NotificationController.usersWs().webSocketURL()")
   
  socket.onmessage = function(event) {
    var user = jQuery.parseJSON(event.data);
    ...
  }
</script>

Как уже упоминалось, WebSockets являются двунаправленным каналом связи: не только веб-сервер может отправлять данные веб-клиенту, но и веб-клиент может инициировать некоторые сообщения. Мы не рассмотрели эту часть в приведенном здесь примере, но в документации Play Framework это подробно обсуждается.

10. Отправленные сервером события

WebSockets являются чрезвычайно мощными, но часто потребности веб-приложений могут быть подкреплены гораздо более простой реализацией. В случае, если полнодуплексный канал не требуется, веб-сервер может полагаться на отправленные сервером события (или SSE ), чтобы просто отправлять данные веб-клиенту односторонним способом. В Play Framework (и во многих других платформах) он реализован путем поддержки фрагментированных (или потоковых) ответов со специальным типом контента text/event-stream .

1
2
3
4
5
def usersSse = Action {
  Ok.chunked(
    Source.actorPublisher(Props[UsersSseActor]) via EventSource.flow[User]
  ).as(ContentTypes.EVENT_STREAM)
}

В этом случае сервер может использовать полноценные конвейеры обработки данных Akka Streams и доставлять данные клиенту, используя скаффолдинг EventSource . Чтобы проиллюстрировать еще одну интересную особенность Akka Streams , мы используем в качестве источника потока актер UsersSseActor , функционально аналогичный UsersWebSocketActor .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
class UsersSseActor extends ActorPublisher[User] with ActorLogging {
  var buffer = Vector.empty[User]
   
  override def preStart() = {
    context.system.eventStream.subscribe(self, classOf[UserAdded])
  }
   
  override def postStop() = {
    context.system.eventStream.unsubscribe(self)
  }
   
  def receive = {
    case UserAdded(user) if buffer.size < 100 => {
        buffer :+= user
        send()
      }
     
    case Request(_) => send()
    case Cancel => context.stop(self)
  }
   
  private[this] def send(): Unit = if (totalDemand > 0) {
    val (use, keep) = buffer.splitAt(totalDemand.toInt)
    buffer = keep
    use foreach onNext
  }
}

Это немного сложнее из-за того, что мы должны следовать соглашению и API Akka Streams, чтобы иметь хорошего издателя, но по сути он также использует поток событий для подписки на уведомления. Опять же, со стороны, просто голый JavaScript:

1
2
3
4
5
6
7
8
9
<script type="text/javascript">
var event = new EventSource(
  "@routes.NotificationController.usersSse().absoluteURL()");
          
event.addEventListener('message', function(event) {
  var user = jQuery.parseJSON(event.data);
  ...
});
</script>

И не забыть добавить еще одну запись в таблицу маршрутизации:

1
GET  /notifications/sse  controllers.NotificationController.usersSse

11. Запуск приложений Play

Есть несколько способов запустить наше приложение Play Framework , но, вероятно, самый простой — использовать инструмент sbt , с которым мы уже хорошо знакомы:

1
sbt run

Однако лучший способ, все еще используя sbt , — запустить приложение в непрерывном цикле edit-compile- (re) deploy, чтобы (в основном) мгновенно отразить изменения в исходных файлах:

1
sbt ~run

По умолчанию каждое приложение Play работает на HTTP-порту 9000 , поэтому вы можете свободно переходить в браузере по адресу http: // localhost: 9000, чтобы играть с пользователями, или по адресу http: // localhost: 9000 /, чтобы просматривать WebSockets и отправленные сервером. события в действии.

12. Безопасный HTTP (HTTPS)

Использование безопасного HTTP ( HTTPS ) в рабочей среде является обязательным правилом для современных веб-сайтов и порталов в наши дни. Но очень часто во время разработки необходимо запускать приложение Play Framework с поддержкой HTTPS . Обычно это делается путем создания самозаверяющих сертификатов и их импорта в хранилище ключей Java , которое находится всего в одной команде:

01
02
03
04
05
06
07
08
09
10
11
keytool -genkeypair -v
  -alias localhost
  -dname "CN=localhost"
  -keystore conf/play-webapp.jks
  -keypass changeme
  -storepass changeme
  -keyalg RSA
  -keysize 4096
  -ext KeyUsage:critical="keyCertSign"
  -ext BasicConstraints:critical="ca:true"
  -validity 365

conf/play-webapp.jks ключей conf/play-webapp.jks можно использовать для настройки приложения Play Framework для работы с поддержкой HTTPS , например:

1
sbt run -Dhttps.port=9443 -Dplay.server.https.keyStore.path=conf/play-webapp.jks -Dplay.server.https.keyStore.password=changeme

Теперь мы можем перейти к https: // localhost: 9443 /, чтобы получить список пользователей (того же, который мы увидим, используя http: // localhost: 9000 / ). Очень просто и легко, не правда ли?

13. Тестирование

В мире веб-приложений у тестирования есть много форм и граней, но Play Framework делает действительно хорошую работу, предоставляя необходимые леса для их упрощения. Более того, обе платформы ScalaTest и specs2 поддерживаются одинаково.

Вероятно, самый простой и быстрый способ приблизиться к тестированию в Play Framework — использовать модульные тесты. Например, давайте посмотрим на этот набор specs2 для тестирования методов UserController .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
class UserControllerUnitSpec extends PlaySpecification with Mockito {
  "UserController" should {
    "render the users page" in {
      val userService = mock[UserService]
      val controller = new UserController(userService)
       
      userService.findAll() returns Future.successful(Seq(
        User(Some(1), "a@b.com", Some("Tom"), Some("Tommyknocker"))))
      val result = controller.getUsers()(FakeRequest())
 
      status(result) must equalTo(OK)
      contentAsString(result) must contain("a@b.com")
    }
  }
}

Полезные леса Play Framework для интеграции specs2 делают написание модульных тестов так же легко, как дышать. Поднявшись на один уровень в тестировании пирамиды , нам, возможно, придется подумать о написании интеграционных тестов, и в этом случае Play Framework лучше использовать со ScalaTest из-за выделенных дополнительных функций, предоставляемых из коробки.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
class UserControllerSpec extends PlaySpec with OneAppPerTest with ScalaFutures {
  "UserController" should {
    "render the users page" in {
      val userService = app.injector.instanceOf[UserService]
      
      whenReady(userService.insert(User(None, "a@b.com",
          Some("Tom"), Some("Tommyknocker")))) { user =>
        user.id mustBe defined
      }
       
      val users = route(app, FakeRequest(GET, "/")).get
      status(users) mustBe OK
      contentType(users) mustBe Some("text/html")
      contentAsString(users) must include("a@b.com")
        .and(include ("Tommyknocker"))
    }
  }
}

В этом случае создается полноценное приложение Play Framework , включая экземпляр базы данных, настроенный со всеми примененными эволюциями. Однако, если вы хотите максимально приблизиться к реальному развертыванию, вы можете рассмотреть возможность добавления тестовых случаев веб-интерфейса (с браузером или без него), и опять же, интеграция ScalaTest предлагает необходимые компоненты .

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
class UserControllerBrowserSpec extends PlaySpec
    with OneServerPerSuite with OneBrowserPerSuite
      with HtmlUnitFactory {
  "Users page" must {
    "should show emtpy users" in {
      go to s"http://localhost:$port/"
      pageTitle mustBe "Manage Users"
 
      textField("email").value = "a@b.com"
      submit()
       
      eventually { pageTitle mustBe "Manage Users" }
      find(xpath(".//*[@class='table']/tbody/tr[1]/td[4]")) map {
        _.text mustBe ("a@b.com")
      }
    }
  }
}

Эта хорошо известная стратегия тестирования основана на автоматизации веб-браузера Selenium, которая красиво встроена в OneBrowserPerSuite вместе с HtmlUnitFactory без браузера.

14. Выводы

Без сомнения, Play Framework возвращает удовольствие и радость от разработки веб-приложений на платформе JVM. С Scala и Akka в своей основе, современным, чрезвычайно многофункциональным и продуктивным, построенным на основе парадигмы реактивного программирования , все это делает Play Framework выбором, о котором вы никогда не пожалеете. Не забывайте, что отличное разделение между внешним интерфейсом и внутренним интерфейсом позволяет объединить лучшие части двух миров, что приводит к созданию красивых и поддерживаемых веб-приложений.

15. Что дальше

В следующем разделе руководства мы поговорим о разработке веб-API REST (ful) с использованием HTTP- модуля Akka .

Полный исходный код доступен для скачивания.