Пошук уроків, статей та іншого контенту
Пояснить життєвий цикл WebSocket-з’єднання, обмін повідомленнями та масштабування постійних каналів.
WebSocket — це протокол для тривалого двонапрямного з’єднання між клієнтом і сервером.
На відміну від звичайного HTTP-запиту:
клієнт може надіслати повідомлення серверу в будь-який момент;
сервер може самостійно надіслати повідомлення клієнту;
для кожного повідомлення не потрібно створювати новий HTTP-запит;
після встановлення з’єднання обмін відбувається з меншими накладними витратами.
Типові сценарії:
чати;
сповіщення в реальному часі;
оновлення статусу замовлення;
спільне редагування;
ігри;
моніторинг метрик або стану сервісів.
Для незахищеного з’єднання використовується схема ws://, а для з’єднання через TLS — wss://. У production зазвичай використовують саме wss://.
Життєвий цикл має кілька етапів:
Клієнт створює WebSocket-з’єднання.
Відбувається HTTP-запит на оновлення протоколу.
Сервер підтверджує перехід від HTTP до WebSocket.
Клієнт і сервер обмінюються повідомленнями.
З’єднання підтримується за допомогою службових кадрів.
Одна зі сторін закриває з’єднання.
Інша сторона отримує інформацію про закриття та звільняє ресурси.
Клієнт починає з HTTP-запиту приблизно такого виду:
GET /socket HTTP/1.1
Host: example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: ...
Sec-WebSocket-Version: 13Сервер відповідає:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: ...Статус 101 Switching Protocols означає, що з’єднання перейшло з HTTP-протоколу на WebSocket.
Після цього з’єднання залишається відкритим, а дані передаються у WebSocket-кадрах. Застосунок працює не з окремими HTTP-запитами, а з повідомленнями.
У браузері WebSocket має такі стани:
CONNECTING — з’єднання встановлюється;
OPEN — з’єднання готове до обміну;
CLOSING — розпочато закриття;
CLOSED — з’єднання закрите.
Основні події браузерного WebSocket:
open — з’єднання успішно встановлено;
message — отримано повідомлення;
error — сталася помилка;
close — з’єднання закрито.
Не можна викликати send() до події open або після переходу з’єднання у стан CLOSING чи CLOSED.
WebSocket передає текстові або бінарні повідомлення. Формат повідомлень визначає сам застосунок. Часто використовують JSON:
{
"type": "chat.message",
"payload": {
"text": "Привіт"
}
}Поле type допомагає розрізняти події. Наприклад:
chat.message — нове повідомлення;
user.joined — користувач приєднався;
notification.created — нове сповіщення;
error — помилка обробки запиту.
Нижче наведено простий сервер на Node.js з пакетом ws.
Встановлення залежності:
npm init -y
npm install wsФайл server.js:
const { WebSocketServer, WebSocket } = require("ws");
const server = new WebSocketServer({ port: 8080 });
const clients = new Set();
server.on("connection", (socket, request) => {
clients.add(socket);
console.log(`Клієнт підключився: ${request.socket.remoteAddress}`);
socket.isAlive = true;
socket.on("pong", () => {
// Клієнт відповів на перевірку активності.
socket.isAlive = true;
});
socket.on("message", (data, isBinary) => {
if (isBinary) {
socket.send(JSON.stringify({
type: "error",
payload: {
message: "Бінарні повідомлення не підтримуються"
}
}));
return;
}
let message;
try {
message = JSON.parse(data.toString());
} catch {
socket.send(JSON.stringify({
type: "error",
payload: {
message: "Повідомлення має бути коректним JSON"
}
}));
return;
}
if (message.type !== "chat.message") {
socket.send(JSON.stringify({
type: "error",
payload: {
message: "Невідомий тип повідомлення"
}
}));
return;
}
const text = message.payload?.text;
if (typeof text !== "string" || text.trim().length === 0) {
socket.send(JSON.stringify({
type: "error",
payload: {
message: "Поле payload.text є обов'язковим"
}
}));
return;
}
const outgoingMessage = JSON.stringify({
type: "chat.message",
payload: {
text: text.trim(),
sentAt: new Date().toISOString()
}
});
// Надсилаємо повідомлення всім підключеним клієнтам.
for (const client of clients) {
if (client.readyState === WebSocket.OPEN) {
client.send(outgoingMessage);
}
}
});
socket.on("close", (code, reason) => {
clients.delete(socket);
console.log(
`Клієнт відключився: код=${code}, причина=${reason.toString()}`
);
});
socket.on("error", (error) => {
console.error("Помилка WebSocket:", error);
});
});
// Перевіряємо, чи відповідають клієнти.
const heartbeatInterval = setInterval(() => {
for (const socket of clients) {
if (!socket.isAlive) {
// З'єднання вважається непрацездатним.
socket.terminate();
continue;
}
socket.isAlive = false;
socket.ping();
}
}, 30_000);
server.on("close", () => {
clearInterval(heartbeatInterval);
});
console.log("WebSocket-сервер слухає ws://localhost:8080");
process.on("SIGTERM", () => {
// Коректно закриваємо нові підключення під час завершення процесу.
server.close(() => {
process.exit(0);
});
for (const socket of clients) {
socket.close(1001, "Сервер завершує роботу");
}
});Запуск:
node server.jsФайл client.html можна відкрити в браузері після запуску сервера:
<!doctype html>
<html lang="uk">
<head>
<meta charset="utf-8">
<title>WebSocket-клієнт</title>
</head>
<body>
<input id="messageInput" placeholder="Введіть повідомлення">
<button id="sendButton">Надіслати</button>
<ul id="messages"></ul>
<script>
const input = document.querySelector("#messageInput");
const button = document.querySelector("#sendButton");
const messages = document.querySelector("#messages");
const socket = new WebSocket("ws://localhost:8080");
socket.addEventListener("open", () => {
console.log("З'єднання встановлено");
button.disabled = false;
});
socket.addEventListener("message", (event) => {
const message = JSON.parse(event.data);
const item = document.createElement("li");
item.textContent = `${message.type}: ${message.payload.text}`;
messages.append(item);
});
socket.addEventListener("error", () => {
console.error("Помилка WebSocket");
});
socket.addEventListener("close", (event) => {
console.log(
`З'єднання закрито: код=${event.code}, причина=${event.reason}`
);
button.disabled = true;
});
button.disabled = true;
button.addEventListener("click", () => {
if (socket.readyState !== WebSocket.OPEN) {
return;
}
const text = input.value.trim();
if (!text) {
return;
}
socket.send(JSON.stringify({
type: "chat.message",
payload: {
text
}
}));
input.value = "";
});
</script>
</body>
</html>У цьому прикладі кілька відкритих вкладок отримуватимуть повідомлення одна одної, оскільки сервер транслює кожне повідомлення всім підключеним клієнтам.
WebSocket має спеціальний механізм коректного завершення з’єднання.
Клієнт може викликати:
socket.close(1000, "Роботу завершено");Сервер також може ініціювати закриття. У close передаються:
код закриття;
причина закриття.
Поширені коди:
1000 — нормальне завершення;
1001 — одна зі сторін залишає з’єднання, наприклад через зупинку сервера;
1008 — порушення політики;
1011 — внутрішня помилка сервера.
Потрібно відрізняти коректне закриття від втрати з’єднання. Якщо користувач закрив вкладку, мережа стала недоступною або сервер аварійно завершився, клієнт може отримати подію close без очікуваного коду або причини.
Тривале TCP-з’єднання не гарантує, що віддалена сторона все ще доступна. Наприклад:
пристрій може втратити мережу;
клієнт може перейти в сплячий режим;
проміжний проксі може розірвати неактивне з’єднання;
процес клієнта може завершитися без коректного закриття сокета.
Для перевірки активності використовують службові кадри:
сервер надсилає ping;
клієнт відповідає pong;
якщо відповіді немає, сервер закриває з’єднання.
У браузері відповідь на ping зазвичай обробляється автоматично. На сервері потрібно відстежувати, чи надходять pong-відповіді.
Важливо регулярно видаляти неактивні з’єднання. Інакше список клієнтів зростатиме, а сервер витрачатиме пам’ять на вже недоступні сокети.
WebSocket не підключається повторно автоматично. Якщо з’єднання втрачено, клієнт має самостійно створити новий екземпляр.
Для повторних спроб краще використовувати затримку, яка збільшується:
let socket;
let retryAttempt = 0;
let reconnectTimer;
function connect() {
socket = new WebSocket("wss://example.com/socket");
socket.addEventListener("open", () => {
retryAttempt = 0;
console.log("З'єднання встановлено");
});
socket.addEventListener("message", (event) => {
console.log("Повідомлення:", event.data);
});
socket.addEventListener("close", () => {
const delay = Math.min(30_000, 1_000 * 2 ** retryAttempt);
retryAttempt += 1;
console.log(`Повторне підключення через ${delay} мс`);
clearTimeout(reconnectTimer);
reconnectTimer = setTimeout(connect, delay);
});
socket.addEventListener("error", () => {
// Подія close зазвичай також буде викликана після помилки.
socket.close();
});
}
connect();Збільшення затримки запобігає ситуації, коли тисячі клієнтів одночасно створюють нові підключення після падіння сервера.
Повторне підключення може призвести до повторної відправки повідомлення. Тому для важливих операцій серверу потрібні ідентифікатори повідомлень або інші механізми ідемпотентності. Сам WebSocket не вирішує проблему повторної доставки на рівні бізнес-логіки.
HTTP-запити зазвичай короткоживучі: балансувальник може спрямувати кожен запит на будь-який екземпляр сервера. WebSocket-з’єднання, навпаки, тривале. Після встановлення воно залишається прив’язаним до конкретного процесу та конкретного сервера.
Припустімо, є два екземпляри:
Клієнт A ──> Сервер 1
Клієнт B ──> Сервер 2Якщо клієнт A надіслав повідомлення серверу 1, сервер 2 не дізнається про нього автоматично. Локальний Set або масив клієнтів існує лише всередині одного процесу.
Якщо всі підключення обробляє один процес, локального списку клієнтів достатньо. Це проста схема, але вона має обмеження:
кількість з’єднань обмежена ресурсами одного процесу;
відмова процесу розриває всі його з’єднання;
неможливо рівномірно використовувати кілька екземплярів.
Sticky sessions змушують балансувальник спрямовувати наступні запити того самого клієнта до того самого екземпляра.
Це може бути корисно, якщо стан з’єднання зберігається локально. Але sticky sessions не вирішують проблему обміну повідомленнями між різними екземплярами:
Клієнт A ──> Сервер 1 ──┐
├──> брокер повідомлень
Клієнт B ──> Сервер 2 ──┘Також sticky sessions ускладнюють балансування: один екземпляр може мати значно більше активних клієнтів, ніж інший.
Поширена архітектура використовує брокер повідомлень, наприклад Redis Pub/Sub або інший подібний інструмент.
Кожен екземпляр WebSocket-сервера:
приймає локальні підключення;
підписується на потрібні канали брокера;
публікує у брокер повідомлення від локальних клієнтів;
отримує повідомлення інших екземплярів;
надсилає їх власним локальним клієнтам.
Наприклад:
Клієнт A
│
▼
Сервер 1 ── publish ──> Брокер <── subscribe ── Сервер 2
│ │
└── локальні клієнти Клієнт BУ такій схемі кожен сервер відповідає лише за власні WebSocket-з’єднання, а брокер забезпечує поширення подій між серверами.
Потрібно окремо визначити:
назви каналів;
формат подій;
правила маршрутизації;
час зберігання повідомлень, якщо брокер це підтримує;
поведінку під час тимчасової недоступності брокера.
Звичайний Pub/Sub часто призначений для повідомлень у реальному часі, а не для гарантованого зберігання історії. Якщо клієнт був відключений, він може не отримати подію, опубліковану в цей момент. Для відновлення стану застосунку може знадобитися окреме читання актуального стану з бази даних або іншого сховища.
З’єднання WebSocket потрібно захищати так само уважно, як HTTP API.
Основні правила:
у production використовувати wss://;
перевіряти автентифікацію під час встановлення з’єднання;
перевіряти права доступу до кожного каналу;
не довіряти даним від клієнта;
перевіряти типи, розмір і структуру повідомлень;
обмежувати частоту повідомлень від одного клієнта;
не транслювати повідомлення клієнтам, які не мають доступу до відповідного каналу.
Сам факт успішного встановлення WebSocket-з’єднання не означає, що клієнт має право отримувати всі події сервера.
Кожне WebSocket-з’єднання споживає ресурси:
пам’ять;
файловий дескриптор;
об’єкти обробників подій;
місце в локальних колекціях підключень;
пропускну здатність мережі.
Тому сервер має:
видаляти клієнта з колекції під час close;
обробляти помилки сокета;
закривати неактивні з’єднання;
обмежувати розмір вхідного повідомлення;
контролювати швидкість надсилання;
коректно закривати з’єднання під час завершення процесу.
Якщо клієнт надсилає дані швидше, ніж сервер або мережа можуть їх передати, виникає накопичення повідомлень у буфері. Для великих або частих повідомлень потрібно перевіряти стан буфера та застосовувати обмеження, інакше один повільний клієнт може спожити надто багато пам’яті.
Список клієнтів у пам’яті процесу містить лише підключення до цього процесу. Для обміну між кількома екземплярами потрібен спільний механізм доставки подій.
Без ping/pong сервер може довго зберігати вже непрацездатні з’єднання.
Тимчасове вимкнення мережі закриває WebSocket. Клієнт повинен мати стратегію повторного підключення, якщо це передбачено вимогами застосунку.
Нескінченний цикл миттєвих спроб створює додаткове навантаження на сервер. Використовуйте збільшення затримки між спробами.
JSON може бути синтаксично коректним, але мати неправильну структуру. Сервер повинен перевіряти тип події та її поля.
Перед send() слід перевіряти, що з’єднання перебуває у стані OPEN.
WebSocket забезпечує канал обміну, але бізнес-логіка повинна самостійно визначати, як обробляти повтори, відновлення стану та важливі повідомлення після реконекту.
WebSocket починається з HTTP Upgrade-запиту, після якого створюється тривале двонапрямне з’єднання.
Після переходу у стан OPEN клієнт і сервер можуть незалежно надсилати повідомлення.
Формат і типи повідомлень визначає застосунок, часто використовуючи JSON.
Події open, message, error і close описують основні етапи роботи клієнта.
ping і pong допомагають знаходити неактивні з’єднання.
Клієнту часто потрібен механізм повторного підключення зі збільшенням затримки.
WebSocket-з’єднання прив’язане до конкретного екземпляра сервера.
Для масштабування між екземплярами використовують брокер повідомлень, а не лише локальний список клієнтів.
Сервер повинен перевіряти повідомлення, контролювати ресурси та коректно завершувати з’єднання.