Введение: за пределами базового WebSocket
Базовое понимание WebSocket позволяет создать простой чат или уведомления. Однако для построения production-grade real-time приложений — многопользовательских игр, финансовых терминалов, систем совместного редактирования — необходимо освоить продвинутые техники. В этой статье мы выйдем за рамки "Hello, World" и разберем, как создавать надежные, масштабируемые и безопасные WebSocket-приложения на Node.js.
Мы рассмотрим четыре ключевые области: масштабирование с помощью Redis, управление состояниями и комнатами, безопасность и авторизацию, а также кластеризацию для работы на нескольких процессорах. Все примеры кода написаны на JavaScript с использованием библиотеки ws и Node.js.
1. Масштабирование с помощью Pub/Sub (Redis)
Одно из главных ограничений WebSocket-сервера — он не может "общаться" с другими экземплярами. Если у вас запущено два сервера за балансировщиком нагрузки, клиент, подключенный к серверу A, не получит сообщение, отправленное через сервер B. Решение — использование паттерна Pub/Sub (издатель-подписчик) на базе Redis.
Redis выступает в роли центрального брокера: каждый сервер подписывается на канал и публикует в него сообщения. Таким образом, сообщение, отправленное любым сервером, мгновенно распространяется на все остальные.
const WebSocket = require('ws');
const redis = require('redis');
const wss = new WebSocket.Server({ port: 8080 });
const publisher = redis.createClient();
const subscriber = redis.createClient();
// Подписываемся на канал 'broadcast'
subscriber.subscribe('broadcast');
subscriber.on('message', (channel, message) => {
// Рассылаем сообщение всем подключенным клиентам
wss.clients.forEach(client => {
if (client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
});
wss.on('connection', (ws) => {
ws.on('message', (data) => {
// Публикуем сообщение в Redis
publisher.publish('broadcast', data.toString());
});
});
Этот подход позволяет горизонтально масштабировать ваше приложение практически бесконечно. При добавлении нового сервера он просто создает свою подписку на Redis.
2. Управление комнатами и состояниями
В большинстве real-time приложений необходимо группировать клиентов по "комнатам" (чат-комнаты, игровые лобби) и управлять их состоянием. Базовый wss.clients не предоставляет такой функциональности. Реализуем простую, но эффективную систему комнат.
Мы создадим объект rooms, где ключ — имя комнаты, а значение — Set из WebSocket-клиентов. Добавим методы для входа, выхода и рассылки внутри комнаты.
const rooms = new Map();
function joinRoom(ws, roomName) {
if (!rooms.has(roomName)) {
rooms.set(roomName, new Set());
}
rooms.get(roomName).add(ws);
// Сохраняем комнату на объекте ws для быстрого доступа
ws.room = roomName;
}
function leaveRoom(ws) {
if (ws.room && rooms.has(ws.room)) {
rooms.get(ws.room).delete(ws);
if (rooms.get(ws.room).size === 0) {
rooms.delete(ws.room);
}
delete ws.room;
}
}
function broadcastToRoom(roomName, message, exclude = null) {
if (!rooms.has(roomName)) return;
rooms.get(roomName).forEach(client => {
if (client !== exclude && client.readyState === WebSocket.OPEN) {
client.send(message);
}
});
}
const wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', (ws) => {
ws.on('message', (data) => {
const msg = JSON.parse(data);
switch (msg.type) {
case 'join':
joinRoom(ws, msg.room);
break;
case 'message':
broadcastToRoom(ws.room, msg.text, ws);
break;
}
});
ws.on('close', () => leaveRoom(ws));
});
Для управления состоянием (например, позиция игрока, курсор) можно хранить данные в Map по ключу ws или по userId.
3. Безопасность и авторизация
WebSocket-соединения не должны быть открыты для всех. Критически важно проверять подлинность клиента при установке соединения и в процессе работы. Рассмотрим два подхода: токен при подключении и верификация сообщений.
Подход 1: Токен в URL. Клиент передает JWT-токен через query-параметр при установке соединения.
const jwt = require('jsonwebtoken');
const wss = new WebSocket.Server({
port: 8080,
verifyClient: (info, cb) => {
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, process.env.JWT_SECRET);
info.req.user = decoded; // Передаем данные пользователя дальше
cb(true);
} catch (err) {
cb(false, 401, 'Invalid token');
}
}
});
wss.on('connection', (ws, req) => {
console.log('User connected:', req.user.id);
});
Подход 2: Верификация внутри сообщений. Если токен не может быть передан в URL (например, из-за прокси), используйте первое сообщение для аутентификации.
wss.on('connection', (ws) => {
let authenticated = false;
ws.on('message', (data) => {
if (!authenticated) {
const msg = JSON.parse(data);
if (msg.type === 'auth') {
try {
const decoded = jwt.verify(msg.token, process.env.JWT_SECRET);
ws.userId = decoded.id;
authenticated = true;
ws.send(JSON.stringify({ type: 'auth_ok' }));
} catch {
ws.close(4001, 'Auth failed');
}
}
return;
}
// Обработка обычных сообщений
});
});
Не забывайте про rate limiting и валидацию входящих данных — это защитит от спама и DoS-атак.
4. Кластеризация и использование всех ядер CPU
Node.js работает в одном потоке. Для полной утилизации многоядерных процессоров используйте модуль cluster. Каждый воркер запускает свой WebSocket-сервер на одном порту (через общий мастер-процесс). Сочетание кластеризации с Redis Pub/Sub (из раздела 1) дает максимальную производительность.
const cluster = require('cluster');
const os = require('os');
if (cluster.isMaster) {
const numWorkers = os.cpus().length;
console.log