Масштабування WebSocket-з'єднань за допомогою розділюваних воркерів

11 хв. читання

Повний код з матеріалу можна знайти за посиланням.

Почнемо з визначень.

Вебсокети

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

Масштабування WebSocket-з'єднань за допомогою розділюваних воркерів

Проблема

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

Якщо клієнт відкриває 15 вкладок, у нього буде 15 відкритих з'єднань із сервером.

Тож з'ясуємо, як ми можемо зменшити навантаження в таких випадках.

Масштабування WebSocket-з'єднань за допомогою розділюваних воркерів

WebWorkers, SharedWorkers та BroadcastChannels приходять на допомогу

Вебворкери — просте рішення для вебконтенту під час запуску скриптів у фонових потоках. Потік воркера може виконувати завдання, не перериваючи користувацький інтерфейс. Сформований один раз воркер може надсилати повідомлення JavaScript-коду, з якого і був утворений. Повідомлення перехоплюють спеціальні обробники.

Розділювані воркери — тип вебворкерів, до яких можна отримати доступ з декількох контекстів браузера, тобто вікон, iframe або навіть воркерів подій.

Широкомовні канали дозволяють простий обмін даними між контекстами браузера (вікнами, вкладками, фреймами чи iframes) одного походження.

Всі наведені визначення з MDN.

Зменшуємо навантаження на сервер за допомогою розділюваних воркерів

Коли один клієнт має декілька з'єднань, відкритих одним браузером, на допомогу приходять розділювані воркери. Замість відкривати з'єднання з кожної вкладки/браузера, ми можемо використати SharedWorker для з'єднання з сервером.

Це з'єднання буде відкритим, поки всі вкладки з сайтом не закриються. Всі відкриті вкладки можуть використовувати єдине з'єднання для обміну даними з сервером.

Ми використаємо API широкомовних каналів для трансляції зміни стану вебсокета всім контекстам (вкладкам).

Налаштування базового Web Socket сервера

Перейдемо до коду. Для наочності ми налаштуємо дуже простий вебсервер, який підтримуватиме socket-з'єднання за допомогою npm-модуля ws. Ініціалізуємо npm-проєкт, використовуючи:

$ npm init

Одразу як отримаєте файл package.json, додайте ws-модуль та express для створення базового http-сервера:

$ npm install --save ws express

Далі створіть файл index.js з таким кодом, щоб налаштувати статичний сервер, який обслуговує файли з директорії public, використовуючи порт 3000, а для ws-сервера — порт 3001.

const  express  =  require("express");
const  path  =  require("path");
const  WebSocket  =  require("ws");
const  app  =  express();

// Використовуємо директорію public для запитів статичних файлів
app.use(express.static("public"));

// Запускаємо WS-сервер на порту 3001
const wss = new WebSocket.Server({ port: 3001 });

wss.on("connection", ws => {
  console.log('A new client connected!');
  ws.on("message", data => {
    console.log(`Message from client: ${data}`);

    // Модифікуємо вхідні дані та відправляємо
    const  parsed  =  JSON.parse(data);
    ws.send(
      JSON.stringify({
        ...parsed.data,
        // Додаткове поле field встановлюється сервером 
        // Розглянемо цей момент детальніше далі
        messageFromServer: `Hello tab id: ${parsed.data.from}`
      })
    );
  });
  ws.on("close", () => {
    console.log("Sad to see you go :(");
  });
});

// Відстежуємо запити статичних сторінок на порту 3000
const  server  =  app.listen(3000, function() {
  console.log("The server is running on http://localhost:"  +  3000);
});

Створюємо SharedWorker

Аби створити будь-який вид воркера в JavaScript, необхідно створити окремий файл, котрий визначає його обов'язки.

Всередині цього файлу треба визначити, що робити з воркером, коли його проініціалізовано. Цей код виконається лише раз, коли SharedWorker запуститься вперше. Доки ми не закриємо останню вкладку, з'єднану з воркером, ініціалізація не відбудеться повторно.

Ми можемо визначити обробника події onconnect, аби обробити процес з'єднання кожної вкладки до SharedWorker. Розглянемо файл worker.js:

// Відкриваємо з'єднання. Це спільне з'єднання 
//Тому буде відкритим лише один раз
const ws = new WebSocket("ws://localhost:3001");

// Створюємо широкомовний канал, аби сповіщати про зміни стану
const broadcastChannel = new BroadcastChannel("WebSocketChannel");

// Робимо мапінг, щоб відстежувати порти. Ви можете розглядати порти
// як посередників, через які ми можемо спілкуватись з вкладками
// Тут ми мапимо uuid кожного контексту (вкладки)
// до його порту. Ми вимушені робити так, адже Port API
// не має ідентифікатора, щоб визначити, звідки приходить повідомлення
const  idToPortMap  = {};

// Дамо знати всім з'єднаним контекстам (вкладкам) про зміни стану 
ws.onopen = () => broadcastChannel.postMessage({ type: "WSState", state: ws.readyState });
ws.onclose = () => broadcastChannel.postMessage({ type: "WSState", state: ws.readyState });

// Коли ми отримуємо дані з сервера 
ws.onmessage  = ({ data }) => {
  console.log(data);
  // Створюємо об'єкти, аби передати їх обробникам 
  const parsedData = { data:  JSON.parse(data), type:  "message" }
  if (!parsedData.data.from) {
    // Транслюємо всім контекстам (вкладкам). Усе тому, що
    // не було встановлено певного id полю from. 
    // Ми використовуємо це поле, аби визначити, яка вкладка надсилає повідомлення 
    broadcastChannel.postMessage(parsedData);
  } else {
    // Отримуємо порт, куди відправлятимемо повідомлення, використовуючи uuid
    // тобто до певної вкладки
    idToPortMap[parsedData.data.from].postMessage(parsedData);
  }
};

// Обробник події викликається, коли вкладка намагається з'єднатися з воркером 
onconnect = e => {
  // Отримуємо MessagePort з об'єкта event. Він буде каналом зв'язку між 
  // SharedWorker та вкладкою
  const  port  =  e.ports[0];
  port.onmessage  =  msg  => {
    // Розміщуємо інформацію про port у map
    idToPortMap[msg.data.from] =  port;
    
    // Перенаправляємо це повідомлення до ws-з'єднання.
    ws.send(JSON.stringify({ data:  msg.data }));
  };

  // Тепер треба сповістити щойно створений контекст про поточний стан 
  // WS-з'єднання.
  port.postMessage({ state: ws.readyState, type: "WSState"});
};

У фрагменті коду вище деякі речі могли здатися вам не дуже очевидними на початку. Роз'яснимо їх покроково:

  • Ми використовуємо Broadcast Channel API, аби транслювати зміни стану з сокета;
  • Ми використовуємо метод postMessage, аби встановити початковий стан контексту (вкладки);
  • Ми використовуємо поле from, яке передається вкладками (контекстами), щоб з'ясувати куди перенаправляти відповідь.
  • Якщо ж такого поля немає, ми транслюємо повідомлення всім з'єднаним контекстам.

Зверніть увагу: console.log тут не показуватимуть результат в консолі браузера. Для цього необхідно відкрити консоль SharedWorker. Аби відкрити інструменти розробника для SharedWorker, перейдіть на chrome://inspect.

Звертаємось до SharedWorker

Спочатку створимо HTML-сторінку, куди додамо скрипти для зв'язку з розділюваним воркером.

<!DOCTYPE  html>
<html  lang="en">
<head>
  <meta  charset="UTF-8"  />
  <title>Web Sockets</title>
</head>
<body>
  <script  src="https://cdnjs.cloudflare.com/ajax/libs/node-uuid/1.4.8/uuid.min.js"></script>
  <script  src="main.js"></script>
</body>
</html>

Отже, ми визначили наш воркер у файлі worker.js та створили HTML-сторінку. Розглянемо, як ми можемо використати спільне вебсокет-з'єднання з будь-якого контексту (вкладки). Створимо файл main.js з таким вмістом:

// Створюємо екземпляр SharedWorker, використовуючи файл worker.js. 
// Нам потрібно, щоб він був в усіх JS-файлах, які матимуть доступ до сокета 
const worker = new SharedWorker("worker.js");

// Створюємо унікальний ідентифікатор, використовуючи бібліотеку uuid. 
// Так ми зможемо визначити вкладку, з якої було відправлено повідомлення
// А якщо відповідь надсилається з сервера на цю вкладку, ми можемо  
// перенаправити її, використавши вже згаданий ідентифікатор.
const id = uuid.v4();

// Встановлюємо початковий стан вебсокета як з'єднання. 
// Модифікуватимемо його залежно від подій
let  webSocketState  =  WebSocket.CONNECTING;
console.log(`Initializing the web worker for user: ${id}`);

// З'єднуємось з shared worker
worker.port.start();

// Встановлюємо слухача подій, який або встановлює стан вебсокета
// Або обробляє дані, які приходять лише з поточної вкладки 
worker.port.onmessage = event => {
  switch (event.data.type) {
    case "WSState":
      webSocketState = event.data.state;
      break;
    case "message":
      handleMessageFromPort(event.data);
      break;
  }
};

// Налаштовуємо широкомовний канал на прослуховування подій вебсокета.
// Код тут подібний до обробника вище. 
// Але тепер обробник слухає події всіх вкладок. 
const broadcastChannel = new BroadcastChannel("WebSocketChannel");
broadcastChannel.addEventListener("message", event => {
  switch (event.data.type) {
    case  "WSState":
      webSocketState  =  event.data.state;
      break;
    case  "message":
      handleBroadcast(event.data);
      break;
  }
});

// Обробляємо широкомовні повідомлення сервера 
function  handleBroadcast(data) {
  console.log("This message is meant for everyone!");
  console.log(data);
}

// Обробляємо події, призначені лише для визначеної вкладки
function  handleMessageFromPort(data) {
  console.log(`This message is meant only for user with id: ${id}`);
  console.log(data);
}

// Використовуємо цей метод, аби відправити дані на сервер 
function  postMessageToWSServer(input) {
  if (webSocketState  ===  WebSocket.CONNECTING) {
    console.log("Still connecting to the server, try again later!");
  } else  if (
    webSocketState  ===  WebSocket.CLOSING  ||
    webSocketState  ===  WebSocket.CLOSED
  ) {
    console.log("Connection Closed!");
  } else {
    worker.port.postMessage({
      // Додаємо інформацію про відправника як uuid, щоб отримати відповідь назад 
      from:  id,
      data:  input
    });
  }
}

// Відправляємо повідомлення серверу після приблизно 2,5 секунд.
// Цього часу достатньо, аби було створено сокет-з'єднання.
setTimeout(() =>  postMessageToWSServer("Initial message"), 2500);```

Надсилання повідомлень SharedWorker

Як ми бачили вище, надіслати повідомлення можна за допомогою метода worker.port.postMessage(). Ви можете передати будь-який JS-об'єкт, масив, примітив.

Вдалим рішенням вважається передача об'єкта, що визначає, з якого контексту прийшло повідомлення, тож воркер може здійснити відповідні дії. Наприклад, якби у нас був чат і на одній з вкладок ми хотіли б надіслати повідомлення, то могли б використовувати подібний об'єкт:

{
  type: 'message',
  from: 'Tab1'
  value: {
    text: 'Hello',
    createdAt: new Date()
  }
}

Якби у нас був застосунок для обміну файлами, то при видаленні файлу, надсилаємо ту саму структуру, проте з іншим типом та значеннями:

{
  type: 'deleteFile',
  from: 'Tab2'
  value: {
    fileName: 'a.txt',
    deletedBy: 'testUser'
  }
}

Так воркер знатиме, що саме робити з даними.

Відстежуємо повідомлення воркера

На початку ми створили вкладку, щоб відстежувати MessagePorts різних вкладок. Тоді ми визначили обробник worker.port.onmessage для подій одразу з SharedWorker до вкладки.

Коли сервер не встановлює поле from, ми просто транслюємо повідомлення всім вкладкам, використовуючи широкомовний канал. Всі вкладки матимуть слухача повідомлень для WebSocketChannel, який оброблятиме усі трансляції повідомлень.

Подібні налаштування можуть використовуватись за двох сценаріїв:

  • Якщо ви граєте в гру на певній вкладці. Ви хочете, щоб повідомлення приходили лише на активну вкладку. Іншим вкладкам не потрібна буде ця інформація. Для такого випадку ви можете використовувати перший об'єкт;
  • Якщо ж ви граєте в гру на фейсбуці й отримали повідомлення, ця інформація має поширитись на всі вкладки, оскільки індикатор повідомлень повинен змінитись.

Остаточна діаграма

Отже, ми використали розділювані воркери для оптимізації вебсокетів. Поглянемо на фінальну послідовність процесів:

Масштабування WebSocket-з'єднань за допомогою розділюваних воркерів
Помітили помилку? Повідомте автору, для цього достатньо виділити текст з помилкою та натиснути Ctrl+Enter
Codeguida 5.8K
Приєднався: 8 місяців тому
Коментарі (0)

    Ще немає коментарів

Щоб залишити коментар необхідно авторизуватися.

Вхід / Реєстрація