При работе с современными веб-приложениями реального времени незаменима возможность отправлять события с сервера на клиент. Именно этой необходимостью продиктовано то, что за годы работы было изобретено несколько методов для этой цели, каждый с собственным набором достоинств и недостатков. Первоначально единственным вариантом был длинный опрос. Затем в качестве альтернативы появились веб-сокеты — более надёжное решение для двунаправленной коммуникации. Вслед за веб-сокетами появились события, отправляемые сервером (SSE), более простой метод, обеспечивающий однонаправленную связь от сервера к клиенту. Забегая вперёд, сейчас разрабатывается ещё и протокол WebTransport, который может тем более изменить ландшафт этой области, обеспечивая более эффективный и гибкий подход, располагающий к масштабированию. В некоторых нишевых случаях можно присмотреться и к технологии WebRTC, предназначенной для работы с событиями в направлении сервер-клиент.
В этой статье мы подробно разберём данные технологии, сравним их производительность, подчеркнём их достоинства и недостатки, а также порекомендуем, что делать в различных практических случаях, расскажем, как принимать информированные решения при создании веб-приложений реального времени. Эта статья — экстракт моего совокупного опыта, приобретённого в ходе реализации протокола репликации RxDB, обеспечивающего совместимость с различными технологиями серверной части.
Что такое длинные опросы
Длинные опросы — это первая в истории «приспособа» для обеспечения обмена сообщениями между клиентом и сервером. Длинные опросы можно использовать в браузерах по протоколу HTTP. Данная технология эмулирует серверную пуш-коммуникацию, организуемую при помощи обычных XHR-запросов. Такие опросы отличаются от традиционных: при традиционных клиент неоднократно (с регулярными интервалами) запрашивает данные с сервера. Напротив, при длинном опросе устанавливается соединение с сервером, остающееся открытым до тех пор, пока не будут доступны новые данные. Как только новая информация на сервере появится, сервер отправит ответ клиенту, и соединение будет закрыто. Клиент сразу же после получения ответа от сервера инициирует новый запрос, и процесс повторится. Такой подход способствует более оперативному обновлению данных и сокращает ненужный сетевой трафик, а также снижает нагрузку на сервер. Тем не менее, при применении такого метода всё равно могут возникать задержки при коммуникации, и этот подход уступает в эффективности другим технологиям для работы в режиме реального времени, например, веб-сокетам.
// длинный опрос в клиенте JavaScript
function longPoll() {
fetch('http://example.com/poll')
.then(response => response.json())
.then(data => {
console.log("Received data:", data);
longPoll(); // сразу же выполнить новый запрос в режиме длинного опроса
})
.catch(error => {
/**
* Ошибки могут возникать и в обычных условиях, когда
* достигнута максимальная задержка при соединении или когда клиент оказывается оффлайн.
* При ошибках мы просто перезапускаем запрос после некоторой задержки.
*/
setTimeout(longPoll, 10000);
});
}
longPoll(); // Инициировать длинный опрос
Не составляет труда реализовать длинный опрос на стороне клиента, именно это и показано в вышеприведённом коде. Правда, на стороне сервера могут возникать многочисленные сложности, если необходимо обеспечить, что клиент получит все события и не пропустит никаких обновлений, которые могли поступить во время переподключения.
Что такое веб-сокеты
Технология WebSockets обеспечивает полнодуплексный канал связи, устанавливаемый между клиентом и сервером через одно длительное соединение. Такая технология позволяет серверу и браузеру обмениваться данными без издержек, затрачиваемых на циклы «запрос-отклик» при работе по протоколу HTTP. Такая технология способствует переносу данных в режиме реального времени в таких приложениях как чат-мессенджеры, игры или платформы для финансового трейдинга. Технология WebSockets значительно более совершенна, чем традиционный HTTP. Ведь при её использовании, как только соединение установлено, обе стороны могут независимо отправлять данные. Поэтому она идеально подходит для сценариев, в которых предполагаются частые обновления и как можно более низкая задержка.
// WebSocket на клиенте JavaScript
const socket = new WebSocket('ws://example.com');
socket.onopen = function(event) {
console.log('Connection established');
// Отправка сообщени на сервер
socket.send('Hello Server!');
};
socket.onmessage = function(event) {
console.log('Message from server:', event.data);
};
Притом, что на базовом уровне использовать WebSocket API не составляет труда, в продакшене эта технология зарекомендовала себя как достаточно сложная. Сокет может потерять соединение, и в таком случае его придётся соответствующим образом воссоздавать. Особенно нетривиально бывает определить, работает ли соединение до сих пор или уже нет. Как правило, можно добавить в программу обмен heartbeat-сообщениями по принципу пинг-понга, чтобы гарантировать таким образом, что уже открытое соединение не закроется. Именно из-за таких сложностей принято использовать поверх WebSockets библиотеку, например Socket.IO, которая обрабатывала бы все эти случаи и даже при необходимости предоставляла возможность отката к использованию длинных опросов.
Что такое события, отправляемые сервером (SSE)
События, отправляемые сервером (SSE) — это стандартный способ переброски серверных обновлений на клиент по протоколу HTTP. В отличие от WebSockets, SSE предназначены исключительно для однонаправленной коммуникации от сервера к клиенту, поэтому идеально подходят для таких сценариев, как динамически обновляемые ленты новостей, ведение спортивного счёта или вообще для любых ситуаций, где клиент необходимо обновлять в режиме реального времени, не отправляя данных на сервер.
События, отправляемые сервером, можно трактовать как единственный HTTP-запрос, и при этом серверная часть не посылает всего тела запроса сразу. Вместо этого держим соединение открытым и по капле просачиваем отклик, отправляя по одной строке всякий раз, когда на клиент требуется отправить событие.
Создать соединение для получения событий по механизму SSE не составляет труда. На стороне клиента, в браузере, мы инициализируем экземпляр EventSource с URL скрипта, работающего на стороне сервера и генерирующего события.
При слушании сообщений требуется прикреплять обработчики событий непосредственно к экземпляру EventSource. Этот API различает обобщённые события-сообщения и именованные события, благодаря чему коммуникацию удаётся лучше структурировать. Вот как можно обустроить этот механизм на JavaScript:
// Подключение к потоку событий, генерируемых на стороне сервера
const evtSource = new EventSource("https://example.com/events");
// Обработка обобщённых событий-сообщений
evtSource.onmessage = event => {
console.log('got message: ' + event.data);
};
Источник событий (EventSource), в отличие от WebSockets, автоматически переподключается в случае потери соединения.
На стороне сервера ваш скрипт должен установить заголовок Content-Type
в значение text/event-stream
и отформатировать каждое из сообщений в соответствии со спецификацией SSE. В частности, нужно указывать типы событий, какие данные содержатся в полезной нагрузке, а также заполнить опциональные поля, например ID события и тайминг повторной попытки.
Вот как можно настроить простую конечную точку для SSE в приложении Node.js Express:
import express from 'express';
const app = express();
const PORT = process.env.PORT || 3000;
app.get('/events', (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
});
const sendEvent = (data) => {
// перед всеми строками сообщений должен стоять префикс 'data: '
const formattedData = `data: ${JSON.stringify(data)}\n\n`;
res.write(formattedData);
};
// Отправлять события каждые 2 секунды
const intervalId = setInterval(() => {
const message = {
time: new Date().toTimeString(),
message: 'Hello from the server!',
};
sendEvent(message);
}, 2000);
// Произвести очистку после того, как соединение закрыто
req.on('close', () => {
clearInterval(intervalId);
res.end();
});
});
app.listen(PORT, () => console.log(`Server running on http://localhost:${PORT}`));
Что такое WebTransport API
WebTransport — это ультрасовременный API, спроектированный для эффективной коммуникации (с минимальными значениями) между веб-клиентами и серверами. Здесь используется протокол HTTP/3 QUIC, в котором предусмотрены различные варианты передачи данных, например, отправлять данные сразу несколькими потоками, как надёжными, так и ненадёжными методами, и даже разрешать неупорядоченную пересылку данных. Именно поэтому инструмент WebTransport отлично подходит для программирования приложений, требующих высокопроизводительных сетевых взаимодействий. В частности, речь об играх в режиме реального времени, стриминге и платформах для совместной работы. Но важно отметить, что в настоящее время WebTransport пребывает в состоянии рабочего проекта (working draft) и пока не пользуется широкой популярностью в сообществе. По состоянию на март 2024 года WebTransport не поддерживается в браузере Safari, а также не имеет нативной поддержки в Node.js. Поэтому его не слишком удобно использовать при работе одновременно на разных платформах и в разных окружениях.
Даже при условии, что WebTransport будет пользоваться широкой поддержкой, этот API всё равно слишком сложен. Скорее всего, поверх WebTransport станут возводить библиотеки, а не использовать его непосредственно в исходном коде приложения.
Что такое WebRTC
WebRTC (Веб-коммуникация в режиме реального времени) — это опенсорсный проект и стандарт API, обеспечивающий возможности коммуникации в режиме реального времени (RTC) прямо внутри веб-браузеров и мобильных приложений, не требующий обустраивать сложную серверную инфраструктуру или устанавливать дополнительные плагины. Он поддерживает пиринговые соединения для потоковой передачи аудио, видео, а также для обмена данными между браузерами. Технология WebRTC предназначена для того, чтобы работать, минуя преобразование сетевых адресов (NAT) и брандмауэры, а также для использования таких протоколов как ICE, STUN и TURN, чтобы устанавливать соединения между пирами.
Притом, что технология WebRTC создавалась для клиент-клиентских взаимодействий, она также применима и для коммуникации сервер-клиент, где сервер всего лишь симулируется и тоже находится на клиенте. Такой подход целесообразен только при некоторых нишевых вариантах использования, поэтому далее в этой статье мы не будем рассматривать WebRTC.
Проблема в следующем: чтобы заставить WebRTC работать, вам всё равно понадобится сигнальный сервер, который будет работать по технологии веб-сокетов, событий, отправляемых сервером или WebTransport. Именно поэтому WebRTC теряет всякий смысл в качестве замены для этих технологий.
Недостатки описанных технологий
Двунаправленная передача данных
Только WebSockets и WebTransport обеспечивают двунаправленную передачу данных в таком ключе, что в рамках одного соединения можно как получать данные с сервера, так и отправлять данные с клиента.
Притом, что, теоретически, такой эффект достижим и при помощи длинных опросов, применять их не рекомендуется, так как при отправке «новых» данных в уже существующее соединение, по которому идут длинные опросы, так или иначе потребуется дополнительный http-запрос. Так что лучше вместо такого подхода лучше отправлять данные непосредственно с клиента на сервер с привлечением дополнительного http-запроса, не прерывая того соединения, по которому идут длинные опросы.
События, отправляемые сервером не поддерживают передачу каких-либо дополнительных данных на сервер. Можно выполнить только начальный запрос, и даже тогда вы не сможете по умолчанию отправлять в теле http POST-подобные данные, если будете пользоваться нативным EventSource API. Напротив, вам придётся положить все данные в параметры url, а так не рекомендуется делать из соображений безопасности. Дело в том, что личные данные могут просочиться в логи сервера, прокси и кэши. Для решения этой проблемы в RxDB, например, применяется полифилл eventsource, а не нативный EventSource API. Эта библиотека привносит дополнительный функционал, например, позволяет отправлять собственные http-заголовки. Есть и такая библиотека от Microsoft, позволяющая отправлять данные из тела запроса и работать с запросами POST, а не GET.
Ограничение в 6 запросов на домен
В большинстве современных браузеров допускается устанавливать не более 6 соединений с одним доменом (), из-за чего становится не столь удобно работать с любыми нединамическими методами передачи сообщений от сервера к клиенту. Такое ограничение действует даже на уровне различных вкладок браузера. То есть, если вы открываете одну и ту же страницу в нескольких вкладках, то они будут делить на всех этот пул из шести соединений. Это ограничение действует на уровне HTTP/1.1-RFC (где рекомендуется даже ещё более серьёзное ограничение, всего в два соединения).
Цитата из RFC 2616 – раздел 8.1.4: «Клиентам, использующим персистентные соединения, СЛЕДУЕТ ограничить количество соединений, одновременно поддерживаемых с конкретным сервером. Однопользовательскому клиенту НЕ СЛЕДУЕТ поддерживать более 2 соединений с любым сервером или прокси-сервером. Прокси-серверу СЛЕДУЕТ использовать до 2*N соединений с другим сервером или прокси-сервером, где N — количество пользователей, активных одновременно. Цель этих рекомендаций — улучшить время отклика по HTTP и избежать перегрузки сети».
Притом, что такая политика действительно оправдана, чтобы не позволять владельцам сайтов задействовать собственных посетителей для D-DOS атак на другие сайты, возможны большие проблемы во вполне цивилизованных случаях, когда требуется множество соединений для обработки коммуникации сервер-клиент. Чтобы обойти это ограничение, вам придётся использовать HTTP/2 или HTTP/3. При работе по таким технологиям браузер всегда будет открывать одно соединение на домен, а затем при помощи мультиплексирования перегонять все данные через это единственное соединение. Да, в таком случае вы получаете практически неограниченное количество параллельных соединений, но есть настройка SETTINGS_MAX_CONCURRENT_STREAMS, задающая предел фактическому количеству соединений. По умолчанию в большинстве конфигураций предусматривается до 100 конкурентных потоков.
Теоретически, предел на количество соединений также можно увеличить в самом браузере, как минимум, для конкретных API, например, EventSource, но эти проблемы были помечены как «неустранимые» в chromium и firefox.
Уменьшите количество соединений в браузерных приложениях
Собирая браузерное приложение, приходится исходить из того, что ваши пользователи будут работать с этим приложением не в одном экземпляре, а параллельно в нескольких вкладках браузера. По умолчанию, вероятно, на каждую вкладку будет открываться одно потоковое соединение с сервером, которое зачастую вообще бывает не нужно. Вместо этого открывайте единственное соединение и совместно используйте его в разных вкладках, независимо от того, сколько вкладок открыто. В RxDB это делается методом выбора лидера из пакета broadcast-channel npm, чтобы между сервером и клиентами существовал всего один поток выполнения репликации. Этот пакет можно использовать сам по себе (без RxDB) в приложениях любого типа.
В мобильных приложениях соединения открытыми не держат
Что касается мобильных приложений, работающих в таких операционных системах, как Android и iOS, там довольно сложно держать открытые соединения для работы с WebSockets и другими подобными технологиями. Мобильные операционные системы проектируются в расчёте на то, что после определённого периода неактивности приложение автоматически переводится в фоновый режим. При этом все открытые соединения фактически закрываются. Такое поведение согласуется со стратегией управления ресурсами операционной системы, чтобы сэкономить заряд батареи и оптимизировать производительность. В результате разработчики зачастую полагаются на мобильные пуш-уведомления, так как это действительно эффективный и надёжный метод отправки данных с серверов на клиенты. При помощи пуш-уведомлений сервер может уведомлять приложение о новых данных, приглашая его к действию или обновлению. При этом не требуется держать непрерывное открытое соединение.
Прокси-серверы и брандмауэры
Мне доводилось консультировать множество пользователей RxDB, и я убедился, что в корпоративных окружениях (читай: «на работе») зачастую бывает сложно встроить веб-сокеты в имеющуюся инфраструктуру, так как в системе стоит множество прокси-серверов и брандмауэров, блокирующих такие соединения, которые идут не по HTTP. Следовательно, если вы пользуетесь событиями, которые отправляет сервер, то и интегрировать их в инфраструктуру большого предприятия будет проще. Также учтите, что при длинных опросах используются только обычные HTTP-запросы, так что это тоже вариант.
Сравнение производительности
Сравнивая производительность веб-сокетов, событий, отправляемых сервером, длинных опросов и WebTransport, мы не сможем обойтись без оценки таких показателей, как задержка, пропускная способность, нагрузка на сервер и масштабируемость в различных условиях.
Для начала рассмотрим голые числа. Хорошее сравнение производительности дано в этом репозитории: здесь проверяется, сколько времени уходит на обмен сообщениями в реализации сервера Go Lang. Как видите, показатели производительности WebSockets, WebRTC и WebTransport вполне сопоставимы:
![](https://habrastorage.org/getpro/habr/upload_files/655/7eb/cbc/6557ebcbcc256e80c0ec887f90df3999.png)
Напомню, что WebTransport — это весьма новая технология, основанная на не менее новом протоколе HTTP/3. Возможно, в будущем (после марта 2024) её производительность будет далее оптимизирована. Кроме того, WebTransport оптимизирован для экономного расхода энергии, а этот параметр не тестировался.
Далее давайте сравним показатели задержки, пропускной способности и масштабируемости:
Задержка
Веб-сокеты: для них характерна минимальная задержка, так как работает полнодуплексная коммуникация через единственное постоянное соединение. Идеально подходит для приложений реального времени, где критически важен мгновенный обмен данными.
События, отправляемые сервером: также обеспечивает низкую задержку при коммуникации в направлении «сервер‑клиент», но не может нативно отправлять сообщения обратно на сервер без дополнительных HTTP‑запросов.
Длинные опросы: сопряжены с более высокими задержками, так как требуют устанавливать новые HTTP‑соединения для каждой операции передачи данных, поэтому такая технология не слишком эффективна для обновлений в режиме реального времени. Может случиться и так, что сервер попробует отправить на клиент событие, пока тот всё ещё занят открытием нового соединения. В таком случае задержка существенно увеличится.
WebTransport: считается, что здесь удастся минимизировать задержку почти как в WebSockets, а также добиться дополнительных плюсов — например, эффективно использовать протокол HTTP/3 для повышения качества мультиплексирования и контроля над перегрузками в сети.
Пропускная способность
Веб-сокеты: характеризуются высокой пропускной способностью, так как работают с постоянным сетевым соединением. Тем не менее, пропускная способность может страдать из‑за обратного вытеснения, где клиент не успевает обрабатывать данные так быстро, как сервер успевает их отправлять.
События, отправляемые сервером: эффективный подход для широковещательной передачи сообщений сразу множеству клиентов с меньшими издержками, чем при применении веб‑сокетов. Потенциально так можно добиться повышения пропускной способности при однонаправленной коммуникации от сервера к клиенту.
Длинные опросы: как правило, отличаются пониженной пропускной способностью из‑за издержек, связанных с частым открытием и закрытием соединений, на что потребляются значительные серверные ресурсы.
WebTransport: ожидается, что будет поддерживать высокую пропускную способность как для однонаправленных так и для двунаправленных потоков информации в рамках одного соединения. Эта технология будет превосходить веб‑сокеты в сценариях, где требуется множество потоков.
Масштабируемость и нагрузка на сервер
Веб-сокеты: если приходится поддерживать сразу множество соединений с веб‑сокетами, может существенно возрасти нагрузка на сервер, что потенциально повлияет на масштабируемость приложений у многих пользователей.
События, отправляемые сервером: повышенная масштабируемость в таких сценариях, которые требуют, прежде всего, поступления обновлений с сервера на клиент. Дело в том, что при этой технологии тратится меньше издержек на соединения, чем при работе с веб‑сокетами, так как здесь используются «нормальные» HTTP‑запросы, без всяких обновлений протоколов, которые приходится эксплуатировать с веб‑сокетами.
Длинные опросы: наименее масштабируемая технология в силу высокой нагрузки на сервер, возникающей из‑за частого установления новых соединений. Этот механизм подходит только в качестве резервного.
WebTransport: рассчитана на высокую масштабируемость, так как выгодно использует эффективность HTTP/3 при обработке соединений и потоков. Потенциально позволяет снизить нагрузку на сервер по сравнению с использованием веб‑сокетов и SSE.
Рекомендации и подбор под конкретные практические случаи
В ландшафте сервер‑клиентской коммуникации у каждой технологии есть свои ярко выраженные преимущества и уместность для конкретных практических случаев. События, отправляемые сервером (SSE) представляются наиболее простым в реализации вариантом. Эта технология работает по тем же протоколам HTTP/S, что и традиционные веб‑запросы. Поэтому она позволяет обходить ограничения корпоративных брандмауэров и иные технические проблемы, которые могут возникать с другими протоколами. Они легко интегрируются с Node.js и другими серверными фреймворками. Поэтому они идеально подходят для приложений, в которых требуются частые обновления клиента, поступающие с сервера, например, при работе с новостными лентами, биржевыми котировками, потоковыми трансляциями.
С другой стороны, веб‑сокеты превосходны в таких сценариях, где требуется текущая двунаправленная коммуникация. Веб‑сокеты — выбор номер один для реализации браузерных игр, чатов, спортивных трансляций, благодаря возможности поддерживать непрерывное взаимодействие.
Но WebTransport, несмотря на весь потенциал, пока плохо приживается. Эта технология не так широко поддерживается в серверных фреймворках, в том числе, в Node.js, а также она несовместима с safari. Более того, зависимость от HTTP/3 тем более ограничивает непосредственную применимость этой технологии, так как во многих веб‑серверах, например, в nginx, есть лишь экспериментальная поддержка HTTP/3. Притом, что она кажется многообещающей технологией как для надёжной, так и для ненадёжной передачи данных, пока она неприменима в большинстве практических случаев.
Длинные опросы когда‑то были общеупотребительны, но в настоящее время в основном устарели из‑за неэффективности и высоких издержек на неоднократное установление новых HTTP‑соединений. Хотя эта технология и может служить резервной в окружениях, где не поддерживаются веб‑сокеты или SSE, в целом не рекомендуется её использовать из‑за серьёзных ограничений производительности.
Выявленные проблемы
Известны проблемы, характерные для всех стриминговых технологий, используемых в режиме реального времени. Выстраивая что-либо на основе этих технологий, имейте данные проблемы в виду.
При переподключении клиент может пропустить некоторые события
Когда клиент подключается, переподключается или находится офлайн, он может пропустить некоторые события, произошедшие на сервере, но не отправленные на клиент. Эти события не так важны, если серверу всё равно удалось передать весь поток целиком, как, например, при обновлении биржевых котировок. Но когда настройки машинного интерфейса позволяют передавать в потоке и неполные данные, пропущенные события приходится учитывать. Попытки исправить такие проблемы на сервере плохо поддаются масштабированию, поскольку в таком случае серверу приходилось бы вспоминать каждый клиент, события с которого уже были успешно отосланы. Нет, такой функционал следует реализовывать средствами клиентской логики.
Например, протокол репликации RxDB может решать эту задачу в двух режимах. Во-первых, это режим перебора контрольных точек, где при помощи обычных http-запросов мы перебираем серверные данные, пока клиент с сервером вновь не синхронизируются. Затем можно переключиться в режим наблюдения событий, когда на основе потока, поступающего в режиме реального времени, поддерживается синхронизация между сервером и клиентом. Всякий раз при разрыве соединения или возникновении какой-либо ошибки на клиенте репликация ненадолго переключается в режим перебора контрольных точек, пока клиент и сервер снова не синхронизируются. Такой метод позволяет учесть пропущенные события и гарантирует, что клиент всегда сможет синхронизироваться с конкретным равноценным состоянием сервера.
Проблемы могут возникать из-за корпоративных брандмауэров
Известно множество проблем с корпоративной инфраструктурой, возникающих при попытке использовать какие-либо стриминговые технологии. Прокси-сервера и брандмауэры могут блокировать трафик или случайно нарушать обмен запросами и откликами. Всякий раз, когда приходится реализовать в такой инфраструктуре приложение для работы в режиме реального времени, обязательно протестируйте приложение и проверьте, не возникает ли таких проблем.
Отклик
Шер/лайк моего твита с анонсом этой статьи
О том, как при помощи событий, отправляемых сервером, реплицировать клиентскую часть базы данных RxDB средствами серверной части.
Быстрое руководство, помогающее научиться работе с RxDB
Репозиторий RxDB на github, не забудьте поставить звёздочку ⭐