Pull to refresh

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

Reading time 10 min
Views 31K

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

Описанная схема имеет все недостатки толстого клиента. Это и необходимость разработки отдельного приложения для работы из браузеров (что в современном мире является нормальным требованием), и необходимость установки дополнительного ПО, и проблема его обновления, и вообще необходимость найма специалистов по разработке десктоп приложений. Согласитесь, нам, как веб разработчикам, проблема работы офлайн всегда была костью в горле.

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

HTML5


Если Вы знакомы с HTML 5 – смело пропускайте эту главу, если нет – здесь вы найдете краткое описание используемых технологий.

Application Cache

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

<html manifest="http://site.ru/names.appcache">…</html>

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

AddType text/cache-manifest .appcache

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

<mime-mapping>
    <extension>appcache</extension>
    <mime-type>text/cache-manifest</mime-type>
</mime-mapping>

Пример простейшего файла-манифеста:

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:

<!-- Обратите внимание на указание файла-манифеста Application Cache -->
<html manifest="UsersApp.appcache">
<head>
    <link rel="stylesheet" type="text/css" href="ext-4.0.7-gpl/resources/css/ext-all.css" />
    <link rel="stylesheet" type="text/css" href="style.css" />
    <script src="ext-4.0.7-gpl/ext-dev.js" type="text/javascript" charset="utf-8"></script>
    <script src="app.js" type="text/javascript" charset="utf-8"></script>
</head>
<body style="padding: 25px;"><div id="console"><h2>Тестовое приложение</h2></div></body>
</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.

Примечание: механизм динамической загрузки удобен на этапе разработки, в production системе лучше собрать весь JS код в один файл с помощью Sencha SDK Tools (пример) во избежание загрузки большого количества файлов с кодом, генерирования лишних запросов к серверу и т.п.

Итак, наше приложение создает виджет 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. Ценителей с новым альбомом Руставели — и тёплых зимних программерских вечером Вам:)
Tags:
Hubs:
+37
Comments 5
Comments Comments 5

Articles