Продвинутая работа с 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 и обработка ошибок
Корректное завершение работы сервера — признак зрелого приложения. При получении сигнала
Блоги
Рекомендаций по книгам
Читайте также