Асинхронный HTTP в Play Framework

в 12:42, , рубрики: Без рубрики

При написании собственных веб-приложений часто возникают такие ситуации, в которых приходится делать выбор между синхронным и асинхронным выполнением запросов. С одной стороны, идея синхронной работы выглядит вполне логичной: мы запускаем некоторый процесс, дожидаемся его завершения и после этого продолжаем работу. Но на деле такой подход хорош только в случае простых вычислений. Представьте, что в процессе выполнения вам нужно, к примеру, сделать сложный запрос к базе данных — а то и, еще лучше, отправить запрос к другому серверу и дождаться от него результатов. В этом случае дальнейшая работа потока будет остановлена на довольно продолжительное время — а это, понятное дело, далеко не всегда является приемлемым. Именно в таких случаях на помощь приходят асинхронные запросы. Давайте посмотрим, как работа с ними реализована в Play framework.

Future[Result]

Описанная выше ситуация с затратными вычислениями в Play framework решается при помощи Future[Result]. Идея, стоящая за ним, проста: зачем ждать результата вычислений, если вместо этого можно переключиться на другие задачи? Разницы для пользователя, конечно, не будет — его браузер по-прежнему будет ожидать ответа от сервера. А вот сам сервер сможет вместо того, чтобы ждать результата, перебросить ресурсы на обработку запросов от других пользователей. Ну а как только мы получим результат, его можно будет быстро обработать и отправить пользователю.

Все, что нужно для того, чтобы выполнить блок кода асинхронно с получением Future[Result] — это обернуть его в простую структуру вида:

import play.api.libs.concurrent.Execution.Implicits.defaultContext

val futureInt: Future[Int] = scala.concurrent.Future {
  intensiveComputation()}

А чтобы вернуть получившееся значение — воспользоваться методом Action.async:

import play.api.libs.concurrent.Execution.Implicits.defaultContext

def index = Action.async {
  val futureInt = scala.concurrent.Future { intensiveComputation() }
  futureInt.map(i => Ok("Got result: " + i))}

И этот вариант работает хорошо, пока код выполняется верно. Но от ошибок при выполнении не застрахован никто, а если ошибка случится в блоке кода, выполняющемся асинхронно, она может привести к тому, что результат так и не будет сформирован. А это значит, что пользователь не получит ответа и его браузер останется в ожидании пока он, наконец, не закроет вкладку. Избежать этого, однако, довольно просто: каждому обещанию (то есть Future[Result]) можно поставить тайм-аут: если в течение этого времени мы не получим реальный результат — будет сформирована ошибка. Делается это тоже довольно просто:

import play.api.libs.concurrent.Execution.Implicits.defaultContext
import scala.concurrent.duration._

def index = Action.async {
  val futureInt = scala.concurrent.Future { intensiveComputation() }
  val timeoutFuture = play.api.libs.concurrent.Promise.timeout("Oops", 1.second)
  Future.firstCompletedOf(Seq(futureInt, timeoutFuture)).map {
    case i: Int => Ok("Got result: " + i)
    case t: String => InternalServerError(t)
  }}

Но стоит отметить, что при всех плюсах использовать Future[Result] следует с умом: в конце концов, при его использовании все еще будет блокироваться один из потоков, просто в данном случае — не основной. А это может привести к самым разнообразным проблемам, особенно при большом числе конкурентных запросов.

Потоковые HTTP ответы

В HTTP 1.1 одно соединение вполне можно использовать для отправки нескольких запросов (http://en.wikipedia.org/wiki/HTTP_persistent_connection). С одним условием: сервер должен включать в ответ заголовок Content-Length. Обычно при отправке ответов это условие не так важно, потому что Play вполне способен вычислить размер контента самостоятельно. Но на деле не всегда все оказывается так просто: из-за того, что в простейшем случае (при использовании SimpleResult) для формирования тела ответа фреймворк использует Enumerator (play.api.libs.iteratee.Enumerator), для вычисления длины ему необходимо загрузить весь контент в память. И в случае с большими объемами данных (представьте, что вы хотите вернуть пользователю видеофайл) это может привести к некоторым проблемам.

Понятие Enumerator в Play тесно связано с понятием Iteratee. Связка Iteratee-Enumerator служат в этом фреймворке для неблокирующей реализации паттерна customer-producer. Другими словами, Iteratee — это потребитель, который может сформировать некоторый результат на основе поглощенных фрагментов данных. Enumerator же — это производитель, как раз и поставляющий эти фрагменты.

Примечание: для стандартных случаев работы с Enumerator в Play предусмотрен набор фабричных функций для его создания: Enumerator.fromFile, к примеру, создает его на основе содержимого файла; Enumerator.fromStream — на основе потока данных. В общем случае, однако, создавать Enumerator придется вручную.

Но вернемся к потоковым ответам. Чтобы избежать проблем с производительностью при вычислении размера данных, лучше рассчитать его заранее и подставить получившийся результат в заголовок запроса. При этом отпадает необходимость загружать в память все содержимое Enumerator, а значит, экономятся ресурсы и повышается производительность. Для описанного выше примера с видеофайлом поступить можно следующим образом:

def index = Action {

  val file = new java.io.File("/tmp/fileToServe.mov")
  val fileContent: Enumerator[Array[Byte]] = Enumerator.fromFile(file)    
    
  SimpleResult(
    header = ResponseHeader(200, Map(CONTENT_LENGTH -> file.length.toString)),
    body = fileContent
  )}

Или просто воспользоваться функцией Play framework, предназначенной для передачи пользователю файла:

def index = Action {
  Ok.sendFile(new java.io.File("/tmp/fileToServe.mov"))}

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

Однако все описанные выше способы хороши только в случае со статическим контентом, то есть таким, который известен нам заранее. Но что, если к моменту начала передачи весь контент еще не готов — а значит, и рассчитать его длину при всем желании будет невозможно? В таком случае на помощь приходят фрагментированные ответы, использующие в своей работе механизм chunked transfer encoding (http://en.wikipedia.org/wiki/Chunked_transfer_encoding). Такой подход позволяет обойтись без использования Content-Length, а значит, начинать передавать данные можно по ходу их генерации, еще не зная их финального размера. Умеет ли работать с ним Play? Вы еще спрашиваете:

def index = Action {

  val data = getDataStream
  val dataContent: Enumerator[Array[Byte]] = Enumerator.fromStream(data)
  
  ChunkedResult(
    header = ResponseHeader(200),
    chunks = dataContent
  )}

Или же:

def index = Action {

  val data = getDataStream
  val dataContent: Enumerator[Array[Byte]] = Enumerator.fromStream(data)
  
  Ok.chunked(dataContent)}

Можно передавать и Enumerator:

def index = Action {
  Ok.chunked(
    Enumerator("kiki", "foo", "bar").andThen(Enumerator.eof)
  )}

Метод andThen служит для того, чтобы создать последовательность из нескольких Enumerator. В данном случае, к примеру, вслед за данными передается Enumerator.eof — фрагмент нулевой длины, сигнализирующий о том, что передача завершена.

Примечание: вместо метода andThen можно использовать оператор >>> — функционально между ними нет никакой разницы, но использование последнего может повысить читаемость кода, избавив его от чрезмерного количества скобок.

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

Comet sockets

Одним из применений описанного выше способа фрагментированного ответа является создание Comet sockets. Что это такое? Если просто, то это ответы, состоящие из <script> элементов. Каждый раз, когда в браузер приходит подобный ответ, он автоматически исполняется — такой подход может быть очень удобен для динамической отправки браузеру событий, да и чего угодно еще.

Вид ответа для данного случая не сильно отличается от фрагментированного ответа, разве что на смену Ok.chunked приходит Ok.stream:

def comet = Action {
  val events = Enumerator(
    """<script>console.log('kiki')</script>""",
    """<script>console.log('foo')</script>""",
    """<script>console.log('bar')</script>"""
  )
  Ok.stream(events >>> Enumerator.eof).as(HTML)}

Формировать <script> элементы на основе поступивших данных можно и другим способом: воспользовавшись специальной функцией Play framework:

def comet = Action {
  val events = Enumerator("kiki", "foo", "bar")
  Ok.stream((events &> Comet(callback = "console.log")) >>> Enumerator.eof)}

WebSockets

Сами создатели Play признают, что использование Comet sockets является своеобразным хаком. Обладают они и другими недостатками: так, работают они только в одном направлении, а значит, чтобы передать события в обратном направлении, браузеру все так же приходится пользоваться Ajax-запросами. Обойти это можно, используя WebSockets (http://en.wikipedia.org/wiki/WebSocket), которые предоставляют двухстороннюю связь между клиентом и сервером.

Для того, чтобы пользоваться WebSockets, в первую очередь придется отказаться от использования объекта Action: из-за разницы в имплементации они оказываются несовместимы. На смену ему приходит объект WebSocket, главным отличием которого от Action является то, что, имея доступ к заголовкам запроса, он не имеет доступа ни к его телу, ни к HTTP ответу.

В простейшем случае использование WebSocket выглядит следующим образом:

def index = WebSocket.using[String] { request => 
  
  // Just consume and ignore the input
  val in = Iteratee.consume[String]()
  
  // Send a single 'Hello!' message and close
  val out = Enumerator("Hello!").andThen(Enumerator.eof)
  
  (in, out)}

In — входной канал — это Iteratee, который потребляет сообщения, пришедшие от клиента; out — выходной канал — Enumerator, отправляющий клиенту ответные сообщения.

Заключение

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

Автор: ohromov

Источник

Поделиться

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