Статьи

HTML5: отправленные сервером события с Angular.js, Node.js и Express.js

Я играл с Node.js и Express.js и случайно наткнулся на статью об отправленных сервером событиях. Отправленные сервером события — это спецификация W3C, которая описывает, как сервер может передавать события клиентам браузера. Все это с использованием стандартного протокола HTTP. Пару лет назад это была очень многообещающая спецификация, но затем появились веб-сокеты, и интерес к этой спецификации немного снизился. Однако это очень хороший, простой в использовании и легкий способ отправки обновлений с сервера на несколько подключенных клиентов. Код, особенно на стороне клиента, очень тривиален, и вы можете приступить к работе через пару минут.
Поэтому я решил немного углубиться в эту спецификацию, и в этой статье я покажу вам, как использовать отправленные сервером события в Angular.js. Я создам простой сервер в Node.js и Express.js, который будет отправлять события нашему веб-интерфейсу Angular.js.

В основном то, что мы собираемся сделать в этой статье, это две вещи:

  • Создайте минимальное приложение Angular.js, которое показывает системную статистику
  • Создайте бэкэнд для node.js и Express.js, который передает эту статистику в подключенные браузеры

После завершения это будет выглядеть примерно так:

statsangular.png

Сборка приложения Angular.js

Я не буду вдаваться в подробности того, как работает Angular.js. Если вы читаете это, вы, вероятно, знаете основы Angular.js, поэтому я просто перейду к части событий, отправляемых сервером. Если мы хотим слушать отправленные сервером события (sse), нам нужно только включить небольшой фрагмент JavaScript:

        var source = new EventSource('/stats');
        source.addEventListener('message', handleCallback, false);

Это создаст слушателя, который автоматически пытается подключиться к / stats, используя стандартный HTTP-запрос. Если он потерпит неудачу или потеряет соединение, он немедленно попытается восстановить соединение без необходимости что-либо предпринимать. Когда сервер отправляет событие, функция handleCallback будет вызываться с полученными данными. В отличие от веб-сокетов мы можем получать только текстовые данные, используя sse. Это, однако, не должно быть слишком большой проблемой, так как в любом случае большая часть данных отправляется в виде строк JSON.

Итак, как нам это использовать, давайте начнем с рассмотрения угловых вещей:

    // define the module we're working with
    var app = angular.module('sse', []);

    // define the ctrl
    function statCtrl($scope) {

        // the last received msg
        $scope.msg = {};

        // handles the callback from the received event
        var handleCallback = function (msg) {
            $scope.$apply(function () {
                $scope.msg = JSON.parse(msg.data)
            });
        }

        var source = new EventSource('/stats');
        source.addEventListener('message', handleCallback, false);
    }

Изменить: изменил $ scope. $ Apply, как предложено Джимом Хоскинсом (см. Первый комментарий)
Примечание: Это очень тривиальный пример. Обычно хорошей практикой является объединение этой конкретной функции события в службу, которую вы вводите в контроллер, поэтому контроллер содержит только логику приложения.
Как видите, мы создали один контроллер, который при запуске регистрирует слушателя и добавляет обратный вызов. В этом обратном вызове все, что мы делаем, это назначаем полученные данные переменной области видимости. Однако этого недостаточно для запуска обновлений в нашей модели. Поскольку события принимаются вне жизненного цикла Angular.js, мы должны сообщить Angular.js, что значение «msg» изменилось. Это делается с помощью $ scope. $ Apply. С этим фрагментом кода каждый раз, когда мы получаем событие, отправленное сервером, наша модель обновляется, и обновляются любые выражения представления.

Для полноты рассмотрим определение таблицы:

div class="container main" ng-controller="statCtrl">
    <div class="row">
        <div class="span10 offset1" style="text-align: center">
            <h2>System details updated using server-sent events</h2>
        </div>
    </div>
    <div class="row">
        <div class="span8 offset2">
            <table class="table table-striped">
                <thead>
                <tr>
                    <th>Property</th>
                    <th>Value</th>
                </tr>
                </thead>
                <tbody>
                <tr>
                    <td>Hostname:</td>
                    <td>{{msg.hostname}}</td>
                </tr>
                <tr>
                    <td>Type:</td>
                    <td>{{msg.type}}</td>
                </tr>
                <tr>
                    <td>Platform:</td>
                    <td>{{msg.platform}}</td>
                </tr>
                <tr>
                    <td>Arch:</td>
                    <td>{{msg.arch}}</td>
                </tr>
                <tr>
                    <td>Release:</td>
                    <td>{{msg.arch}}</td>
                </tr>
                <tr>
                    <td>Uptime:</td>
                    <td>{{msg.arch}}</td>
                </tr>
                <tr>
                    <td>Load avg.:</td>
                    <td>{{msg.loadaverage}}</td>
                </tr>
                <tr>
                    <td>Total mem:</td>
                    <td>{{msg.totalmem}}</td>
                </tr>
                <tr>
                    <td>Free mem:</td>
                    <td>{{msg.freemem}}</td>
                </tr>
                </tbody>
            </table>
        </div>
    </div>
</div>

Ничего особенного. Просто простая таблица (с использованием начальной загрузки для макета) с угловыми выражениями.

Теперь, когда мы увидели интерфейс, нам нужен сервер, который может отправлять события. В этом примере я использовал Node.js и Express.js, так как экспериментирую с этими двумя технологиями. Но, как вы увидите, реализация этого на любом другом HTTP-сервере будет тривиальной.

Сборка на стороне сервера с помощью node.js и express.js

В спецификации sse объясняется, как должен вести себя сервер, если он хочет поддерживать sse. Он должен держать HTTP-соединение открытым и отвечать конкретным ответом, чтобы браузер знал, что он может ожидать события, поступающие через это открытое соединение. Я перечислю полный app.js, использованный в этом примере, и выделю интересные части:

// most basic dependencies
var express = require('express')
  , http = require('http')
  , os = require('os')
  , path = require('path');

// create the app
var app = express();

// configure everything, just basic setup
app.configure(function(){
  app.set('port', process.env.PORT || 3000);
  app.set('views', __dirname + '/views');
  app.set('view engine', 'jade');
  app.use(express.favicon());
  app.use(express.logger('dev'));
  app.use(express.bodyParser());
  app.use(express.methodOverride());
  app.use(app.router);
  app.use(express.static(path.join(__dirname, 'public')));
});

// simple standard errorhandler
app.configure('development', function(){
  app.use(express.errorHandler());
});

//---------------------------------------
// mini app
//---------------------------------------
var openConnections = [];

// simple route to register the clients
app.get('/stats', function(req, res) {

    // set timeout as high as possible
    req.socket.setTimeout(Infinity);

    // send headers for event-stream connection
    // see spec for more information
    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive'
    });
    res.write('\n');

    // push this res object to our global variable
    openConnections.push(res);

    // When the request is closed, e.g. the browser window
    // is closed. We search through the open connections
    // array and remove this connection.
    req.on("close", function() {
        var toRemove;
        for (var j =0 ; j < openConnections.length ; j++) {
            if (openConnections[j] == res) {
                toRemove =j;
                break;
            }
        }
        openConnections.splice(j,1);
        console.log(openConnections.length);
    });
});

setInterval(function() {
    // we walk through each connection
    openConnections.forEach(function(resp) {
        var d = new Date();
        resp.write('id: ' + d.getMilliseconds() + '\n');
        resp.write('data:' + createMsg() +   '\n\n'); // Note the extra newline
    });

}, 1000);

function createMsg() {
    msg = {};

    msg.hostname = os.hostname();
    msg.type = os.type();
    msg.platform = os.platform();
    msg.arch = os.arch();
    msg.release = os.release();
    msg.uptime = os.uptime();
    msg.loadaverage = os.loadavg();
    msg.totalmem = os.totalmem();
    msg.freemem = os.freemem();

    return JSON.stringify(msg);
}

// startup everything
http.createServer(app).listen(app.get('port'), function(){
  console.log("Express server listening on port " + app.get('port'));
})

Как вы, вероятно, можете видеть и читать из комментариев, ничего особенного не происходит. Мы создали маршрут «/ stats», который отвечает на запросы GET. Это конечная точка, которую вызывает наше приложение Angular.js, когда оно устанавливает слушателя. В этой функции вы можете видеть, что мы отвечаем определенным HTTP-заголовком. Это сообщает браузеру, что этот сервер будет использовать sse для отправки событий.

    res.writeHead(200, {
        'Content-Type': 'text/event-stream',
        'Cache-Control': 'no-cache',
        'Connection': 'keep-alive'
    });
    res.write('\n');

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

    // push this res object to our global variable
    openConnections.push(res);

    // When the request is closed, e.g. the browser window
    // is closed. We search through the open connections
    // array and remove this connection.
    req.on("close", function() {
        var toRemove;
        for (var j =0 ; j < openConnections.length ; j++) {
            if (openConnections[j] == res) {
                toRemove =j;
                break;
            }
        }
        openConnections.splice(j,1);
        console.log(openConnections.length);
    });

Обновления отправляются в виде строки JSON в подключенные браузеры каждую секунду с помощью функции setInterval:

setInterval(function() {
    // we walk through each connection
    openConnections.forEach(function(resp) {
        var d = new Date();
        resp.write('id: ' + d.getMilliseconds() + '\n');
        resp.write('data:' + createMsg() +   '\n\n'); // Note the extra newline
    });

}, 1000);

function createMsg() {
    msg = {};

    msg.hostname = os.hostname();
    msg.type = os.type();
    msg.platform = os.platform();
    msg.arch = os.arch();
    msg.release = os.release();
    msg.uptime = os.uptime();
    msg.loadaverage = os.loadavg();
    msg.totalmem = os.totalmem();
    msg.freemem = os.freemem();

    return JSON.stringify(msg);
}

Вот и все. С помощью этой пары строк кода мы настроили сервер «отправленное сервером событие» и создали клиентское приложение, которое автоматически обновляет свой вид с помощью Angular.js.