Використання websocket в додатках Extjs

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", ширина: 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">&Надіслати lt;/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 аналогів. Крім того, сокети додають нові можливості, зокрема, дозволяють легко створювати системи працюють у режимі реального часу.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.