Серверные события

81

Объект XMLHttpRequest позволяет веб-странице задать серверу вопрос и получить на него немедленный ответ. Но это обмен типа "один к одному" — после предоставления сервером ответа данный сеанс взаимодействия завершается. Веб-сервер не может повременить несколько минут и отправить странице другое сообщение с обновленной информацией.

Но некоторые типы веб-страницы могли бы извлечь пользу из более длительных сеансов взаимодействия с сервером. Возьмем, например, страницу биржевых котировок на Google Finance. Если оставить это страницу открытой, то периодически мы будем видеть автоматическое обновление котировок. Подобным образом доставляются новые сообщения в ящик поступающих сообщений любой программы электронной почты с веб-интерфейсом.

Во всех этих примерах применяется метод, называющийся опросом (polling). Суть его состоит в том, что веб-страница периодически (например, каждые несколько минут) запрашивает у веб-сервера новые данные. Для реализации этого механизма обновления веб-страницы используется функция JavaScript setInterval() или setTimeout(), которая активирует код по истечении установленного периода времени.

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

Одно из возможных решений этой проблемы — это использование отправляемых сервером событий (server-sent events), что позволяет удерживать открытым подключение к веб-серверу. Веб-сервер может отправлять странице сообщения в любое время, и при этом отсутствует необходимость постоянно отключаться и подключаться к серверу и исполнять один и тот же сценарий. (Но если в этом есть необходимость, то можно использовать и опрос, т.к. отправляемые сервером события поддерживают эту возможность.)

Лучшее этой технологии в том, с какой легкостью используется система отправляемых сервером событий, а также в том, что она работает на большинстве веб-хостов и исключительно надежная. Но это сравнительно новая система с соответствующим уровнем поддержки в браузерах:

Поддержка браузерами системы отправляемых сервером событий
Браузер IE Firefox Chrome Safari Opera Safari iOS Android
Минимальная версия - 6 5 5 11 4 4

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

Формат сообщений

В отличие от объекта XMLHttpRequest, система отправляемых сервером событий не разрешает передавать данные в произвольном формате, а требует придерживаться простого, но установленного формата. Каждое сообщение должно начинаться текстом data:, за которым следует собственно текст сообщения, а в заключение — последовательность символов перехода на новую строку, которая во многих языках программирования состоит из символов \n\n.

Вот пример текста сообщения:

data: Это сообщение было отправлено вам веб-сервером. \n\n

Сообщение разрешается разбить на несколько частей. Для этого используется последовательность символов окончания строки, которая часто состоит из одной пары \n. Это облегчает отправку сложных данных, наподобие следующих:

data: Это сообщение было отправлено вам веб-сервером.\n
data: Надеемся, вам оно понравится.\n\n

Обратите внимание, что каждую часть сообщения нужно начинать текстом data:, а все сообщение завершать признаком перехода на новую строку \n\n.

Этот метод можно использовать даже для отправки данных в формате JSON, что позволяет преобразовать текст в объект в один прием:

data: {\n
data: "messageType", "statusUpdate",\n
data: "messageData", "Work in progress",\n
data: }\n\n

Вместе с данными сообщения веб-сервер может отправить однозначное идентифицирующее значение (используя префикс id:) и время тайм-аута для подключения (используя префикс retry:):

id: 495\n
retry: 15000\n
data: Это сообщение было отправлено вам веб-сервером.\n\n

Веб-страница обращает внимание только на данные сообщения и игнорирует идентификатор сообщения и время тайм-аута. Этими подробностями занимается браузер. Например, прочитав предыдущее сообщение, браузер знает, если он потеряет подключение к веб-серверу, то должен попытаться подключиться повторно после 15 000 мс (т.е. 15 секунд). При повторном подключении браузер также должен отправить номер идентификатора 495, чтобы сервер мог его распознать.

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

Отправка сообщений с помощью серверного сценария

Зная формат сообщений, мы можем с легкостью создать серверный код для их отправки. Опять же, для создания простого примера, который будет поддерживаться практически всеми веб-хостами, удобно использовать язык серверных сценариев PHP. На рисунке ниже показана страница, которая получает регулярные сообщения от сервера. В данном случае сообщения предназначены просто для демонстрационных целей и содержат лишь текущее время на веб-сервере:

Прослушивание серверных событий

Когда эта страница находится в режиме прослушивания, она получает постоянный поток сообщений от веб-сервера — приблизительно одно сообщение каждую секунду. Каждое сообщение добавляется в верх прокручиваемого списка, а табло внизу отображает время получения последнего сообщения.

Серверная часть этого примера просто информирует о текущем времени по регулярным интервалам. Весь код PHP для этого выглядит так:

<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');

// Функция отправки сообщения
function sendMsg($id, $msg) {
  echo "id: $id" . PHP_EOL;
  echo "data: $msg" . PHP_EOL;
  echo PHP_EOL;
  ob_flush();
  flush();
}

// Запускаем бесконечный цикл
while(true) {
  // Получаем текущее время
  $serverTime = time();
  
  // Отправляем полученное время в сообщении
  sendMsg($serverTime, 'Новое время: ' . date("h:i:s", time()));
  
  // Ожидаем 1 секунду перед тем, как создавать новое сообщение
  sleep(1);
}
?>

В начале этого сценария устанавливаются два важных заголовка. Сначала MIME-типу присваивается значение text/event-stream, что требуется стандартом для отправляемых сервером событий. Потом веб-серверу (а также прокси-серверам) дается указание отключить кэширование. Если этого не сделать, то сообщения могут прибывать в пакетах по нерегулярным интервалам.

Обратите внимание, что в этом коде сообщение завершается константой PHP_EOL, которая представляет комбинацию символов \n, обозначающих конец строки.

Функция flush() обеспечивает немедленную отправку данных, а не помещение в буфер для отправки после завершения выполнения кода PHP. Функция sleep() приостанавливает исполнение кода на секунду, после чего начинает исполнение новой итерации цикла.

Обработка сообщений в веб-странице

Создание веб-страницы для обработки отправляемых сообщений даже проще, чем кода для отправки этих сообщений. Блок <body> страницы разделяется на три блока <div> — один для окна списка сообщений, другой для табло для отображения текущего времени и один для кнопок запуска и остановки процесса:

<div id="messageLog">[Лог сообщений:]</div>
<div id="timeDisplay">[Время не получено]</div>
<div id="controls">
   <button onclick="startListening()">Начать прослушивание</button>
   <button onclick="stopListening()">Остановить прослушивание</button>
</div>
body {
  font-family: Verdana;
  font-size: 12px;
}

#messageLog {
  width: 400px;
  height: 230px;
  border: darkgray 2px solid;
  border-radius: 5px;
  margin: 20px;
  padding: 10px;
  overflow: scroll;
  overflow-x: hidden;
}

#timeDisplay {
  color: darkblue;
  font-size: 30px;
  width: 400px;
  font-weight: bold;
  border: black 1px solid;
  border-radius: 10px;
  margin: 10px 20px 10px 20px;
  padding: 10px;
  background-color: #FBF3CB;
}

button {
  padding: 8px;
  margin: 5px 0px 0px 20px;
}

При загрузке страница находит элементы messageLog и timeDisplay и сохраняет их в глобальных переменных, что обеспечивает всем нашим функциям легкий доступ к ним:

var messageLog;
var timeDisplay;

window.onload = function() {  
  messageLog = document.getElementById("messageLog");
  timeDisplay = document.getElementById("timeDisplay");
}

Процесс отправки сообщений веб-сервером и получения их страницей начинается нажатием кнопки "Начать прослушивание". В это время код создает новый объект EventSource, предоставляя URL серверного ресурса, который будет отправлять сообщения. (В данном примере это PHP-сценарий server_events.php.) Потом к событию onMessage подключается функция receiveMessage, которая срабатывает при каждом получении сообщения страницей:

function startListening() {
  source = new EventSource("server_events.php");
  source.onmessage = receiveMessage;
  messageLog.innerHTML += "<br>" + "Начинаем слушать сообщения."
}

function receiveMessage(event) {
  messageLog.innerHTML += "<br>" + event.data;
  timeDisplay.innerHTML = event.data;
}

Обратите внимание, что из сообщения удалена вся служебная информация (текст data: и признак перехода на новую строку \n\n), и отображается только требуемое содержимое.

Наконец, прослушивание событий сервера страницей можно прекратить в любое время, вызвав метод close() объекта EventSource. Делается это так:

function stopListening() {
  source.close();
  messageLog.innerHTML += "<br>" + "Больше не прослушивать сообщения."
}

Опрос посредством серверных событий

В предыдущем примере мы рассмотрели наиболее простое использование системы серверных событий, которое, по большому счету, сводится к следующему: страница отправляет запрос, подключение остается открытым, и сервер периодически отправляет странице информацию. Браузеру может потребоваться выполнить повторное подключение (что он делает автоматически), но только в случае проблемы с подключением или же если оно было временно прервано по другим причинам (например, с целью экономии заряда батареи на мобильном устройстве).

Но что произойдет, если прекратится исполнение сценария на веб-сервере, и последний закроет подключение? Довольно интересно, хотя отключение выполняется не вследствие неполадок, а в результате целенаправленных действий веб-сервера, веб-страница автоматически возобновляет подключение (после ожидания, по умолчанию равному 3 секундам) и снова запрашивает данный сценарий, начиная его исполнение с самого начала.

Это поведение можно обернуть в свою пользу. Допустим, что мы создали сравнительно небольшой серверный сценарий, который отправляет только одно сообщение. Теперь действия веб-страницы похожи на действия при опросе в том, что она периодически восстанавливает подключение. Разница состоит лишь в том, что период между подключениями устанавливается веб-сервером, а не JavaScript-кодом страницы, как делается при использовании традиционного опроса.

В следующем примере используется комбинированный подход. Серверный сценарий удерживает подключение открытым и периодически отправляет сообщения в течение 1 минуты. Потом сценарий дает указание браузеру подключиться через 2 минуты и закрывает подключение:

<?php
header('Content-Type: text/event-stream');
header('Cache-Control: no-cache');

// 
echo "retry: 120000" . PHP_EOL;

$starttime = time();

// Функция отправки сообщения
function sendMsg($id, $msg) {
  echo "id: $id" . PHP_EOL;
  echo "data: $msg" . PHP_EOL;
  echo PHP_EOL;
  ob_flush();
  flush();
}

// Запускаем бесконечный цикл
while(true) {
  // Получаем текущее время
  $serverTime = time();
  
  // Отправляем полученное время в сообщении
  sendMsg($serverTime, 'Новое время: ' . date("h:i:s", time()));
  
  if ((time() - $starttime) > 60) {
	  die();
  }
  
  // Ожидаем 5 секунд перед тем, как создавать новое сообщение
  sleep(5);
}
?>

Теперь при просмотре страницы мы будем получать регулярные сообщения в течение 1 минуты, после чего следует перерыв в 2 минуты, после чего процесс повторяется. В настоящем приложении при закрытии подключения веб-сервер может отправить браузеру специальное сообщение, информирующее, что больше нет причин ожидать обновленные данные (например, потому что фондовые биржи закрылись). Тогда веб-страница могла бы прекратить процесс, вызвав метод close() объекта EventSource.

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

Пройди тесты
Лучший чат для C# программистов