WebSocket на JavaScript: продвинутые real-time приложения на Node.js

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Продвинутая работа с WebSocket на JavaScript: от прототипа к production

WebSocket — это протокол, обеспечивающий полнодуплексную связь между клиентом и сервером через одно постоянное TCP-соединение. В предыдущих статьях мы разбирали основы: установку соединения, отправку сообщений и простые чаты. Однако для создания по-настоящему надежных и масштабируемых real-time приложений (онлайн-игры, торговые платформы, системы коллаборативного редактирования) необходимо освоить продвинутые техники. В этом материале мы рассмотрим архитектуру production-grade WebSocket сервера на Node.js, включая масштабирование, управление состоянием, авторизацию и обработку ошибок.

1. Продвинутая архитектура сервера и управление комнатами

Простейший WebSocket сервер (на базе библиотеки ws) обрабатывает всех клиентов в одном глобальном объекте. В реальных проектах требуется логическая изоляция — комнаты (rooms) и каналы.

Рассмотрим пример архитектуры с использованием класса RoomManager:

const WebSocket = require('ws');

class RoomManager {
    constructor() {
        this.rooms = new Map(); // roomName -> Set<WebSocket>
    }

    join(ws, roomName) {
        if (!this.rooms.has(roomName)) {
            this.rooms.set(roomName, new Set());
        }
        this.rooms.get(roomName).add(ws);
        ws.room = roomName; // Сохраняем комнату на сокете
    }

    leave(ws) {
        if (ws.room && this.rooms.has(ws.room)) {
            this.rooms.get(ws.room).delete(ws);
            if (this.rooms.get(ws.room).size === 0) {
                this.rooms.delete(ws.room);
            }
        }
    }

    broadcast(roomName, message, excludeWs = null) {
        if (!this.rooms.has(roomName)) return;
        this.rooms.get(roomName).forEach(client => {
            if (client !== excludeWs && client.readyState === WebSocket.OPEN) {
                client.send(JSON.stringify(message));
            }
        });
    }
}

// Использование
const wss = new WebSocket.Server({ port: 8080 });
const roomManager = new RoomManager();

wss.on('connection', (ws) => {
    ws.on('message', (data) => {
        const msg = JSON.parse(data);
        switch (msg.type) {
            case 'join':
                roomManager.join(ws, msg.room);
                break;
            case 'message':
                roomManager.broadcast(ws.room, { user: msg.user, text: msg.text }, ws);
                break;
        }
    });

    ws.on('close', () => roomManager.leave(ws));
});

Этот код позволяет создавать изолированные чаты или игровые сессии. Обратите внимание на обработку закрытия соединения — это критично для предотвращения утечек памяти.

2. Масштабирование: кластеризация и Redis Pub/Sub

Один процесс Node.js не может эффективно использовать более одного ядра CPU. Для масштабирования мы используем встроенный модуль cluster. Однако возникает проблема: WebSocket соединения привязаны к конкретному воркеру. Как отправить сообщение пользователю, подключенному к другому воркеру?

Решение — внешний брокер сообщений, например, Redis Pub/Sub.

const cluster = require('cluster');
const http = require('http');
const { Server } = require('ws');
const redis = require('redis');

const REDIS_CHANNEL = 'ws:messages';

if (cluster.isMaster) {
    // Запускаем воркеры по числу ядер
    const numCPUs = require('os').cpus().length;
    for (let i = 0; i < numCPUs; i++) {
        cluster.fork();
    }

    cluster.on('exit', (worker) => {
        console.log(`Worker ${worker.process.pid} died`);
        cluster.fork(); // Перезапуск
    });
} else {
    // Worker
    const server = http.createServer();
    const wss = new Server({ server });

    // Подключаемся к Redis
    const pub = redis.createClient();
    const sub = redis.createClient();

    sub.subscribe(REDIS_CHANNEL);
    sub.on('message', (channel, message) => {
        // Получаем сообщение от другого воркера
        const { userId, payload } = JSON.parse(message);
        // Находим нужный сокет в этом воркере и отправляем
        wss.clients.forEach(client => {
            if (client.userId === userId && client.readyState === WebSocket.OPEN) {
                client.send(JSON.stringify(payload));
            }
        });
    });

    wss.on('connection', (ws) => {
        ws.on('message', (data) => {
            const msg = JSON.parse(data);
            // Публикуем сообщение в Redis для всех воркеров
            pub.publish(REDIS_CHANNEL, JSON.stringify({
                userId: msg.targetUserId,
                payload: { type: 'private_message', text: msg.text }
            }));
        });
    });

    server.listen(8080);
}

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

3. Авторизация и управление сессиями

В production нельзя доверять клиенту. WebSocket соединение должно быть аутентифицировано. Рекомендуемый подход — проверка JWT токена при установке соединения (через query параметр или заголовки).

const jwt = require('jsonwebtoken');
const SECRET = 'your-secret-key';

const wss = new WebSocket.Server({ 
    port: 8080,
    verifyClient: (info, cb) => {
        // Проверяем токен из query-строки
        const token = new URL(info.req.url, 'http://localhost').searchParams.get('token');
        if (!token) {
            cb(false, 401, 'Unauthorized');
            return;
        }
        try {
            const decoded = jwt.verify(token, SECRET);
            info.req.user = decoded; // Сохраняем данные пользователя
            cb(true);
        } catch (err) {
            cb(false, 401, 'Invalid token');
        }
    }
});

wss.on('connection', (ws, req) => {
    ws.user = req.user; // Теперь у сокета есть информация о пользователе
    console.log(`User ${ws.user.username} connected`);

    // Пример: отправка личного приветствия
    ws.send(JSON.stringify({ type: 'welcome', user: ws.user.username }));
});

Важно: не используйте токен в URL для production (остается в логах). Лучше передавать его через заголовок Sec-WebSocket-Protocol или после установки соединения в первом сообщении (handshake).

4. Graceful Shutdown и обработка ошибок

Корректное завершение работы сервера — признак зрелого приложения. При получении сигнала

Блоги

Рекомендаций по книгам