Архитектура обработки запросов. Протокол HTTP.

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

План первой части

  1. Источники асинхронности
  2. Каждому клиенту по потоку
  3. Java NIO и мультиплексированный I/O
  4. HTTP протокол и REST

Вторая часть - Akka HTTP.

Откуда берется асинхронность?

  • Многопоточность
  • Планировщик
  • Сокеты
  • Асинхронный файловый ввод-вывод
  • Экзотика: файловая система,
    native библиотеки.

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

(в TCP и HTTP сервисах)

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

Самая простая модель: основной поток принимает соединения и передает обработку "рабочим" потокам.
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 год).

Редко когда пишем на "сыром" NIO - используем готовые компоненты.

java.io - потоковые чтение/запись

Java.nio - блочные чтение/запись,
неблокирующий ввод-вывод
(+ работа с файловой системой)

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

(ByteBuffer - кошмар разработчика)

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

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

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

в JVM обычно не получается работать в одном потоке.

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

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

"Работу" делаем вне event loop.
Запускаем асинхронный процесс.

Ввод-вывод делаем в event loop
(i/o не потокобезопасный)

Вызов такой функции делается просто:
selectionKeys.foreach { sk ⇒
  sk.interestOps(0) // не ждем новых событий
  sk.attachment().asInstanceOf[Runnable].run()
}
но как "вернуть" сокет обратно в event loop?
Создаем специальный 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 executor: Executor = { r ⇒
  queue.add(r)
  selector.wakeup()
}

используем этот Executor для
возвращения в event loop

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

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

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

Даже для сервлетов
(blocking over non-blocking)

Event-loop в HTTP-клиенте,
драйверах СУБД (кроме JDBC).

Число параллельных запросов не регулируется размером thread pool!

HTTP протокол

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

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

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

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

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

спасибо gmail за изобретение такой схемы работы

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

Клиенты и серверы есть для всех языков программирования.

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

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

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

На практике не видел 2.0/3.0 между сервисами.

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

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

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

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

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

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

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

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

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

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


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

"Метод" - действие над ресурсом

Путь - "адрес" ресурса на сервере

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

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

У методов есть своя семантика по кешированию, безопасности и идемпотентности.

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

  • Возможности и предпочтения клиента
  • Наличие и состав тела запроса
  • Наличие данных в кеше
  • 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
					

переносы строк запрещены (в отличии от почты)

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

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

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

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)

Конец первой части

Вторая часть - Akka HTTP.