Pull to refresh

Создание приложений реального времени с помощью Server-Sent Events

JavaScript *
Буквально недавно стало известно, что Firefox 6 получит SSE (уже есть в Opera 10.6+, Chrome, WebKit 5+, iOS Safari 4+, Opera Mobile 10+) так, что поддержка более половины всех браузеров (охват аудитории пользователей) уже не за горами. Настало время присмотреться к этой технологии. SSE предложил Ian Hickson более 7 лет назад, но только год назад она стала появляться в браузерах. У нас же есть WebSockets зачем нам ещё один какой-то протокол?! Но во всем есть свои плюсы и минусы, давайте посмотрим чем же SSE может быть полезен.

Идея SSE проста — клиент подписывается на события сервера и как только происходит событие — клиент сразу же получает уведомление и некоторые данные, связанные с этим событием. Чтобы понять полезность протокола SSE необходимо сравнить его с привычными методами получения событий, вкратце объясню их суть:

Polling


Самый простой, но самый не эффективный, метод: клиент раз в несколько секунд опрашивает сервер на наличие событий. Даже если ничего нет, то клиент всеравно делает запрос — а мало ли что придет.
Плюсы:
— Просто
— Данные могут быть пожаты
Минусы:
— Очень много лишних запросов
— События всегда приходят с опозданием
— Серверу приходится хранить события пока клиент не заберет их или пока они не устареют

Long Polling


Улучшенный вариант предыдущего метода. Клиент отправляет запрос на сервер, сервер держит открытым соединение пока не придут какие-нибудь данные или клиент не отключится самостоятельно. Как только данные пришли — отправляется ответ и соединение закрывается и открывается следующее и так далее.
Плюсы по сравнению с Polling:
— Минимальное количество запросов
— Высокая временная точность событий
— Сервер хранит события только на время реконнекта
Минусы по сравнению с Polling:
— Более сложная схема

WebSockets


Это бинарный дуплексный протокол, позволяющий клиенту и серверу общаться на равных. Этот протокол можно применять для игр, чатов и всех тех приложений где вам нужны предельно точные события близкие к реальному времени.
Плюсы по сравнению с Long Polling:
— Поднимается одно соединение
— Предельно высокая временная точность событий
— Управление сетевыми сбоями контролирует браузер
Минусы по сравнению с Long Polling:
— HTTP не совместимый протокол, нужен свой сервер, усложняется отладка

Так почему же стоит применять SSE, раз у нас есть такой прекрасный протокол WebSockets?! Во-первых не каждому веб-приложению необходима двусторонняя связь — подойдет и SSE. Во-вторых SSE — HTTP совместимый протокол и вы можете реализовать рассылку событий на любом веб-сервере.

Протокол Server-Sent Events


Клиент отправляет запрос на сервер, сервер в ответ отправляет следующий заголовок:
Content-Type: text/event-stream

И не закрывает соединение (на php можно создать бесконечный цикл, как сделать на node.js будет объеснено в примере статьи). Вот и все — SSE работает! Чтобы отправить клиенту какие-то данные сервер просто пишет в сокет строку следующего формата:
data: My message\n\n

Если необходимо отправить несколько строк данных, то формат будет следующим:
data: {\n
data: "msg": "hello world",\n
data: "id": 12345\n
data: }\n\n  

Вот, впринципе, и вся база протокола. Кроме этого сервер может отправлять id сообщения это нужно на случай если соединение было разорвано. Если соединение было сброшено, то клиент при попытке подключения отправит специальный заголовок (Last-Event-ID), чтобы восстановить утраченные события:
id: 12345\n
data: GOOG\n
data: 556\n\n

Время переподключения (retry) в случае ошибок:
retry: 10000\n
data: hello world\n\n

Поле id и retry не обязательны.

На клиенте все будет выглядеть следующим образом:
var source = new EventSource('http://localhost/stream.php');
source.addEventListener('message', function(e) {
  // Пришли какие-то данные
  console.log(e.data);
}, false);

source.addEventListener('open', function(e) {
  // Соединение было открыто
}, false);

source.addEventListener('error', function(e) {
  if (e.eventPhase == EventSource.CLOSED) {
    // Соединение закрыто
  }
}, false);

Все предельно просто. Давайте построим приложение на основе протокола SSE. Как водится, это будет чат.

Multipart XMLHTTPRequest


Ещё называют multipart streaming (Поддерживает только Firefox). Очень похожий на SSE протокол.
Его заголовок имеет фомат:
Content-type: multipart/x-mixed-replace;boundary=smthing

А части отсылаются в таком формате:
Content-type: text/html\r\n\r\n
--smthing\n
Message\n
--smthing\n


В клиенте создается обычный XHR, но перед отправкой запроса необходимо поставить флаг req.multipart = true;
Правда похоже на SSE? Подробнее

Есть ещё один протокол, который можно привести к SSE:

XMLHTTPRequest: Interactive


Для использования его необходима поддержка браузером специального readyState с кодом 3 (interactive) — этот статус сообщает о том, что часть данных пришла, но соединение ещё не закрыто. Для jQuery есть одноименный плагин, использующий readyState с кодом 3. И как всегда не все браузеры поддерживают readyState с кодом 3.

Пример: Чат на Server-Sent Events


Мы будем принимать поток событий по SSE: уход в оффлайн, приход в онлайн, сообщение. Т.к. по SSE нельзя отправлять сообщение, то мы будем отправлять их по HTTP.

Схема работы такая:
— При входе в чат запрашивается имя
— Клиент подключается к серверу чата. Создается поток событий.
— При подключении клиента чат рассылает всем событие: %username% online
— При отключении клиента чат рассылает всем событие: %username% offline
— Клиент может отправлять сообщение по HTTP «POST /message» Cервер принимает это сообщение и рассылает всем клиентам принятое сообщение по SSE

Разберем код клиента. Для того, чтобы у некоторых браузеров не было бесконечной загрузки мы вместо $.ready выполняем setTimeout:
setTimeout(function () { // Ставлю именно таймаут, а не $.ready иначе у вебкитов будет бесконечная загрузка
}, 50);

Запрашиваем имя пользователя:
// Получаем имя из localStorage и запрашиваем новое
var name = (prompt('Name:', window.localStorage ? window.localStorage['name'] || '' : '') || 'anonymous').substr(0, 20);

// Пытаемся сохранить имя
if (window.localStorage) {
    window.localStorage['name'] = name;
}

Создаем EventSource и передаем ему имя пользователя (теперь пользователь онлайн) и слушаем необходимые события:
var eventSrc = new EventSource("/event?name=" + name);

// Слушаем событие EventSource - "message"
eventSrc.addEventListener("message", function(event) {
    var data = JSON.parse(event.data);
    // Отрисовываем пришедшее с сервера сообщение
    renderMessage(data);
}, false);

// Слушаем событие EventSource - "error"
eventSrc.addEventListener("error", function(event) {
    // Сообщаем о проблеме с подключением
    renderMessage({
        isbot: true,
        message: 'connection error',
        name: '@Chat'
    });
}, false);

Не буду рассматривать метод renderMessage и разметку страницы. Весь код клиента можно посмотреть тут: index.html

На стороне сервера у нас будет Node.js. Тут все сложнее, но основная сложность в мультикасте сообщений от одного пользователя ко всем, а не в построении коммуникации по SSE.

Подключаем необходимые модули

var http = require('http'),
    fs = require('fs'),
    qs = require('querystring'),
    parse = require('url').parse;

// Кэшируем статику (index.html мы будет отдавать с помошью Node.js)
var indexFile = fs.readFileSync('index.html'); // Buffer

Роуты

Создаем список роутов Routes, включающий в себя следующие объекты:
1. Статика. Индексная страница, мы просто шлем статику:
    'GET /': function (request, response) {
        // Шлем правильные заголовки
        response.writeHead(200, {'Content-Type': 'text/html; charset=UTF-8'});
        response.write(indexFile);
        response.end();
    }

2. Поднятие SSE соедиения:
    'GET /event': function (request, response) {
        var url = parse(request.url, true);
        var name = (url.query.name || 'anonymous').substr(0, 20);
        var clientId = Clients.generateClientId();

        // Шлем спец заголовок для EventSource
        response.writeHead(200, {'Content-Type': 'text/event-stream'});

        // Выставляем больший таймаут на сокет, иначе сокет запроется через 2 минуты
        request.socket.setTimeout(1000 * 60 * 60); // 1 Час

        // Если соединение упало - удаляем этого клиента
        request.on('close', function () {
            Clients.remove(clientId);
        });

        // Добавляем клиента в список
        Clients.add(clientId, response, name);
    }

3. Сообщение от клиента:
    'POST /message': function (request, response) {
        var data = '';
        // Пришел кусочек тела POST
        request.on('data', function (chunk) {
            data += chunk;
        });

        // Все кусочки POST тела собраны
        request.on('end', function () {
            // Парсим тело
            data = qs.parse(data);

            // Рассылаем всем сообщение
            Clients.broadcast(data.message, data.name, false);
            response.writeHead(200);
            response.end();
        });
    }

4. Роут по умолчанию — Страница 404:
    $: function (request, response) {
        response.writeHead(404);
        response.end();
    }

Менеджер клиентов — Clients

При добавлении нового клиента (add) менеджер рассылает все сообщение о том, что клиент пришел:
    add: function (clientId, response, name) {
        this._clients[clientId] = {response: response, name: name || 'anonymous'};
        this.count++;

        // Рассылаем сообщения от имени бота
        this.unicast(clientId, 'Hello, ' + name + '! Online ' + this.count, '@ChatBot', true);
        this.broadcast(name + ' online', '@ChatBot', true);
    }

При удалении закрывает соединение и рассылает всем, что клиент оффлайн:
    remove: function (clientId) {
        // Если клиента нет, то ничего не делаем
        var client = this._clients[clientId];
        if (!client) {
            return;
        }
        // Закрываем соединение
        client.response.end();
        // Удаляем клиента
        delete this._clients[clientId];
        this.count--;
        
        // Сообщаем всем оставшимся, что он вышел
        // Рассылаем сообщения от имени бота
        this.broadcast(client.name + ' offline', '@ChatBot', true);
    }

Для отправки сообщений клиентам используется private метод _send:
    _send: function (clients, message, name, isbot) {
        if (!message || !name) {
            return;
        }
        // Подготавливаем сообщение
        var data = JSON.stringify({
            message: message.substr(0, 1000),
            name: (name || 'anonymous').substr(0, 20),
            isbot: isbot || false
        });

        // Создаем новый буфер, чтобы при большом количестве клиентов
        // Отдача была более быстрой из-за особенностей архитектуры Node.js
        data = new Buffer("data: " + data + "\n\n", 'utf8'); // Формат сообщение SSE

        // Рассылаем всем
        clients.forEach(function (client) {
            client.response.write(data); // Отсылаем буфер
        });
    }

Метод _send используют public методы broadcast и unicast для рассылки сообщения всем и одному клиенту соответственно.

Создаем и включаем сервер

// Создаем сервер
var httpServer = http.createServer(function (request, response) {
    var key = request.method + ' ' + parse(request.url).pathname;

    // Если роута нет, то отдаем по умолчанию Routes.$ - 404
    (Routes[key] || Routes.$)(request, response);
});

// Включаем сервер
httpServer.listen(80);
console.log('Online'); 

Исходный код server.js

Наш чат на SSE готов. Запускаем сервер:
$ node server.js

Открываем один из браузеров: Firefox 6, Opera 10.6+, Chrome, WebKit 5+, iOS Safari 4+, Opera Mobile 10+. Переходим на http://localhost/ и чатим!

Заключение


SSE — хорошая технология, которая должна вытеснить Long Poling она проста и не менее эффективна, чем WebSockets. Сейчас SSE поддерживают Opera 10.6+ (Opera 9 поддерживает старый стандарт SSE), Chrome, Safari 5+. Firefox поддерживает Multipart XMLHTTPRequest, для которого можно написать обертку и использовать как интерфейс SSE.

Ссылки


1. Онлайн пример SSE чата можно посмотреть вот тут: sse-chat.nodester.com
Это несколько урезанная версия чата из-за особенностей проксирования Nodester (нет сообщения о количестве пользователей онлайн и нет сообщений о выходе из чата, может быть частый реконнект)
2. Исходник примера: github.com/azproduction/event-source-chat
3. Ещё один туториал по SSE
4. Спецификация

PS Похоже, что чат накрыл хабраэффект, но возможно что-то с nodester(у него часто такое бывает). Если вам интересен результат, то скачайте исходник с GitHub.

UPD Добавил Multipart XMLHTTPRequest, XMLHTTPRequest: Interactive спасибо за дополнение yui_room9
Tags:
Hubs:
Total votes 87: ↑85 and ↓2 +83
Views 45K
Comments Comments 55