Кроссплатформенный CommonJS на практике
О чём речь?
О 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).
modules/dal/browser/sessionStorage.js module.exports.set = function () { sessionStorage.setItem.apply (sessionStorage, arguments); }
module.exports.get = function () { return sessionStorage.getItem.apply (sessionStorage, arguments); } modules/dal/node/memcache.js 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: modules/user/dal/browser.js 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; }
}; modules/user/dal/node.js var app = require ('…/…/…/app');
module.exports = {
/** * @returns {String} */ getId: function () { return app.get ('userId'); // устанавливается ранее с помощью middleware }
}; modules/user/dal/package.json { «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-уровне для браузера:
modules/dal/browser/rest.js 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: modules/list/dal/browser.js 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'); }
}; modules/list/dal/node.js 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)); }); }
}; modules/list/dal/package.js { «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 = '
- '
+ '<% _.forEach(points, function(point) { %>'
+ '
- © Habrahabr.ru
