Вам посылка, или Как мы доставляем сообщения с сервера на клиент в реальном времени
- среда, 2 февраля 2022 г. в 00:43:41
Меня зовут Алексей Комаров, я — старший frontend-разработчик в SuperJob. Хочу поделиться опытом реализации механизма обновления данных в реальном времени у нас на сайте. Под катом — подробности о выборе подхода, о проблемах, с которыми мы столкнулись при разработке, о наших кейсах оптимизации клиентской стороны и, конечно, немного кода и наглядных схем.
Все началось с того, что перед нами встала задача реализовать в браузере чат; обмен сообщениями должен происходить в реальном времени.
Для знакомых с web людей не является секретом, что протокол HTTP предполагает клиент-серверную архитектуру, в которой клиент всегда инициирует передачу данных, а сервер только отвечает на запросы клиентов. В режиме реального времени необходимо, чтобы сервер инициировал отправку данных.
Давайте рассмотрим, какие есть решения этой проблемы.
Polling
Клиент по таймеру опрашивает сервер: «А не появилось ли чего-нибудь новенького?» Это самый старый и прямолинейный способ организации real-time. Минусов у этого подхода больше, чем плюсов: нагрузка на сеть и сервер; данные приходят не в реальном времени, а с задержкой между наступлением события и отправкой данных.
Long Polling
Клиент открывает соединение, а сервер держит его до наступления события, потом отправляет данные, после чего клиент переоткрывает соединение. Это уже настоящий real-time — нагрузка на сеть и сервер снижается. Но остается необходимость самостоятельно организовывать непрерывное соединение, следить за его обрывами и тем, чтобы передаваемые данные не потерялись в этот момент.
Server Sent Events (SSE)
Поддерживаемая браузерами технология непрерывного HTTP-соединения, в котором данные передаются потоком от сервера к клиенту.
WebSocket
Независимый протокол поверх TCP. Это самое современное и популярное решение задачи организации передачи данных между клиентом и сервером в реальном времени.
Polling и Long Polling мы не рассматривали, потому что это устаревшие и не оптимальные подходы. Поэтому выбор был между SSE и WebSocket.
Давайте кратко пройдемся по плюсам и минусам обеих технологий.
Плюсы SSE:
SSE использует HTTP, поэтому на сервере не нужна поддержка дополнительных протоколов и нет проблем с сетевыми экранами.
Простой браузерный API и широкая поддержка браузерами.
Встроенный механизм переподключения при обрыве соединения и защита от потери данных.
Минусы SSE:
Однонаправленная передача данных от сервера к клиенту; передавать можно только текст.
SSE позволяет открыть не более шести соединений для одного домена в браузере.
Плюсы WebSocket:
Поскольку WebSocket — это независимый протокол, то с его помощью можно передавать бинарные данные и организовать двунаправленную передачу данных.
Количество соединений в браузере не ограничено.
Минусы WebSocket:
WebSocket является отдельным протоколом, поэтому необходима его поддержка на сервере и возможны проблемы с сетевыми экранами.
Необходима самостоятельная реализация протокола на стороне клиента, хотя существуют библиотеки, решающие эту задачу.
В целом можно сказать, что SSE является более простой для реализации и понимания технологией. WebSocket — более сложный, но при этом гибкий подход.
Так как WebSocket является более современной и популярной технологией, которая позволяет более гибко решать задачи, вначале мы начали прорабатывать решение с ее использованием. Однако вскоре вскрылось одно обстоятельство, заставившее нас пересмотреть решение: если использовать WebSocket, то необходимо дублировать авторизацию или придумывать «костыли» для использования осуществленной по HTTP авторизации. В SSE такой проблемы нет, потому что он реализован на основе HTTP, и при его использовании все заголовки авторизации проставляются без дополнительных телодвижений.
Авторизация на нашем сайте является довольно сложным механизмом, и делать его еще сложнее нам совсем не хотелось, поэтому в итоге мы остановились на 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');
};
Это все, что нужно сделать на сервере, чтобы заработала передача данных.
Таким образом у нас сформировалось архитектурное решение передачи данных с сервера в реальном времени:
Серверная часть SSE реализуется на Node.js-сервере.
В качестве транспорта между PHP-сервером и Node.js используется Redis.
Для каждого авторизованного пользователя создается Redis-канал.
SSE используется для нотификации о новых данных, а не для передачи данных.
Давайте рассмотрим схему, которая иллюстрирует работу сервера с клиентом.
На клиенте есть браузерное 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 бэкенда.
Собственно, так мы организовали доставку сообщений с сервера на клиент в реальном времени. Реализация системы оказалась достаточно простой, так как мы выбрали подходящую технологию и использовали уже имеющиеся механизмы приложения.
Итак, наш чат работает, и на этом можно было бы остановиться, но нам захотелось оптимизаций (шутка!) — в них была потребность.
Для оптимальной работы системы необходимо:
Уменьшение объема данных, передаваемых по SSE-соединению, для повышения скорости оповещения клиентов о новых данных.
Уменьшение количества открытых SSE-каналов.
С уменьшением передаваемых данных все понятно, а уменьшение количества SSE-соединений необходимо, во-первых, чтобы уменьшить нагрузку на сервер, а во-вторых, из-за лимита в шесть подключений, накладываемого браузерами. Для нас эта оптимизация была особенно важна, так как наши клиенты любят открывать множество вкладок, когда просматривают вакансии, а каждая вкладка — это SSE-соединение.
Уменьшение передаваемых через SSE-соединение данных мы реализовали, решив пересылать по SSE только нотификации, а данные получать по обычному API backend.
Для уменьшения количества соединений по SSE мы использовали два приема.
Первый — режим реального времени доступен только для авторизованных пользователей. На самом деле, это изначально закладывалось в архитектуру, так как имена каналов Redis содержат данные авторизованных пользователей. Все гости сайта живут без режима реального времени.
Второй способ уменьшить количество SSE соединений — для каждого браузера создавать только одно соединение (а не для каждой вкладки сайта). Рассмотренная выше система так не умеет, поэтому нам необходимы некоторые доработки.
Итак, чтобы одна вкладка открывала SSE-соединение, а остальные получали сообщения от нее, нам нужны: среда обмена сообщениями, обеспечение контроля работоспособности вкладки с SSE-соединением и механизм выбора вкладки, открывающей соединение. Рассмотрим подробнее, как реализовать каждое требование.
В качестве транспорта мы используем локальное хранилище браузера (LocalStorage). Оно позволяет записывать значения с некоторым ключом и подписываться на изменения. Вкладка-отправитель записывает сообщение с помощью функции setItem. Другие вкладки получают сообщение, подписавшись на браузерное событие «storage». Таким образом, мы можем общаться между вкладками.
Контроль за работоспособностью активной вкладки осуществляется с помощью двух взаимосвязанных механизмов:
Heartbeat — периодический сигнал контролируемой системы, который оповещает о ее работоспособности. Если этот сигнал не приходит, то все заинтересованные части системы понимают, что произошла исключительная ситуация и надо ее обработать.
const heartbeatIntervalId = setInterval(() => {
setSseId(genUuid()) ;
}, SSE_CONNECTION_HEARTBEAT_INTERVAL) ;
Сторожевой таймер — схема контроля за зависанием, состоящая из таймера, который периодически сбрасывается контролируемой системой.
const setConnectionWatcher = () => {
if (sseConnectionWatchTimer) {
clearTimeout (sseConnectionWatchTimer);
}
sseConnectionWatchTimer = setTimeout(() => {
openConnection();
}, SSE_CONNECTION_WATCH_INTERVAL);
};
Для определения того, какая вкладка будет открывать соединение, используется лотерея. В каждой вкладке задается случайная задержка установки соединения. Вкладка с наименьшей задержкой выигрывает.
const delayBase = SSE_CONNECTION_WATCH_INTERVAL;
const delayRandom = Math.random() * 1000;
sseConnectionWatchTimer = setTimeout(() => {
openConnection();
}, delayBase + delayRandom);
Теперь рассмотрим полную схему работы приложения на клиенте. Схему можно разделить на две части: синхронизация вкладок и обработка SSE-сообщения от сервера.
Синхронизацию вкладок можно описать с помощью трех возможных сценариев:
Пользователь открывает браузер, и запускается сразу несколько вкладок. Для каждой вкладки запускается сторожевой таймер и срабатывает лотерея. Вкладка, которая выиграла лотерею, вызывает менеджер соединений, а он, в свою очередь, открывает SSE-соединение и запускает HeartBeat. В остальных вкладках сторожевой таймер начинает регулярно сбрасываться по сигналу HeartBeat, который приходит из local storage. Система приходит в согласованное состояние.
Пользователь открывает новую вкладку при уже установленном SSE-соединении. В этом случае для открытой вкладки запускается сторожевой таймер, но он начинает сбрасываться уже работающим сигналом HeartBeat. Таким образом, вкладка становится синхронной со всеми остальными.
Пользователь закрывает вкладку с SSE-соединением. В этом случае менеджер соединений закрывает SSE-соединение и останавливает HeartBeat. Срабатывают сторожевые таймеры остальных вкладок, и снова запускается лотерея.
Схема обработки SSE-сообщений похожа на уже рассмотренную ранее схему, за исключением того, что вкладка с установленным SSE-соединением записывает полученное сообщение в local storage, а остальные вкладки, получив его в обработчике события новых данных в хранилище, вызывают свой диспетчер сообщений.
С такой архитектурой достаточно иметь одно SSE-соединение, и все вкладки будут получать сообщения от сервера в реальном времени.
Несмотря на то что некоторые считают SSE устаревшей технологией, это далеко не так, и во многих случаях она является оптимальным решением. Это решение позволяет минимизировать трудозатраты и не увеличивать сложность системы. К тому же ряд оптимизаций позволил добиться стабильной работы системы и снизить нагрузку на сеть и сервер.
В качестве бонуса мы получили возможность обновлять в реальном времени любые данные на сайте, так как механизм оповещения сервером клиентов в реальном времени был реализован независимо от чата.
Надеюсь, наш опыт окажется для вас полезным.