16 липня 2023 р.

WebSocket

WebSocket – це протокол, описаний у специфікації RFC 6455, і забезпечує спосіб обміну даними між браузером і сервером через постійне з’єднання. Дані можна передавати в обох напрямках у вигляді “пакетів”, без розриву з’єднання та додаткових HTTP-запитів.

WebSocket відмінно підходить для сервісів, що потребують безперервного обміну даними, наприклад онлайн-ігри, системи торгівлі в реальному часі тощо.

Простий приклад

Щоб відкрити з’єднання websocket, нам потрібно створити об’єкт new WebSocket використовуючи спеціальний протокол ws в URL-адресі:

let socket = new WebSocket("ws://javascript.info");

Також існує зашифрований протокол wss://. Це як HTTPS для вебсокетів.

Завжди віддавайте перевагу wss://

Протокол wss:// не тільки зашифрований, але й більш надійний.

Це тому, що дані ws:// не зашифровані та видимі для будь-якого посередника. Старі проксі-сервери не знають про WebSocket, тож вони можуть побачити “дивні” заголовки та перервати з’єднання.

З іншого боку, wss:// – це WebSocket що використовує TLS (так само, як HTTPS – це HTTP що використовує TLS), тобто рівень безпеки транспорту шифрує дані у відправника та розшифровує в одержувача. Таким чином, пакети даних передаються зашифрованими, проксі не можуть побачити, що всередині, і пропускають їх.

Після того, як сокет створено, ми повинні прослуховувати його події. Всьго їх 4:

  • open – з’єднання встановлено,
  • message – отримані дані,
  • error – помилка websocket,
  • close – з’єднання закрите.

…І якщо ми хочемо щось надіслати, то виклик socket.send(data) зробить це.

Ось приклад:

let socket = new WebSocket("wss://javascript.info/article/websocket/demo/hello");

socket.onopen = function(e) {
  alert("[open] З’єднання встановлено");
  alert("Відправка на сервер");
  socket.send("Мене звати Джон");
};

socket.onmessage = function(event) {
  alert(`[message] Дані отримані із сервера: ${event.data}`);
};

socket.onclose = function(event) {
  if (event.wasClean) {
    alert(`[close] З’єднання закрите чисто, код=${event.code} причина=${event.reason}`);
  } else {
    // наприклад сервер завершив процес або мережа не працює
    // у цьому випадку event.code зазвичай дорівнює 1006
    alert('[close] З’єднання перервано');
  }
};

socket.onerror = function(error) {
  alert(`[error]`);
};

Для демонстраційних цілей є невеликий сервер server.js, написаний на Node.js, для прикладу вище. Він відповідає “Привіт із сервера, Джон”, потім чекає 5 секунд і закриває з’єднання.

Таким чином, ви побачите події openmessageclose.

Ось і все, ми вже можемо використовувати WebSocket. Досить просто, чи не так?

Тепер поговоримо глибше.

Відкриття веб-сокета

Коли створюється new WebSocket(url), він починає підключатися негайно.

Під час підключення браузер (за допомогою заголовків) запитує сервер: “Чи підтримуєте ви Websocket?” І якщо сервер відповідає “так”, то розмова продовжується за протоколом WebSocket, який взагалі не є HTTP.

Ось приклад заголовків браузера для запиту, зробленого new WebSocket("wss://javascript.info/chat").

GET /chat
Host: javascript.info
Origin: https://javascript.info
Connection: Upgrade
Upgrade: websocket
Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q==
Sec-WebSocket-Version: 13
  • Origin – джерело клієнтської сторінки, наприклад https://javascript.info. Об’єкти WebSocket є перехресними за своєю природою. Немає спеціальних заголовків чи інших обмежень. Старі сервери все одно не можуть обробляти WebSocket, тому проблем із сумісністю немає. Але заголовок Origin важливий, оскільки він дозволяє серверу вирішувати, спілкуватися чи ні WebSocket з цим веб-сайтом.
  • Connection: Upgrade – сигналізує про те, що клієнт хоче змінити протокол.
  • Upgrade: websocket – запитуваний протокол – “websocket”.
  • Sec-WebSocket-Key – випадковий ключ, згенерований браузером, який використовується для забезпечення підтримки сервером протоколу WebSocket. Він є випадковим, щоб проксі-сервери не кешували будь-які наступні повідомлення.
  • Sec-WebSocket-Version – версія протоколу WebSocket, 13 є поточною.
Рукостискання WebSocket неможливо емулювати

Ми не можемо використати XMLHttpRequest або fetch для виконання такого роду HTTP-запитів, оскільки JavaScript заборонено встановлювати ці заголовки.

Якщо сервер погоджується перейти на WebSocket, він повинен надіслати відповідь з кодом 101:

101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g=

Тут Sec-WebSocket-Accept – це Sec-WebSocket-Key, перекодований за допомогою спеціального алгоритму. Браузер використовує його, щоб переконатися, що відповідь відповідає запиту.

Після цього дані передаються по протоколу WebSocket, скоро ми побачимо його структуру (“фрейми”). І це зовсім не HTTP.

Розширення та підпротоколи

Можуть бути додаткові заголовки Sec-WebSocket-Extensions та Sec-WebSocket-Protocol, які описують розширення та підпротоколи.

Наприклад:

  • Sec-WebSocket-Extensions: deflate-frame означає, що браузер підтримує стиснення даних. Розширення(extension) – це щось, пов’язане з передачею даних, тобто функціональність, яка розширює протокол WebSocket. Заголовок Sec-WebSocket-Extensions автоматично надсилається браузером зі списком усіх розширень, які він підтримує.

  • Sec-WebSocket-Protocol: soap, wamp означає, що ми хочемо передати не просто будь-які дані, а дані в SOAP або WAMP (“The WebSocket Application Messaging Protocol”). Підпротоколи WebSocket зареєстровані в каталозі IANA. Отже, цей заголовок описує формати даних, які ми збираємося використовувати.

    Цей необов’язковий заголовок встановлюється за допомогою другого параметра new WebSocket у вигляді масиву підпротоколів. Наприклад, якщо ми хочемо використовувати SOAP або WAMP:

    let socket = new WebSocket("wss://javascript.info/chat", ["soap", "wamp"]);

Сервер повинен відповісти зі списком протоколів і розширень, які він погоджується використовувати.

Наприклад, запит:

GET /chat
Host: javascript.info
Upgrade: websocket
Connection: Upgrade
Origin: https://javascript.info
Sec-WebSocket-Key: Iv8io/9s+lYFgZWcXczP8Q==
Sec-WebSocket-Version: 13
Sec-WebSocket-Extensions: deflate-frame
Sec-WebSocket-Protocol: soap, wamp

Відповідь:

101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: hsBlbuDTkk24srzEOTBUlZAlC2g=
Sec-WebSocket-Extensions: deflate-frame
Sec-WebSocket-Protocol: soap

Тут сервер відповідає, що підтримує розширення “deflate-frame”, і тільки SOAP із запитуваних підпротоколів.

Передача даних

Зв’язок WebSocket складається з “фреймів” – фрагментів даних, які можуть бути відправлені будь-якою стороною і можуть бути кількох видів:

  • “текстові фрейми” – містять текстові дані, які сторони надсилають одна одній.
  • “фрейми двійкових даних” – містять бінарні дані, які сторони надсилають одна одній.
  • “ping/pong фрейми” використовуються для перевірки з’єднання, надсилаються сервером, браузер реагує на них автоматично.
  • також існує “фрейм закриття з’єднання” та кілька інших сервісних фреймів.

У браузері ми безпосередньо працюємо тільки з текстовими або двійковими фреймами.

Метод WebSocket .send() може надсилати текстові або двійкові дані.

Виклик socket.send(body) дозволяє відправляти body у вигляді рядка або двійковому форматі, включаючи Blob, ArrayBuffer тощо. Ніяких налаштувань не потрібно: просто надсилайте в будь-якому форматі.

Коли ми отримуємо дані, текст завжди надходить у вигляді рядка. А для двійкових даних ми можемо вибирати між форматами Blob і ArrayBuffer.

Це встановлюється властивістю socket.binaryType, і типово використовується "blob", тому двійкові дані надходять як об’єкти Blob.

Blob – це двійковий об’єкт високого рівня, він безпосередньо інтегрується з тегами <a>, <img> та іншими, тому це нормальне типове значення. Але для двійкової обробки, щоб отримати доступ до окремих байтів даних, ми можемо змінити його на "arraybuffer":

socket.binaryType = "arraybuffer";
socket.onmessage = (event) => {
  // event.data є або рядком (якщо текст) або arraybuffer (якщо двійковий)
};

Обмеження швидкості

Уявіть, що наш додаток генерує багато даних для надсилання. Але у користувача повільне підключення до мережі, можливо, через мобільний інтернет чи погане покриття за містом.

Ми можемо викликати socket.send(data) знову і знову. Але дані буферизуються (зберігаються) у пам’яті та надсилатимуться лише настільки швидко, наскільки це дозволяє швидкість мережі.

Властивість socket.bufferedAmount зберігає, скільки байтів залишилося у буфері в цей момент, очікуючи відправлення мережею.

Ми можемо перевірити його, щоб побачити, чи дійсно сокет доступний для передачі.

// кожні 100 мс перевіряємо сокет і надсилаємо більше даних
// тільки якщо були надіслані всі наявні дані
setInterval(() => {
  if (socket.bufferedAmount == 0) {
    socket.send(moreData());
  }
}, 100);

Закриття з’єднання

Зазвичай, коли одна зі сторін хоче закрити з’єднання (і браузер, і сервер мають рівні права), вони надсилають “фрейм закриття з’єднання” з цифровим кодом і текстовою причиною.

Метод для цього:

socket.close([code], [reason]);
  • code це спеціальний код закриття WebSocket (необов’язковий параметр)
  • reason це рядок, який описує причину закриття (необов’язковий параметр)

Після цього інша сторона в обробнику події close отримує код і причину, наприклад:

// закриваюча сторона:
socket.close(1000, "Work complete");

// інша сторона
socket.onclose = event => {
  // event.code === 1000
  // event.reason === "Work complete"
  // event.wasClean === true (clean close)
};

Найпоширеніші значення коду закриття:

  • 1000 – типове значення, звичайне закриття (використовується, якщо не вказано code),
  • 1006 – немає можливості встановити такий код вручну, вказує на те, що з’єднання було втрачено (немає закриваючого фрейму).

Є й інші коди, наприклад:

  • 1001 – одна зі сторін відключається, наприклад сервер вимикається або браузер залишає сторінку,
  • 1009 – повідомлення завелике для обробки,
  • 1011 – неочікувана помилка на сервері,
  • …і так далі.

Повний список можна знайти в RFC6455, §7.4.1.

Коди WebSocket дещо схожі на коди HTTP, але відрізняються. Зокрема, будь-які коди менше 1000 зарезервовані. Якщо ми спробуємо встановити такий код, виникне помилка.

// у разі розриву зв’язку
socket.onclose = event => {
  // event.code === 1006
  // event.reason === ""
  // event.wasClean === false (немає закриваючого фрейму)
};

Стан з’єднання

Щоб отримати стан з’єднання, додатково є властивість socket.readyState зі значеннями:

  • 0 – “CONNECTING”: з’єднання ще не встановлено,
  • 1 – “OPEN”: обмін даними,
  • 2 – “CLOSING”: з’єднання закривається,
  • 3 – “CLOSED”: з’єднання закрите.

Приклад чату

Розгляньмо приклад чату з використанням WebSocket API браузера та модуля WebSocket Node.js https://github.com/websockets/ws. Ми приділимо основну увагу клієнтській стороні, але сервер також простий.

HTML: нам потрібна форма <form> для надсилання повідомлень та <div> для вхідних повідомлень:

<!-- форма повідомлення -->
<form name="publish">
  <input type="text" name="message">
  <input type="submit" value="Send">
</form>

<!-- div з повідомленнями -->
<div id="messages"></div>

Від JavaScript ми хочемо трьох речей:

  1. Відкрити підключення.
  2. Під час надсилання форми – викликати socket.send(message) для повідомлення.
  3. При вхідному повідомленні – додайти його до div#messages.

Ось код:

let socket = new WebSocket("wss://javascript.info/article/websocket/chat/ws");

// надіслати повідомлення з форми
document.forms.publish.onsubmit = function() {
  let outgoingMessage = this.message.value;

  socket.send(outgoingMessage);
  return false;
};

// повідомлення отримано - показати повідомлення в div#messages
socket.onmessage = function(event) {
  let message = event.data;

  let messageElem = document.createElement('div');
  messageElem.textContent = message;
  document.getElementById('messages').prepend(messageElem);
}

Серверний код трохи виходить за рамки поточної теми. Тут ми будемо використовувати Node.js, але вам не обов’язково це робити. Інші платформи також мають свої засоби для роботи з WebSocket.

Алгоритм на стороні сервера буде таким:

  1. Створити clients = new Set() – набір сокетів.
  2. Для кожного прийнятого веб-сокета додати його до набору clients.add(socket) і налаштувати прослуховувач події message, щоб отримати повідомлення.
  3. Коли отримано повідомлення: переглянути клієнтів та надіслати його всім.
  4. Коли з’єднання закрито: clients.delete(socket).
const ws = new require('ws');
const wss = new ws.Server({noServer: true});

const clients = new Set();

http.createServer((req, res) => {
  // тут ми обробляємо лише з’єднання websocket
  // у реальному проекті у нас був би також код для обробки запитів, які не є веб-сокетами
  wss.handleUpgrade(req, req.socket, Buffer.alloc(0), onSocketConnect);
});

function onSocketConnect(ws) {
  clients.add(ws);

  ws.on('message', function(message) {
    message = message.slice(0, 50); // максимальна довжина повідомлення буде 50

    for(let client of clients) {
      client.send(message);
    }
  });

  ws.on('close', function() {
    clients.delete(ws);
  });
}

Ось робочий приклад:

Ви також можете завантажити його (кнопка вгорі справа в iframe) та запустити локально. Тільки не забудьте перед запуском встановити Node.js і npm install ws.

Підсумки

WebSocket – це сучасний спосіб мати постійне з’єднання браузер-сервер.

  • WebSockets не має кросдоменних обмежень.
  • Він добре підтримується в браузерах.
  • Може надсилати/отримувати рядки та двійкові дані.

API простий.

Методи:

  • socket.send(data),
  • socket.close([code], [reason]).

Події:

  • open,
  • message,
  • error,
  • close.

WebSocket сам по собі не включає повторне підключення, аутентифікацію та багато інших високорівневих механізмів. Тому для цього існують клієнтські та серверні бібліотеки, а також ці можливості можна реалізувати вручну.

Іноді, щоб інтегрувати WebSocket в наявний проект, люди запускають WebSocket-сервер паралельно з основним HTTP-сервером, і вони спільно використовують єдину базу даних. Запити до WebSocket використовують wss://ws.site.com, субдомен, який веде до сервера WebSocket, а https://site.com спрямовується на основний HTTP-сервер.

Звісно, можливі й інші шляхи інтеграції.

Навчальна карта

Коментарі

прочитайте це, перш ніж коментувати…
  • Якщо у вас є пропозиції, щодо покращення підручника, будь ласка, створіть обговорення на GitHub або одразу створіть запит на злиття зі змінами.
  • Якщо ви не можете зрозуміти щось у статті, спробуйте покращити її, будь ласка.
  • Щоб вставити код, використовуйте тег <code>, для кількох рядків – обгорніть їх тегом <pre>, для понад 10 рядків – використовуйте пісочницю (plnkr, jsbin, codepen…)