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

в 18:52, , рубрики: node.js

Кроссплатформенный 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')? Попробую изобразить основные шаги на диаграммах. Для начала рассмотрим взаимодействие модулей в браузере:

Диаграмма

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

Как видно, модуль list 2 раза обращается к своему DAL-модулю, который выполняет http-запросы к нашему API.
Вот так выглядит взаимодействие тех же (по большей части) модулей на стороне сервера:

Диаграмма побольше

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

Вот что получается: схема взаимодействия модулей предметной области и 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

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js