Howtojs

Real-time в вебе

29 апреля 2021 г. • ☕️ 5 мин.

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

Запрос-ответ

Сценарий запрос-ответ всегда выглядит одинаково.

  • Клиент делает запрос на сервер
  • Сервер подготавливает данные для ответа, в то время, как клиент ожидает ответа (заблокирован)
  • Сервер отправляет ответ на запрос

Real-time

Со временем к веб-приложениям появлялось все больше требований, в том числе возможность обновлять данные в режиме реального времени. Для примера это могли быть:

  • Чат
  • Трейдинговые площадки
  • Игры

Как же можно решить данные задачи в вебе?

Polling (Short-polling)

Polling подразумевает опрос сервера на наличие новых данных с определенным интервалом.

  • Для опроса используется XMLHttpRequest или fetch
  • Клиент посылает запрос каждые n секунд
  • Сервер отвечает как обычно

Этот подход рабочий, хотя его основным недостатоком является чрезмерное расходование ресурсов сервера.

Для каждого запроса нужно

  • Установить TCP соединение (исключение Keep-Alive) и передать заголовки запроса
  • Сделать запрос в базу данных
  • Вернуть данные (чаще всего устаревшие, так как новых еще не было) и закрыть соединение
async function subscribe() {
  // этот запрос выполняется быстро и повторяется каждые 5 секунд
  const response = await fetch("/messages")
  handleResponse(response)
  setTimeout(subscribe, 5000)
}

В HTTP/1.1 появилась поддержка заголовков Keep-Alive, которая позволяет держать TCP соединение открытым, для передачи по нему последующих HTTP запросов.

Это был не настоящий Real-time, так как при появлении новых данных, они приходили не раньше, чем следующий “тик” интервала.

Long-Polling

Long-Polling тоже подразумевает опрос, но с отличиями.

  • Для опроса используется XMLHttpRequest или fetch
  • Клиент посылает запрос сразу же, как только приходит ответ на предыдущий
  • Сервер поддерживает запрос в состоянии ожидания, до тех пор пока не появятся новые данные и только потом отвечает
async function subscribe() {
  // этот запрос может висеть очень долго в ожидании ответа
  const response = await fetch("/messages")
  handleResponse(response)
  subscribe()
}

Это уже настоящий Real-time. Здесь мы не делаем лишних запросов, но не каждый веб-сервер сможет поддерживать большое количество запросов в памяти одновременно.

Для каждого запроса нужно

  • Установить TCP соединение (исключение Keep-Alive) и передать заголовки запроса
  • Поддерживать запрос в ожидании, до тех пор пока не появятся новые данные (либо запрос не разорвется клиентом/таймаутом)
  • Вернуть новые данные и закрыть соединение

Такой подход хорошо работает для серверов, работающих по асинхронной модели (как Node.js), но достаточно сложен для масштабирования (поэтому и мало популярен) на серверах, которые работают по модели процесс/поток на запрос.

Так же перед разработчиками стоит задача по синхронизации подключенных клиентов, при масштабировании сервера более чем на 1 процесс.

WebSockets

Это совершенно новый протокол для обмена данными, предоставляющий full-duplex двустороннюю коммуникацию по верх TCP.

WebSockets позволяет серверу и клиенту делать меньше лишней работы и обмениваться сообщениями в режиме реального времени.

  • Для соединения используется WebSocket
  • Клиент посылает специальный HTTP запрос, который трансформируется в WebSocket
  • Сервер с клиентом поддерживают соединение и могут обмениваться сообщениями
  • Любая из сторон может закрыть соединение
const ws = new WebSocket("ws://socket")

ws.addEventListener("open", function(e) {
  ws.send("Hello")
})

ws.addEventListener("message", function(e) {
  console.log(e.data)
})

ws.addEventListener("close", function(e) {
  // ...
})

ws.addEventListener("error", function(err) {
  // ...
})

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

WebSockets часто блокируется балансировщиками нагрузки и файерволами, что может требовать дополнительной настройки.

Server Sent Events (SSE)

В отличии от WebSockets, SSE позволяет передавать поток событий только в одну сторону (от сервера на клиент) за счет поддержания HTTP соединения.

  • Для соединения используется EventSource
  • Клиент посылает HTTP запрос, который поддерживается с обеих сторон
  • Сервер может посылать события в специальном формате text/event-stream
  • Любая из сторон может закрыть соединение
const sse = new EventSource('/sse')

sse.addEventListener("notice", function(e) {
  console.log(e.data)
})

sse.addEventListener("update", function(e) {
  console.log(e.data)
})

// "message" специальное событие, которое не имеет имени, как например "notice" или "update"
sse.addEventListener("message", function(e) {
  console.log(e.data)
})

Все те же “проблемы”, что и у WebSockets, но плюс ко всему:

  • Есть ограничение на количество открытых соединений тут
  • Хуже поддержка браузерами
  • Нет возможности передачи бинарных данных, как в WebSockets
  • Меньше готовых библиотек, упрощающих работу

Что использовать?

WebSockets является лучшим и универсальным решением, но так же имеет свою цену в сложности первоначальной настройки и поддержке.

SSE я не рекомендую использовать.

Для проектов, где нужно “совсем чуть-чуть” Real-time, подойдет Long-Polling за счет своей простоты.

Short-polling остается выбором для старых проектов, в которые сложно добавить поддержку других технологий.