Веб-сокеты в Play Framework

WebSockets in Play! framework

Часть 1. Веб-сокеты в Play Framework

Первым делом давайте разберёмся, что собой представляет WebSocket. Протокол WebSocket полностью меняет подход к общению между клиентом и сервером. Вместо старой привычной синхронной модели общения "запрос-ответ" с разделением ролей на "клиент" и "сервер", WebSocket использует асинхронную модель общения. В такой модели клиент и сервер являются равноправными сторонами "диалога", которые общаются независимо друг от друга. После отправки запроса клиент или сервер не дожидаются ответа, а продолжают свою работу.

В отличии от HTTP, где на каждый запрос открывается новое TCP соединение, которое необходимо закрыть при получении ответа, WebSocket открывает всего лишь одно TCP соединение для коммуникации между клиентом и сервером. После легковесного этапа обмена служебными данными для устаноки соединения, происходит открытие канала, по которому начинают передаваться данные в обе стороны. Такой подход позволяет существенно снизить затраты и объем данных, несущих в себе служебную информацию.

Теперь мы можем перейти к следующему этапу и рассмотреть, как устроена работа с WebSocket в стремительно развивающемся фреймворке Play. Play Framework является fullstack фреймворком и предоставляет возможности работы с базами данных, роутинга, рендеринга страниц при помощи шаблонизатора и множество других полезных инструментов. Одна из крутых возможностей Play "из коробки" - работа с веб-сокетами.

HTTP-контроллер в Play представляет собой метод, который возвращает объект типа Action. Такой контроллер может возвращать разные типы результата - XML, JSON, текст. Вот, например, контроллер, который возвращает результат в виде обычной строки:

def user(user: User): Action[AnyContent] = Action {
 Ok(user.name)
}

А вот так можно вернуть результат рендеринга страницы встроенным в Play движком:

def clientsList: Action[AnyContent] = Action {
val clients = userService.listClients()
Ok(views.html.admin.clientsList(clients))
}

Теперь давайте посмотрим на возможности Play относительно веб-сокет контроллеров. Есть два способа создать такой контроллер:

  • асинхронные коллекции Iteratee/Enumerator,
  • акторы из библиотеки Akka.

Оба способа позволяют асинхронно принимать и отправлять сообщения через веб-сокеты, однако они сильно отличаются в плане архитектуры построения обработчиков этих сообщений. Давайте рассмотрим эти различия.

Веб-сокет контроллер на Iteratee/Enumerator

Этот способ лучше подходит, когда по веб-сокету нужно передавать непрерывный поток данных, например длинные тексты или файлы в виде массива байт. В качестве приёмника данных от клиента используется Iteratee, а в качестве генератора ответа - Enumerator:

def socket: WebSocket[String, String] = WebSocket.using[String] { request =>


  val out = Enumerator("Hello!")

  val in = Iteratee.foreach[String] { msg =>
    println(msg)
    out >>> Enumerator(msg)
  } map { _ =>
    println("Disconnected.")
  }

  (in, out)
}

Как видно из примера, веб-сокет контроллер возвращает в качестве результата WebSocket, а не Action. Сам же WebSocket строится на основе Tuple (кортеж) из двух элементов - входного и выходного канала. При установке соединения клиенту будет отправлено сообщение "Hello". Когда от клиента придёт сообщение во входной канал in, оно будет выведено в консоль и отправлено обратно клиенту через выходной канал out. Когда соединение будет разорвано, в консоль будет выведено сообщение "Disconnected".

Веб-сокет контроллер на акторах

Второй вариант - обработка веб-сокета при помощи актора. Этот способ лучше подходит, когда сообщения приходят как отдельные запросы и не являются частью потока. Например JSON или XML сообщения. В качестве обработчика таких сокетов используется экземпляр класса Actor из библиотеки Akka.

object WebSocketActor {
 def props(out: ActorRef) = Props(new WebSocketActor(out))
}

class WebSocketActor(out: ActorRef) extends Actor {

 out ! "Hello"

 override def receive: Receive = {
   case msg: String =>
     println(msg)
     out ! msg
 }

 override def postStop() {
   println("Disconnected.")
 }

}

def actorSocket = WebSocket.acceptWithActor[String, String] { request => out =>
 WebSocketActor.props(out)
}

Метод acceptWithActor имеет два generic параметра - типы входящих и исходящих сообщений. WebSocketActor является наследником класса Actor и содержит реализацию одного абстрактного метода receive, в который приходят все сообщения от клиента. В данном примере тип входящих и исходящих сообщений - String. Ссылка на актор out - это выходной канал. Все сообщения отправленные этому актору будут переданы клиенту. Как и в предыдущем примере, обработчик выводит полученное сообщение в консоль и отсылает сообщение обратно клиенту. Для обработки разрыва соединения можно переопределить метод postStop, который будет вызван после того, как актор будет остановлен.

Кроме обработки сообщений в виде строки, Play также умеет работать массивами байт и объектами JsValue:

def actorSocket = WebSocket.acceptWithActor [JsValue, JsValue] { request => out =>
 WebSocketActor.props(out)
}

Этот способ удобнее, если сообщения являются не просто строками, а сообщениями в формате JSON.

Часть 2. Применение на практике

Постановка задачи

Практическая постановка задачи выглядела следующим образом:

  1. Реализовать возможность устанавливать соединение по веб-сокету между клиентом (Android-устройством) и сервером.
  2. Обмен сообщениями между клиентом и сервером ведётся в формате JSON.
  3. Обмен сообщениями ведётся в режиме запрос-ответ: клиент отправляет серверу запрос и ждет ответа от сервера.
  4. Должен быть предусмотрен механизм аутентификации клиента.
  5. Необходимо предусмотреть последующее развитие протокола с асинхронными запросами от сервера к подключённому клиенту.

Учитывая требования к формату данных и дискретный характер отправки/приёма сообщений, для реализации серверной части веб-сокетов мы решили использовать акторы.

Механизм обмена сообщения выглядит следующим образом

Каждое JSON сообщение, помимо данных запроса/ответа, в обязательном порядке содержит идентификатор и тип сообщения. Для упрощения работы с сообщениями в сервисах, был написан ряд классов-обёрток для каждого типа сообщения. Контроллер, принимающий сообщения от клиента, выглядит так:

def socket = WebSocket.acceptWithActor[JsValue, JsValue] { request => out =>
 ClientWebSocketActor.props(out)
}

Актор-обработчик выглядит примерно так:

class ClientWebSocketActor(out: ActorRef) extends Actor {

 def handleMessage(msg: ClientMessage): JsValue = {
   lazy val responseTimestamp = currentTime
   msg match {
     case msg: ClientMessage if !isAuthenticated() => ErrorMessage(responseTimestamp, "Client not logged in")
     case msg: MessageA => handleMessageA(msg)
     case msg: MessageB => handleMessageB(msg)
     case _ => ErrorMessage(responseTimestamp, "Unsupported message type")
   }
 }

 def receive = {
   case request: JsValue =>
     val response = handleMessage(request)
     out ! response
 }
 
 def handleMessageA(msg: MessageA): JsValue = {
   // Message handling…
   ResponseMessageA(...)
 }
 def handleMessageB(msg: MessageB): JsValue = {
   // Message handling…
   ResponseMessageB(...)
 }
}

В приведенном выше коде есть немного "магии". Обратите внимание на вызов метода handleMessage и возвращаемый результат методов handleMessageA и handleMessageB. В этих строках происходит преобразование сообщений из JsValue в ClientMessage и наоборот. Такой подход позволяет убрать низкоуровневую обработку полей JSON строки "под капот" и сделать код обработчика чище. Давайте заглянем под этот самый капот и посмотрим что же там происходит.

Абстрактный класс-предок с полем, хранящим тип сообщения.

abstract class MessageObjectTypeAware(val MSG_TYPE: String)

Объект-компаньон для case-класса сообщения.

object MessageA extends MessageObjectTypeAware("message_a") {
 implicit val format = Json.format[MessageA]
}

Сам case-класс сообщения.

case class MessageA(timestamp: Long, data: String, messageType: String = ErrorMessage.MSG_TYPE) extends ClientMessage

Объект, выполняющий неявные преобразования.

object ClientMessage {

 implicit def jsValue2ClientMessage(jsValue: JsValue): ClientMessage = {
   (jsValue \ "messageType").as[String] match {
     case ErrorMessage.MSG_TYPE => jsValue.as[ErrorMessage]
     case MessageA.MSG_TYPE => jsValue.as[MessageA]
     case MessageB.MSG_TYPE => jsValue.as[MessageB]
     case messageType => ErrorMessage(currentTime, ErrorCodes.UNKNOWN_MESSAGE_TYPE)
   }
 }

 implicit def clientMessage2jsValue(clientMessage: clientMessage): JsValue = {
   clientMessage match {
     case error: ErrorMessage => Json.toJson(error)
     case msgA: MessageA => Json.toJson(msgA)
     case msgB: MessageB => Json.toJson(msgB)
   }
 }

}

Схема работы вышеприведенного кода:

  1. Каждое сообщение - это case class, унаследованный от ClientMessage. Сообщение содержит тип, метку времени и связанные данные.
  2. Для каждого сообщения существует объект-компаньон, который содержит тип сообщения и форматтер для конвертации сообщения в JSON и обратно.
  3. Поле MSG_TYPE объектов-компаньонов вынесено в общий абстрактный класс-предок MessageObjectTypeAware.
  4. Объект ClientMessage содержит неявные преобразования, благодаря которым и происходит автоматическая конвертация между JsValue и ClientMessage в клиентском коде.

На практике в этом решении обнаружилась проблема - при добавлении новых типов сообщений начал разрастаться объект ClientMessage, т.к. он содержит ветку case для каждого типа сообщений. К сожалению, данную проблему не удалось решить эффективно, вписавшись при этом во временные рамки проекта. Поэтому было принято решение разделить сообщения на группы по смысловой нагрузке. Это позволило разбить большой объект-конвертер на несколько объектов поменьше - каждый для своей группы сообщений.

В проекте был разработан следующий механизм аутентификации

Первым сообщением пользователь отправляет свой уникальный uuid (например уникальный uuid мобильного устройства). В методе обработки этого сообщения (handleUuidSignIn) происходит сохранение пользователя с таким uuid, если такого пользователя ещё нет, иначе из базы данных извлекается сохраненный раннее пользователь. В случае успешного выполнения предыдущей операции, мы сохраняем пользователя в переменную signedInUser класса-актора, генерируем токен авторизации, который возвращаем в ответе клиенту для возможности последующей авторизации по токену.

private def handleUuidSignIn(responseTimestamp: => Long, uuid: String): JsValue = {
...
    // getting existent user or create new one

    user match {
      case Success(u) =>
        signedInUser = Some(u)// store user to variable
        val token = userTokenService.getOrCreate(u)// token generating
        SignInResponseMessage(responseTimestamp, token.securityToken)// return response

      case Failure(e) => ErrorMessage(responseTimestamp, e.getMessage)
    }
  }

При авторизации с использованием токена, в сообщении передается токен. В методе обработки сообщения происходит получение пользователя с таким токеном и сохранение полученного пользователя в переменную signedInUser класса-актора.

При авторизации с использованием логина и пароля, выполняются те же действия, что и при авторизации по токену, только пользователь ищется в БД по логину и происходит проверка корректности пароля.

Переменная signedInUser используется для получения информации об авторизированном пользователе, которая используется в последующих запросах, а также для отсеивания повторных попыток авторизации.

class ClientWebSocketActor(out: ActorRef) extends Actor {
  private var signedInUser: Option[User] = None
  private def userUuid = signedInUser.get.uuid

 def handleMessage(msg: ClientMessage): JsValue = {
   lazy val responseTimestamp = currentTime
   msg match {
     case msg: case _@(UuidSignInMessage(_, _, _)| TokenSignInMessage(_, _, _)) if signedInUser.isDefined =>
        ErrorMessage(responseTimestamp, "Already autorized!")
...
     case msg: MessageA => handleMessageA(msg)
...
     case _ => ErrorMessage(responseTimestamp, "Unsupported message type")
   }

 def handleMessageA(msg: MessageA): JsValue = {
...
   ResponseMessageA(...)
 }
}

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

Связаться