Примечание редактора: следующее сообщение в блоге является частью серии размышлений и наблюдений, сделанных отдельными членами команды GreyMatter.io во время проведения исследований и разработок в поддержку сообщества с открытым исходным кодом Envoy Proxy. Этот пост также был опубликован по адресу https://dzone.com/articles/ought-on-server-sent-events-http2-and-envoy-1.

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

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

  • Опрос сервисов на предмет обновлений каждые 5 секунд
  • Не требует двунаправленной связи
  • Не требует поддержки устаревших браузеров.
  • Обменивается данными со службами за прокси на базе Envoy.

Я рассмотрел два решения для push-уведомлений сервера - WebSockets и Server-Sent Events (SSE). В конце концов, я понял, что WebSockets - это излишек для нашего варианта использования, и решил сосредоточиться на SSE, главным образом потому, что это просто старый добрый HTTP и позволяет нам воспользоваться некоторыми действительно интересными функциями HTTP / 2.

Краткое руководство по SSE

SSE является частью стандарта HTML5 EventSource и в основном просто предоставляет нам API для управления и анализа событий от длительных HTTP-соединений.

Чтобы реализовать клиент SSE, создайте экземпляр объекта EventSource и начните прослушивать события. Когда этот код загружается в браузер, он открывает TCP-соединение и отправляет HTTP-запрос на сервер, сообщая ему, что он должен отправлять события по строке, когда они у него есть. Мы даже можем прислушиваться к индивидуальным событиям, например, к отцовским шуткам:

const source = new EventSource(`https://localhost:7080/events`, {
 withCredentials: true
});
​
source.addEventListener(“dadJoke”, function(event) {
 console.log(event.data)
});

Если соединение разрывается, EventSource отправит сообщение об ошибке и автоматически попытается восстановить соединение!

Реализация сервера также очень проста. Чтобы сервер стал SSE-сервером, он должен:

  • Установите соответствующие заголовки (text/event-stream)
  • Следите за подключенными клиентами
  • Вести журнал сообщений, чтобы клиенты могли узнать о пропущенных сообщениях (необязательно)
  • Отправляйте сообщения в определенном формате - блок текста, заканчивающийся парой символов новой строки:
id: 150
event: dadJoke
retry: 10000
data: They’re making a movie about clocks. It’s about time.

Вот и все! Существует множество серверных пакетов SSE, чтобы упростить задачу, или вы можете накатать свои собственные.

Проблемы с SSE

Вы прочитаете несколько общих недостатков SSE:

  • Нет встроенной поддержки двоичных типов
  • Одностороннее общение
  • Нет поддержки IE
  • Невозможно добавить заголовки с помощью объекта EventSource
  • Максимум 6 клиентских подключений с одного хоста

Первые 4 пункта не были для нас большой проблемой. Нам не нужно отправлять в браузер ничего, кроме JSON, клиенту не нужно отправлять данные на сервер, для IE есть полифиллы, и мы можем прикреплять любые заголовки или данные, специфичные для маршрута, с помощью нашего прокси.

Последнее - большое дело. Одно из самых больших предполагаемых ограничений SSE - максимальное количество параллельных подключений браузера, которое в большинстве современных браузеров составляет 6 на домен, в соответствии со спецификацией HTTP / 1.1. Я читал, что HTTP / 2 решает эту проблему, поэтому я углубился немного дальше, чтобы понять, как это сделать.

От клиентского запроса к серверу

В HTTP (‹1.0) клиент запрашивает у сервера ресурс, и сервер отвечает. Это соотношение 1: 1.

Когда появился HTTP / 1.1, он представил концепцию конвейерной обработки, при которой несколько запросов HTTP отправляются через одно соединение TCP. Таким образом, клиент может запросить кучу ресурсов с сервера одновременно, но ему нужно будет открыть только одно соединение.

Однако была огромная проблема с реализацией - сервер должен был отвечать в том порядке, в котором он получал запросы от клиента, потому что не было другого способа определить, какой запрос отправляется с каким ответом. Это приводит к так называемой проблеме блокировки заголовка (HOL), когда, если клиент отправляет запросы A, B и C, но запрос A требует много ресурсов сервера, B и C блокируются до тех пор, пока A не закончит. .

Вместе с ним идет HTTP / 2, который был разработан для решения проблем с производительностью HTTP / 1.x. Он дает нам сжатые заголовки HTTP, приоритезацию запросов и, что наиболее важно, позволяет мультиплексировать несколько запросов через одно соединение TCP. Подождите, разве у нас еще не было последнего с HTTP / 1.1? Да и нет; из-за таких проблем, как проблема HOL, в браузерах отсутствовала надежная поддержка конвейерной обработки, поэтому сообщество веб-разработчиков выбрало вместо этого собственные решения для повторного использования соединений - сегментирование домена, конкатенацию, спрайтинг и встраивание ресурсов.

HTTP / 2 - реальное решение для мультиплексирования, поскольку работает базовый «слой кадрирования». Вместо данных открытого текста HTTP / 1.x сообщения HTTP / 2 разбиваются на более мелкие части, такие как заголовки и тела запроса / ответа, а затем кодируются. Эти небольшие закодированные фрагменты называются «кадрами» и включают идентификатор, чтобы их можно было связать с конкретным сообщением. Это означает, что их не нужно отправлять сразу - кадры, принадлежащие разным сообщениям, можно чередовать. Когда эти кадры достигают места назначения, они могут быть повторно собраны и декодированы в стандартное сообщение HTTP серверами, совместимыми с HTTP / 2.

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

Здесь есть еще одно значение для SSE: поскольку поток SSE - это просто длительный HTTP-запрос, мы можем иметь столько отдельных потоков SSE, сколько захотим, через одно и то же соединение. Мы можем отправлять сообщения с сервера ‹-› клиент И клиент ‹-› сервер.

SSE и посланник

Пока что у нас есть отличная настройка - HTTP / 2 обеспечивает эффективный уровень передачи данных, в то время как SSE предоставляет нам собственный веб-API и формат обмена сообщениями для клиента.

Теперь мне было любопытно, смогу ли я заставить это работать в распределенной системе, где сервер (ы) и клиент развернуты в отдельных контейнерах за шлюзом или «пограничным» прокси. Этот прокси обрабатывает все входящие запросы и направляет их в соответствующее место, что важно, потому что он позволяет нам размещать все под одним и тем же именем хоста («example.com» на диаграмме ниже). Таким образом, мы можем использовать мультиплексное соединение. Вот настройка:

Вы можете видеть, что у нас есть два потока событий, мультиплексированных по одному соединению, и мы также получаем статические ресурсы (index.html) по этому соединению!

Я воспроизвел эту настройку в небольшой докер-компоновке - два сервера SSE, отправляющих отцовские шутки каждые 10 секунд, и клиент, который отображает события в браузере. Попробуйте сами, клонировав репо здесь, а затем перейдя на https: // localhost: 8080 с открытыми инструментами разработчика.

git clone https://github.com/kaitmore/simple-sse
cd simple-sse
docker-compose up -d

На вкладке «Сеть» вы должны увидеть запросы к двум нашим серверам событий с использованием протокола h2, а также ответы, отображаемые на странице (возможно, вам придется немного подождать):

Вам может быть интересно, откуда вы знаете, что запросы / ответы мультиплексируются? Я вижу там два потока!

Просмотрите столбец «Идентификатор подключения» и обратите внимание, что все они одинаковы: 260121. Элементы, перечисленные на вкладке «Сеть», являются запросами, а не TCP-соединениями. Если бы мы запускали эту же docker-compose с отключенным HTTP / 2, вы бы увидели, что каждый запрос имеет другой идентификатор соединения *:

* Ну… вроде как. localhost (index.html) и первый поток событий фактически имеют один и тот же идентификатор соединения, потому что после возврата index.html это соединение освобождается для повторного использования в следующем запросе.

Конфигурация посланника

Чтобы настроить Envoy для работы с SSE, пришлось немного поэкспериментировать. Окончательную конфигурацию вы можете увидеть здесь.

Включение HTTP / 2

Первое, что мне нужно было сделать, это включить HTTP / 2. Согласно Документам Посланника, alpn_protocolsfield необходимо установить везде, где есть tls_context *, чтобы оно могло принимать соединения HTTP / 2. Я также обнаружил, что необходимо установить http2_protocol_options для каждого кластера, которому требуется HTTP / 2, хотя я не указывал никаких параметров.

* HTTP / 2 требует TLS

Настройка тайм-аутов

Как только я включил http / 2, я быстро заметил, что происходит что-то странное. Новый поток событий создавался каждые 10–15 секунд. Водопад показывает, что соединение действительно разорвано, поэтому браузер пытается восстановить соединение:

После некоторого поиска в Google я наткнулся на эту маленькую жемчужину в часто задаваемых вопросах по документации Envoy:

Этот тайм-аут [уровня маршрута] по умолчанию равен 15 секундам, однако он несовместим с потоковой передачей ответов (ответы, которые никогда не заканчиваются), и его необходимо отключить. Таймауты простоя потока должны использоваться в случае потоковых API, как описано в другом месте на этой странице.

Обновление всех определений маршрутов для отключения тайм-аутов решило проблему:

routes:
 — match:
     prefix: “/”
   route:
     cluster: client
     timeout: 0s # Disable the 15s default timeout

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

Подведение итогов

Отправленные сервером события дают нам надежную альтернативу опросу со встроенным веб-API, автоматическим повторным подключением, настраиваемыми событиями и поддержкой HTTP / 2. Соединение SSE с Envoy в качестве шлюза позволяет нам воспользоваться преимуществами этой поддержки HTTP / 2, проксируя разные потоковые серверы под одним именем хоста, что снижает сетевой беспорядок и ускоряет наш пользовательский интерфейс.

Сноска: HTTP / 2 Server push против SSE

Когда я впервые узнал о HTTP / 2 и SSE, я продолжал читать о «HTTP / 2 server push» и не совсем понимал, как эти вещи связаны между собой. Они одинаковы? Являются ли они конкурирующими технологиями? Можно ли их использовать вместе?

Оказывается, они разные. Серверная отправка - это способ отправить активы клиенту до того, как он попросит. Типичным примером может служить интерфейс, который запрашивает index.html. Сервер отправляет этот запрошенный файл обратно, но вместо того, чтобы браузер анализировал и запрашивал другие ресурсы в index.html, сервер уже знает, чего хочет клиент, и «выталкивает» остальные статические ресурсы, такие как style.css и bundle.js. Вместо 3 вызовов для сервер для каждого из этих ресурсов, это делается через одно HTTP-соединение. Push-сервер использует преимущества той же базовой технологии, но вариант использования немного отличается.