О чём речь?
О JS модулях, которые можно использовать в браузере и на сервере. Об их взаимодействии и внешних зависимостях. Меньше теории, больше практики. В рамках курса молодого бойца мы реализуем простое и весьма оригинальное приложение на базе Node.JS: ToDo-лист. Для этого нам предстоит:
- «Завести» кроссплатформенные модули на базе фреймворка Express;
- Научить их работать с платформозависимыми коллегами;
- Создать транспортный уровень между клиентом и сервером;
- Таки сделать ToDo-лист;
- Осмыслить результат.
Требования к приложению
Сосредоточимся на сути всей затеи и возьмём на реализацию минимальный функционал. Требования сформулируем следующим образом:
- Приложение доступно с помощью браузера;
- Пользователь работает со своим ToDo-листом в рамках одной сессии. При перезагрузке страницы список должен сохраниться, после закрытии вкладки или браузера — создаться новый;
- Пользователь может добавлять новые пункты в список;
- Пользователь может отметить добавленный пункт как выполненный.
Делаем каркас
Без проблем поднимаем каркас приложения на базе фреймворка Express. Немного доработаем структуру, которую мы получили из коробки:
.
├── bin
├── client // здесь будут лежать клиентские скрипты, использующие модули
├── modules // а здесь, собственно, сами CommonJS модули
├── public
│ └── stylesheets
├── routes
└── views
Создадим наш первый модуль из предметной области — Point, конструктор пункта ToDo-листа:
// modules/Point/Point.js
/**
* Пункт списка дел
* @param {Object} params
* @param {String} params.description
* @param {String} [params.id]
* @param {Boolean} [params.isChecked]
* @constructor
*/
function Point(params) {
if (!params.description) {
throw 'Invalid argument';
}
this._id = params.id;
this._description = params.description;
this._isChecked = Boolean(params.isChecked);
}
Point.prototype.toJSON = function () {
return {
id: this._id,
description: this._description,
isChecked: this._isChecked
};
}
/**
* @param {String} id
*/
Point.prototype.setId = function (id) {
if (!id) {
throw 'Invalid argument';
}
this._id = id;
}
/**
* @returns {String}
*/
Point.prototype.getId = function () {
return this._id;
}
Point.prototype.check = function () {
this._isChecked = true;
}
Point.prototype.uncheck = function () {
this._isChecked = false;
}
/**
* @returns {Boolean}
*/
Point.prototype.getIsChecked = function () {
return this._isChecked;
}
/**
* @returns {String}
*/
Point.prototype.getDescription = function () {
return this._description;
}
module.exports = Point;
Замечательно. Это наш первый кроссплатформенный модуль и мы уже можем использовать его на сервере, например, так:
// routes/index.js
var express = require('express');
var router = express.Router();
/* GET home page. */
router.get('/', function (req, res) {
var Point = require('../modules/Point');
var newPoint = new Point({
description: 'Do something'
});
console.log('My new point:', newPoint);
});
module.exports = router;
Есть несколько способов обеспечить работу с CommonJS модулем в браузере, наиболее простым в настройке и использовании мне показался middleware для Express browserify-middleware:
// app.js
// ...
var browserify = require('browserify-middleware');
app.use('/client', browserify('./client'));
// ...
Добавив такой простой код, мы сразу можем написать первые строчки нашего клиентского приложения:
// client/todo.js
var console = require('console'); // загрузит `node_modules/browserify/node_modules/console-browserify`
var Point = require('../modules/Point');
Browserify использует нодовский алгоритм загрузки модулей, а также предоставляет браузерные реализации core библиотек. Об этом и без того много написано, поэтому скажу лишь, что теперь скрипт, загруженный по адресу /client/todo.js полностью работоспособен в браузере.
Поговорим о модулях
В своём проекте я использовал следующее условное деление модулей:
Утилитарные модули
С их помощью разработчик организует и сопровождает код. Для примера, в нашем случае это библотека промисов Vow, lodash, console. В большинстве своём подобные модули являются не только кроссплатформенными, но и поддерживают несколько форматов загрузки (CommonJS, AMD).
Модули предметной области
Предоставляют интерфейс для работы с объектами предметной области. У нас уже создан один такой модуль — конструктор Point, вскоре появятся модуль list, предоставляющий необходимый нам интерфейс (addPoint, getPoints, checkPoint) и модуль user, отвечающий за инициализацию пользовательской сессии.
Такие модули могут быть как полностью кроссплатформенными, так и иметь платформозависимые части. Например, некоторые методы или свойства не должны быть доступны в браузере. Но чаще всего платформозависимая часть попадает в следующую категорию модулей.
DAL модули (Data Access Layer)
Это модули, отвечающие за доступ к данным из произвольного набора источников и их преобразование во внутреннее представление (объекты, коллекции) и обратно. Для браузера это могут быть localStorage, sessionStorage, cookies, внешнее API. На сервере выбор ещё больше: целая вереница баз данных, файловая система и, опять же, некое внешнее API.
Если кроссплатформенный модуль предметной области взаимодействует с DAL, то DAL-модуль должен иметь браузерную и серверную реализацию с единым интерфейсом. Технически мы можем это организовать, используя полезную фичу browserify, которая состоит в указании свойства browser в package.json модуля. Таким образом, модули предметной области могут работать с различными DAL-модулями в зависимости от среды исполнения:
{
"name" : "dal",
"main" : "./node.js", // будет загружен на сервере
"browser": "./browser.js" // будет загружен browserify для передачи на клиент
}
Реализуем модули
Какие же модули потребуются для нашей задачи? Пусть на сервере в качестве хранилища выступит memcache, в нём мы будем хранить наши ToDo-списки. Идентификация пользователя будет происходить в браузере, идентификатор сессии положим в sessionStorage и будем передавать с каждым запросом на сервер. Соответственно, на сервере нам надо будет забирать этот идентификатор из параметров запроса.
Получается, что на DAL уровне мы должны реализовать протокол взаимодействия с sessionStorage и memcache (получение параметров запроса реализуем стандартными инструментами Express).
module.exports.set = function () {
sessionStorage.setItem.apply(sessionStorage, arguments);
}
module.exports.get = function () {
return sessionStorage.getItem.apply(sessionStorage, arguments);
}
var vow = require('vow');
var _ = require('lodash');
var memcache = require('memcache');
var client = new memcache.Client(21201, 'localhost');
var clientDefer = new vow.Promise(function(resolve, reject) {
client
.on('connect', resolve)
.on('close', reject)
.on('timeout', reject)
.on('error', reject)
.connect();
});
/**
* Выполнить запрос к Memcache
* @see {@link https://github.com/elbart/node-memcache#usage}
* @param {String} clientMethod
* @param {String} key
* @param {*} [value]
* @returns {vow.Promise} resolve with {String}
*/
function request(clientMethod, key, value) {
var requestParams = [key];
if (!_.isUndefined(value)) {
requestParams.push(value);
}
return new vow.Promise(function (resolve, reject) {
requestParams.push(function (err, data) {
if (err) {
reject(err);
} else {
resolve(data);
}
});
clientDefer.then(function () {
client[clientMethod].apply(client, requestParams);
}, reject);
});
}
/**
* Установить значение для ключа
* @param {String} key
* @param {*} value
* @returns {vow.Promise}
*/
module.exports.set = function (key, value) {
return request('set', key, value);
}
/**
* Получить значение по ключу
* @param {String } key
* @returns {vow.Promise} resolve with {String}
*/
module.exports.get = function (key) {
return request('get', key);
}
Теперь мы можем реализовать следующий модуль предметной области User, который будет нам предоставлять объект с единственным методом getId:
var storage = require('../../dal/browser/sessionStorage');
var key = 'todo_user_id';
/**
* Сгенерировать случайный id
* @returns {String}
*/
function makeId() {
var text = "";
var possible = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789";
var i;
for (i = 0; i < 10; i++) {
text += possible.charAt(Math.floor(Math.random() * possible.length));
}
return text;
}
module.exports = {
/**
* @returns {String}
*/
getId: function () {
var userId = storage.get(key);
if (!userId) {
userId = makeId();
storage.set(key, userId);
}
return userId;
}
};
var app = require('../../../app');
module.exports = {
/**
* @returns {String}
*/
getId: function () {
return app.get('userId'); // устанавливается ранее с помощью middleware
}
};
{
"name" : "dal",
"main" : "./node.js",
"browser": "./browser.js"
}
// modules/user/user.js
var dal = require('./dal'); // в браузере будет использован ./dal/browser.js, на сервере - ./dal/node.js
function User() { }
/**
* Получить идентификатор сессии
* @returns {String}
*/
User.prototype.getId = function () {
return dal.getId();
}
module.exports = new User();
Взаимодействие между браузером и сервером мы организуем на основе протокола REST, что потребует от нас его реализации на DAL-уровне для браузера:
var vow = require('vow');
var _ = require('lodash');
/**
* Выполнить запрос к REST API
* @param {String} moduleName - вызываемый модуль
* @param {String} methodName - вызываемый метод
* @param {Object} params - параметры запроса
* @param {String} method - тип запроса
* @returns {vow.Promise} resolve with {Object} xhr.response
*/
module.exports.request = function (moduleName, methodName, params, method) {
var url = '/api/' + moduleName + '/' + methodName + '/?',
paramsData = null;
if (_.isObject(params)) {
paramsData = _.map(params, function (param, paramName) {
return paramName + '=' + encodeURIComponent(param);
}).join('&');
}
if (method !== 'POST' && paramsData) {
url += paramsData;
paramsData = null;
}
return new vow.Promise(function (resolve, reject) {
var xhr = new XMLHttpRequest();
xhr.open(method, url);
xhr.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');
xhr.responseType = 'json';
xhr.onload = function() {
if(xhr.status === 200) {
resolve(xhr.response);
} else {
reject(xhr.response || xhr.statusText);
}
};
xhr.send(paramsData);
});
}
и специального роутера для Express, который будет работать с нашими модулями предметной области:
// routes/api.js
// ...
router.use('/:module/:method', function (req, res) {
var module = require('../modules/' + req.params.module),
method = module[req.params.method];
if (!method) {
res.send(405);
return;
}
method.apply(module, req.apiParams)
.then(function (data) {
res.json(data);
}, function (err) {
res.send(400, JSON.stringify(err));
});
});
// ...
Исходя из условий задачи мы должны предоставить в API следующие методы:
- GET, /list/getPoints – получить список дел в ToDo-листе текущего пользователя;
- POST, /list/addPoint – получить новый пункт в ToDo-лист текущего пользователя;
- POST, /list/checkPoint – отметить пункт как сделанный;
В случае с добавлением нового пункта нам придётся возложить на роутер дополнительные обязанности: конвертация параметров запроса во внутреннее представление для передачи модулю:
router.post('/list/addPoint', function (req, res, next) {
var Point = require('../modules/Point'),
point;
req.apiParams = [];
try {
point = new Point(JSON.parse(req.param('point')));
req.apiParams.push(point);
} catch (e) {}
next();
});
Отлично, теперь мы можем реализовать заключительный модуль предметной области list:
var _ = require('lodash');
var rest = require('../../dal/browser/rest');
var Point = require('../../Point');
module.exports = {
/**
* @param {User} user
* @returns {vow.Promise} resolve with {Point[]}
*/
getPoints: function (user) {
return rest.request('list', 'getPoints', {userId: user.getId()}, 'GET')
.then(function (points) {
return _.map(points, function (point) {
return new Point(point);
});
});
},
/**
* @param {User} user
* @param {Point} point
* @returns {vow.Promise} resolve with {Point}
*/
addPoint: function (user, point) {
var requestParams = {
userId: user.getId(),
point: JSON.stringify(point)
};
return rest.request('list', 'addPoint', requestParams, 'POST')
.then(function (point) {
return new Point(point);
});
},
/**
* @param {User} user
* @param {Point} point
* @returns {vow.Promise}
*/
checkPoint: function (user, point) {
var requestParams = {
userId: user.getId(),
pointId: point.getId()
};
return rest.request('list', 'checkPoint', requestParams, 'POST');
}
};
var _ = require('lodash');
var memcache = require('../../dal/node/memcache');
var Point = require('../../Point');
/**
* Получить ключ для списка указанного пользователя
* @param {User} user
* @returns {String}
*/
function getListKey(user) {
return 'list_' + user.getId();
}
module.exports = {
/**
* @param {User} user
* @returns {vow.Promise} resolve with {Point[]}
*/
getPoints: function (user) {
return memcache.get(getListKey(user))
.then(function (points) {
if (points) {
try {
points = _.map(JSON.parse(points), function (point) {
return new Point(point);
});
} catch (e) {
points = [];
}
} else {
points = [];
}
return points;
});
},
/**
* @param {User} user
* @param {Point} point
* @returns {vow.Promise} resolve with {Point}
*/
addPoint: function (user, point) {
return this.getPoints(user)
.then(function (points) {
point.setId('point_' + (new Date().getTime()));
points.push(point);
return memcache.set(getListKey(user), JSON.stringify(points))
.then(function () {
return point;
});
});
},
/**
* @param {User} user
* @param {Point} point
* @returns {vow.Promise}
*/
checkPoint: function (user, point) {
return this.getPoints(user)
.then(function (points) {
var p = _.find(points, function (p) {
return p.getId() === point.getId();
});
if (!p) {
throw 'Point not found';
}
p.check();
return memcache.set(getListKey(user), JSON.stringify(points));
});
}
};
{
"name" : "dal",
"main" : "./node.js",
"browser": "./browser.js"
}
// modules/list/list.js
// утилитарные модули
var _ = require('lodash');
var vow = require('vow');
var console = require('console');
// DAL-модуль
var dal = require('./dal');
// модули предметной области
var Point = require('../Point');
var user = require('../user');
var list = {};
var cache = {}; // локальный кэш
/**
* Добавить новый пункт в список дел
* @param {Point} newPoint
* @returns {vow.Promise} resolve with {Point}
*/
list.addPoint = function (newPoint) { /* ... */ }
/**
* Отметить пункт как выполненный
* @param {String} pointId
* @returns {vow.Promise}
*/
list.checkPoint = function (pointId) { /* ... */ }
/**
* Получить все пункты в списке
* @returns {vow.Promise} resolve with {Point[]}
*/
list.getPoints = function () {
console.log('list / getPoints');
return new vow.Promise(function (resolve, reject) {
var userId = user.getId();
if (_.isArray(cache[userId])) {
resolve(cache[userId]);
return;
}
dal.getPoints(user)
.then(function (points) {
cache[userId] = points;
console.log('list / getPoints: resolve', cache[userId]);
resolve(points);
}, reject);
});
}
module.exports = list;
Структурно модули нашего приложения стали выглядеть так:
modules
├── dal
│ ├── browser
│ │ ├── rest.js
│ │ └── sessionStorage.js
│ └── node
│ └── memcache.js
├── list
│ ├── dal
│ │ ├── browser.js // использует dal/browser/rest.js
│ │ ├── node.js // использует dal/node/memcache.js
│ │ └── package.json
│ ├── list.js
│ └── package.json
├── Point
│ ├── package.json
│ └── Point.js
└── user
├── dal
│ ├── browser.js // использует dal/browser/sessionStorage.js
│ ├── node.js
│ └── package.json
├── package.json
└── user.js
Всё вместе
Пришло время реализовать логику нашего приложения. Начнём с добавления нового пункта в ToDo-лист:
// client/todo.js
// ...
// на уровне реализации бизнес-логики мы взаимодействуем только с утилитарными модулями и модулями предметной области
var console = require('console');
var _ = require('lodash');
var list = require('../modules/list');
var Point = require('../modules/Point');
var todo = {
addPoint: function (description) {
var point = new Point({
description: description
});
list.addPoint(point);
}
};
// ...
Что же произойдёт при вызове todo.addPoint('Test')? Попробую изобразить основные шаги на диаграммах. Для начала рассмотрим взаимодействие модулей в браузере:
Как видно, модуль list 2 раза обращается к своему DAL-модулю, который выполняет http-запросы к нашему API.
Вот так выглядит взаимодействие тех же (по большей части) модулей на стороне сервера:
Вот что получается: схема взаимодействия модулей предметной области и DAL-модулей в браузере и на сервере идентична. Отличаются, как мы и планировали, протоколы взаимодействия и источники данных на DAL-уровне.
Аналогично будет работать кейс с «зачеркиванием» пункта:
list.checkPoint(pointId);
Ещё пара минут — и наше приложение готово.
// client/todo.js
(function () {
var console = require('console');
var _ = require('lodash');
var list = require('../modules/list');
var Point = require('../modules/Point');
var listContainer = document.getElementById('todo_list');
var newPointContainer = document.getElementById('todo_new_point_description');
var tmpl = '<ul>'
+ '<% _.forEach(points, function(point) { %>'
+ '<li data-id="<%- point.getId() %>" data-checked="<%- point.getIsChecked() ? 1 : '' %>" class="<% if (point.getIsChecked()) { %>todo_point_checked <% }; %>">'
+ '<%- point.getDescription() %>'
+ '</li><% }); %>'
+ '</ul>';
var todo = {
addPoint: function (description) {
var point = new Point({
description: description
});
list.addPoint(point)
.then(todo.render, todo.error);
},
checkPoint: function (pointId) {
list.checkPoint(pointId)
.then(todo.render, todo.error);
},
render: function () {
list.getPoints()
.then(function (points) {
listContainer.innerHTML = _.template(tmpl, { points: points });
});
},
error: function (err) {
alert(err);
}
};
newPointContainer.addEventListener('keyup', function (ev) {
if (ev.keyCode == 13 && ev.ctrlKey && newPointContainer.value) {
todo.addPoint(newPointContainer.value);
newPointContainer.value = '';
}
});
listContainer.addEventListener('click', function (ev) {
var targetData = ev.target.dataset;
if (!targetData.checked) {
console.debug(targetData.checked);
todo.checkPoint(targetData.id);
}
});
todo.render();
})();
Код в репозитории: github.
Осмыслим
К чему, собственно, весь этот разговор? На данный момент у меня есть некоторое моральное удовлетворение от проделанной работы и ряд вопросов о её целесообразности. Насколько пригодна данная модель для сложных проектов и где находятся границы её применения? Готов ли я мириться с неизбежными накладными расходами, которые будет иметь приложение на основе кроссплатформенных модулей?
До полного осмысления пока что далеко. В любом случае, хорошо иметь возможность сделать нечто подобное и подумать над перспективами. Кроссплатформенные фреймворки и компонентные тесты — почему бы и нет?
Автор: psyduckinattack