Janusjs: концепт системы, где клиент и сервер — сиамские близнецы

от автора

image По роду своей деятельности мне часто приходится заниматься разработкой разнообразных crm-систем. Клиентскую часть уже очень давно собираю на Extjs (начинал еще со 2й версии). На сервере пару лет назад прочно обосновался Nodejs, заменив привычный PHP.

В прошлом году появилась идея унифицированной платформы для клиентской и серверной частей веб-приложения на базе Extjs. После года проб и ошибок, пазл более-менее сложился. В этой статье я хочу поделиться концептом фрэймворка, код которого выглядит одинаково на клиентской и серверной стороне.

Несколько причин для выбора библиотеки от Sencha в качестве базы:

  1. Продукты компании Sencha (Extjs в частности) известны в среде разработчиков, соответственно, есть достаточное количество специалистов в этой теме. По той же причине, нет проблем с документацией, примерами и сообществом.
  2. Extjs — один из немногих js-фреймворков с логичной продуманной архитектурой, которая одинаково хорошо применима как на клиентской так и на серверной стороне приложения.
  3. Единая кодовая база позволяет в одном файле описывать и клиентскую и серверную логику. Это сокращает количество строк кода и позволяет избежать дублирования.

Установка

Нам понадобится 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/