18 августа 2019 г.

COMET с XMLHttpRequest: длинные опросы

Материал на этой странице устарел, поэтому скрыт из оглавления сайта.

Более новая информация по этой теме находится на странице https://learn.javascript.ru/long-polling.

В этой главе мы рассмотрим способ организации COMET, то есть непрерывного получения данных с сервера, который очень прост и подходит в 90% реальных случаев.

Частые опросы

Первое решение, которое приходит в голову для непрерывного получения событий с сервера – это «частые опросы» (polling), т.е периодические запросы на сервер: «эй, я тут, изменилось ли что-нибудь?». Например, раз в 10 секунд.

В ответ сервер во-первых помечает у себя, что клиент онлайн, а во-вторых посылает сообщение, в котором в специальном формате содержится весь пакет событий, накопившихся к данному моменту.

При этом, однако, возможна задержка между появлением и получением данных, как раз в размере этих 10 секунд между запросами.

Другой минус – лишний входящий трафик на сервер. При каждом запросе браузер передаёт множество заголовков и в ответ получает, кроме данных, также заголовки. Для некоторых приложений трафик заголовков может в 10 и более раз превосходить трафик реальных данных.

Недостатки
  • Задержки между событием и уведомлением.
  • Лишний трафик и запросы на сервер.
Достоинства
  • Простота реализации.

Причём, простота реализации тут достаточно условная. Клиентская часть – довольно проста, а вот сервер получает сразу большой поток запросов.

Даже если клиент ушёл пить чай – его браузер каждые 10 секунд будет «долбить» сервер запросами. Готов ли сервер к такому?

Длинные опросы

Длинные опросы – отличная альтернатива частым опросам. Они также удобны в реализации, и при этом сообщения доставляются без задержек.

Схема:

  1. Отправляется запрос на сервер.
  2. Соединение не закрывается сервером, пока не появится сообщение.
  3. Когда сообщение появилось – сервер отвечает на запрос, пересылая данные.
  4. Браузер тут же делает новый запрос.

Ситуация, когда браузер отправил запрос и держит соединение с сервером, ожидая ответа, является стандартной и прерывается только доставкой сообщений.

Схема коммуникации:

При этом если соединение рвётся само, например, из-за ошибки в сети, то браузер тут же отсылает новый запрос.

Примерный код клиентской части:

function subscribe(url) {
  var xhr = new XMLHttpRequest();

  xhr.onreadystatechange = function() {
    if (this.readyState != 4) return;

    if (this.status == 200) {
      onMessage(this.responseText);
    } else {
      onError(this);
    }

    subscribe(url);
  }
  xhr.open("GET", url, true);
  xhr.send();
}

Функция subscribe делает запрос, при ответе обрабатывает результат, и тут же запускает процесс по новой.

Сервер, конечно же, должен уметь работать с большим количеством таких «ожидающих» соединений.

Демо: чат

Демо:

Результат
browser.js
server.js
index.html
// Посылка запросов -- обычными XHR POST
function PublishForm(form, url) {

  function sendMessage(message) {
    var xhr = new XMLHttpRequest();
    xhr.open("POST", url, true);
    // просто отсылаю сообщение "как есть" без кодировки
    // если бы было много данных, то нужно было бы отослать JSON из объекта с ними
    // или закодировать их как-то иначе
    xhr.send(message);
  }

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

// Получение сообщений, COMET
function SubscribePane(elem, url) {

  function showMessage(message) {
    var messageElem = document.createElement('div');
    messageElem.appendChild(document.createTextNode(message));
    elem.appendChild(messageElem);
  }

  function subscribe() {
    var xhr = new XMLHttpRequest();
    xhr.onreadystatechange = function() {
      if (this.readyState != 4) return;

      if (this.status == 200) {
        if (this.responseText) {
          // сервер может закрыть соединение без ответа при перезагрузке
          showMessage(this.responseText);
        }
        subscribe();
        return;
      }

      if (this.status != 502) {
        // 502 - прокси ждал слишком долго, надо пересоединиться, это не ошибка
        showMessage(this.statusText); // показать ошибку
      }

      setTimeout(subscribe, 1000); // попробовать ещё раз через 1 сек
    }
    xhr.open("GET", url, true);
    xhr.send();
  }

  subscribe();

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

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

var subscribers = {};

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

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

  subscribers[id] = res;
  //console.log("новый клиент " + id + ", клиентов:" + Object.keys(subscribers).length);

  req.on('close', function() {
    delete subscribers[id];
    //console.log("клиент "+id+" отсоединился, клиентов:" + Object.keys(subscribers).length);
  });

}

function publish(message) {

  //console.log("есть сообщение, клиентов:" + Object.keys(subscribers).length);

  for (var id in subscribers) {
    //console.log("отсылаю сообщение " + id);
    var res = subscribers[id];
    res.end(message);
  }

  subscribers = {};
}

function accept(req, res) {
  var 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');
    var message = '';
    req.on('data', function(chunk) {
      message += chunk;
    }).on('end', function() {
      publish(message); // собственно, отправка
      res.end("ok");
    });

    return;
  }

  // всё остальное -- статика
  fileServer.serve(req, res);

}


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

if (!module.parent) {
  http.createServer(accept).listen(8080);
  console.log('Сервер запущен на порту 8080');
} else {
  exports.accept = accept;

  process.on('SIGINT', function() {
    for (var id in subscribers) {
      var res = subscribers[id];
      res.end();
    }
  });
}
<!DOCTYPE html>
<html>

<head>
  <meta charset="utf-8">
  <script src="browser.js"></script>
</head>

<body>

  Несколько человек при заходе на эту страницу будут получать сообщения друг друга.

  <form name="publish">
    <input type="text" name="message" />
    <input type="submit" value="Отправить" />
  </form>

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

  <script>
    new PublishForm(document.forms.publish, 'publish');
    // random url to fix https://code.google.com/p/chromium/issues/detail?id=46104
    new SubscribePane(document.getElementById('subscribe'), 'subscribe?random=' + Math.random());
  </script>

</body>

</html>

Область применения

Длинные опросы отлично работают в тех случаях, когда сообщения приходят редко.

При большом количестве частых сообщений график приёма-отправки, приведённый выше, превращается в «пилу». Каждое сообщение – это новый запрос, дополнительный трафик заголовков.

В этих случаях используются другие способы получения данных, подразумевающие непрерывное соединение с сервером. Мы рассмотрим их в следующих главах.

Карта учебника

Комментарии

перед тем как писать…
  • Если вам кажется, что в статье что-то не так - вместо комментария напишите на GitHub.
  • Для одной строки кода используйте тег <code>, для нескольких строк кода — тег <pre>, если больше 10 строк — ссылку на песочницу (plnkr, JSBin, codepen…)
  • Если что-то непонятно в статье — пишите, что именно и с какого места.