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.
  • Дополнительные возможности: выполнение скриптов по расписанию, почтовые функции.
  • Использование реляционных баз данных, использование нескольких СУБД в одном проекте.
  • Мультиязычные проекты.
  • Настройка продакшн сервера: включение логов, использование всех ядер процессора, распределение нагрузки.

Комментарии (0)