Как работают WebSockets: подробное руководство по соединениям в реальном времени

На заре развития веба браузер представлял собой простой инструмент для просмотра документов. Вы запрашивали страницу, сервер ее отрисовывал, и соединение закрывалось. Этот цикл «запрос-ответ» лежит в основе протокола HTTP (Hypertext Transfer Protocol).

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

Чтобы получать обновления в реальном времени, разработчики поначалу полагались на обходные пути:

  • Короткие опросы (Short Polling): Браузер регулярно отправляет HTTP-запросы на сервер каждые несколько секунд, чтобы проверить наличие новых данных. Это создает огромную нагрузку на заголовки и тратит ресурсы сервера.
  • Длинные опросы (Long Polling / Comet): Браузер отправляет запрос, а сервер держит соединение открытым до тех пор, пока не появятся новые данные. После отправки данных соединение закрывается, и браузер сразу же открывает новый запрос. Этим процессом сложно управлять, и он по-прежнему требует больших затрат на установку соединений.

Технология WebSockets решила эти проблемы, предложив стандартизированный протокол для постоянного двустороннего полнодуплексного обмена данными по одному TCP-соединению.


Что такое WebSocket?

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

Вот фундаментальное правило WebSockets:

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


Пошаговое руководство: Жизненный цикл соединения

Соединение WebSocket проходит через три отдельные фазы: рукопожатие (Handshake), передачу данных и закрытие.

WebSocket Connection Lifecycle Diagram

1. HTTP-рукопожатие (обновление протокола)

Поскольку сетевые экраны и маршрутизаторы настроены на пропуск стандартного веб-трафика через порты 80 (HTTP) и 443 (HTTPS), подключение WebSockets начинается как обычный запрос HTTP/1.1. Это называется Upgrade Handshake.

Запрос клиента

Клиент отправляет HTTP-запрос GET со специальными заголовками, запрашивающими смену протокола:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Version: 13
Origin: https://example.com
  • Upgrade: websocket и Connection: Upgrade: сообщают серверу, что клиент хочет сменить протокол.
  • Sec-WebSocket-Key: случайное 16-байтное значение, закодированное в Base64. Оно используется для подтверждения того, что сервер получил запрос и понимает протокол WebSocket.
  • Sec-WebSocket-Version: указывает версию протокола WebSocket (обычно 13).
  • Origin: используется сервером для проверки безопасности и защиты от межсайтовых подключений с неавторизованных ресурсов.

Ответ сервера

Если сервер поддерживает WebSockets, он проверяет запрос и отвечает кодом состояния HTTP 101 Switching Protocols:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
  • Как сервер вычисляет Sec-WebSocket-Accept:
    1. Сервер берет заголовок Sec-WebSocket-Key (dGhlIHNhbXBsZSBub25jZQ==).
    2. Объединяет его со стандартным уникальным идентификатором GUID: "258EAFA5-E914-47DA-95CA-C5AB0DC85B11".
    3. Вычисляет хеш SHA-1 объединенной строки.
    4. Кодирует полученный результат в Base64.
    5. Если клиент подтверждает совпадение этого значения со своими ожиданиями, рукопожатие считается успешным, HTTP-соединение переключается на чистый TCP-сокет, и обе стороны переходят на работу по протоколу WebSocket.

2. Структурирование и передача данных

В отличие от HTTP, который отправляет текстовые заголовки, а затем тело запроса, WebSockets передают данные в виде структурированных двоичных пакетов, называемых фреймами (frames).

Фрейм WebSocket имеет небольшой заголовок (от 2 до 14 байт), за которым следуют полезные данные (payload). Этот заголовок содержит:

  • FIN-бит (1 бит): указывает, является ли этот фрейм последним в сообщении.
  • Opcode (4 бита): определяет тип фрейма:
    • 0x1: текстовый фрейм (в кодировке UTF-8)
    • 0x2: бинарный фрейм
    • 0x8: запрос на закрытие соединения
    • 0x9: пинг (Ping)
    • 0xA: понг (Pong)
  • Mask-бит (1 бит): указывает, замаскированы ли полезные данные.
  • Payload Length: размер данных.
  • Masking Key (4 байта): важнейшее требование безопасности: все фреймы, отправляемые от клиента к серверу, должны быть замаскированы (обфусцированы методом XOR) с использованием случайного 4-байтового ключа. Это предотвращает чтение трафика прокси-серверами и защищает от атак отравления кэша. Фреймы от сервера к клиенту маскироваться не должны.

Проверка активности (Heartbeats)

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

3. Закрытие соединения

Для правильного закрытия соединения:

  1. Один из участников отправляет фрейм Close, содержащий код состояния (например, 1000 для штатного закрытия, 1006 для сбоя) и необязательное текстовое описание причины.
  2. Второй участник отвечает своим фреймом Close.
  3. Базовый TCP-сокет закрывается.

Пример кода: Реализация WebSockets на Node.js

Чтобы увидеть WebSockets в действии, напишем простое приложение на Node.js. Мы создадим локальный сервер, возвращающий любые полученные сообщения, и клиентский скрипт для подключения.

WebSocket-сервер (server.js)

const { WebSocketServer } = require('ws');
const http = require('http');

// 1. Создаем стандартный HTTP-сервер
const server = http.createServer((req, res) => {
    res.writeHead(200, { 'Content-Type': 'text/plain' });
    res.end('HTTP Server running. Use WebSocket to connect.\n');
});

// 2. Подключаем WebSocket-сервер к HTTP-серверу
const wss = new WebSocketServer({ server });

wss.on('connection', (ws, req) => {
    const clientIp = req.socket.remoteAddress;
    console.log(`[Сервер] Подключился новый клиент с адреса ${clientIp}`);

    // Отправляем приветственное сообщение клиенту
    ws.send(JSON.stringify({ type: 'welcome', message: 'Вы подключились к серверу Ghaznix!' }));

    // Слушаем входящие сообщения от клиента
    ws.on('message', (message) => {
        console.log(`[Сервер] Получено: ${message}`);
        
        try {
            const data = JSON.parse(message);
            ws.send(JSON.stringify({
                type: 'echo',
                message: `Эхо сервера: ${data.text.toUpperCase()}`,
                timestamp: new Date().toISOString()
            }));
        } catch (e) {
            ws.send(JSON.stringify({ type: 'error', message: 'Неверный формат JSON' }));
        }
    });

    // Обрабатываем отключение клиента
    ws.on('close', (code, reason) => {
        console.log(`[Сервер] Клиент отключился (Код: ${code}, Причина: ${reason.toString() || 'Нет'})`);
    });

    ws.on('error', (error) => {
        console.error(`[Сервер] Ошибка сокета: ${error.message}`);
    });
});

server.listen(8080, () => {
    console.log('WebSocket-сервер запущен на ws://localhost:8080');
});

Браузерный клиент (JavaScript на стороне клиента)

Вы можете запустить этот код прямо в консоли вашего браузера:

// 1. Устанавливаем соединение с сервером
const socket = new WebSocket('ws://localhost:8080');

// 2. Обработчик успешного открытия соединения
socket.addEventListener('open', (event) => {
    console.log('[Клиент] Соединение установлено.');
    
    const payload = JSON.stringify({ text: 'привет, сервер!' });
    socket.send(payload);
    console.log(`[Клиент] Отправлено: ${payload}`);
});

// 3. Слушаем входящие сообщения от сервера
socket.addEventListener('message', (event) => {
    const response = JSON.parse(event.data);
    console.log('[Клиент] Получено сообщение от сервера:', response);
});

// 4. Слушаем закрытие соединения
socket.addEventListener('close', (event) => {
    console.log(`[Клиент] Соединение закрыто (Код: ${event.code})`);
});

// 5. Обработка ошибок
socket.addEventListener('error', (error) => {
    console.error('[Клиент] Ошибка WebSocket:', error);
});

Сравнение HTTP и WebSockets

Характеристика HTTP/1.1 WebSockets
Тип связи Однонаправленный (только от клиента) Двунаправленный (клиент или сервер)
Модель подключения Запрос-ответ (краткосрочное) Постоянное (длительное)
Накладные расходы Высокие (заголовки при каждом запросе) Очень низкие (минимальный объем фрейма)
Сохранение состояния Нет Да (контекст подключения сохраняется)
Протокол http:// или https:// ws:// или wss://
Сфера применения Загрузка документов, REST API Чаты, панели мониторинга, онлайн-игры

Вопросы безопасности при использовании WebSockets

Поскольку после рукопожатия WebSockets обходят стандартную маршрутизацию HTTP, они создают специфические риски безопасности:

  1. Используйте защищенный протокол WebSocket Secure (wss://): Всегда запускайте WebSockets поверх TLS/SSL (порт 443). Шифрование фреймов защищает полезную нагрузку от прослушивания и перехвата.
  2. Проверка заголовка Origin: Для WebSockets не действует правило ограничения домена (Same-Origin Policy). Обязательно проверяйте заголовок Origin на стороне сервера во время рукопожатия.
  3. Авторизация при рукопожатии: Аутентифицируйте пользователей до установки соединения. Обычно это делается путем передачи токена (например, JWT) в параметрах запроса или проверки сессионных файлов cookie.
  4. Фильтрация ввода: Относитесь к любому входящему сообщению через WebSockets как к потенциально опасному. Очищайте и проверяйте входящие данные для предотвращения XSS-атак.

Резюме

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


Узнайте больше о веб-разработке в блоге Ghaznix →