По роду своей деятельности мне часто приходится заниматься разработкой разнообразных crm-систем. Клиентскую часть уже очень давно собираю на Extjs (начинал еще со 2й версии). На сервере пару лет назад прочно обосновался Nodejs, заменив привычный PHP.
В прошлом году появилась идея унифицированной платформы для клиентской и серверной частей веб-приложения на базе Extjs. После года проб и ошибок, пазл более-менее сложился. В этой статье я хочу поделиться концептом фрэймворка, код которого выглядит одинаково на клиентской и серверной стороне.
Несколько причин для выбора библиотеки от Sencha в качестве базы:
- Продукты компании Sencha (Extjs в частности) известны в среде разработчиков, соответственно, есть достаточное количество специалистов в этой теме. По той же причине, нет проблем с документацией, примерами и сообществом.
- Extjs — один из немногих js-фреймворков с логичной продуманной архитектурой, которая одинаково хорошо применима как на клиентской так и на серверной стороне приложения.
- Единая кодовая база позволяет в одном файле описывать и клиентскую и серверную логику. Это сокращает количество строк кода и позволяет избежать дублирования.
Установка
Нам понадобится Nodejs, Mongodb, Memcached. Nodejs и Mongodb, желательно, свежих версий (особенно, Nodejs). Не вижу смысла описывать в статье процесс установки этих программ, в сети достаточно инструкций на любой вкус и ОС.
Перед началом установки обязательно проверьте, запущены ли процессы mongodb и memcached. Установочный скрипт проверяет возможность коннекта. Если соединения нет, установка завершится с ошибкой.
Устанавливаем фреймворк:
npm i janusjs
В конце инсталляции установщик задаст несколько наводящих вопросов для генерации проекта-примера (параметры подключения к БД, пользователь по-умолчанию и т.п.).
После всех манипуляций, в текущем каталоге у вас будет такое содержимое:
node_modules projects cluster.config.js cluster.js daemon.js server.js
projects — каталог с проектами
из остальных файлов, нас, пока что, интересует server.js, его и запустим:
node server
Если все в порядке, в консоли будет написано: “Server localhost is listening on port 8008”
В случае ошибки проверьте, не занят ли порт и запущены ли Mongodb и Memcached.
Веб-интерфейс системы доступен по адресу localhost:8008/admin/ (если при установке вы не меняли пользователя, то admin:admin). Параметры доступа можно проверить в настоечном файле проекта
projects/crm/config.json
Пользовательский интерфейс вполне стандартный. Система позволяет для разных групп пользователей создать разнотипные интерфейсы.
На видео можно посмотреть пример работы crm агентства недвижимости:
Создание модулей
При установке Януса должен был создаться пустой проект в каталоге projects/crm. Структура каталогов проекта:
protected static admin css extended locale modules news controller model view config.json config.json
static/admin/modules/news — это пример модуля CRM. Там простой список новостей. Посмотрим как это сделано.
Модули Janusjs строятся на базе шаблона MVP. Подробнее рассмотрим каждую часть модуля:
controller/News.js — контроллер (Presenter). Модуль реализует стандартное поведение (список записей — карточка записи), поэтому вся функциональность «живет» в родительском классе. Код контроллера:
Ext.define('Crm.modules.news.controller.News', { extend: 'Core.controller.Controller', launcher: { text: 'News', // название модуля iconCls:'fa fa-newspaper-o' // иконка модуля, поддержка Font Awesome } });
У модуля новостей 2 стандартных представления — список новостей и карточка отдельной новости. Важно представлениям давать названия, соответствующие контроллеру:
Представление для списка (view/NewsList.js)
Ext.define('Crm.modules.news.view.NewsList', { extend: 'Core.grid.GridWindow', sortManually: true, // Ручная сортировка новостей в списке filterbar: true, // включить возможность фильтрации /* перечисляем колонки таблицы */ buildColumns: function() { return [{ text: 'Title', flex: 1, sortable: true, dataIndex: 'name', filter: true },{ text: 'Date start', flex: 1, sortable: true, xtype: 'datecolumn', dataIndex: 'date_start', filter: true },{ text: 'Date finish', flex: 1, sortable: true, xtype: 'datecolumn', dataIndex: 'date_end', filter: true }] } })
Представление для карточки новости (view/NewsForm.js)
Ext.define('Crm.modules.news.view.NewsForm', { extend: 'Core.form.DetailForm' ,titleIndex: 'name' // имя поля, данные из которого будут выведены в заголовок окна формы ,layout: 'border' ,border: false ,bodyBorder: false ,height: 450 ,width: 750 ,buildItems: function() { return [{ xtype: 'panel', region: 'north', border: false, bodyBorder: false, layout: 'anchor', bodyStyle: 'padding: 5px;', items: [{ name: 'name', anchor: '100%', xtype: 'textfield', fieldLabel: 'Title' },{ xtype: 'fieldcontainer', layout: 'hbox', anchor: '100%', items: [{ xtype: 'datefield', fieldLabel: 'Date start', name: 'date_start', flex: 1, margin: '0 10 0 0' },{ xtype: 'datefield', fieldLabel: 'Date finish', name: 'date_end', flex: 1 }] },{ xtype: 'textarea', anchor: '100%', height: 60, name: 'stext', emptyText: 'Announce' }] }, this.fullText() ] } ,fullText: function() { return Ext.create('Desktop.modules.pages.view.HtmlEditor', { hideLabel: true, region: 'center', name: 'text' }) } })
Представления не должны вызывать сложности — это стандартные компоненты Extjs. Контроллеры разных модулей могут использовать одни и те же представления.
Теперь самое интересное — модель. В Janusjs код модели используется и на клиентской стороне и на серверной. Рассмотрим модель модуля новости:
Модель новостей (model/NewsModel.js)
Ext.define('Crm.modules.news.model.NewsModel', { extend: "Core.data.DataModel" ,collection: 'news' // имя коллекции/таблицы в БД ,removeAction: 'remove' // что делать с записями при удалении /* список полей записи */ ,fields: [{ name: '_id', // имя поля type: 'ObjectID', // тип данных visible: true // отдавать данные при запросе },{ name: 'name', type: 'string', filterable: true, // допустим поиск по этому полю editable: true, // данные можно изменять visible: true },{ name: 'date_start', type: 'date', filterable: true, editable: true, visible: true },{ name: 'date_end', type: 'date', filterable: true, editable: true, visible: true },{ name: 'stext', type: 'string', filterable: false, editable: true, visible: true },{ name: 'text', type: 'string', filterable: false, editable: true, visible: true }] })
Название файла модели должно, так же, соответствовать названию контроллера. В противном случае, в контроллере нужно вручную указать с какой моделью он должен работать (параметр ‘modelName’).
В корне каталога модуля новостей находится файл “manifest.json”. Этот файл нужен для того, что бы модуль появился в главном меню пользовательского интерфейса. В сложных случаях модуль может состоять из нескольких контроллеров и система должна знать какой из них главный, для этого и нужен манифест. Если файл манифеста отсутствует в каталоге модуля, модуль не будет виден в главном меню.
Важное замечание: при любых изменениях в серверной части системы следует перезапустить сервер Janusjs!
Один код на клиенте и сервере
Что бы проиллюстрировать архитектурные особенности Janusjs, немного доработаем модуль новостей. Добавим кнопку, при клике по которой, все выделенные в списке новости будут опубликованы на неделю (дата начала = текущая дата, дата окончания = +7 дней).
В представление списка добавим кнопку:
Ext.define('Crm.modules.news.view.NewsList', { … // добавляем кнопку в стандартный Tbar ,buildTbar: function() { // получим массив со стандартными кнопками // из родительского класса var items = this.callParent(); // добавим новую кнопку items.splice(2,0, { text: 'Publish selected', action: 'publish' }) return items; } … })
Добавим в контроллер обработчик для новой кнопки:
Ext.define('Crm.modules.news.controller.News', { extend: 'Core.controller.Controller' ,addControls: function(win) { var me = this me.control(win,{ "[action=publish]": {click: function() {me.publish(win)}} }) me.callParent(arguments) } ,publish: function(win) { var grid = win.down('grid') // получим выделенные строки ,selected = grid.getSelectionModel().selected // тут сохраним идентификаторы отмеченных новостей ,ids = []; if(selected && selected.items) { selected.items.forEach(function(item) { ids.push(item.data._id) }) if(ids.length) { // Вызываем клиентский метод модели // передаем ему идентификаторы выделенных новостей this.model.publish(ids, function() { grid.getStore().reload(); }) } } } })
Доработаем модель новостей:
Ext.define('Crm.modules.news.model.NewsModel', { extend: "Core.data.DataModel" ,collection: 'news' ,removeAction: 'remove' ,fields: [ ....... ] ,publish: function(ids, cb) { // Передадим идентификаторы новостей на сервер this.runOnServer('publish', {ids: ids}, cb) } // со стороны клиента можно вызвать на сервере только методы // имя которых начинается с $, в вызове серверного метода // на клиенте символ $ можно опустить (см. 6 строк выше) ,$publish: function(data, cb) { var me = this ,date_start = new Date() // текущая дата ,date_end = new Date(date_start.getTime() + 86400000 * 7) // + 7 дней ,ids = data.ids || null; if(!ids) { cb({ok: false}) return; } // преобразуем идентификаторы новостей // из строки в монговский ObjectId ids.each(function(id) { return me.src.db.fieldTypes.ObjectID.getValueToSave(null, id) }, true) // изменим даты в БД // me.dbCollection - ссылка на коллекцию текущего модуля me.dbCollection.update({ _id:{$in: ids} }, { $set: { date_start: date_start, date_end: date_end } }, { multi: true }, function() { cb({ok: true}) }) } })
Таким образом, метод «publish» отработает на клиенте, а метод "$publish" на сервере.
Вопрос безопасности
С нашей моделью осталась одна существенная проблема: т.к. код модели доступен на клиенте и на сервере, можно снаружи увидеть, что происходит внутри. Показывать методы серверной логики наружу не кашерно, поэтому, спрячем их. Делается это с помощью специальных серверных директив, которые помещаются в комментарии. Предусмотрено 2 директивы: scope:server и scope:client
/* scope:server */ — убирает следующий за этим комментарием метод из кода при отдаче клиенту
// scope:server — убирает всю строку из кода при отдаче клиенту
/* scope:client */ — убирает следующий за этим комментарием метод из кода перед выполнением кода на сервере
// scope:client — убирает всю строку из кода перед выполнением кода на сервере
Используя эти знания, сделаем нашу модель безопасной:
Ext.define('Crm.modules.news.model.NewsModel', { extend: "Core.data.DataModel" ,collection: 'news' // scope:server ,removeAction: 'remove' // scope:server ,fields: [{ name: '_id', type: 'ObjectID', // scope:server visible: true },{ name: 'name', type: 'string', // scope:server filterable: true, editable: true, visible: true },{ name: 'date_start', type: 'date', // scope:server filterable: true, editable: true, visible: true },{ name: 'date_end', type: 'date', // scope:server filterable: true, editable: true, visible: true },{ name: 'stext', type: 'string', // scope:server filterable: false, editable: true, visible: true },{ name: 'text', type: 'string', // scope:server filterable: false, editable: true, visible: true }] /* scope:client */ ,publish: function(ids, cb) { this.runOnServer('publish', {ids: ids}, cb) } /* scope:server */ ,$publish: function(data, cb) { var me = this ,date_start = new Date() // текущая дата ,date_end = new Date(date_start.getTime() + 86400000 * 7) // + 7 дней ,ids = data.ids || null; if(!ids) { cb({ok: false}) return; } ids.each(function(id) { return me.src.db.fieldTypes.ObjectID.getValueToSave(null, id) }, true) me.dbCollection.update({ _id:{$in: ids} }, { $set: { date_start: date_start, date_end: date_end } }, { multi: true }, function() { cb({ok: true}) }) } })
Теперь, можно спать спокойно, серверные методы снаружи не видны. К слову, используя директивы «scope» можно давать клиентским и серверным методам и свойствам одной модели одинаковые имена.
Связанные модули
Janusjs позволяет легко комбинировать несколько связанных модулей в один. На видео в начале статьи в карточку объекта недвижимости подтягивается список заинтересованных клиентов, комментарии агентов, связанные с объектом документы.
Добавим в карточку новости вкладку с комментариями. Для начала, создадим каталоги и файлы для модуля комментариев (все пути ниже даны относительно каталога проекта projects/crm/):
static admin modules comments controller Comments.js model CommentsModel.js view CommentsList.js CommentsForm.js
Код контроллера (Comments.js):
Ext.define('Crm.modules.comments.controller.Comments', { extend: 'Core.controller.Controller', launcher: { text: 'Comments', // название модуля iconCls:'fa fa-comment-o' // иконка модуля } });
Представление списка комментариев (CommentsList.js):
Ext.define('Crm.modules.comments.view.CommentsList', { extend: 'Core.grid.GridWindow', // в таблице списка будет только одна колонка с текстами комментариев buildColumns: function() { return [{ text: 'Comment', flex: 1, sortable: true, dataIndex: 'text', filter: true }] } })
Форма добавления и редактирования комментария (CommentsForm.js):
Ext.define('Crm.modules.comments.view.CommentsForm', { extend: 'Core.form.DetailForm' ,titleIndex: 'text' // имя поля, данные из которого будут выведены в заголовок окна формы ,buildItems: function() { return [ // поле для ввода текста комментария { fieldLabel: 'Comment text', name: 'text', xtype: 'textarea', anchor: '100%', height: 150 }, // идентификатор записи к которой относится редактируемый комментарий // заполняется автоматически { name: 'pid', hidden: true }] } })
И, наконец, клиент-серверная модель (CommentsModel.js):
Ext.define('Crm.modules.comments.model.CommentsModel', { extend: "Core.data.DataModel" ,collection: 'comments' // scope:server ,removeAction: 'remove' // scope:server ,fields: [{ name: '_id', type: 'ObjectID', // scope:server visible: true },{ name: 'pid', type: 'ObjectID', // scope:server visible: true, filterable: true, editable: true },{ name: 'text', type: 'string', // scope:server filterable: true, editable: true, visible: true }] })
Как видно, в подчиненном модуле достаточно объявить поле, где будет храниться внешний ключ. Далее, добавим вкладку комментариев на форму редактирования новости (static/admin/modules/news/view/NewsForm.js):
Ext.define('Crm.modules.news.view.NewsForm', { extend: 'Core.form.DetailForm' ,titleIndex: 'name' // имя поля, данные из которого будут выведены в заголовок окна формы ,layout: 'border' ,border: false ,bodyBorder: false ,height: 450 ,width: 750 // добавим tabpanel в качестве основного елемента ,buildItems: function() { return [{ xtype: 'tabpanel', region: 'center', items: [ this.buildMainFormTab(), this.buildCommentsTab() ] }] } // панель с формой новости ,buildMainFormTab: function() { return { xtype: 'panel', title: 'Новость', layout: 'border', items: this.buildMainFormTabItems() } } // поля формы новости ,buildMainFormTabItems: function() { return [{ xtype: 'panel', region: 'north', border: false, bodyBorder: false, layout: 'anchor', bodyStyle: 'padding: 5px;', items: [{ name: 'name', anchor: '100%', xtype: 'textfield', fieldLabel: 'Title' },{ xtype: 'fieldcontainer', layout: 'hbox', anchor: '100%', items: [{ xtype: 'datefield', fieldLabel: 'Date start', name: 'date_start', flex: 1, margin: '0 10 0 0' },{ xtype: 'datefield', fieldLabel: 'Date finish', name: 'date_end', flex: 1 }] },{ xtype: 'textarea', anchor: '100%', height: 60, name: 'stext', emptyText: 'Announce' }] }, this.fullText() ] } ,fullText: function() { return Ext.create('Desktop.modules.pages.view.HtmlEditor', { hideLabel: true, region: 'center', name: 'text' }) } ,buildCommentsTab: function() { return { xtype: 'panel', title: 'Comments', layout: 'fit', // параметр, указывающий, что в данной панели нужно // показать связанный модуль childModule: { // контроллер модуля controller: 'Crm.modules.comments.controller.Comments', // название поля ключа родительской записи (_id новости) outKey: '_id', // название поля ключа в дочерней записи (pid в комментариях) inKey: 'pid' } } } })
Таким образом, достаточно указать параметр childModule у одной из панелей окна с карточкой новости.
Websocket вместо AJAX
В Янусе я отказался от использования привычного AJAX для обмена данными между клиентом и сервером в пользу веб-сокетов. Такое решение позволяет создавать системы работающие в реальном времени. Например, при создании новости, она моментально появляется в списках новостей у других пользователей. Немного удивило то, что в стандартном комплекте Extjs (даже последних версий) не нашлось прокси на веб-сокетах и пришлось попотеть, что бы заставить extjs общаться с сервером через них. Вообще, тема использования веб-сокетов в приложениях extjs интересна сама по себе, думаю, написать про это отдельно.
Создание сайтов
Janusjs можно использовать и для построения обычных сайтов. Для примера, давайте выведем список наших новостей на отдельной странице. Для начала, создадим простой html-шаблон и разместим его в файле protected/view/index.tpl
Код шаблона:
<!DOCTYPE HTML> <html> <head> <title>{[values.metatitle? values.metatitle:values.name]}</title> </head> <body> <tpl if="blocks && blocks[1]"> <tpl for="blocks[1]">{.}</tpl> </tpl> </body> </html>
В качестве шаблонизатора используется немного доработанный XTemplate из стандартного пакета Extjs (http://docs.sencha.com/extjs/4.2.2/#!/api/Ext.XTemplate). Тут я не буду рассматривать вопросы, как сделать навигацию, это тема для отдельной статьи. В массиве blocks передается контент. Количество блоков не ограничено и они могут располагаться в разных местах кода шаблона.
Далее, создадим модуль новостей, он будет состоять из 3х файлов: контроллера и 2х шаблонов. Начнем с шаблона списка новостей:
<tpl for="list"> <h4> <a href="/news/{_id}">{name}</a> <i class="date">Дата: {[Ext.Date.format(new Date(values.date_start),'d.m.Y')]}</i> </h4> <p>{stext}</p> </tpl>
Файл сохраним в protected/site/news/view/list.tpl
Шаблон новости:
<h4> {name} <i class="date">Дата: {[Ext.Date.format(new Date(values.date_start),'d.m.Y')]}</i> </h4> {text} <a href="./">К списку</a>
Файл сохраним в protected/site/news/view/one.tpl
Контроллер использует модель модуля из CRM. Для наглядности, реализуем простейшую функциональность без пейджинга, сортировок и т.п.
Ext.define('Crm.site.news.controller.News',{ extend: "Core.Controller" ,show: function(params, cb) { // если в url есть идентификатор новости, покажем страницу с полным текстом if(params.pageData.page) this.showOne(params, cb) else // в противном случае, выводится список новостей this.showList(params, cb) } ,showOne: function(params, cb) { var me = this; Ext.create('Crm.modules.news.model.NewsModel', { scope: me }).getData({ filters: [{property: '_id', value: params.pageData.page}] }, function(data) { me.tplApply('.one', data.list[0] || {}, cb) }); } ,showList: function(params, cb) { var me = this; Ext.create('Crm.modules.news.model.NewsModel', { scope: me }).getData({ filters: [] }, function(data) { me.tplApply('.list', data, cb) }); } });
Контроллер сохраним в protected/site/news/controller/News.js
В Janusjs можно реализовать любые подходы для организации роутинга. Все зависит от того, какой контроллер путей подключен к серверу. По-умолчанию, подключен контроллер, который реализует следующий алгоритм:
- Пути вида <domain.name>/Crm.model.moduleName.methodName/ зарезервированы для вызова публичных методов моделей (это нужно для построения, всякого рода, API)
- Пути вида <domain.name>/page1/page2/ предназначены для доступа к виртуальным страницам публичного сайта. Модули для управления виртуальными страницами находятся в админке в разделе меню Пуск->Панель управления.
Итак, для вывода списка новостей на публичной стороне нужно создать виртуальную страницу и к одному из блоков контента привязать публичный метод контроллера модуля новостей. Видео как это сделать:
Страница со списком новостей будет доступна по адресу:
http://localhost:8008/news/
Фрагментация и работа оффлайн
Рассмотрим типичный кейс. Предположим, мы разрабатываем систему для управления сетью небольших отелей. Менеджер при создании брони должен иметь доступ к номерному фонду всех отелей. При этом, каждый отель должен уметь работать изолированно в случае обрыва связи. В Янусе реализован экспериментальный подход, позволяющий запускать отдельные копии сервера от имени отдельных пользователей. Такие сервера содержат только тот набор данных, доступ к которым есть у пользователя от имени которого запущен сервер. Другие пользователи в локальной сети отеля могут подключаться к такому серверу под своими учетными записями. Кроме того, такие сервера синхронизируются в режиме реального времени с центральным сервером. В случае отключения интернет в отеле, система продолжает работать с локальным набором данных накапливая изменения. При восстановлении подключения данные синхронизируются с центральным сервером.
Заключение
В заключении перечислю по пунктам, зачем мне понадобился собственный велосипед. Нужна была система:
- с унифицированным программным кодом клиентской и серверной частей;
- которую можно поддерживать силами одного или нескольких взаимозаменяемых специалистов;
- которая может работать в режиме реального времени;
- поддерживающая не ограниченное количество языков;
- позволяющая быстро создавать прототипы проектов со сложным пользовательским интерфейсом;
- была бы полностью открытая.
PS Это статья обзорная и многие вопросы остались за кадром. По каждому из них можно написать отдельную статью. Вот примерный список не раскрытых тем:
- Типы данных, добавление кастомизированных типов, связанные поля.
- Создание пользовательского интерфейса: связанные модули, нестандартные элементы UI, работа с изображениями и файлами.
- Использование веб-сокетов, системы реального времени на базе Janusjs.
- Изменение внешнего вида рабочего стола для разных групп пользователей.
- Система распределения прав доступа, кастомизация набора прав для модулей Janusjs.
- Создание CMS и сайтов на базе Janusjs.
- Интеграция с поисковой системой Elasticsearch.
- Дополнительные возможности: выполнение скриптов по расписанию, почтовые функции.
- Использование реляционных баз данных, использование нескольких СУБД в одном проекте.
- Мультиязычные проекты.
- Настройка продакшн сервера: включение логов, использование всех ядер процессора, распределение нагрузки.
ссылка на оригинал статьи http://habrahabr.ru/post/269791/
Добавить комментарий