![image](https://habrastorage.org/getpro/habr/post_images/2a9/a0d/f35/2a9a0df3552ff6e8e0925bb157d18c59.jpg)
В прошлом году появилась идея унифицированной платформы для клиентской и серверной частей веб-приложения на базе 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/ предназначены для доступа к виртуальным страницам публичного сайта. Модули для управления виртуальными страницами находятся в админке в разделе меню Пуск->Панель управления.
Итак, для вывода списка новостей на публичной стороне нужно создать виртуальную страницу и к одному из блоков контента привязать публичный метод контроллера модуля новостей. Видео как это сделать:
Страница со списком новостей будет доступна по адресу:
localhost:8008/news/
Фрагментация и работа оффлайн
Рассмотрим типичный кейс. Предположим, мы разрабатываем систему для управления сетью небольших отелей. Менеджер при создании брони должен иметь доступ к номерному фонду всех отелей. При этом, каждый отель должен уметь работать изолированно в случае обрыва связи. В Янусе реализован экспериментальный подход, позволяющий запускать отдельные копии сервера от имени отдельных пользователей. Такие сервера содержат только тот набор данных, доступ к которым есть у пользователя от имени которого запущен сервер. Другие пользователи в локальной сети отеля могут подключаться к такому серверу под своими учетными записями. Кроме того, такие сервера синхронизируются в режиме реального времени с центральным сервером. В случае отключения интернет в отеле, система продолжает работать с локальным набором данных накапливая изменения. При восстановлении подключения данные синхронизируются с центральным сервером.
Заключение
В заключении перечислю по пунктам, зачем мне понадобился собственный велосипед. Нужна была система:
- с унифицированным программным кодом клиентской и серверной частей;
- которую можно поддерживать силами одного или нескольких взаимозаменяемых специалистов;
- которая может работать в режиме реального времени;
- поддерживающая не ограниченное количество языков;
- позволяющая быстро создавать прототипы проектов со сложным пользовательским интерфейсом;
- была бы полностью открытая.
PS Это статья обзорная и многие вопросы остались за кадром. По каждому из них можно написать отдельную статью. Вот примерный список не раскрытых тем:
- Типы данных, добавление кастомизированных типов, связанные поля.
- Создание пользовательского интерфейса: связанные модули, нестандартные элементы UI, работа с изображениями и файлами.
- Использование веб-сокетов, системы реального времени на базе Janusjs.
- Изменение внешнего вида рабочего стола для разных групп пользователей.
- Система распределения прав доступа, кастомизация набора прав для модулей Janusjs.
- Создание CMS и сайтов на базе Janusjs.
- Интеграция с поисковой системой Elasticsearch.
- Дополнительные возможности: выполнение скриптов по расписанию, почтовые функции.
- Использование реляционных баз данных, использование нескольких СУБД в одном проекте.
- Мультиязычные проекты.
- Настройка продакшн сервера: включение логов, использование всех ядер процессора, распределение нагрузки.