Websocket, наверное, самое серьезное и полезное расширение протокола HTTP с момента его появления в начале девяностых. Использование websockets для обмена данными с сервером намного более выгодно, чем привычный AJAX. Экономия трафика в стандартных приложениях существенна, особенно, при активном обмене клиента и сервера небольшими сообщениями. Также, существенно сокращается время отклика при запросах данных. Основным препятствием на пути широкого распространения этой технологии долгое время было то, что многие прокси-сервера криво поддерживали расширенную версию http-протокола. Что приводило, в худшем случае, к проблемам безопасности (пруф). За последние пару лет ситуация с поддержкой вебсокетов стала выправляться и сейчас, на мой взгляд, настало их время.

В этой статье описаны рецепты использования вебсокетов в стандартных компонентах Extjs (gridpanel, treepanel, combobox). И, также, в качестве замены Ext.Ajax.

Disclaimer


Изначально, статья планировалась в качестве продолжения к моему предыдущему посту про систему Janusjs. Но, мне показалось, что эта тема может быть интересной сама по себе. Поэтому, первая часть поста будет про websockets, extjs и nodejs, во второй части опишу нюансы использования websockets в системе Janusjs.

Целиком код примера из этой статьи можно найти на Github.

Что есть готовое


Идея подружить Extjs и Websocket пришла в светлые головы уже достаточно давно. Существует такой компонент: Ext.ux.data.proxy.WebSocket. Этот компонент сделан по подобию Ext.data.proxy.Ajax. Т.е. вебсокет используется по стандартной AJAX-схеме: клиент послал запрос на определенный URL, прочитал ответ. От сюда основной минус этой реализации — на каждый компонент читающий сервер нам понадобится отдельный сокет. Таким образом, теряются многие преимущества веб-сокетов. Если в вашем приложении только одна таблица, то эта реализация вполне сгодится. Для более сложных задач нужно нечто другое.

Когда писал эту статью, наткнулся еще на одну подобную библиотеку: jWebSocket. Судя по документации, это серьезная разработка, заслуживающая внимания. Но тут сервер собран на Java.

Взвесив трудоемкость адаптации jWebSocket под Nodejs, я пришел к выводу, что проще доработать Ext.ux.data.proxy.WebSocket.

Как это работает (теория)


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

Второе отличие от AJAX в том, что у нас нет URL в котором можно закодировать какие данные нужны.

Учитывая все это, набросаем простенький протокол для обмена данными. Будем в запросе клиента передавать название функции которую нужно запустить на сервере и параметры для нее.
Формат запроса:
{
    "event": "",
    "opid": "",
    "data": { <data object> }
}

event — идентификатор действия (create/read/update/delete);
opid — идентификатор операции, случайное число. По этому коду будем искать нужный ответ;
data — дополнительные параметры запроса. Например, если это запрос от data.proxy, то тут будут параметры фильтрации, пейджинга, сортировки.

Формат ответа точно такой-же, как и запроса.

Сервер


Начнем с серверной части. Добавим необходимые Node.js модули:
npm i node-static websocket


Что бы не было проблем с кросс-доменными запросами, создадим комбинированный сервер, который будет на одном порту обрабатывать обычные http-запросы и ws.
Заготовка для веб-сервера:
var http = require("http")
    ,port = 8008
    // статический контент будет браться из каталога ./static
    ,StaticServer = new(require('node-static').Server)(__dirname + '/static')
    ,WebSocketServer = require('websocket').server;  
// Создаем основной http-сервер
var server = http.createServer(function(req, res) {
    // По обычному http-запросу отдаем статический контент  
    StaticServer.serv(req, res)
})
// запускаем сервер
server.listen(8008, function() {
    console.log((new Date()) + ' Server is listening on port 8008');
});
// создаем websocket сервер
var wsServer = new WebSocketServer({
    // подключаем его к http-серверу
    httpServer: server, 
    // в документации рекомендуют отключать этот параметр,
    // что бы работала стандартная защита от кросс-доменных атак
    autoAcceptConnections: false
});
// добавим обработчик для запросов подключения по веб-сокету
wsServer.on('request', function(request) {
    wsRequestListener(request)
});
// обработчик для новых подключений
var wsRequestListener = function(request) {    
    var conn;
    try {
        // создадим соединение
        conn = request.accept('ws-my-protocol', request.origin);
    } catch(e) { /* ошибка */}    
    // подключим обработчик сообщений
    conn.on('message', function(message) {
        wsOnMessage(conn, message);
    });    
    // обработка закрытия сокета
    conn.on('close', function(reasonCode, description) {
        wsOnClose(conn, reasonCode, description);
    });    
}
// обработчик сообщений
var wsOnMessage = function(conn, message) {
    
}
// обработчик закрытия сокета
var wsOnClose = function(conn, reasonCode, description) {
    
}


Запросы от клиента будут поступать в виде строки, содержащей JSON. Его нужно транслировать в объект и вызвать соответствующий метод модели.

...
// обработчик сообщений
var wsOnMessage = function(conn, message) {
    var request;
    // попытка парсинга входных данных в объект
    try {
        request = JSON.parse(message.utf8Data);
    } catch(e) {
        console.log('Error')
        return;
    }
    if(request && request.data) {
        // поиск подходящей модели и проверка наличия у модели заданного в запросе метода
        if(!!this[request.data.model] && !!this[request.data.model][request.data.action]) {
            // вызов метода модели и передача в него параметров запроса
            this[request.data.model][request.data.action](request.data, function(responseData) {
                // в ответные данные добавляем служебную информацию,
                // по которой на клиенте будет найдена
                // соответствующая каллбэк функция
                // scope - идентификатор элемента-инициатора запроса на клиенте (store)    
                // opid - идентификатор операции
                responseData.scope = request.data.scope;
                if(request.opid)
                    responseData.opid = request.opid
                // передаем ответ клиенту
                conn.sendUTF(JSON.stringify({event: request.event, data: responseData}))
            })    
        }
    }
}
...


Для простоты, модель в том же файле, что и сервер и ее единственной функцией будет отдать массив статических данных:

...
// Объект с методами обработки данных
gridDataModel = {
    // чтение данных
    // data - параметры запроса (фильтры, сортировка, пейджинг и т.п.)
    // cb - каллбэк функция, куда нужно передать выходные данные
    read: function(data, cb) {
        cb({
            list: [{
                Author: 'Author1',
                Title: 'Title1',
                Manufacturer: 'Manufacturer1',
                ProductGroup: 'ProductGroup1',
                DetailPageURL: 'DetailPageURL1'
            },{
                Author: 'Author2',
                Title: 'Title2',
                Manufacturer: 'Manufacturer2',
                ProductGroup: 'ProductGroup2',
                DetailPageURL: 'DetailPageURL2'    
            }],
            total: 2 
        })
    }
}
...


Клиент


Из стандартного набора примеров extjs возьмем пример простой таблицы и заменим в нем AJAX-прокси на доработанный WS-прокси:
Ext.Loader.setConfig({
    enabled: true,
    paths: {
        'Ext.ux': 'src/ux'
    }
});
Ext.onReady(function(){ 
    // определим тип протокола
    var protocol = location.protocol == 'https:'? 'wss':'ws';
    // Создадим веб-сокет
    var WS = Ext.create('Ext.ux.WebSocket', {
        url: protocol + "://" + location.host + "/" ,
        protocol: "ws-my-protocol",
        communicationType: 'event'
    });     
    var proxy = Ext.create('Ext.ux.data.proxy.WebSocket',{
        // нужно указать идентификатор store
        storeId: 'stor-1',  
        // все прокси приложения могут работать через один и тот же сокет
        websocket: WS,
        // нужно указать параметры запроса к серверу
        params: {
            model: 'gridDataModel',
            scope: 'stor-1'   
        },
        // параметры обработчика  данных
        reader: {
             type: 'json',
             rootProperty: 'list',
             totalProperty: 'total',
             successProperty: 'success'
        },
        simpleSortMode: true,
        filterParam: 'query',
        remoteFilter: true
    });    
    // модель
    Ext.define('Book',{
        extend: 'Ext.data.Model',
        fields: [
            'Author',
            'Title',
            'Manufacturer',
            'ProductGroup',
            'DetailPageURL'
        ]
    });
    // создаем data store
    var store = Ext.create('Ext.data.Store', {
        id: 'stor-1',
        model: 'Book',
        proxy: proxy
    });
    // Создаем gridpanel
    Ext.create('Ext.grid.Panel', {
        title: 'Book List',        
        renderTo: 'binding-example',
        store: store,
        bufferedRenderer: false,
        width: 580,
        height: 400,
        columns: [
            {text: "Author", width: 120, dataIndex: 'Author', sortable: true},
            {text: "Title", flex: 1, dataIndex: 'Title', sortable: true},
            {text: "Manufacturer", width: 125, dataIndex: 'Manufacturer', sortable: true},
            {text: "Product Group", width: 125, dataIndex: 'ProductGroup', sortable: true}
        ],
        forceFit: true,
        height:210,
        split: true,
        region: 'north'
    });
    // загружать данные можно только по готовности соединения.
    // будем проверять готовность сокета каждые 0.1с
    var loadData = function() {
        if(WS.ws.readyState) {
            store.load();
        } else {
            setTimeout(function() {loadData()}, 100)    
        }
    }
    loadData()
});


В простом примере, рассмотренном выше, клиентский скрипт по запросу получает данные с сервера. Т.е. имеем стандартную AJAX схему взаимодействия клиента-сервера, разница только в способе получения данных. Но, в реальных приложениях, если меняем привычный XHR на новомодный WS хочется получить нечто большее. Например, если один из клиентов поменял данные на сервере, остальные должны узнать об этих изменениях. Для этой цели, в настройках WS-прокси и передается идентификатор data.Story: при поступлении сигнала от сервера, что данные изменились, WS-proxy должен инициировать соответствующие действия по отображению этих изменений в UI.

Наиболее полно алгоритмы взаимодействия клиента и сервера по WS реализованы в системе Janusjs. Ниже описываются особенности использования вебсокетов в этой системе (код примера доступен на Github).

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

Для начала, расширим шаблон страницы новости (protected/site/news/view/one.tpl):
<h4>
    {name} 
    <i class="date">Дата: {[Ext.Date.format(new Date(values.date_start),'d.m.Y')]}</i>
</h4>
{text}
<tpl if="isCommentCreated">
    Комментарий отправлен.
</tpl>
<h4>Комментарии</h4>
<tpl for="comments">
    <p>{text}</p>
</tpl>
<form method="post">
    <textarea rows="5" cols="50" name="comment"></textarea><br>
    <button type="submit">Отправить</button>
</form>


В публичном контроллере модуля новостей (protected/site/news/controller/News.js) допишем метод показа карточки новости:
Ext.define('Crm.site.news.controller.News',{
    extend: "Core.Controller"
...
    ,showOne: function(params, cb) {
        var me = this
            ,out = {}
            // создадим экземпляр модели комментариев
            ,commentsModel = Ext.create('Crm.modules.comments.model.CommentsModel', {
                scope: me
            });
        [
            function(next) {
                // если в запросе есть параметр "comment"
                // создадим новый комментарий
                if(params.gpc.comment) {
                    commentsModel.write({
                        pid: params.pageData.page, // идентификатор новости
                        text: params.gpc.comment // текст комментария
                    }, function() {
                        next(true) // к следующему шагу       
                    }, {add: true}); // последний параметр -- список разрешений
                } else
                    next(false) // к следующему шагу
            }
            ,function(isCommentCreated, next) { 
                out.isCommentCreated = isCommentCreated; // комментарий отправлен
                // получим список комментариев текущей новости
                commentsModel.getData({
                    filters: [{property: 'pid', value: params.pageData.page}]
                }, function(data) {
                    out.comments = data.list;
                    next()           
                })
            }
            ,function(next) {    
                // прочитаем карточку новости 
                Ext.create('Crm.modules.news.model.NewsModel', {
                    scope: me
                }).getData({
                    filters: [{property: '_id', value: params.pageData.page}]
                }, function(data) {
                    if(data && data.list && data.list[0])
                        out = Ext.merge(out, data.list[0])                    
                    me.tplApply('.one', out, cb)            
                }); 
            }
        ].runEach()
    }
...


На видео, как это работает:


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

Создадим в каталоге модуля комментариев новый контроллер (static/admin/modules/comments/controller/Eventer.js):
Ext.define('Crm.modules.comments.controller.Eventer', {
    extend: 'Core.controller.Controller', 
    autorun: function() {
        // Подпишемся на события модели модуля комментариев
        // 1й параметр -- идентификатор подписчика (произвольная строка)
        // 2й параметр -- имя класса модели, за которой следим
        // 3й -- каллбэк функция
        Core.ws.subscribe('eventer', 'Crm.modules.comments.model.CommentsModel', function(eventName, data) {
            // eventName -- имя события (ins, upd, del и т.д.)
            if(eventName == 'ins' && confirm('Новый комментарий. Открыть форму модерации?'))
                location = '#!Crm-modules-comments-controller-Comments_' + data._id
        })  
    }
});


В принципе, этого достаточно для реализации нужной нам функции, но Янус требует, что бы у всех модулей были свои модели (это нужно для подсистемы распределения прав доступа). Поэтому, создадим пустую модель (static/admin/modules/comments/model/EventerModel.js):
Ext.define('Crm.modules.comments.model.EventerModel', {    
    extend: "Core.data.DataModel"
})


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


Еще одно преимущество использования WS в том, что теперь можно некоторые «тяжелые» функции перенести на клиента, разгрузив сервер. Например, при импорте данных из локальных файлов на клиент можно перенести парсинг и подготовку данных из файла и на сервер отправлять небольшие порции готовых JSON-объектов.

Выводы


На сегодняшний день, на мой взгляд, websockets вполне годная технология для широкого применения. Системы где используются WS визуально заметно отзывчивей AJAX аналогов. Кроме того, сокеты добавляют новые возможности, в частности, позволяют легко создавать системы работающие в режиме реального времени.

Комментарии (3)


  1. alexstz
    25.11.2015 15:10
    +1

    Здорово, спасибо за пример реализации! Сходу вопрос о клиентской части. Эта вещь

    {
        "event": "",
        "opid": "",
        "data": { <data object> }
    }
    

    до боли напомнила формат, в котором работает встроенный функционал Ext.direct. Там же есть PollingProvider, на основе которого можно попробовать сделать свой вебсокет-провайдер. Не пробовали смотреть в его сторону?


    1. kolbaskinmax
      25.11.2015 16:24

      Мне кажется, обычного GRUD для веб-сокетов будет маловато. Инициатором каких-то действий может выступать и сервер (обновить часть данных, например). Надо подумать на эту тему…


  1. xkorolx
    03.12.2015 01:00

    Без WS сейчас трудно представить приложение, которому нужен быстрый отклик.
    Ещё не хватает переключения на другой протокол, если вдруг не получилось соединиться по WS.