Библиотека ExtJS/Sencha / Пишем MVC приложение на Ext JS 4 с возможностью офлайн работы

в 18:26, , рубрики: extjs, extjs 4, html5, офлайн, тонкие клиенты, метки: , , , ,

Библиотека ExtJS/Sencha / Пишем MVC приложение на Ext JS 4 с возможностью офлайн работы
До недавнего времени при необходимости дать пользователю возможность работать офлайн, то есть без активного подключения к Интернет, приходилось разрабатывать толстые клиенты. При таком подходе пользователь вводит данные в приложение, которые сохраняются локально, затем, воткнув шнурок сети, жмет магическую кнопку Синхронизировать и, довольный собой, идет пить чай.
Описанная схема имеет все недостатки толстого клиента. Это и необходимость разработки отдельного приложения для работы из браузеров (что в современном мире является нормальным требованием), и необходимость установки дополнительного ПО, и проблема его обновления, и вообще необходимость найма специалистов по разработке десктоп приложений. Согласитесь, нам, как веб разработчикам, проблема работы офлайн всегда была костью в горле.
Сегодня этот вопрос решается элегантно — с помощью HTML5 с его локальным хранилищем (local storage), Ext JS 4 с возможностью прозрачно работать с этим хранилищем, и HTML5 кэшем приложений (Application Cache). Совокупность этих технологий позволяет реализовать следующую схему: при наличии сети статичные файлы (HTML/CSS/JS код и картинки) загружаются с сайта и мы работаем с серверной централизованной базой данных, при отсутствии сети статика загружается из Application Cache и мы работаем с локальным хранилищем, которое сохраняется в серверную БД при появлении доступа к Интернет. При этом без активного подключения по URL адресу страницы браузер отображает не ошибку доступа к сети, а функциональную систему, работающую с локальным хранилищем. Пояснения и рабочий пример (да не упадет мой vds под хаброэффектом) — под катом. Статья получилась немаленькая, но, надеюсь, весьма содержательная.
HTML5

Если Вы знакомы с HTML 5 – смело пропускайте эту главу, если нет – здесь вы найдете краткое описание используемых технологий.Application Cache
Application Cache – кэш приложения, позволяет сохранять локально статичные файлы и использовать их без подключения к сети. Список файлов к кэшированию находится в файле-манифесте, адрес которого указывается в тэге html, например:

Mime-type файла-манифеста должен быть установлен в text/cache-manifest. Для веб сервера Apache, например, добавьте в конфигурационный файл:
AddType text/cache-manifest .appcache

Для Java добавьте в web.xml:

appcache
text/cache-manifest

Пример простейшего файла-манифеста:
CACHE MANIFEST
index.html
stylesheet.css
images/logo.png
scripts/main.js

Первая строка (CACHE MANIFEST) обязательна. Если Вы хотите добавить ресурсы, которые всегда требуют наличия сети – добавьте их после строки NETWORK:
CACHE MANIFEST
index.html

NETWORK:
login.php

Поближе познакомиться с форматом манифест-файлов можно здесь. Обновиться Application Cache может тремя способами: его может принудительно удалить пользователь в браузере, кэш обновится при обновлении файла-манифеста, и, наконец, кэш можно принудительно обновить из JavaScript.
Поддержка браузеров: Chrome 4+, Firefox 4+, Safari 4+, Opera 11+, IE 10, iOS 5+, Android 3+.Local storage
Локальное хранилище в HTML5 позволяет сохранять данные локально, рекомендуемое ограничение на размер – 5 Mb, однако, в ряде браузеров может быть увеличено. Данные не пропадают после закрытия страницы или браузера.
Хранилище одно на домен, то есть одни и те же данные доступны с разных страниц Вашего сайта. Более того, Вы можете отслеживать изменения данных в хранилище со всех открытых одновременно страниц. Например, одна из страниц может вызвать изменение хранилища, которое приведет к изменениям на другой странице, открытой в соседней вкладке. Круто, не правда ли?
Работать с локальным хранилищем просто – это всего лишь key-value структура:
localStorage.setItem('name', 'Hello World!');
localStorage.getItem('name');
localStorage.removeItem('name');

Поддерживаемые браузеры: Chrome 5+, Firefox 3.6+, Opera 10+, Safari 4+, IE 8+.

Локальное хранилище в Ext JS 4

Четвертый Ext JS позволяет работать с локальным хранилищем прозрачно, предоставляя для него отдельный прокси. Таким образом, Вы просто изменяете тип proxy с ajax на localstorage – и все данные Ext JS хранилища (store) загружаются не с сервера, а из локального хранилища браузера.
Погнали?
Напишем приложение, позволяющее вести список людей — скриншот которого Вы наблюдали в заголовке статьи. При отсутствии сети должна оставаться возможность ввода новых лиц, при появлении сети введенные ранее в локальное хранилище данные должны автоматически загрузиться в серверную базу данных.
Структура файлов показана на скриншоте, для нас фактически важны файлы index.html, /app.js и каталог /app/.
Четвертая версия Ext JS предлагает использовать для разработки интерфейса модель MVC, будем следовать ей:/app/model/ — директория моделей. Model в данном случае — описание полей хранилищ, то есть структура данных (аналог Record в третьей версии фреймворка) и, при необходимости, описание прокси. Прокси описывает способ получения данных — Ajax, JSON-P, local storage и т.д. Прокси может быть привязан к модели или хранилищу, использующему эту модель

/app/view/ — директория отображений. View — визуальные элементы (виджеты)

/app/controller/ — директория контроллеров. Controller — логика отображения, создание экземпляров моделей и т.д., то есть все, что связывает модели, хранилища и виджеты

Приложение будет состоять из двух виджетов — окна (window) с табицей (grid) внутри. Таблица в зависимости от наличия подключения к Интернет будет использовать серверное или локальное хранилище.
Итак, все, конечно, начинается с HTML:

Тестовое приложение

Здесь нет ничего особенного — указывается файл-манифест с указанием необходимых к кэшированию ресурсов, подключаются стили фреймворка и собственные стили, загружается ядро Ext JS и входная точка приложения app.js. В Ext JS 4 реализован механизм динамической загрузки, позволяющей на лету подключать необходимые JS файлы — таким образом, непосредственно в html прописывается только один JS файл самого приложения app.js.
Входной JavaScript файл прост:
// включаем динамическую загрузку JS файлов
Ext.Loader.setConfig({
enabled: true,
disableCaching: false,
paths: {UsersApp: 'app', Ext: 'ext-4.0.7-gpl/src'}
});

// указываем зависимости, которые необходимо предварительно загрузить
Ext.require(["UsersApp.view.win"]);

Ext.application({
name: 'UsersApp',
launch: function(){
Ext.create("UsersApp.view.win").show();
},
controllers: ["Main"]
});

Конструкция Ext.require() предназначена для указания зависимостей, то есть объектов, которые необходимо загрузить предварительно — до запуска приложения вызовом метода launch(). Вообще говоря, если такие зависимости не указать — при настроенном загрузчике Ext.Loader они загрузятся автоматически в процессе выполнения, но это может несколько снизить скорость работы и вообще не является кошерным, Ext.Loader в процессе такой неоптимальной загрузки выдаст сообщение в JS консоль браузера о целесообразности использования Ext.require().
Обратите внимание, что фактически имена объектов соответствуют пути, в которых хранятся эти объекты. Например, объект UsersApp.store.storeLocal хранится в директории /app/store/storeLocal.js, в то время как сопоставление имени приложения UsersApp имени физической директории app задано в настройках загрузчика Ext.Loader.
Итак, наше приложение создает виджет UsersApp.view.win и использует контроллер Main. Контроллер всегда вызывается до вызова метода launch(), он выполняет все необходимые подготовительные работы по связыванию компонентов системы.
Код окна UsersApp.view.win прост (здесь и далее приведены основные конфигурационные параметры, визуальные конфиги типа высоты-ширины и прочие маловажные моменты можно посмотреть в исходниках по ссылке в конце статьи):
Ext.define('UsersApp.view.win', {
extend: 'Ext.Window',
requires: ['UsersApp.view.grid'],
itemId: 'usersWindow',
layout: 'fit',
items: [
{ xtype: 'NamesGridPanel', itemId: 'NamesGrid' }
]
});

Здесь мы определяем класс UsersApp.view.win, расширяющий класс стандартного окна Ext.Window, и требующий для себя загрузки UsersApp.view.grid. Код таблицы:
Ext.define('UsersApp.view.grid', {
extend: 'Ext.grid.Panel',
alias: 'widget.NamesGridPanel',
requires: ['Ext.grid.plugin.CellEditing', 'Ext.form.field.*'],
itemId: 'usersGrid',
// конструктор таблицы - будет вызван при создании экземпляра
initComponent : function() {
// хранилище для таблицы будет установлено в контроллере

// устанавливаем возможность редактирования таблицы, для
// добавляем плагин CellEditing
this.cellEditing = Ext.create('Ext.grid.plugin.CellEditing', {
clicksToEdit: 2
});
this.plugins = this.cellEditing;

this.columns = this.columnsGet();
this.tbar = this.tbarGet();
// и не забываем вызывать родительский конструктор
this.callParent();
},

tbarGet: function(){
return[
{
text: 'Добавить',
iconCls: 'add',
handler: this._onUserAddClick
},
{
text: 'Удалить',
iconCls: 'delete',
handler: this._onUserDelClick
}
]
},

columnsGet: function(){
return [
{
text: 'Имя',
field: 'textfield',
dataIndex: 'firstName'
},
{
text : 'Фамилия',
field: 'textfield',
dataIndex: 'secondName'
}
]
},

_onUserAddClick: function(button){
// код метода добавления новых записей
},

_onUserDelClick: function(button){
// код метода удаления выделенной записи
}
})

Здесь ничего нового — создается класс таблицы, производится настройка колонок (columns), добавляется Toolbar с кнопками добавления/удаления записей (реализация этих методов скрыта для лучшей читаемости кода). Обратите внимание, что к таблице пока не привязано хранилище, это будет сделано в контроллере.
Пришло время создать два хранилища — серверное и локальное. Оба эти хранилища будут иметь одну модель (так как они содержат фактически данные одной структуры), но разные прокси. Модель описывается классом UsersApp.model.Names:
Ext.define('UsersApp.model.Names', {
fields: [{name: 'id', type: 'int', useNull: true}, {name: 'firstName'}, {name: 'secondName'}],
extend: 'Ext.data.Model',

// имя и фамилия не могут быть пустыми, запретим это
validations: [{
type: 'length',
field: 'firstName',
min: 1
},{
type: 'length',
field: 'secondName',
min: 1
}
]
});

Модель состоит из трех полей — идентификатора человека, его имени и фамилии. Для идентификатора указан целочисленный тип и использован параметр useNull, устанавливающий значение в null, если оно не может быть распознано как целочисленное (в противном случае оно будет приравнено к 0). Также для модели указываются валидаторы — имя и фамилия человека должны быть не короче 1 символа.
Создаем хранилище с серверной загрузкой данных:
Ext.define('UsersApp.store.store', {
extend: 'Ext.data.Store',

requires : ['UsersApp.model.Names', 'Ext.data.proxy.Ajax'],
model: 'UsersApp.model.Names',

proxy: {
type: 'ajax',
api: {
read: 'crud.php?act=read',
update: 'crud.php?act=update',
create: 'crud.php?act=create',
destroy: 'crud.php?act=delete'
},

reader: {
type: 'json',
root: 'names',
idProperty: 'id'
},
writer: {
type: 'json',
writeAllFields: false,
root: 'names'
}
}
});

Итак, модель с серверной загрузкой использует созданную модель, описывающую структуру данных, и Ajax прокси с настроенным «читателем» reader и «писателем» writer для чтения и записи данных соответственно. Параметр api указывает URL адреса, по которым будет обращаться Ext JS для операций чтения, обновления, добавления и удаления данных.
Код локального хранилища:
Ext.define('UsersApp.store.storeLocal', {
extend: 'Ext.data.Store',
requires : ['UsersApp.model.Names', 'Ext.data.proxy.LocalStorage'],
model: "UsersApp.model.Names",

proxy: {
type: 'localstorage',
id : 'Names'
}
});

В качестве прокси указываем localstorage — все данные будут загружаться из локального хранилища. В качестве id указываем уникальный идентификатор прокси, используемый для создания имен в key-value локальном хранилище.
Подведем итоги. У нас есть окно, содержащее в себе таблицу с настроенными колонками, но не подключенным хранилищем, есть два хранилища — серверное и локальное — с одной моделью. Нужно связать всё это добро в рабочее приложение! Этим займется контроллер:
Ext.define("UsersApp.controller.Main", {
extend: 'Ext.app.Controller',
requires: [
// утилита проверки наличия связи - "пингует" сервер
'UsersApp.Utils',
'UsersApp.store.storeLocal', 'UsersApp.store.store'
],

init: function(){
// метод getStore контроллера возвращает экземпляр хранилища,
// если он уже создан - или создаёт его
var storeLocal = this.getStore("storeLocal");
var store = this.getStore("store");
// вешаем обработчик на событие загрузки локального хранилища, он будет вызван
// сразу _после_ успешной загрузки
storeLocal.addListener('load', function(){
// локальное хранилище загружено - самое время
// проверить, есть ли связь с Интернет. UsersApp.Utils.ping принимает
// в качестве параметров callback функции
UsersApp.Utils.ping({
success: this._onPingSuccess, // Интернет есть
failure: this._onPingFailure // Интернета нет
}, this);
}, this);

// инициируем загрузку локальное хранилище
storeLocal.load();
},

_onPingSuccess: function(){
// сеть есть
var win = Ext.ComponentQuery.query('#usersWindow')[0];
var storeLocal = this.getStore('storeLocal');
var store = this.getStore('store');
var grid = win.getComponent('NamesGrid');

win.setTitle("Люди, онлайн")
// выясняем количество записей в локальном хранилище
localCnt = storeLocal.getCount();

// проверяем состояние локального хранилища,
// выясняя, необходима ли синхронизация
if (localCnt > 0){
// синхронизация нужна, добавляем записи
// по одной из локального хранилища
// в серверное
for (i = 0; i < localCnt; i++){
var localRecord = storeLocal.getAt(i);
var deletedId = localRecord.data.id;
delete localRecord.data.id;
store.add(localRecord.data);
localRecord.data.id = deletedId;
}
// сохраняем серверное хранилище
store.sync();
// очищаем локальное хранилище
for (i = 0; i < localCnt; i++){
storeLocal.removeAt(0);
}
}

store.load();
// подключаем к таблице серверное хранилище
grid.reconfigure(store);
grid.store.autoSync = true;
},
_onPingFailure: function(){
// сети нет, работаем с локальным хранилищем
var win = Ext.ComponentQuery.query('#usersWindow')[0];
var storeLocal = this.getStore('storeLocal');
var store = this.getStore('store');
var grid = win.getComponent('NamesGrid');

win.setTitle("Люди, офлайн")
// устанавливаем хранилище таблицы на локальное
grid.reconfigure(storeLocal);
grid.store.autoSync = true;
}
});

Много кода? Пройдем по порядку. В первую очередь для контроллера нужно указать нужные ему зависимости — в нашем случае это внутренние утилиты UsersApp.Utils и два наших хранилища. Метод init будет вызван при инициализации контроллера, то есть до запуска приложения, в нем должны быть выполнены все подготовительные действия. Мы создаем экземпляры хранилищ и загружаем локальное хранилище (оно работает вне зависимости от наличия доступа к сети), предварительно указав callback — после загрузки проверяем наличие сети вызовом метода UsersApp.Utils.ping. Функция ping посылает Ajax запрос к файлу на сервере, и в случае успеха вызывает callback функцию success, в противном случае вызывается failure.
Итак, если сеть есть, в серверное хранилище добавляются записи хранилища локального, после чего хранилище таблицы устанавливается на серверное. В случае отсутствия сети хранилище таблицы просто устанавливается на локальное.
Пример работы можно посмотреть здесь. Исходники (в качестве серверной части PHP, писал еще на Java — если кому надо, выложу) здесь.
PS. Ценителей с новым альбомом Руставели — и тёплых зимних программерских вечером Вам:)

* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js