Пожалуйста, опишите ошибку

Нашли баг? Помогите нам его исправить, заполнив эту форму

Web-сервис на Node.js и Express.js. Часть 1 — самое начало

Максим Лимонов
Web-разработчик

Node.js — это кроссплатформенная среда исполнения JavaScript с открытым исходным кодом, основанная на движке V8 компании Google и предназначенная для создания web-приложений на стороне сервера. Node создан в 2009 году Райаном Далом и на сегодняшний день имеет вокруг себя большое сообщество разработчиков. Node.js отлично подходит для построения высоконагруженных и масштабируемых web-сервисов и используется такими компаниями, как PayPal и LinkedIn.

js-1000-488

Введение

В серии статей мы разберем основы создания web-сервиса на Node.js и Express.js, продемонстрируем взаимодействие с базами данных MySQL, MongoDB и Redis, покажем, как организовать авторизацию пользователей на web-сервисе c помощью логина и пароля, а также аккаунта в социальной сети с использованием модуля Passport, расскажем, как разграничить доступ к различным ресурсам web-сервиса с помощью ролей (Access Control List), а также сделаем сервис отказоустойчивым и масштабируемым.

Наш web-сервис будет представлять из себя REST API системы по предзаказам товаров. Предполагаются две роли — потенциальный покупатель, который фиксирует в системе свое намерение приобрести продукт, когда тот станет доступен, и продавец, который размещает на сервисе продукт с характеристиками и предполагаемую дату доступности. При регистрации в системе на первом шаге пользователь указывает номер телефона, на который отправляется смс-код, на втором шаге вводится указанный ранее номер телефона, смс-код и пароль. Авторизация в системе будет происходить, соответственно, по номеру телефона и паролю. На одном номере телефона могут быть обе роли. Подробности системы и дополнительный функционал будем описывать по мере реализации.

В этой части мы познакомимся с архитектурой Node.js и его экосистемой, всецело опирающейся на пакетный менеджер npm. Установим Node с помощью утилиты nvm и создадим mvc каркас приложения с помощью модуля express-generator. Создадим файл конфигурации и разберемся в тонкостях работы функции require и в разнице между module.exports и exports. Затем подключим к приложению базы данных, и, наконец, запустим то, что у нас получилось.

Архитектура Node.js

Node состоит из следующих компонентов.

V8 — Javascript движок компании Google с открытым исходным кодом, написанный на C++ (тот самый, который выполняет javascript код в браузере Google Chrome). На него возложена функция интерпретирования Javascript кода в машинный язык. Также недавно появилась возможность использовать Node на движке ChakraCore от Microsoft (тот самый, который выполняет javascript код в браузере Microsoft Edge).

libuv — написанная специально для Node мультиплатформенная библиотека на C, обеспечивающая работу с неблокирующими асинхронными операциями ввода/вывода. libuv опирается на зависимые от платформы библиотеки — iocp для windows, epoll для linux, kqueue для mac/bsd (по этой схеме реализована асинхронная работа с сетью). Для операций, которые не могут быть сделаны асинхронно на уровне операционной системы, библиотека использует внутренний пул потоков (работа с файловой системой и dns). Именно в этом компоненте реализован цикл событий (Event Loop), о котором немного ниже.

Другие C/C++ зависимостиc-ares, crypto (OpenSSL), http-parser и zlib — взаимодействуя с сервером на низком уровне, обеспечивают важную функциональность, относящуюся к работе с сетью, шифрованием, сжатием и т.д.

Модули, написанные на Javascript — реализуют Node.js API

Бандинги (Bindings) — служат связующей прослойкой между кодом, написанным на С/С++, и кодом, написанным на Javascript.

Вместо традиционной для серверов многопоточной модели — на каждое подключение выделяется один поток — Node все подключения обрабатывает в одном потоке. Этот поток называется циклом событий(именно в нем исполняется проинтерпретированный пользовательский javascript код приложения). Когда поступает запрос ввода/вывода (действие, емкое с точки зрения времени), цикл событий назначает эту задачу либо операционной системе, когда дело касается работы с сетью, либо контроллеру пула потоков, когда требуется работа с файловой системой или dns (по умолчанию используется 4 потока). Затем регулярно опрашивает о состоянии операции и по ее завершении выполняет заранее назначенное действие (запускается callback-функция или срабатывает прослушиватель событий — в этом заключается событийная ориентированность Node). Возникающие события выстраиваются в специальную очередь (Event Queue), которая определяет порядок обработки событий циклом событий. Поскольку цикл событий не ждет результата операции ввода/вывода, очередной запрос не блокируется на время выполнения операции ввода/вывода, а сама операция выполняется асинхронно по отношению к циклу событий. «Из коробки» Node не поддерживает асинхронное выполнение операций, требующих ресурсов CPU, и такие задачи блокируют цикл событий. Обойти эту проблему позволяют сторонние модули (например, webworker-threads).

В статье по ссылке приведена интересная аналогия между приложением на Node.js и кофейней, наглядно демонстрирующая упрощенную схему работы цикла событий. Приведем ее здесь, дополнив некоторыми деталями. Итак, вообразим, что посетителей (события) обслуживает только один крайне производительный и хорошо натренированный официант (цикл событий). Когда большое количество посетителей приходит выпить чашку кофе в одно время, они выстраиваются в очередь (очередь событий) и ждут, пока их обслужит официант. Как только официант принимает заказ, он сразу отдает его менеджеру (libuv), который назначает выполнение баристе (потоку в пуле потоков, либо платформенно-зависимому механизму, в зависимости от задачи). Бариста будет использовать различные ингредиенты и машины (низкоуровневые C/C++ компоненты), чтобы приготовить различные виды напитков, в зависимости от предпочтения посетителя. Обычно в смене одновременно работают 4 бариста (пул потоков), которые готовят только определенные виды кофе (файловый ввод/вывод, dns и пользовательские задачи, назначенные через uv_queue_work()). Если четырех баристов не хватает, то их количество может быть увеличено, и это должно быть сделано заранее — либо в начале дня, либо перед первым заказом баристу (количество потоков в пуле может быть установлено через переменную при запуске приложения, либо может быть задано программно перед первым обращением к пулу; максимальное количество потоков — 128). Когда заказ передан менеджеру, официант не дожидается готового напитка, а идет обслуживать следующего в очереди посетителя. Как только напиток приготовлен, он отправляется в конец очереди посетителей. Официант окликнет посетителя, когда его напиток, двигаясь в очереди, дойдет до стойки. Иногда случается так, что посетитель у стойки просит обслужить его приятеля вне очереди — сразу за ним или, по крайней мере, как можно быстрее (функция setImmediate()), а иногда отправляет знакомого в конец очереди (функция process.nextTick()).

Установка Node.js и пакетный менеджер npm

Установка nvm. Node.js является кроссплатформенным продуктом и может быть установлен на большинство современных операционных систем. Мы будем использовать Mac OS X. При разработке приложений на Node рано или поздно возникают ситуации, когда необходимо использовать разные его версии. Такое может случиться, когда разработчик занимается несколькими проектами с различными требованиями, или когда создается приложение, совместимое с рядом версий Node. На этот случай создана утилита nvm (Node Version Manager), позволяющая с легкостью устанавливать разные версии Node и быстро переключаться между ними. Утилита nvm работает под Linux и OS X. Пользователи Windows могут воспользоваться аналогичной утилитой nvm-windows.

Прежде, чем устанавливать nvm, желательно удалить Node.js и npm. Как это сделать описано здесь и здесь. Также в системе должен быть установлен C++ компилятор. Для OS X достаточно установить консольную утилиту Xcode:

xcode-select --install

Далее удостоверьтесь, что файл ~/.bash_profile существует, иначе создайте его командой touch ~/.bash_profile. Теперь все готово для установки nvm (замените версию ниже на последнюю):

curl -o- https://raw.githubusercontent.com/creationix/nvm/v0.31.4/install.sh | bash

В рамках данной статьи из всех возможностей утилиты nvm мы воспользуемся только установкой последней версии Node.

Принцип нумерации версий Node.js. Стоит пояснить принцип нумерации версий Node. Каждая новая версия (v.5, v.6, …) выходит раз в полгода. Четные версии (выходят в апреле) фокусируются на стабильности и безопасности и имеют длительный период поддержки — Long Term Support plan (18 месяцев активной поддержки и год обычной). Этот статус очередная четная версия приобретает во время выхода новой нечетной версии. С обновлениями LTS версии уже не наделяются новым функционалом, а получают только исправление багов, влияющих на стабильность, обновление безопасности, некритические улучшения производительности и пополнение документации. Четные версии подходят для компаний со сложной организацией кода, для которых частое обновление обременительно. Напротив, нечетные версии (выходят в октябре) получают обновления часто. С обновлениями активно нарабатывается новый функционал, улучшаются существующие API и производительность. Такие версии поддерживаются не более восьми месяцев и носят больше характер экспериментальной площадки.

На момент написания статьи последней версией была v.6.3.1. Шестая версия получила существенные улучшения производительности, надежности, удобства работы и безопасности. Хотя v.6 пока еще не достигла LTS статуса, работать мы будет именно с ней. Для установки последней версии выполните в терминале

nvm install node

И проверим установку

node -v
v.6.3.1

Если все правильно установилось, эта команда должна вывести версию Node.js.

Построение приложений на Node.js основано на модульном подходе. В силу популярности Node, большим сообществом приверженцев создано огромное количество модулей, значительно расширяющих функционал. Для установки модулей предназначен пакетный менеджер npm, который поставляется вместе с Node.js. Проверим версию менеджера.

npm -v
3.10.3

Помимо установки модулей, npm следит за их версиями — два модуля могут быть зависимы от третьего, но использовать разные его версии. Раньше менеджер npm помещал зависимый модуль в папку модуля, который его использует. Получалась вложенная структура и зачастую в разных папках хранились одинаковые модули с одинаковыми версиями. Начиная с npm v.3, модули хранятся линейно в одной папке, и только когда возникает конфликт версий, модули помещаются вложенно.

Наш web-сервис будет создаваться на основе модуля Express.js — самого популярного web фреймворка для Node.js. Node не накладывает строгих ограничений на организацию кода, предоставляя разработчикам свободу выбора. Мы будем использовать шаблон mvc. Для генерации mvc-каркаса приложения воспользуемся пакетом express-generator, для этого в терминале выполним команду.

npm install -g express-generator

В результате пакетный менеджер npm установит модуль express-generator. Флаг -g означает глобальную установку, т.е. запускать этот модуль в терминале мы можем из-под любого пути. Заметьте, что если бы для установки Node мы не использовали nvm, то при глобальной установке модулей приходилось бы прибегать к использованию sudo, что не является безопасным. Далее создадим mvc-каркас командой

express ~/Documents/site/app

Директория ~/Documents/site/app будет корневой для нашего проекта (все относительные пути в статье будут вестись от корневой директории). Перейдем в созданную директорию и установим зависимости, прописанные в файле ./package.json (модули устанавливаются в ./node_modules)

cd ~/Documents/site/app && npm install

И запустим приложение

npm start

При этом npm выполнит команду, прописанную в ./package.json

{
    ...
    "scripts": {
        "start": "node ./bin/www"
    },
    ...
}

Теперь переходите по адресу http://localhost:3000. Если отобразилась приветственная надпись «Welcome to Express», то поздравляем — все готово для следующего шага!

Базы данных

Наш сервис для хранения пользователей, продуктов, предзаказов и системы прав доступа будет использовать MySQL, для сессий — Redis, а для отправленных смс-кодов — MongoDB.

MySQL

Эта реляционная СУБД в представлении не нуждается. Скачаем DMG образ MySQL сервера и запустим установку. В конце установки появится диалоговое окно, сообщающее временный пароль пользователя root.

image1

Изменим временный пароль

/usr/local/mysql/bin/mysqladmin -u root -p password
Enter password: 
New password: 
Confirm new password: 
Warning: Since password will be sent to server in plain text, use ssl connection to ensure password safety.

Запускается и останавливается сервер через панель MySQL в системных настройках.

image2

Работать со схемой базы данных будем в MySQL Workbench.

Redis и MongoDB

Redis является сетевым журналируемым хранилищем данных типа “ключ — значение”, относится к нереляционным СУБД высокой производительности, поскольку хранит базу данных в оперативной памяти. MongoDB — нереляционная СУБД для хранения JSON объектов.

Как Redis, так и MongoDB удобно устанавливать через Homebrew — пакетный менеджер для Mac OS X. Обновим базу пакетов менеджера и установим Redis командой

brew update && brew install redis

Запустим как сервис

brew services start redis

MongoDB устанавливается командой

brew install mongodb

Создадим директорию

mkdir ~/Documents/site/data

И запустим MongoDB по этому пути

mongod --dbpath ~/Documents/site/data

Для проверки наберите в другом окне терминала

mongo

Должно появиться следующее:

image3

На скриншоте сервер выдал предостережение, которое можно просто проигнорировать, если ваш проект в стадии разработки. Иначе можете посмотреть здесь.

Файл конфигурации приложения

Создадим в корне проекта две вложенные директории и пустой файл

mkdir -p config/development && touch ./config/development/index.js

Этот файл будет отвечать за конфигурационные настройки приложения. Поместите в него следующий код (не забудьте поменять указанные в комментариях значения на свои)

// файл ./config/development/index.js
var config = {
    db : {
        mysql : {
            host     : 'localhost',
            user     : 'root',
            database : 'appdb', // можете заменить 'appdb' на свое название
            password : 'yourPasswordHere' // замените это на root пароль 
        },                                // от MySQL Server
        mongo : 'mongodb://localhost/ourProject' // можете заменить 'ourProject'
    },                                           // на свое название
    redis : {
        port : 6379,
        host : '127.0.0.1'
    },
    port : 3000
};

module.exports =  config;

Подключим этот файл к ./bin/www следующим образом

// файл ./bin/www
    //...
    var config = require('../config/' + (process.env.NODE_ENV || 'development'));
    //...
    //var port = normalizePort(process.env.PORT || '3000');
    var port = normalizePort(process.env.PORT || config.port);
    //...

Это позволит нам подключать разные файлы конфигурации в зависимости от параметров запуска приложения. Например, если мы создадим конфигурационный файл ./config/production/index.js, то чтобы он применился, приложение следует запускать так

NODE_ENV=production npm start

Несколько слов о require, module.exports и exports

Node.js исповедует модульный подход построения приложений, причем в качестве модуля всегда выступает отдельный файл (*.js, *.json, *.node, либо файл с javascript кодом без расширения). За подключение модулей отвечает функция require. Если модуль встроенный или находится в ./node-modules (или в ../node-modules и выше), то в качестве параметра функции require указывается только название модуля, например require('http'). Иначе функции require передается путь (т.е. строка, начинающаяся с './' или '/', или '../'), например такой './libs/dbs.js'. Расширение модуля можно не указывать и просто писать './libs/dbs'. В таком случае, если dbs не окажется файлом модуля, dbs воспринимается как директория, и в ней ищется файл модуля с именем index. Так мы поступили выше при подключении файла конфигурации к ./bin/www. Весь алгоритм получения пути к модулю по строке описывается здесь.

Переменная, объявленная в модуле обычным образом, не будет доступна через require — ее надо передать через module.exports или exports (является ссылкой на module.exports и используется для более короткой записи). Пример ниже поясняет ситуацию

// файл module.js
var a = [1,2];
var b = [3,4];

exports.a = a;

// файл uses_module.js
var module = require('./module');

console.log(module.a) // [1,2]
console.log(module.b) // undefined

Таким образом можно передавать любые переменные, включая функции и конструкторы. Переменную можно напрямую присваивать объекту module.exports (но не exports, поскольку это только ссылка), как мы и сделали с конфигурационным файлом выше.

При первом подключении модуля функция require кэширует его и помещает в объект require.cache. При последующих подключениях того же модуля объект грузится из кэша. Такая модель реализует шаблон Singleton.

Подключение баз данных к Node.js

Установка драйверов

Для MySQL мы не будем использовать ORM — вся нагрузка ляжет на хранимые процедуры, которые будут вызываться из репозиториев соответствующих объектов. Стандартным драйвером для работы с MySQL в Node является npm модуль mysql (есть более быстрый драйвер mysql2, но он пока находится на стадии релиз-кандидата). При написании кода мы будем придерживаться стиля с использованием промисов, и поскольку модуль mysql не поддерживает такой стиль, воспользуемся модулем-оберткой mysql-promise. Перейдем к корневой директории проекта и установим его

cd ~/Documents/site/app && npm install mysql-promise --save

Флаг --save указывает пакетному менеджеру на сохранение зависимости в файл package.json. С драйвером для Redis дела обстоят похожим образом — воспользуемся модулем-оберткой promise-redis вокруг стандартного драйвера redis.

npm install promise-redis --save

И, наконец, установим Mongoose — ODM (Object-Document Mapper) для MongoDB

npm install mongoose --save

Mongoose имеет собственную встроенную библиотеку mpromise, реализующую промисы. На данный момент эта библиотека считается устаревшей, и рекомендуется заменять ее на другую (мы заменим на стандартные ES6 промисы).

Инициализация баз данных

Создадим папку и файл в ней

mkdir libs && touch ./libs/dbs.js

со следующим содержанием

// файл ./libs/dbs.js
var mysqlPromise = require('mysql-promise')(),
    mongoose = require('mongoose'),
    Redis = require('promise-redis')(),
    config = require('../config/' + (process.env.NODE_ENV || 'development'));

mysqlPromise.configure(config.db.mysql);
var redis = Redis.createClient(config.redis.port, config.redis.host);

mongoose.Promise = Promise;                 // [1]

function checkMySQLConnection(){            // [2]
    return mysqlPromise.query('SELECT 1');
}

function checkRedisReadyState() {           // [3]
    return new Promise((resolve,reject) => {
        redis.once("ready", () => {redis.removeAllListeners('error'); resolve()});
        redis.once('error', e => reject(e));
    })
}

function init() {                           // [4]
    return Promise.all([
        checkMySQLConnection(),
        new Promise((resolve,reject) => {mongoose.connect(config.db.mongo, err =>
            err ? reject(err):resolve())}),
        checkRedisReadyState()
    ]);
}

module.exports = {                          // [5]
    mysql: mysqlPromise,
    redis: redis,
    init:  init
};

Опишем, что здесь происходит. Вначале подключаются драйверы баз данных и файл конфигурации. Затем конфигурируется подключение к базе MySQL, и создается клиент базы Redis. Далее по пунктам.

  1. Заменяем встроенную библиотеку промисов на стандартную ES6 библиотеку.
  2. Функция checkMySQLConnection() проверяет подключение к MySQL простым запросом. Важно отметить, что при конфигурировании подключения к MySQL создается пул подключений, и команда query вначале берет подключение из пула, выполняет запрос, затем освобождает подключение.
  3. Функция checkRedisReadyState() проверяет готовность Redis сервера.
  4. Функция init() с помощью ES6 метода Promise.all параллельно запускает проверку готовности MySQL, MongoDB и Redis. Как только одна из функций возвращает ошибку, выполнение других останавливается, и Promise.all возвращает ошибку, ловить которую будем в ./bin/www методом .catch(). Если ошибка не возвращается, управление передается методу .then().
  5. И, наконец, экспортируем переменные.

Изменим файл ./bin/www

// файл ./bin/www

// ...
// Добавим зависимости
var config = require('../config/' + (process.env.NODE_ENV || 'development')),
    dbs = require('../libs/dbs');

// ...

// Заменим строчку
// server.listen(port);
// На следующий блок

dbs.init().then(() => {
  console.log('Соединения с базами данных установлены успешно');
  server.listen(port);
}).catch(err => {
  console.log(err);
  process.exit(1);
});

// ...

В результате наш web-сервис не запустится, если будут проблемы с подключением к базам данных. Теперь создайте базу данных в MySQL с именем appdb и запустите наше приложение. Если все в полном порядке, в консоли мы увидим сообщение об успешном соединении с базами данных.

На этом первая часть заканчивается. В следующей части мы займемся регистрацией и ролями пользователей.

Читать и комментировать