Кроссплатформенный CommonJS на практике

от автора

О чём речь?

О JS модулях, которые можно использовать в браузере и на сервере. Об их взаимодействии и внешних зависимостях. Меньше теории, больше практики. В рамках курса молодого бойца мы реализуем простое и весьма оригинальное приложение на базе Node.JS: ToDo-лист. Для этого нам предстоит:

  1. «Завести» кроссплатформенные модули на базе фреймворка Express;
  2. Научить их работать с платформозависимыми коллегами;
  3. Создать транспортный уровень между клиентом и сервером;
  4. Таки сделать ToDo-лист;
  5. Осмыслить результат.

Требования к приложению

Сосредоточимся на сути всей затеи и возьмём на реализацию минимальный функционал. Требования сформулируем следующим образом:

  1. Приложение доступно с помощью браузера;
  2. Пользователь работает со своим ToDo-листом в рамках одной сессии. При перезагрузке страницы список должен сохраниться, после закрытии вкладки или браузера — создаться новый;
  3. Пользователь может добавлять новые пункты в список;
  4. Пользователь может отметить добавленный пункт как выполненный.

Делаем каркас

Без проблем поднимаем каркас приложения на базе фреймворка 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 следующие методы:

  1. GET, /list/getPoints – получить список дел в ToDo-листе текущего пользователя;
  2. POST, /list/addPoint – получить новый пункт в ToDo-лист текущего пользователя;
  3. 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 = '<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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *