![фото от сюда https://github.com/tj/palette](https://habrastorage.org/webt/wl/0h/yu/wl0hyunbvr2q-wqwydwamsfk804.png)
Можно по-разному относится к этому детищу компании Sencha, но даже принципиальные ее противники признают — лучшего решения для построения серьезных интранет проектов найти сложно.
На мой взгляд, самое ценное в Ext JS не коллекция UI компонент, а довольно удачная архитектура ООП. Даже с учетом бурного развития JS в последние годы, многие нужные вещи которые были реализованы в Ext JS еще 7 лет назад, отсутствуют в нативных классах до сих пор (нэймспэйсы, mixins, статические свойства, удобный вызов родительских методов). Именно это побудило меня несколько лет назад поэкспериментировать с запуском Ext JS классов в бакэнде. Про первые подобные опыты я уже делал посты на Хабре. В этой статье описана новая реализация старых идей и ряд свежих.
Перед тем как начнем, внимание вопрос: как вы думаете, где выполняется и что делает приведенный ниже фрагмент кода?
Ext.define('Module.message.model.Message', {
....
/* scope:server */
,async newMessage() {
.........
this.fireEvent('newmessage', data);
......
}
...
})
Этот код выполняется на сервере и вызывает событие «newmessage» во всех инстансах класса «Module.message.model.Message» на всех подключенных к серверу клиентских машинах.
Для иллюстрации возможностей использования серверного Ext JS разберем простенький проект чата. Логина никакого делать не будем, просто, при входе пользователь вводит ник. Можно постить общие или личные сообщения. Чат должен работать в реальном времени. Желающие могут сразу попробовать все это хозяйство в деле.
Установка
Для запуска нам потребуются nodejs 9+ и redis-server (предполагается, что они уже установлены).
git clone https://github.com/Kolbaskin/extjs-backend-example
cd extjs-backend-example
npm i
Заводим сервер:
node server
В броузере открываем страницу localhost:3000/www/auth/
Вводим какой-нибудь ник и жмем «enter».
Проект демонстрационный, поэтому тут нет поддержки старых броузеров (есть конструкции ES8), пользуйтесь новым Хромом или ФФ.
Сервер
Пойдем по порядку.
Код сервера (server.js)
// это обычный http-сервер на express
// параллельно с Ext JS можно использовать обычные плагины express
const express = require('express');
const staticSrv = require('extjs-express-static');
const app = express();
const bodyParser = require('body-parser');
// конфиг с настройками
global = {
config: require('config')
}
// подключаем библиотеку с серверным Ext JS
require('extjs-on-backend')({
// передаем ссылку на приложение express
app,
// имя класса для сопряжения клиентской и серверной частей
wsClient: 'Base.wsClient'
});
// определяем пространства имен
Ext.Loader.setPath('Api', 'protected/rest');
Ext.Loader.setPath('Base', 'protected/base');
Ext.Loader.setPath('Www', 'protected/www');
// подключаем парсер http параметров запросов
app.use( bodyParser.json() );
app.use(bodyParser.urlencoded({ extended: true }));
// в качестве роутов используем Ext JS объекты
app.use('/api/auth', Ext.create('Api.auth.Main'));
app.use('/www/auth', Ext.create('Www.login.controller.Login'));
// отдаем статический контент
app.use(staticSrv(__dirname + '/static'));
// слушаем порт
const server = app.listen(3000, () => {
console.log('server is running at %s', server.address().port);
});
Как видим, тут все более-менее стандартно для сервера на express. Интерес представляет подключение классов Ext JS для обслуживания соответствующих роутов:
app.use('/api/auth', Ext.create('Api.auth.Main'));
app.use('/www/auth', Ext.create('Www.login.controller.Login'));
Реализация REST API
Класс Api.auth.Main обслуживает запросы к REST API (protected/rest/auth/Main.js).
Ext.define('Api.auth.Main', {
extend: 'Api.Base',
// определяем суброуты
// и определяем соответствующие методы
routes: [
{ path: '/', get: 'login'},
{ path: '/restore', post: 'restoreLogin' },
{ path: '/registration', post: 'newuser'},
{ path: '/users', get: 'allUsers'}
]
// на вход подаются параметры запроса:
// {query: <...>, params: <...>, body: <...>}
,async login(data) {
return {data:[{
id:1,
subject: 111,
sender:222,
}]}
}
,async restoreLogin() {
...
}
,async newuser() {
...
}
,async allUsers() {
....
}
})
Генерация HTML-страниц, использование XTemplate на сервере
Второй класс Www.login.controller.Login строит обычную html-страницу с формой логина (protected/www/login/controller/Login.js).
Ext.define('Www.login.controller.Login', {
// в базовом классе строится все "сквозные" элементы:
// навигация, реклмные банеры и т.п.
extend: 'Www.Base'
// базовый шаблон страницы
// содержит блоки навигации, стили и т.п.
,baseTpl: 'view/inner'
// шаблон для контентной области
// непосредственно, форма авторизации
,loginFormTpl: 'login/view/login'
// роуты
,routes: [
{ path: '/', get: 'loginForm', post: 'doLogin'}
]
// возвращаем html контентного блока
// остальные элементы построятся в базовом классе
,async loginForm () {
return await this.tpl(this.loginFormTpl, {
pageTitle: 'Login page',
date: new Date()
});
}
,async doLogin (params, res) {
if(params.body.name && /^[a-z0-9]{2,10}$/i.test(params.body.name)) {
this.redirect(`/index.html?name=${params.body.name}`, res);
return;
}
return await this.tpl(this.loginFormTpl, {
pageTitle: 'Login page',
date: new Date()
});
}
})
В шаблонах используется стандартный XTemplate (protected/www/login/view/login.tpl)
<h2>{pageTitle} (date: {[Ext.Date.format(values.date,'d.m.Y')]})</h2>
<form method="post">
<input name="name" placeholder="name">
<button type="submit">enter</button>
</form>
Все описанное выше является вполне стандартным набором, скажет дотошный читатель, и для этого незачем было городить этот огород с переносом Ext JS на сервер. Поэтому, переходим ко второй части статьи, где будет показано то, ради чего все это задумывалось.
Клиент
Создадим обычное клиентское Ext JS приложение в каталоге static. В этом примере я умышлено не рассматриваю использование cmd, взял уже собранный ext-all и стандартную тему оформления. Вопросы сборки это отдельная тема, которой, возможно, посвящу отдельный пост.
Все начинается с app.js
// определим нэймспэйсы
Ext.Loader.setConfig({
enabled: true,
paths: {
"Core": "app/core",
"Admin": "app/admin",
"Module": "app/admin/modules",
"Ext.ux": "ext/ux"
}
});
// Генерация токена сессии
this.token = Ext.data.identifier.Uuid.createRandom()();
// Подключаемся к серверу по вебсокету
// передаем токен сессии (обязательно)
// и имя пользователя (опционально для этого проекта)
Ext.WS = Ext.create('Core.WSocket', {
token: this.token,
user: new URLSearchParams(document.location.search).get("name")
});
// Инициализируем приложение
Ext.application({
name: 'Example',
extend: 'Ext.app.Application',
requires: ['Admin.*'],
autoCreateViewport: 'Admin.view.Viewport'
})
Наличие веб-сокета принципиально важный момент, именно он позволяет реализовать всю магию описанную ниже.
Компоновка элементов на странице содержится в классе Admin.view.Viewport (static/app/view/Viewport.js). Там ничего интересного.
Основные функциональные элементы (список пользователей, панель сообщений и форма отправки) реализованы в виде отдельных модулей.
Список пользователей
Незамысловатый алгоритм работы этого списка такой: в момент открытия страницы с сервера загружаются текущие пользователи. При подключении новых пользователей сервер генерирует событие «add» в классе «Module.users.model.UserModel», при отключении, в том же классе, вызывается событие «remove». Вся штука в том, что событие инициируется на стороне сервера, а отследить его можно на клиенте.
Теперь, обо всем по порядку. С клиентской стороны данными жонглирует Store (static/app/modules/users/store/UsersStore.js)
Ext.define('Module.users.store.UsersStore', {
extend: 'Ext.data.Store'
,autoLoad: true
,total: 0
,constructor() {
// создадим экземпляр класса модели для работы с пользователями
this.dataModel = Ext.create('Module.users.model.UserModel');
// добавим обработчики на нужные события
this.dataModel.on({
add: (records) => { this.onDataAdd(records) },
remove: (records) => { this.onDataRemove(records) }
})
this.callParent(arguments)
}
// заменим стандартный load
,async load() {
// прочитаем список пользователей с сервера
const data = await this.dataModel.$read();
// всего записей
this.total = data.total;
// покажем данные в UI
this.loadData(data.data);
}
,getTotalCount() {
return this.total;
}
// при подключении нового пользователя добавим его к имеющимся данным
,onDataAdd(records) {
this.add(records[0]);
}
// при отключении -- уберем
,onDataRemove(records) {
this.remove(this.getById (records[0].id))
}
});
Тут 2 интересных момента. Во-первых, в строке «const data = await this.dataModel.$read();» вызывается серверный метод модели. Теперь не нужно использовать Ajax, поддерживать протоколы и т.п., просто вызываем серверный метод как локальный. При этом не приносится в жертву безопасность (об этом ниже).
Во-вторых, стандартная конструкция this.dataModel.on(...) позволяет отслеживать события, которые будут сгенерированы сервером.
Модель является мостом между клиентской и серверной частью приложения. Она как дуализм света — реализует свойства как фронтенда, так и бакенда. Посмотрим на модель внимательно.
Ext.define('Module.users.model.UserModel', {
extend: 'Core.data.DataModel'
/* scope:client */
,testClientMethod() {
...
}
,testGlobalMethod() {
...
}
/* scope:server */
,privateServerMethod() {
....
}
/* scope:server */
,async $read(params) {
// прочитаем текущие ключи пользователей в redis
const keys = await this.getMemKeys('client:*');
let data = [], name;
for(let i = 0;i<keys.length;i++) {
// получим имена пользователей по ключам и формируем список
name = await this.getMemKey(keys[i]);
if(name) {
data.push({
id: keys[i].substr(7),
name
})
}
}
// отправляем результат клиенту
return {
total: data.length,
data
}
}
})
Обратите внимание на комментарии /* scope:server */ и /* scope:client */ — эти конструкции являются метками для сервера, по которым он определяет тип метода.
testClientMethod — этот метод выполняется исключительно на клиенте и доступен только на клиентской стороне.
testGlobalMethod — этот метод выполняется на клиенте и на сервере и доступен для использования для клиентской и серверной части.
privateServerMethod — метод выполняется на сервере и доступен для вызова только на сервере.
$read — самый интересный тип метода, который выполняется только на серверной стороне, но вызвать его можно как на клиенте, так и на сервере. Префикс "$" превращает любой серверный метод в доступный на клиентской стороне.
Отследить подключение и отключение клиента можно по веб-сокету. Для каждого пользовательского подключения создается экземпляр класса «Base.wsClient» (protected/base/wsClient.js)
Ext.define('Base.wsClient', {
extend: 'Core.WsClient'
// одного экземпляра модели вполне достаточно
,usersModel: Ext.create('Module.users.model.UserModel')
// метод вызывается после успешной установки соединения
,async onStart() {
// вызываем событие "add" для всех клиентов
this.usersModel.fireEvent('add', 'all', [{id: this.token, name: this.req.query.user}]);
// добавляем ключ клиента в redis
await this.setMemKey(`client:${this.token}`, this.req.query.user || '');
// клиент начинает "слушать" очередь и обрабатывает только те задачи,
// которые адресованы конкретно ему
await this.queueProcess(`client:${this.token}`, async (data, done) => {
const res = await this.prepareClientEvents(data);
done(res);
})
}
// метод вызывается при обрыве соединения
,onClose() {
// вызываем событие "remove" для всех клиентов
this.usersModel.fireEvent('remove', 'all', [{id: this.token, name: this.req.query.user}])
this.callParent(arguments);
}
})
Метод «fireEvent», в отличие от стандартного, имеет дополнительный параметр, где передается на каком клиенте должно вызваться событие. Допустимо передать один идентификатор клиента, массив идентификаторов или строку «all». В последнем случае событие будет вызвано на всех подключенных клиентах. В остальном, это стандартный fireEvent.
Отправка и прием сообщений
За отправку сообщений отвечает контроллер формы (static/app/admin/modules/messages/view/FormController.js).
Ext.define('Module.messages.view.FormController', {
extend: 'Ext.app.ViewController'
,init(view) {
this.view = view;
// Создаем инстанс модели данных
this.model = Ext.create('Module.messages.model.Model');
// ссылка на поле ввода текста
this.msgEl = this.view.down('[name=message]');
// ссылка на таблицу пользователей
this.usersGrid = Ext.getCmp('users-grid')
// обработчик нажатия кнопки "отправить"
this.control({
'[action=submit]' : {click: () => {this.newMessage() }}
})
}
// Готовим и отправляем сообщения
,newMessage() {
let users = [];
// читаем идентификаторы отмеченных пользователей
const sel = this.usersGrid.getSelection();
if(sel && sel.length) {
sel.forEach((s) => {
users.push(s.data.id)
})
}
// добавляем свой идентификатор если он не отмечен
if(users.length && users.indexOf(Ext.WS.token) == -1)
users.push(Ext.WS.token);
// Вызываем серверный метод для отправки сообщения
this.model.$newmessage({
to: users,
user: Ext.WS.user,
message: this.msgEl.getValue()
})
// очищаем поле ввода
this.msgEl.setValue('');
}
});
На сервере сообщение нигде не сохраняется, просто вызывается событие «newmessage». Интерес представляет вызов «this.fireEvent('newmessage', data.to, msg);», где в качестве адресатов сообщений передаются идентификаторы клиентов. Таким образом, реализуется рассылка приватных сообщений (static/app/admin/modules/messages/model/Model.js).
Ext.define('Module.messages.model.Model', {
extend: 'Core.data.DataModel'
/* scope:server */
,async $newmessage(data) {
const msg = {
user: data.user,
message: data.message
}
if(data.to && Ext.isArray(data.to) && data.to.length) {
this.fireEvent('newmessage', data.to, msg);
} else {
this.fireEvent('newmessage', 'all', msg);
}
return true;
}
})
Как и в случае с пользователями данными для списка сообщений рулит Store (static/app/admin/modules/messages/store/MessagesStore.js)
Ext.define('Module.messages.store.MessagesStore', {
extend: 'Ext.data.Store',
fields: ['user', 'message'],
constructor() {
// отслеживаем события модели и добавляем данные
Ext.create('Module.messages.model.Model', {
listeners: {
newmessage: (mess) => {
this.add(mess)
}
}
})
this.callParent(arguments);
}
});
В целом, это все что есть интересного в этом примере.
Возможные вопросы
Доступность серверных методов на клиенте это, конечно, хорошо, но как быть с безопасностью? Получается, что злобный хакер может увидеть серверный код и попытаться взломать бакенд?
Нет, это у него не получится. Во-первых, все серверные методы удаляются из кода класса при отправке в клиентский броузер. Именно для этого, предназначены комментарии-директивы /* scope:… */. Во-вторых, код самого публичного серверного метода подменяется на промежуточную конструкцию, реализующую механизм удаленного вызова на клиентской стороне.
Опять про безопасность. Если серверные методы можно вызывать на клиенте, получается, я могу вызвать любой такой метод? А если это метод очистки базы данных?
Из клиента вы можете вызвать только методы, имеющие в своем названии префикс $. Для таких методов вы сами определяете логику проверок и доступов. К серверным методам без $ у внешнего пользователя нет никакого доступа, он их даже не увидит (смотри предыдущий ответ)
С виду у вас получилась монолитная система в которой клиент и сервер неразрывно связаны. Возможно ли горизонтальное масштабирование?
Система, действительно, выглядит монолитно, но это не так. Клиент и сервер могут «жить» на разных машинах. Клиент может быть запущен на любом стороннем веб-сервере (Nginx, Apache и т.п.). Вопрос разделения клиента и сервера очень просто решается автоматическим сборщиком проекта (об этом могу написать отдельный пост). Для реализации механизма внутреннего обмена служебными сообщениями система использует очереди (именно, для этого требуется Redis). Таким образом серверную часть можно легко горизонтально масштабировать простым добавлением новых машин.
При обычном подходе в разработке, как правило, бакэнд предоставляет некий набор API, к которым можно подключиться разноплановыми клиентскими приложениями (сайт, мобильное приложение). В вашем случае получается, что с бакендом может работать только клиент написанный на Ext JS?
На сервере, в частности в моделях модулей, реализуется некая бизнес-логика. Для того, что бы предоставить доступ к ней через REST API достаточно небольшой «обертки». Соответствующий пример представлен в первой части этой статьи.
Выводы
Как видим, для комфортного кодирования достаточно сложных приложений вполне можно обойтись одной библиотекой на фронтенде и бакенде. Это дает серьезные преимущества.
Ускорение процесса разработки. Каждый из участников команды может работать над бакэндом и фронтендом. Простои по причине «я жду когда этот АПИ появится на сервере» становятся не актуальными.
Меньше кода. Одни и те-же участки кода могут использоваться на клиенте и на сервере (проверки, верификации и т.п.).
Поддерживать такую систему гораздо проще и дешевле. Вместо двух разноплановых программистов, систему сможет поддерживать один (или те же двое но взаимозаменяемые). По той же причине, риски связанные с текучкой в команде тоже ниже.
Возможность «из коробки» создавать системы реального времени.
Использование единой системы тестирования для бакенда и фронтента.