Вам посылка, или Как мы доставляем сообщения с сервера на клиент в реальном времени

image-loader.svg

Меня зовут Алексей Комаров, я старший frontend-разработчик в SuperJob. Хочу поделиться опытом реализации механизма обновления данных в реальном времени у нас на сайте. Под катом — подробности о выборе подхода, о проблемах, с которыми мы столкнулись при разработке, о наших кейсах оптимизации 
клиентской стороны и, конечно, немного кода и наглядных схем.

Выбор технологии для real-time

Все началось с того, что перед нами встала задача реализовать в браузере чат; обмен сообщениями должен происходить в реальном времени.

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

Давайте рассмотрим, какие есть решения этой проблемы.

Способы организации real time в web

Клиент по таймеру опрашивает сервер: «А не появилось ли чего-нибудь новенького?» Это самый старый и прямолинейный способ организации real-time. Минусов у этого подхода больше, чем плюсов: нагрузка на сеть и сервер; данные приходят не в реальном времени, а с задержкой между наступлением события и отправкой данных.

  • Long Polling

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

  • Server Sent Events (SSE)

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

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

Polling и Long Polling мы не рассматривали, потому что это устаревшие и не оптимальные подходы. Поэтому выбор был между SSE и WebSocket.

SSE vs WebSocket

Давайте кратко пройдемся по плюсам и минусам обеих технологий. 

516ee31a002e5c93d8b0daa780937dfe.jpeg

Плюсы SSE:

  1. SSE использует HTTP, поэтому на сервере не нужна поддержка дополнительных протоколов и нет проблем с сетевыми экранами.

  2. Простой браузерный API и широкая поддержка браузерами.

  3. Встроенный механизм переподключения при обрыве соединения и защита от потери данных.

Минусы SSE:

  1. Однонаправленная передача данных от сервера к клиенту; передавать можно только текст.

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

Плюсы WebSocket:

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

  2. Количество соединений в браузере не ограничено.

Минусы WebSocket:

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

  2. Необходима самостоятельная реализация протокола на стороне клиента, хотя существуют библиотеки, решающие эту задачу.

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

Так как WebSocket является более современной и популярной технологией, которая позволяет более гибко решать задачи, вначале мы начали прорабатывать решение с ее использованием. Однако вскоре вскрылось одно обстоятельство, заставившее нас пересмотреть решение: если использовать WebSocket, то необходимо дублировать авторизацию или придумывать «костыли» для использования осуществленной по HTTP авторизации. В SSE такой проблемы нет, потому что он реализован на основе HTTP, и при его использовании все заголовки авторизации проставляются без дополнительных телодвижений.

Авторизация на нашем сайте является довольно сложным механизмом, и делать его еще сложнее нам совсем не хотелось, поэтому в итоге мы остановились на SSE.

Реализация real-time с помощью SSE

Рассмотрим, как можно реализовать соединение SSE в браузере и на сервере. На клиенте нам нужно создать объект браузерного API для SSE и подписаться на событие сообщения onmessage. Данные от сервера приходят в параметре коллбека в виде строки.

eventSource = new EventSource('/SSE/', options);

eventSource.onmessage = ({ data }) => {

  if (typeof data !== 'string') {

    log.warn('Unsupported sse data type (should be string)’, { data, dataType: typeof data });

    return;

  }

  try {

    const {type, data: message} = JSON.parse(data);

    ///…

  }

});

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

Мы используем фреймворк Express на Node.js-сервере, поэтому создаем новый роут для клиентского SSE-запроса и в поток ответа отправляем два переноса строки в начале соединения, свидетельствующие о начале передачи данных, а потом и сами данные, каждая порция которых отделяется переносом строки.

export const sendSSEEvent = (res, data) => {

  if (!data) {

    res.write('\n\n');

    return;

  }

  res.write('data: ${JSON.stringify(data)}\n');

  res.write('\n');

};

Это все, что нужно сделать на сервере, чтобы заработала передача данных. 

Клиент-серверная архитектура

Таким образом у нас сформировалось архитектурное решение передачи данных с сервера в реальном времени:

  1. Серверная часть SSE реализуется на Node.js-сервере. 

  2. В качестве транспорта между PHP-сервером и Node.js используется Redis. 

  3. Для каждого авторизованного пользователя создается Redis-канал.

  4. SSE используется для нотификации о новых данных, а не для передачи данных.

Давайте рассмотрим схему, которая иллюстрирует работу сервера с клиентом. 

image-loader.svg

На клиенте есть браузерное API для SSE и диспетчер сообщений, реализованный в функции processServerMessage. На сервере — express route, библиотека для работы с Redis и пул SSE-каналов для каждого пользователя и его устройства.

Работу схемы можно представить как два потока. Вначале SSE API инициирует соединение, которое обрабатывается на сервере, и создается канал для прослушивания сообщений Redis и канал в пуле SSE-каналов, если они еще не были созданы.

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

Пул каналов — это хэш-таблица, ключами которой являются тип пользователя и ID пользователя, а значение — это массив из идентификаторов клиентских устройств и открытых соединений express.

После того как закончилась инициализация, возможна передача данных. Backend записывает в Redis нотификацию о том, что произошло некое событие и, возможно, вспомогательные данные (например, ID чата, в который пришло сообщение). Redis передает это в канал, который связан с Node.js-сервером. По имени канала серверная часть нашей системы определяет, какому пользователю предназначено сообщение, и находит в пуле каналов соответствующие открытые соединения для всех устройств, которые в этот момент соединены с сервером. Далее сообщение передается в каждое открытое соединение на клиентские устройства. На клиентском устройстве API SSE получает данные и вызывает диспетчер сообщений, который в зависимости от типа сообщения и вспомогательных данных вызывает API бэкенда для получения данных по HTTP.

Таким образом, мы не используем SSE-соединение для передачи данных — только для нотификации о том, что они появились на сервере. Дальше запрос данных происходит по обычному API бэкенда.

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

От хорошего к лучшему: оптимизация системы 

Итак, наш чат работает, и на этом можно было бы остановиться, но нам захотелось оптимизаций (шутка!) — в них была потребность.

image-loader.svg

Для оптимальной работы системы необходимо:

  1. Уменьшение объема данных, передаваемых по SSE-соединению, для повышения скорости оповещения клиентов о новых данных. 

  2. Уменьшение количества открытых SSE-каналов.

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

Уменьшение передаваемых через SSE-соединение данных мы реализовали, решив пересылать по SSE только нотификации, а данные получать по обычному API backend.

Для уменьшения количества соединений по SSE мы использовали два приема.

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

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

Один браузер — одно SSE-соединение

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

Среда обмена сообщениями

В качестве транспорта мы используем локальное хранилище браузера (LocalStorage). Оно позволяет записывать значения с некоторым ключом и подписываться на изменения. Вкладка-отправитель записывает сообщение с помощью функции setItem. Другие вкладки получают сообщение, подписавшись на браузерное событие «storage». Таким образом, мы можем общаться между вкладками.

image-loader.svg

Контроль работоспособности

Контроль за работоспособностью активной вкладки осуществляется с помощью двух взаимосвязанных механизмов:

  1. Heartbeat — периодический сигнал контролируемой системы, который оповещает о ее работоспособности. Если этот сигнал не приходит, то все заинтересованные части системы понимают, что произошла исключительная ситуация и надо ее обработать.

const heartbeatIntervalId = setInterval(() => {
  setSseId(genUuid()) ;
}, SSE_CONNECTION_HEARTBEAT_INTERVAL) ;
  1. Сторожевой таймер — схема контроля за зависанием, состоящая из таймера, который периодически сбрасывается контролируемой системой.

const setConnectionWatcher = () => {
  if (sseConnectionWatchTimer) {
    clearTimeout (sseConnectionWatchTimer);
  }

  sseConnectionWatchTimer = setTimeout(() => {
    openConnection();
  }, SSE_CONNECTION_WATCH_INTERVAL);
};

Механизм выбора активной вкладки

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

53b0381fa0786937e0513e618497a1ab.jpeg

const delayBase = SSE_CONNECTION_WATCH_INTERVAL;

const delayRandom = Math.random() * 1000;

sseConnectionWatchTimer = setTimeout(() => {

 openConnection();

}, delayBase + delayRandom);

Клиентская архитектура

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

5084855b200dac651b78ec5015140fa7.jpeg

Синхронизацию вкладок можно описать с помощью трех возможных сценариев:

  1. Пользователь открывает браузер, и запускается сразу несколько вкладок. Для каждой вкладки запускается сторожевой таймер и срабатывает лотерея. Вкладка, которая выиграла лотерею, вызывает менеджер соединений, а он, в свою очередь, открывает SSE-соединение и запускает HeartBeat. В остальных вкладках сторожевой таймер начинает регулярно сбрасываться по сигналу HeartBeat, который приходит из local storage. Система приходит в согласованное состояние.

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

  1. Пользователь закрывает вкладку с SSE-соединением. В этом случае менеджер соединений закрывает SSE-соединение и останавливает HeartBeat. Срабатывают сторожевые таймеры остальных вкладок, и снова запускается лотерея.

Схема обработки SSE-сообщений похожа на уже рассмотренную ранее схему, за исключением того, что вкладка с установленным SSE-соединением записывает полученное сообщение в local storage, а остальные вкладки, получив его в обработчике события новых данных в хранилище, вызывают свой диспетчер сообщений.

С такой архитектурой достаточно иметь одно SSE-соединение, и все вкладки будут получать сообщения от сервера в реальном времени.

Итоги

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

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

Надеюсь, наш опыт окажется для вас полезным.

© Habrahabr.ru