![](http://habrastorage.org/getpro/habr/post_images/383/9b2/f05/3839b2f05fce04cfa7602d5375950e82.png)
О чём речь?
О 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’)? Попробую изобразить основные шаги на диаграммах. Для начала рассмотрим взаимодействие модулей в браузере:
![](http://habrastorage.org/getpro/habr/post_images/64c/019/0fe/64c0190fe78d7a708f2105c88baf3e18.png)
Как видно, модуль list 2 раза обращается к своему DAL-модулю, который выполняет http-запросы к нашему API.
Вот так выглядит взаимодействие тех же (по большей части) модулей на стороне сервера:
![](http://habrastorage.org/getpro/habr/post_images/f3b/69c/0c1/f3b69c0c1b1e36727b8df2465c7e993a.png)
Вот что получается: схема взаимодействия модулей предметной области и 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.
Осмыслим
К чему, собственно, весь этот разговор? На данный момент у меня есть некоторое моральное удовлетворение от проделанной работы и ряд вопросов о её целесообразности. Насколько пригодна данная модель для сложных проектов и где находятся границы её применения? Готов ли я мириться с неизбежными накладными расходами, которые будет иметь приложение на основе кроссплатформенных модулей?
До полного осмысления пока что далеко. В любом случае, хорошо иметь возможность сделать нечто подобное и подумать над перспективами. Кроссплатформенные фреймворки и компонентные тесты — почему бы и нет?
ссылка на оригинал статьи http://habrahabr.ru/post/222261/
Добавить комментарий