Часть 7. HTTP протокол. Play Framework. Архитектура высоконагруженных приложений.

Страничка курса: https://maxcom.github.io/scala-course-2020/

План

  1. Архитектура обработки запросов.
  2. HTTP протокол и REST.
  3. Статус по практическому заданию
  4. Play Framework: создаем приложение.
  5. Практическое задание: создаем сервис классификации.
Сегодня не будет новых
способов использования Implicit!

Основные источники асинхронности в JVM:

  • Многопоточность
  • Сокеты (tcp)

Архитектура обработки запросов

Протоколы, в которых клиент открывает tcp соединение с сервером и они обмениваются запросами/сообщениями.

Это HTTP, SMTP, XMPP и многие другие.

Каждому клиенту по потоку

Самая простая модель: основной поток принимает соединения и передает обработку "рабочим" потокам.
private val executor: Executor = 
    Executors.newCachedThreadPool()
val serverSocket: ServerSocket = new ServerSocket(2000)
val greetings: Array[Byte] = 
  "Hello, world!".getBytes(US_ASCII)

while (true) { // упрощенный вариант сервера
  val socket: Socket = serverSocket.accept()
  executor.execute { () ⇒
    // блокирующийся ввод-вывод
    socket.getOutputStream.write(greetings)
    socket.close()
  }
}

упрощенная схема реализации

Проблемы подхода (1/3)

"Пассивные" клиенты занимают потоки:

  • Когда держат соединения между запросами
  • Когда читают ответ на своем медленном канале
  • Когда сервер ждет ответ от другого сервиса

Проблемы подхода (2/3)

Не предполагает распараллеливания обработки одного запроса

Проблемы подхода (3/3)

Трудности с обработкой таймаутов и fallback.

Все клиенты в одном потоке

Можно ли обработать всех клиентов в одном потоке?

Да, с использованием мультиплексированного ввода вывода.

Такая архитектура показала свою эффективность.

Например:

  • Веб-сервер NGINX
  • СУБД Redis
  • Сервер приложений node.js
Плюсы такой схемы:
  • "Пассивные клиенты" почти не нагружают приложения
  • Нет параллельности - нет недетерминизма и сложностей
  • Меньше context switch - эффективнее для ОС и процессора

Введение в Java NIO

Java NIO - низкоуровневые API для ввода-вывода.

"New IO" - "новый" API (2002 год).

Дополняется в каждой новой версии JDK.

Java IO - потоковые чтение/запись

Java NIO - блочные чтение/запись

Блочный I/O оптимальнее потокового, он избегает лишних копирований и преобразований данных.*
* ByteBuffer - кошмар разработчика.

Нам NIO интересен тем, что только в нем есть неблокирующий ввод-вывод.

Неблокирующий ввод-вывод

Специальный режим сокета.

socketChannel.configureBlocking(false)

Блокирущий read: читает сколько может;
если данных нет - ждет

Неблокирующий read: мгновенно читает сколько может; нет данных - пустой результат

Блокирующий write: пишет всё что передали; если клиент не успевает - ждет и продолжает

Неблокирующий write: мгновенно пишет сколько может; если клиент не готов - ничего не пишет


  executor.execute { () ⇒
    val buffer: ByteBuffer = ByteBuffer.wrap(greetings)

    while (buffer.hasRemaining) { // busy wait - плохая идея
      socket.write(buffer)
    }

    socket.close()
  }
Постоянно опрашивать сокеты в ожидании готовности не эффективно.
Selector - мониторинг состояния сокетов.

val selector: Selector = Selector.open()
socket.configureBlocking(false)
val key = socket.register(selector, SelectionKey.OP_WRITE)

while (buffer.hasRemaining) {
  selector.select() // блокируется
  if (key.isWritable) {
    socket.write(buffer)
  }
}

Вернулись к блокировкам?

Event Loop - используем один Selector на всех клиентов

val selector: Selector = Selector.open()

// можно зарегистрировать много сокетов
serverSocket.register(selector, SelectionKey.OP_ACCEPT, 
  () -> println("Got new connection"))

while (true) {
  selector.select()

  val selectionKeys = selector.selectedKeys.asScala

  selectionKeys.foreach { sk ⇒
    sk.attachment().asInstanceOf[Runnable].run()
  }
}

Итого:

  • "Реактивная логика", основанная на событиях
  • Неограниченное количество клиентов на один поток

Сложности?

Запутанная логика чтения - нарезка потока от клиента на произвольные блоки.

Запись только по готовности клиента

Запутанная машина состояний TLS

Обычно не проблема - программист работает или с готовым сервером, или с framework (netty, akka-io и т.п.)

Такой подход не сочитается с:
  • Долгими вычислениями на CPU
  • Блокирующимися вызовами (например JDBC)
  • Вызовами асинхронных API

Комбинируем потоки и Event Loop

(пример реализации)

Раньше наши обработчики работали
прямо в нашем потоке.

Сделаем их функциями
Unit ⇒ Future[Action]

Action - кодирует следующую операцию над сокетом

Вызов такой функции делается просто:
selectionKeys.foreach { sk ⇒
  sk.interestOps(0) // не ждем новых событий
  sk.attachment().asInstanceOf[() ⇒ Future[Action]].apply()
}
но как "вернуть" сокет обратно в работу?
  • Selector - не потокобезопасный (почти)
  • да и он все время занят чем-то
Создаем специальный Executor:
 // неблокирующаяся очередь, построенная на CAS операциях
val queue = new ConcurrentLinkedQueue[Runnable]
val executor: Executor = task ⇒ queue.add(task)
очередь разбираем цикле обработки событий:
var task: Runnable = _
while ({ task = queue.poll(); task } != null) {
  task.run()
}
При добавлении задачи прерываем ожидание select()
val reactorExecutor = ExecutionContext.fromExecutor { r ⇒
  queue.add(r)
  selector.wakeup()
}
selectionKeys.foreach { sk ⇒
  sk.interestOps(0)
  sk.attachment()
    .asInstanceOf[() ⇒ Future[Action]]
    .apply()
    .onComplete(processAction)(reactorExecutor)
}

Итого мы получили:

  • event loop для эффективного ввода-вывода
  • возможность использования thread pool вычислений и блокирующихся вызовов
  • возможность использования асинхронных API

Все современные сервера на JVM реализуют комбинированный подход.

Похожий event-loop используется в клиентах к веб-сервисам и к некоторым СУБД.

Функция выполнения запроса возвращает Future, которая обрабатывается в event loop клиента.

HTTP протокол

Протокол передачи данных, придуманный для Web.

Придуман для "классических" сайтов:

  • Генерация HTML на сервере
  • Перезагрузка страниц при переходах
  • Взаимодействие с сервером через формы

Позже эволюционировали в JavaScript-приложения

  • HTML только для первоначальной загрузки
  • State в памяти, взаимодействие с клиентом без перезагрузки
  • Взаимодействие с сервером через AJAX - запросы с XML и JSON телом

HTTP используется для взаимодействия сервисов между собой.

Две "живых" версии стандарта:

  • HTTP 1.1, 1999 год; последняя редакция 2014 года (см. RFC2616 Is Dead).
  • HTTP 2.0, 2015 год - новый транспортный слой

Будем говорить про HTTP/1.1

Схема "запрос-ответ".

Сервер ожидает соединений клиентов.

Сервер получает запрос и формирует ответ.

В одном соединении запросы обрабатываются последовательно.

Клиент может открыть несколько соединений к серверу (браузеры - до 6).

Запрос состоит из:

  • Строки запроса
  • Заголовков (метаданных)
  • Тела запроса (опционально)

Ответ состоит из

  • Строки ответа
  • Заголовков
  • Тела ответа (тоже опционально)

Строка запроса


GET /scala-course-2020/slides/day7.html HTTP/1.1
					

Метод, путь ресурса и версия протокола

Путь - "адрес" ресурса на сервере;
"метод" - действие над ресурсом

Основные методы:

  • GET - получить
  • POST - отправить данные
  • PUT - поместить
  • DELETE - удалить
  • PATCH - модифицировать

Всего около 20 методов, если включить все расширения

Заголовки запроса:

  • Возможности и предпочтения клиента
  • Наличие и состав тела запроса
  • Наличие данных в кеше
  • Cookies
  • Другие модификаторы, например получение с нужного смещения

Host: localhost:9000
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8
Accept-Encoding: gzip, deflate, br
Accept-Language: ru-RU,ru;q=0.9,en-US;q=0.8,en;q=0.7
Cache-Control: max-age=0
If-Modified-Since: Sun, 25 Mar 2018 07:25:21 GMT
Upgrade-Insecure-Requests: 1
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_3) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/65.0.3325.181 Safari/537.36
					

Переносы строк запрещены в современном HTTP

Тело запроса: данные, передаваемые на сервер

Тело - данные запроса, поля формы, загружаемые данные и т.п.

Тело может быть почти у любого метода, даже у GET (см. RFC 7231, раздел 4.3.1).

Два режима передачи тела запроса:
известной длины и неизвестной (chunked).

Строка ответа


HTTP/1.1 200 OK
					

версия протокола и статус ответа

Статус - трехзначный код плюс его описание

    1xx - информационные коды

    2xx - успешные коды

    3xx - перенаправления

    4xx - ошибки клиента

    5xx - ошибки сервера

Хороший справочник по кодам - в английской википедии.

Заголовки ответа

  • Свойства получаемого ресурса - тип данных, кешируемость, размер, язык и т.п.
  • Режимы передачи - сжатие, chunked и т.п.
  • Установка Cookies
  • Security-заголовки
Server: QRATOR
Date: Sun, 25 Mar 2018 08:57:43 GMT
Content-Type: text/html;charset=utf-8
Transfer-Encoding: chunked
Connection: keep-alive
Keep-Alive: timeout=15
Set-Cookie: CSRF_TOKEN="GjxRyqELgF6WoDWtvm3dMQ=="; Version=1; Max-Age=64281600; Expires=Tue, 07-Apr-2020 08:57:43 GMT; Path=/
Cache-Control: private
Strict-Transport-Security: max-age=7776000
X-Content-Type-Options: nosniff
X-Frame-Options: SAMEORIGIN
Expires: Sat, 24 Mar 2018 12:57:43 GMT
Last-Modified: Sun, 25 Mar 2018 08:57:41 GMT
Set-Cookie: JSESSIONID=1485581EB69CB469B9E766D8DCEF4652; Path=/; Secure; HttpOnly
					

Тело ответа - содержимое ресурса

  • Известного размера
  • Chunked
  • Ограничено соединением (устаревшее из HTTP/1.0)

Перерыв 5 минут

Статус по практическому заданию

План задания

  1. Классификатор текстов
  2. Reads для vk.com (только reads! writes не нужно)
  3. Стемминг и диагностика
  4. Получаем сообщения API vk.com
  5. Сервис категоризации
    -------- мы находимся здесь -------
  6. Опрос новых записей и хранение состояния на диске
  7. Realtime обновление

REST API

Как строить API поверх HTTP?

Можно инкапсулировать протокол поверх HTTP.

Но это сложно и не позволяет использовать полезные свойства протокола.

REST - методика создания API поверх протокола HTTP

"REST" как функционально программирование - его все любят, но никто точно не знает что это такое.

Обычно под REST понимают следующее:
  • Отсутствие состояния клиента на сервере
  • URI, указывающие на объекты API
  • Методы HTTP - действия над объектами
  • Ошибки с кодами HTTP
  • Использование кеширования и условных запросов
  • Content-type для выбора формата и версионирования

Подробнее рассмотрим на примере Play Framework

Play Framework

Современный "легкий" framework для создания сайтов и веб-приложений, альтернатива традиционным Java-фреймворкам.

Никаких сервлетов, "контейнеров" и т.п.

Быстрый асинхронный веб-сервер на базе Netty или Akka-HTTP.

плюс опциональные
компоненты:

  • html-шаблоны
  • json и xml
  • http клиент
  • аутентификация
  • работа с JDBC (SQL)
  • и пр.

Play подходит для "классических" сайтов, для разработки JavaScript-приложений, и для разработки сервисов.

В сервисах с сложным API используем Akka-HTTP.
Рассмотрим на 13-й лекции.

sbt-web - сборка JavaScript приложения
средствами sbt

webjars - управление зависимостями JavaScript

Добавляем Play в готовый проект

(создать проект с "нуля" проще - см. документацию)

Выбираем версию sbt: project/build.properties

sbt.version=1.3.8

Создаем project/plugins.sbt


addSbtPlugin("com.typesafe.play" % "sbt-plugin" % "2.8.0")

Включаем Play и используем стандартные пути файлов:


libraryDependencies += guice

enablePlugins(PlayScala)
disablePlugins(PlayLayoutPlugin)

PlayKeys.playMonitoredFiles ++= 
  (sourceDirectories in 
    (Compile, TwirlKeys.compileTemplates)).value

build.sbt

Создаем пустой конфиг
src/main/resources/application.conf

Отключаем фильтр по имени хоста в application.conf:


play.filters.disabled+=play.filters.hosts.AllowedHostsFilter

В демо-проекте он нам не нужен,
в реальной жизни его нужно настроить

На этом этапе можно уже запустить веб-сервер:


sbt run

в терминале или через меню в IDEA

Сервер запускается в режиме разработчика
http://localhost:9000/

при изменениях сервер не нужно перезапускать:
изменения автоматически подхватываются

Hello, world!
src/main/scala/controllers/HelloWorld.scala


package controllers

import javax.inject.Inject
import play.api.mvc.{AbstractController, ControllerComponents}

class HelloWorld @Inject()(cc: ControllerComponents) 
    extends AbstractController(cc) {
  def hello() = Action {
    Ok("Hello, world!") // просто text/plain
  }
}

Таблица роутинга запросов:
src/main/resources/routes

GET         /             controllers.HelloWorld.hello

Action может быть асинхронным:


class HelloWorld @Inject()(cc: ControllerComponents) 
    extends AbstractController(cc) {
  implicit val ec: ExecutionContext = defaultExecutionContext

  def hello() = Action.async {
    Future {
      Ok("Hello, world!")
    }
  }
}

Java-сервлеты научились быть асинхронными через 10 лет после появления первой версии.

И еще 3 года появился асинхронный ввод-вывод.

Вернемся к REST:
URI должен указывать на объект на сервере

Например профиль пользователя admin
/users/admin

Действия над пользователями:

  • GET /users - получить список пользователей
  • POST /users - создать нового
  • GET /users/:login - получить свойства пользователя
  • PUT /users/:login - создать/обновить пользователя
  • PATCH /users/:login - модифицировать пользователя (или POST)
  • DELETE /users/:login - удалить пользователя

В Play роутинг может быть такой:


GET    /users         controllers.Users.users()
POST   /users         controllers.Users.createUser()
GET    /users/:login  controllers.Users.getAdmin(login)
PUT    /users/:login  controllers.Users.setAdmin(login)
PATCH  /users/:login  controllers.Users.updateAdmin(login)
DELETE /users/:login  controllers.Users.dropAdmin(login)

Коды HTTP классифицируют ошибки
(подробности - в теле ответа)

Пример

Ошибки клиента

  • 400 Bad Request - некорректный запрос или ошибка валидации
  • 403 Forbidden - запрещено правами доступа
  • 404 Not Found - объект отсутствует на сервере
  • 409 Conflict - несовместимое изменение
  • 429 Too Many Requests - слишком много запросов

Ошибки сервера

  • 500 Internal Server Error
  • 503 Service Unavailable

Практическое задание:
сервис классификации

Будем делать "классический" сайт, а не приложение.

(не будем писать код на front-end)

Задание:

Сделаем HTML форму для ввода текста.

Обработчик вернет класс текста (позитив/негатив) и исходный текст с диагностикой.

Используем HTML шаблоны Twirl

src/main/twirl/views/form.scala.html

@(text: Option[String], category: Option[String], 
        debug: Option[String])

Классификатор

@if(category.nonEmpty) {

Результат: @category

@debug

}

В контроллере возвращаем HTML:


def showForm() = Action {
  Ok(views.html.form(None, None, None))
}
					

Данные формы при методе GET передаются параметрами в URL

В функцию обработки запроса нужно добавить параметр, Play передаст туда значение из URL.

Страшный "программистский" дизайн?

Можно легко исправить

(опционально, если есть желание)

Используем готовый CSS шаблон

Популярный вариант: Bootstrap

Его можно подключить как зависимость в sbt

Пример - на сайте webjars

Использование - в документации на Bootstrap

Напоминаю: