Использование SSE вместо WebSockets для однонаправленного потока данных через HTTP / 2

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

Когда дело доходит до доставки данных с сервера клиенту, мы ограничены двумя основными подходами: client pull или server push. В качестве простого примера веб-приложения можно привести браузер.

Когда сайт, открытый в браузере запрашивает с сервера данные, это называется client pull. Обратная технология, когда сервер активно перенаправляет обновления на сайт, называется server push.

Существует несколько способов их реализации:

  • Длительный / короткий поллинг (client pull);
  • WebSockets (server push);
  • Server-Sent events (server push).

Бизнес-кейс

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

Виджеты никак не связаны друг с другом. В идеале они все должны быть подписаны на какую-либо конечную точку API и начинать получать данные от нее.

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

Данные, которые получат виджеты, в основном состоят из чисел и обновлений для этих чисел: первоначальный ответ содержит 10 акций со значениями их котировок.

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

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

Начнем с изучения различных вариантов получения данных и посмотрим, что может дать каждый из них.

  • Мы собираемся использовать NGINX для балансировки нагрузки и реализации прокси, чтобы скрыть все конечные точки за одним и тем же доменом.
  • Мы хотим эффективно использовать сетевой трафик и потребление заряда батареи пользовательского устройства.

Возможные варианты реализации

Длительный поллинг

Клиент запрашивает у сервера данные. Сервер не имеет данных и ждет некоторое время перед отправкой ответа:

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

Вызовы AJAX работают по протоколу HTTP. Это означает, что запросы к одному домену должны быть мультиплексированы по умолчанию. Но когда мы попробовали заставить этот механизм работать, то столкнулись с несколькими проблемами:

  • Объем заголовков

Каждый запрос и ответ содержат полный набор HTTP-заголовков, которые составляют значительный объем переданных данных. Фактическая полезная нагрузка намного меньше, чем объем переданных байтов (например, 15 КБ заголовков на 5 КБ полезных данных).

  • Максимальная латентность

После ответа сервер не может отправлять данные клиенту, пока клиент не отправит следующий запрос. Хотя средняя латентность для длинного поллинга приблизительно равна одной сетевой передаче, максимальная латентность превышает три сетевых передачи: ответ, запрос, ответ. Но из-за потери пакетов и повторной передачи максимальная латентность для любого протокола TCP / IP будет состоять более чем из трех сетевых передач (этого можно избежать при конвейерной обработке HTTP). Это становится проблемой, когда пользователь находится в движении и переключается между мобильными вышками.

  • Установление соединения

Этого можно избежать, используя постоянное HTTP-соединение, многократно используемое для многих запросов. Но на практике сложно обеспечить, чтобы все компоненты запрашивали данные в короткое время. В зависимости от ответов сервера, запросы будут рассинхронизированы.

  • Снижение производительности

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

  • Задержки

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

  • Мультиплексирование

Это может случиться, если ответы отправляются одновременно через постоянное HTTP / 2 соединение. Проблему сложно решить, поскольку ответы поллинга не могут быть синхронизированы.

WebSockets

В качестве первого примера метода server push мы рассмотрим WebSockets.

Согласно MDN:

WebSockets — это передовая технология, позволяющая открывать интерактивный сеанс связи между браузером и сервером. С помощью этого API можно отправлять сообщения на сервер и получать ответы на основе событий без необходимости запроса ответов с сервера.

Это протокол связи, обеспечивающий полнодуплексные каналы связи через одно TCP-соединение.

И HTTP, и WebSockets расположены на уровне приложения модели OSI и, зависят от TCP на уровне 4.

  1. Приложение;
  2. Представление;
  3. Сессия;
  4. Транспорт;
  5. Сеть;
  6. Канал передачи данных;
  7. Физическое устройство.

В RFC 6455 говорится, что WebSockets предназначен для работы через HTTP-порты 80 и 443, а также для поддержки HTTP-прокси и посредников. Это делает его совместимым с протоколом HTTP. Для достижения совместимости рукопожатиями WebSockets используется заголовок HTTP Upgrade для перехода от протокола HTTP к протоколу WebSockets.

В Википедии есть отличная статья о WebSockets.

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

Прокси-серверы:

Есть несколько проблем, связанных с WebSockets и прокси:

  • Первая из них связана с провайдерами интернет-услуг и тем, как они реализуют работу своих сетей.
  • Проблема конфигурации прокси-сервера для работы с незащищенным HTTP-трафиком и долговременными подключениями (частично можно решить с помощью HTTPS).
  • Используя WebSockets, вы вынуждены запускать TCP-прокси, а не HTTP-прокси. TCP-прокси не могут вводить заголовки, переписывать URL-адреса, а также выполнять многие задачи, которые мы традиционно возлагаем на HTTP-прокси.

Количество соединений:

Знаменитый лимит подключений для HTTP-запросов, который колеблется в районе 6, не применяется к WebSockets. 50 сокетов = 50 соединений. Десять вкладок браузера на 50 сокетов = 500 соединений и т. д.

Поскольку WebSockets — это другой протокол для доставки данных, он не мультиплексируется автоматически через HTTP / 2 соединения (не работает поверх HTTP). Реализация пользовательского мультиплексирования, как на сервере, так и на стороне клиента слишком сложна для использования. Также это связывает виджеты с нашей платформой, поскольку им потребуется какой-то API в клиенте, чтобы подписаться на него. Поэтому мы не сможем распространять виджеты без API.

Балансировка нагрузки (без мультиплексирования):

Если каждый пользователь открывает n-ное количество сокетов, реализовать корректную балансировку нагрузки очень сложно. Когда серверы перегружены, а вам нужно создавать новые экземпляры и завершать старые, действия, которые выполняются при «повторном подключении», могут вызвать длинные цепочки обновлений и новых запросов. Они будут перегружать систему.

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

DoS:

Обычно это обрабатывается front-end HTTP прокси-серверами, и не может обрабатываться TCP-прокси, которые необходимы для WebSockets. Поэтому злоумышленник может подключиться к сокету и начать атаку на ваш сервер. WebSockets уязвимы для подобных атак.

Нужно заново изобретать велосипед:

При использовании WebSockets необходимо решать множество проблем, с которыми давно и успешно справляется HTTP.

Хорошими вариантами применения WebSockets являются чаты и многопользовательские игры. Их главным преимуществом является дуплексная связь, а нам это не нужно.

Промежуточные итоги

Мы получаем увеличение эксплуатационных затрат с точки зрения разработки, тестирования и масштабирования при использовании поллингов и WebSockets.

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

Но почему еще есть проблемы с мобильными устройствами?

Мобильные устройства имеют антенну, обладающую  малой мощностью, которая может принимать данные с ближайшей вышки. Как только мобильное устройство получает данные о входящем вызове, оно подключает полнодуплексную антенну для установления вызова. Одна и та же антенна используется каждый раз, когда вы хотите позвонить или получить доступ к интернету (если не доступен WiFi).

Полнодуплексная антенна должна установить соединение с сотовой сетью и выполнить аутентификацию. Как только соединение установлено, между мобильным устройством и сотовой вышкой возникает взаимодействие, необходимое, чтобы выполнить сетевой запрос. При этом мы перенаправляемся на внутренний прокси-сервер оператора мобильной связи, который обрабатывает интернет-запросы. С этого момента процедура проста: запрашиваем DNS, где размещается домен www.domainname.ext, получаем URI ресурса и перенаправляемся к нему.

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

Без WiFi, как для WebSockets, так и для поллинга требуется, чтобы полнодуплексная антенна работала почти постоянно. Таким образом, мы сталкиваемся с увеличением объема передаваемых данных и потребления энергии.

SSE

Согласно MDN:

«Интерфейс EventSource используется для приема Server-Sent Events. Он подключается к серверу через HTTP и принимает события в формате text / event-stream без закрытия соединения».

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

Согласно html5doctor.com:

Server-Sent Events представляют собой события в режиме реального времени, исходящие от сервера и получаемые браузером. Они похожи на WebSockets тем, что выполняются в режиме реального времени. Но основной поток данных идет от сервера к клиенту.

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

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

Уникальные функции

  • Поток подключения поступает с сервера и доступен только для чтения.
  • Используются обычные HTTP-запросы для постоянного соединения, а не специальный протокол. Мультиплексирование по протоколу HTTP / 2.
  • Если соединение разрывается, EventSource эмитирует событие ошибки и автоматически пытается восстановить его. Сервер также может управлять задержкой до того момента, когда клиент попытается подключиться.
  • Клиенты могут отправлять вместе с сообщениями уникальный идентификатор. Когда клиент пытается повторно подключиться после сброшенного соединения, он отправляет последний известный идентификатор. Таким образом, сервер может определить, что клиент пропустил n-нное количество сообщений и отправит пропущенные сообщения при повторном подключении.

Реализация образца клиента

Эти события похожи на обычные события JavaScript, которые происходят в браузере. Но при этом можно контролировать имя события и связанные с ним данные.

Рассмотрим простой пример кода для клиентской стороны:

// подписка на события
var source = new EventSource('URL');
// обработка сообщений
source.onmessage = function(event) {
// Делаем что-то с данными:
event.data;
};

Клиентская сторона довольно проста. Клиент подключается к источнику и ждет получения сообщений.

Чтобы серверы могли передавать данные на веб-страницы по протоколу HTTP или с помощью server-push протоколов, спецификация вводит для клиента интерфейс «EventSource». Использование этого API заключается в создании объекта EventSource и регистрации прослушивателя событий.

Реализация клиента для WebSockets выглядит похоже. Сложность с сокетами заключается в ИТ-инфраструктуре и реализации сервера.

EventSource

Каждый объект EventSource содержит следующие компоненты:

  • URL: устанавливается во время сборки.
  • Запрос: изначально имеет значение null.
  • Время повторного соединения: значение в миллисекундах (определяемое пользователем значение).
  • Идентификатор последнего события: сначала пустая строка.
  • Состояние готовности: состояние соединения.

•  ПОДКЛЮЧЕНИЕ (0)

•  ОТКРЫТО (1)

•  ЗАКРЫТО (2)

Помимо URL-адреса все остальные компоненты рассматриваются как закрытые, и к ним нельзя получить доступ извне.

Встроенные события:

  • Open;
  • Message;
  • Error.

Обработка разрывов соединения

Если соединение разрывается, оно автоматически восстанавливается браузером. Сервер может отправить таймаут для повторного завершения или закрытия соединения. В таком случае браузер попытается подключиться после завершения таймаута или не будет ничего делать, если соединение завершено.

Реализация образца сервера

Если клиент такой простой, возможно, сложной окажется реализация сервера? Обработчик сервера для SSE может выглядеть следующим образом:

function handler(response)
{
// настраиваем заголовки для ответа с целью получить постоянное HTTP-соединение
response.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive'
});

// составляем сообщение response.write('id: UniqueIDn'); response.write("data: " + data + 'nn'); // каждый раз, когда мы вводим два символа новой строки, сообщение отправляется автоматически }

Определяем функцию, которая будет обрабатывать ответ:

  1. Устанавливать заголовки;
  2. Создавать сообщение;
  3. Отправлять.

Обратите внимание, что здесь нет вызов метода send() или метода push(). Стандарт определяет: сообщение будет отправлено, как только в него будет добавлено два символа n n, как например: response.write(«data: » + data + ‘nn’);. В результате сообщение будет немедленно отправлено клиенту.

Составление сообщений

Сообщение может содержать несколько свойств:

1.     ID

Если значение этого поля не содержит U + 0000 NULL, устанавливаем для буфера последнего идентификатора события значение поля. Иначе игнорируем поле.

2.     Data

Добавляем значение поля в буфер, затем добавляем в буфер один символ U + 000A LINE FEED (LF).

3.     Event

Устанавливаем для буфера тип события  и значение поля. Это приводит к тому, что для event.type задается пользовательское имя события.

4.     Retry

Если значение поля состоит только из цифр ASCII, тогда интерпретируем значение поля как целое число в десятичной системе исчисления. А также устанавливаем для времени повторного соединения потока событий это целое число. В противном случае игнорируем поле.

Все остальное будет проигнорировано. Мы не можем вводить собственные поля.

Пример с добавленным event:

response.write('id: UniqueIDn');    response.write('event: addn');    response.write('retry: 10000n');    response.write("data: " + data + 'nn');

В клиенте это обрабатывается с помощью addEventListener следующим образом:

source.addEventListener("add", function(event) {        // выполняем действия с данными        event.data;    });

Вы можете отправлять несколько сообщений, разделенных символом новой строки, а также использовав для них разные идентификаторы.

...    id: 54    event: add    data: "[{SOME JSON DATA}]"     id: 55    event: remove    data: JSON.stringify(some_data)     id: 56    event: remove    data: {    data: "msg" : "JSON data"n    data: "field": "value"n    data: "field2": "value2"n    data: }nn

Это значительно упрощает то, что мы можем сделать с нашими данными.

Специальные требования к серверу

В нашем случае лучше всего использовать сервер на основе цикла событий. Например, NodeJS, Kestrel или Twisted. Идея состоит в том, что при использовании потокового решения будет один поток на соединение. То есть, 1000 соединений = 1000 потоков. В решении на основе цикла событий у нас будет один поток для 1000 соединений.

  1. Вы можете принимать запросы EventSource только в том случае, если HTTP-запрос говорит, что он может принимать MIME-тип event-stream;
  2. Необходимо вести список всех подключенных пользователей, чтобы запускать новые события;
  3. Вы должны прослушивать сброшенные соединения и удалять их из списка подключенных пользователей;
  4. Вы должны поддерживать историю сообщений, чтобы при повторном подключении клиентов можно было отправить им пропущенные сообщения.

Мы получили все, чтобы приложение работало эффективно. Но столкнулись с некоторыми проблемами:

  • Устаревшие прокси-серверы в некоторых случаях удаляют HTTP-соединения после короткого таймаута. Чтобы защитить соединения, авторы могут включать строку комментариев (начинающуюся с символа «:») каждые 15 секунд или около того.
  • Авторы, желающие связать соединения источника событий друг с другом или с определенными ранее документами, могут обнаружить, что использование IP-адресов не работает. Отдельные клиенты могут иметь несколько IP-адресов (из-за наличия нескольких прокси-серверов) и отдельные IP-адреса могут иметь несколько клиентов (из-за совместного использования прокси-сервера). Лучше включать в документ уникальный идентификатор и передавать его как часть URL-адреса при установлении соединения.
  • Использование chunked transfer encoding может уменьшить надежность HTTP протокола, если блокирование выполняется другим слоем, не подозревающим о требованиях к синхронизации. Если эта проблема возникнет, блокирование может быть отключено для обслуживания потоков событий.
  • Клиенты, которые поддерживают ограничение на подключение к серверу через протокол HTTP, могут столкнуться с трудностями при открытии нескольких страниц сайта, если на каждой из этих страниц есть источник событий, расположенный в том же домене. Можно избежать этого, применяя механизм уникальных доменных имен для каждого соединения и разрешая пользователям включать функции EventSource для каждой страницы.
  • Поддержка браузера и полифиллы: Microsoft Edge не поддерживает эту реализацию. Но существует полифиллы, которые позволяют решить данную проблему. Тем не менее, самый важный сегмент для SSE — это мобильные устройства, где  браузеры IE / Edge распространены незначительно.

Некоторые из доступных полифиллов:

·        Yaffle.

·        amvtek.

·        remy.

Бесплатное подключение и другие функции

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

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

  1. Браузер подключается к удаленному HTTP-серверу и запрашивает ресурс, указанный автором в конструкторе EventSource.
  2. Сервер отправляет случайные сообщения.
  3. В промежутке между двумя сообщениями браузер обнаруживает, что он неактивен, за исключением активности сети, связанной с поддержанием TCP- соединения, и решает переключиться в спящий режим для экономии энергии.
  4. Браузер отключается от сервера.
  5. Браузер связывается с сервисом в сети и просит, чтобы служба «push proxy» поддерживала соединение.
  6. Служба «push proxy» связывается с удаленным HTTP-сервером и запрашивает ресурс, указанный в конструкторе EventSource (возможно, включая HTTP-заголовок последнего события и т. д.).
  7. Браузер позволяет мобильному устройству перейти в спящий режим.
  8. Сервер отправляет другое сообщение.
  9. Служба «push proxy» использует технологию OMA push для передачи события на мобильное устройство, которое выходит из спящего режима на время, достаточное для обработки события. Затем возвращается в спящий режим.

Подобный подход может снизить объем передаваемых данных и привести к значительной экономии энергии.

Помимо реализации существующего API и формата передаваемых данных ext/event-stream также могут поддерживаться форматы фреймворка событий, определенные другими спецификациями.

Заключение

SSE является решением наших проблем с доставкой данных. Попутно возникли и некоторые сложности, но они оказались простыми для исправления.

Вот как выглядит окончательная рабочая установка:

Обзор окончательной архитектуры. Все конечные точки API обслуживаются NGINX, поэтому клиенты получают мультиплексированный ответ.

NGINX дает нам следующее:

  • Прокси-сервер для конечных точек API в разных местах;
  • HTTP / 2 и все его преимущества, такие как мультиплексирование для соединений;
  • Балансировка нагрузки;
  • SSL.

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

Основные преимущества этого подхода:

  • Эффективность данных;
  • Упрощенная реализация;
  • Соединения автоматически мультиплексируются через HTTP / 2;
  • Механизм экономии заряда аккумулятора пользовательского устройства путем разгрузки соединения с прокси-сервером.

SSE позволяет обойтись без дополнительных затрат ресурсов, в отличие от альтернатив. Что касается серверной реализации, она не сильно отличается от поллинга. Но в клиенте все намного проще, чем при поллинге, поскольку для него требуется первоначальная подписка и назначение обработчиков событий. Это очень похоже на управление WebSockets.

Здесь вы можете найти демо-версию кода простой реализации клиента и сервера.

 

Перевод статьи «Using SSE Instead Of WebSockets For Unidirectional Data Flow Over HTTP/2» был подготовлен дружной командой проекта Сайтостроение от А до Я