6 вересня 2024 р.

Тривале опитування

Тривале опитування (Long polling) – це найпростіший спосіб реалізувати постійне з’єднання із сервером, без використання спеціального протоколу, такого як WebSocket або Server Side Events.

Будучи дуже простим у реалізації, він також достатньо хороший у багатьох випадках.

Періодичне опитування

Найпростіший спосіб отримати нову інформацію з сервера – періодичне опитування. Тобто регулярні запити до сервера: “Привіт, я тут, у вас є якась інформація для мене?”. Наприклад, раз на 10 секунд.

У відповідь сервер спочатку помічає собі, що клієнт онлайн, а по-друге – надсилає пакет повідомлень, які він отримав до цього моменту.

Це працює, але є і мінуси:

  1. Повідомлення передаються із затримкою до 10 секунд (між запитами).
  2. Навіть якщо повідомлень немає, сервер отримує запити кожні 10 секунд, навіть якщо користувач перейшов кудись в інше місце або сайт не використовується. З точки зору продуктивності, це може створювати велике навантаження.

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

Тривале опитування

Так зване “тривале опитування” (Long polling) – це набагато кращий спосіб опитування сервера.

Також він дуже простий у реалізації та дозволяє отримувати повідомлення без затримок.

Як це працює:

  1. На сервер надсилається запит.
  2. Сервер не закриває з’єднання, поки не з’явиться повідомлення для відправки.
  3. Коли з’являється повідомлення – сервер відповідає ним на запит.
  4. Браузер негайно робить новий запит.

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

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

Ось ескіз функції subscribe на стороні клієнта, яка виконує тривале опитування:

async function subscribe() {
  let response = await fetch("/subscribe");

  if (response.status == 502) {
    // Статус 502 -- це помилка тайм-ауту підключення,
    // вона може статися, коли з’єднання було відкрито занадто довго,
    // і віддалений сервер або проксі-сервер закрили його.
    // У цьому випадку повторюємо підключення
    await subscribe();
  } else if (response.status != 200) {
    // У разі помилки -- покажемо її
    showMessage(response.statusText);
    // Повторне підключення через одну секунду
    await new Promise(resolve => setTimeout(resolve, 1000));
    await subscribe();
  } else {
    // Отримуємо та показуємо повідомлення
    let message = await response.text();
    showMessage(message);
    // Знову викликаємо subscribe(), щоб отримати наступне повідомлення
    await subscribe();
  }
}

subscribe();

Як бачите, функція subscribe виконує запит, потім чекає на відповідь, обробляє її та знову викликає сама себе.

Сервер має підтримувати багато відкритих з’єднань

Архітектура сервера повинна вміти працювати з великою кількістю незавершених з’єднаннь.

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

Це досить часто трапляється з серверами, які використовують мови PHP і Ruby.

Сервери, написані за допомогою Node.js, зазвичай не мають таких проблем.

Тим не менш, це не проблема мови програмування. Більшість сучасних мов, включаючи PHP і Ruby, дозволяють реалізувати належну роботу сервера. Просто переконайтеся, що архітектура вашого сервера добре працює з багатьма одночасними з’єднаннями.

Демо: чат

Ось демонстраційний чат, який ви також можете завантажити та запустити локально (якщо ви знайомі з Node.js і вмієте встановлювати модулі):

Результат
browser.js
server.js
index.html
// Відправляємо повідомлення простим POST-запитом
function PublishForm(form, url) {

  function sendMessage(message) {
    fetch(url, {
      method: 'POST',
      body: message
    });
  }

  form.onsubmit = function() {
    let message = form.message.value;
    if (message) {
      form.message.value = '';
      sendMessage(message);
    }
    return false;
  };
}

// Отримуємо повідомлення за допомогою тривалого опитування
function SubscribePane(elem, url) {

  function showMessage(message) {
    let messageElem = document.createElement('div');
    messageElem.append(message);
    elem.append(messageElem);
  }

  async function subscribe() {
    let response = await fetch(url);

    if (response.status == 502) {
      // Тайм-аут з’єднання
      // відбувається, коли з’єднання було відкрито занадто довго,
      // відновлюємо з’єднання
      await subscribe();
    } else if (response.status != 200) {
      // Показуємо помилку
      showMessage(response.statusText);
      // Повторне підключення через одну секунду
      await new Promise(resolve => setTimeout(resolve, 1000));
      await subscribe();
    } else {
      // Отримано повідомлення
      let message = await response.text();
      showMessage(message);
      await subscribe();
    }
  }

  subscribe();

}
let http = require('http');
let url = require('url');
let querystring = require('querystring');
let static = require('node-static');

let fileServer = new static.Server('.');

let subscribers = Object.create(null);

function onSubscribe(req, res) {
  let id = Math.random();

  res.setHeader('Content-Type', 'text/plain;charset=utf-8');
  res.setHeader("Cache-Control", "no-cache, must-revalidate");

  subscribers[id] = res;

  req.on('close', function() {
    delete subscribers[id];
  });

}

function publish(message) {

  for (let id in subscribers) {
    let res = subscribers[id];
    res.end(message);
  }

  subscribers = Object.create(null);
}

function accept(req, res) {
  let urlParsed = url.parse(req.url, true);

  // новий клієнт хоче отримати повідомлення
  if (urlParsed.pathname == '/subscribe') {
    onSubscribe(req, res);
    return;
  }

  // надсилаємо повідомлення
  if (urlParsed.pathname == '/publish' && req.method == 'POST') {
    // приймаємо запит POST
    req.setEncoding('utf8');
    let message = '';
    req.on('data', function(chunk) {
      message += chunk;
    }).on('end', function() {
      publish(message); // надсилаємо його для всіх користувачів
      res.end("ok");
    });

    return;
  }

  // решта -- статичні файли
  fileServer.serve(req, res);

}

function close() {
  for (let id in subscribers) {
    let res = subscribers[id];
    res.end();
  }
}

// -----------------------------------

if (!module.parent) {
  http.createServer(accept).listen(8080);
  console.log('Server running on port 8080');
} else {
  exports.accept = accept;

  if (process.send) {
     process.on('message', (msg) => {
       if (msg === 'shutdown') {
         close();
       }
     });
  }

  process.on('SIGINT', close);
}
<!DOCTYPE html>
<script src="browser.js"></script>

Усі відвідувачі цієї сторінки будуть бачити повідомлення один одного.

<form name="publish">
  <input type="text" name="message" />
  <input type="submit" value="Надіслати" />
</form>

<div id="subscribe">
</div>

<script>
  new PublishForm(document.forms.publish, 'publish');
  // випадковий параметр URL-адреси, щоб уникнути проблем із кешуванням
  new SubscribePane(document.getElementById('subscribe'), 'subscribe?random=' + Math.random());
</script>

Браузерний код знаходиться в browser.js.

Область використання

Тривале опитування чудово працює в ситуаціях, коли повідомлення надходять рідко.

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

Кожне повідомлення є окремим запитом із заголовками, накладними витратами на аутентифікацію тощо.

У цьому випадку перевага віддається іншим методам, наприклад Websocket або Server Sent Events.

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