Введение


Добрый день, дорогой %username%! Сегодня мы будем описывать создание каркаса приложение по типу MVC на Node.js с использованием кластеров, Express.js и mongoose.


Задача — поднять сервер который имеет несколько особенностей.


  • Работает в несколько асинхронных потоков.
  • Сессионная информация будет в общей для всех потоков.
  • Поддержка HTTPS.
  • Авторизация.
  • Легко масштабируем.

Статья написана новичком для новичков. Буду рад любым замечаниям!


С чего начать? Установить Node.js (с которым идет npm). Установить MongoDB (+ Добавить в PATH).


Теперь создание NPM проекта для того что бы не тащить все зависимости в наш git!


$ npm init

Вам предстоит ответить на несколько вопросов (можно пропустить все просто нажимая по Enter-у). Иногда npm багует и записав package.json не завершается.


Дальше! Запишем наш index.js


// Project/bin/index.js
process.stdout.isTTY = true;
// Заставим думать node.js что мой любимый git bash это консоль!
// Смотрите https://github.com/nodejs/node/issues/3006

var cluster = require('cluster');
// загрузим кластер
if(cluster.isMaster)
{
    // если мы <<master>> то запустим код из ветки мастер
    require('./master');
}
else
{
    // Если мы <<worker>> запустим код из ветки для worker-a
    require('./worker');
}

Немного про кластеры.


Что такое кластер? Кластер это система приложений которые где есть две роли: Главная роль (master) и рабочая роль (worker). Есть один мастер на который приходят все запросы, и n-ое количество рабочих (в коде CPUCount).


Если приходит запрос к серверу, то мастер решает какому рабочему дать этот запрос. При создании рабочего node рожает процесс который запускает тот же код, который сейчас запущен и создает IPC. Когда происходит соединение по TCP/IP то мастер отдает Socket одному из рабочих по определенной политике (подробнее здесь) через IPC.


Вернемся к коду. Что там случилось с master-ом и worker-ом? Код мастера:


//Project/bin/master.js
var cluster = require('cluster');
// Загрузим нативный модуль cluster

var CPUCount = require("os").cpus().length;
// Получим количество ядер процессора
// Создание дочернего процесса требует много ресурсов. Поэтому в связке с 8 ядерным сервером и Nodemon-ом дает адские лаги при сохранении.
// Рекомендую при активной разработке ставить CPUCount в 1 иначе вы будете страдать как я....

cluster.on('disconnect', (worker, code, signal) => {
    // В случае отключения IPC запустить нового рабочего (мы узнаем про это подробнее далее)
    console.log(`Worker ${worker.id} died`);
    // запишем в лог отключение сервера, что бы разработчики обратили внимание.
    cluster.fork();
    // Создадим рабочего
});

cluster.on('online', (worker) => {
    //Если рабочий соединился с нами запишем это в лог!
    console.log(`Worker ${worker.id} running`);
});
// Создадим рабочих в количестве CPUCount
for(var i = 0; i < CPUCount; ++i)
{
    cluster.fork(); // Родить рабочего! :)
}

Про arrow function, online, disconnect, шаблонные строки


Что дальше? Дальше рабочий! Здесь мы будем писать один код. Потом я буду говорить что мы пропустили и добавлять его :) НО перед этим для начала загрузим зависимости из npm!


$ npm i express apidoc bluebird body-parser busboy connect-mongo cookie-parser express-session image-type mongoose mongoose-unique-validator nodemon passport passport-local request request-promise --save

Зачем нам каждый модуль?


  • Express — Думаю понятно.
  • apidoc — Удобно для документирование API (необязательно)
  • Bluebird — Promise-ы какие они есть :). Нам он понадобится т.к. в стандартных Promise-ах на 4.х.х был баг из-за чего возникал memory-leak. Также mpromise от которого mongoose зависит более не поддерживается. Нам придется заставить mongoose использовать наш Bluebird.
  • body-parser — Поддержка json в запросах с телом (body)
  • busboy — Поддержка form-data в запросах с телом.
  • cookie-parser — Простой модуль для куков (Cookies).
  • connect-mongo — Нужно для хранения сессий в MongoDB
  • express-session — Для сессий.
  • image-type — Для валидации картинок при загрузке.
  • mongoose — Очевидно для удобного доступа к MongoDB
  • mongoose-unqiue-validator — Для того что бы указать что в модели данные должны быть уникальными (e.g username, email, etc)
  • nodemon — Во время разработки автоматически перезагружает наш сервер при сохранении файла.
  • passport passport-local — Полезные модули для авторизации!
  • request request-promise — Для тестирование нашего кода!

И так? напишем скрипт для запуска nodemon. В package.json добавим (заменим если есть такой field)


"scripts":{
  "start":"nodemon bin/index.js"
}

Для запуска будем теперь использовать


$ npm start

В дальнейшем мы добавим тесты, документацию.


Теперь вернемся к Worker-у. Для начала запустим Express!


var express = require('express');
// Загрузим express
var app = express();
// Создадим новый сервер

app.get('/',(req,res,next)=>{
    //Создадим новый handler который сидить по пути `/`
    res.send('Hello, World!');
    // Отправим привет миру!
});

// Запустим сервер на порту 3000 и сообщим об этом в консоли.
// Все Worker-ы  должны иметь один и тот же порт
app.listen(3000,function(err){
    if(err) console.error(err);
    // Если есть ошибка сообщить об этом
    // Приложение закроется т.к. нет больше handler-ов
    else console.log(`Running server at port 3000!`) 
    // Иначе сообщить что мы успешно соединились с мастером
    // И ждем сообщений от клиентов
});

Это все? Нет. На самом деле есть несколько вещей которые мы забыли про настройку Express-а. Исправим это. Нам ведь нужны файлы для лицевой части? (Front-end). Так добавим их поддержку! Создадим папку public все содержание которого будет доступно по адресу /public. У нас есть два варианта. Поставить NGINX и не ставить его. Самый простой вариант не ставить его. Будем использовать то что встроено в express.


Альтернативный вариант использовать NGINX в качестве мастера, который еще и будет брать на себя ответственность за статические файлы. Оставим это на какое-то время, хоть и это поможет с производительностью и масштабированием.


Перед app.get('/'). Добавим следующее:


//....
var path = require('path');
// app = express(); тут инициализация сервера
// сразу после
// Промонтировать файлы из project/public в наш сайт по адресу /public
app.use('/public',express.static(path.join(__dirname,'../public')));
//...

Это все? ОПЯТЬ НЕТ! Теперь к входным данными. Как мы будем получать входные данные?


var bodyParser = require('body-parser');
//..
/// app.use(express.static(.........));
// JSON Парсер :)
app.use(bodyParser.json({
    limit:"10kb"
}));
//...

Теперь к кукам


// JSON Парсер
// ...
// Парсер Куки!
app.use(require('cookie-parser')());
// ...

Но это не все! Дальше нам нужно заставить работать Mongoose ибо мы будем работать с сессиями! Запустим MongoDB командой


$ mkdir database
$ mongod --dbpath database --smallfiles

Что же здесь происходит? Мы создаем папку database где будет хранится данные сервера. Не забудьте добавить папку в .gitignore. Затем мы запускаем MongoDB указывая на папку database как хранилище. И что бы файлы были маленькими передаем параметр --smallfiles, хотя даже в таком случае MongoDB будет хранить логи размером 200МБ в папке ./database/journal


Также во второй части будет туториал как поднять пропускную способность MongoDB, и установить его как сервис в systemd под Ubuntu.


Теперь к коду. В файле worker.js в начало файла сразу после загрузок модулей вставим следующее


require('./dbinit'); // Инициализация датабазы

Создаем файл dbinit.js в папке bin. В который вставляем такой код:


// Инициализация датабазы!
// Загрузим mongoose
var mongoose = require('mongoose');
// Заменим библиотеку Обещаний (Promise) которая идет в поставку с mongoose (mpromise)
mongoose.Promise = require('bluebird');
// На Bluebird
// Подключимся к серверу MongoDB
// В дальнейшем адрес сервера будет загружаться с конфигов
mongoose.connect("mongodb://127.0.0.1/armleo-test",{
    server:{
        poolSize: 10
        // Поставим количество подключений в пуле
        // 10 рекомендуемое количество для моего проекта.
        // Вам возможно понадобится и то меньше...
    }
});

// В случае ошибки будет вызвано данная функция
mongoose.connection.on('error',(err)=>
{
    console.error("Database Connection Error: " + err);
    // Скажите админу пусть включит MongoDB сервер :)
    console.error('Админ сервер MongoDB Запусти!');
    process.exit(2);
});

// Данная функция будет вызвано когда подключение будет установлено
mongoose.connection.on('connected',()=>
{
    // Подключение установлено
    console.info("Succesfully connected to MongoDB Database");
    // В дальнейшем здесь мы будем запускать сервер.
});

Теперь привяжем сессии к датабазе. В bin/worker.js добавим следующее. В начало к загрузке модулей:


var session = require('express-session'); // Сессии
var MongoStore = require('connect-mongo')(session); // Хранилище сессий в монгодб

И после парсера куков:


// Теперь сессия
// поставить хендлер для сессий
app.use(session({
    secret: 'Химера Хирера',
    // Замените на что нибудь
    resave: false,
    // Пересохранять даже если нету изменений
    saveUninitialized: true,
    // Сохранять пустые сессии
    store: new MongoStore({ mongooseConnection: require('mongoose').connection })
    // Использовать монго хранилище
}));

Несколько пояснений насчет очередности подключений. express.static('/public'). Сидит в самом начале т.к. Браузеры отправляют запросы на файлы паралельно и они будут отправлять запросы с пустыми сессиями и мы будем создавать их тысячами.


Куки парсер и сессий нужны в начале т.к. в дальнейшем они будут использовать для авторизации. После чего идут парсеры тела запроса. NOTE: Последние два можно поменять местами. Сервис авторизации. Он должен идти после парсеров и сессий т.к. пользуется ими, но перед контроллерами т.к. они используют информацию о пользователе. Далее идут контроллеры, к ним вернемся чуть позже.


Теперь обработчик ошибок. Он должен идти последним т.к. в документации Экспресс так написано :)


В файле bin/worker.js добавим перед app.listen(.....); следующее


// Обработчик ошибок
app.use(require('./errorHandler'));

Теперь создадим файл errorHandler.js


// Все обработчики ошибок должны иметь 4 параметра, иначе они будут обычными контроллерами
module.exports = function(err,req,res,next)
{
    // err всегда установлен ибо Express.js проверяет была ли передана ошибка или нет, и вызывает обработчики только если ошибка есть;
    console.error(err);
    // В дальнейшем мы будем отправлять ошибки по почте, записывать в файл и так далее.
    res.status(503).send(err.stack || err.message);
    // Здесь можно вызвать next() или самим сообщить об ошибке клиенту.
    // В будущем можно сделать страниц 503 с ошибкой
};

Практически закончили работу с Worker-ом. Но нам еще нужно настроит модели и их загрузку.
Создадим папку models где будут храниться наши модели. В дальнейшем у нас будут еще миграции с помощью которых мы будем мигрировать из одной версии датабазы на новую.


Создадим в папке models файлы index.js И user.js. Таким образом в index.js мы запишем загрузку всех моделей и их Экспорт, а файл user.js будет содержать модель из Mongoose-а с некоторыми методами и функциями привязанных к модели. Про модели можно почитать на сайте Mongoose или в документации.
В index.js записываем:


module.exports = {
    // Загрузить модель юзера (пользователя)
    // На *nix-ах все файлы чувствительны к регистру
    User:require('./User')
};
// Не забудем точку с запЕтой!

А в user.js записываем:


// Загрузим mongoose т.к. нам требуется несколько классов или типов для нашей модели
var mongoose = require('mongoose');
// Создаем новую схему!
var userSchema = new mongoose.Schema({
    // Логин
    username:{
        type:String, // тип: String
        required:[true,"usernameRequired"],
        // Данное поле обязательно. Если его нет вывести ошибку с текстом usernameRequired
        maxlength:[32,"tooLong"],
        // Максимальная длинна 32 Юникод символа (Unicode symbol != byte)
        minlength:[6,"tooShort"],
        // Слишком короткий Логин!
        match:[/^[a-z0-9]+$/,"usernameIncorrect"],
        // Мой любимй формат! ЗАПРЕТИТЬ НИЖНЕЕ ТИРЕ!
        unique:true // Оно должно быть уникальным
    },
    // Пароль
    password:{
        type:String, // тип String
        // В дальнейшем мы добавим сюда хеширование
        maxlength:[32,"tooLong"],
        minlength:[8, "tooShort"],
        match:[/^[A-Za-z0-9]+$/,"passwordIncorrect"],
        required:[true,"passwordRequired"]
        // Думаю здесь все уже очевидно
    },
    // Здесь будут и другие поля, но сейчас еще рано их сюда ставить!
});

// Теперь подключим плагины (внешние модули)

// Компилируем и Экспортируем модель
module.exports = mongoose.model('User',userSchema);

Теперь разберемся с образами (Попытка перевести view) и контроллерами. Создадим две папки: controllers и views. Теперь выберем нужную нам библиотеку для рендера (прорисовки, отрисовка, компиляция, заполнение) образов. Для меня крайне простым оказалась mustache. Но для того что бы было легко менять движок рендеринга я использую consolidate.


$ npm i consolidate mustache --save

Консолдейт требует что бы движки используемые проектом были установлены, поэтому не забудьте после того как поменяете движок его установить. Теперь вставим заменим весь app.get('/'); на


var cons = require('consolidate');
// Используем движок усов
app.engine('html', cons.mustache);
// установить движок рендеринга
app.set('view engine', 'html');
// папка с образами
app.set('views', __dirname + '/../views');

app.get('/',(req,res,next)=>{
    //Создадим новый handler который сидит по пути `/`
    res.render('index',{title:"Hello, world!"});
    // Отправим рендер образа под именем index
});

Теперь в папке views добавляем наш index.html куда записаваем


{{title}}

Заходим на 127.0.0.1:3000 и видим Hello, World!. Перейдем к контроллерам! Удалим строки app.get(.................). Теперь нам предстоит загрузить контроллеры. (Которые находятся в папке controllers). Вместо нашего удаленного кода вставляем следующее.


app.use(require('./../controllers')); // Монтируем контроллеры!

В файл controllers/index.js записываем


var app = require('express')();

app.use(require('./home'));
module.exports = app;

А в файл controllers/home.js записываем:


var app = require('express')();

app.get('/',(req,res,next)=>{
    //Создадим новый handler который сидит по пути `/`
    res.render('index',{title:"Hello, world!"});
    // Отправим рендер образа под именем index
});
module.exports = app;

На этом конец первой части! Спасибо за внимание. Многое осталось без нашего внимания, и надо будет это исправить во второй части. Здесь есть много спорных моментов. Так же здесь много ошибок, которые будут исправлены во второй части. Код чуть позже будет выложен на github. Суть проекта объясняется во второй части.

Поделиться с друзьями
-->

Комментарии (33)


  1. seryh
    03.11.2016 19:20
    +2

    А можно просто:


    $ npm install express-generator -g
    $ express --view=jade myapp


    1. Armleo
      03.11.2016 19:25
      +1

      Да. Но суть статьи написать все самому что бы понимать, что и как. Про express-generator знаю, но после третьей части от него останется только команда. А от моей статьи частичное понимание внутренностей. К тому же 1-2 части крутятся вокруг express-generator а 2-3 уже уходят ДАЛЕКО от Экспресса.


  1. troyanskiy
    03.11.2016 19:46

    Хорошо разжевал, спасибо!
    Я храню сессии в REDIS, думаю он быстрее будет, чем MongoDB. Так же сессии можно хранить в мастер процессе используя strong-store-cluster библиотеку. Делал сравнительные нагрузочные тесты с десятками миллионов сессий, редис будет по быстрее, чем хранение в мастере.
    Кластеризацию я предпочел делать используя strong-cluster-control, она позволяет автоматически делать рестарт воркеров, если те упали по той или иной причине. Так же, очень важный аспект этой библиотеки в том, что можно произвести тихий (без даунтайма) рестарт воркеров, что очень актуально при апдейте сервера (не мастер части). Причем она запускает сначала воркер, и если тот удачно стартанул, то дает сигнал на нормальное завершение работы старого воркера (т.е. пока есть зависшие процессы в нем, он не выгружается, ну и новые реквесты к нему не отправляются). И так для всех воркеров.


    1. Armleo
      03.11.2016 19:50

      Redis это хорошо, но я решил не усложнять все. Ибо архитектура и так сложная, а изменить архитектуру добавив редис можно за 5 минут.


      1. troyanskiy
        03.11.2016 19:54
        +1

        Да, редис добавить в код стоит 5 минут работы.
        PS: Прошу простить, если Вы подумали, что я прошу добавить это в статью, она, имхо, идеальна для начинающих. Я просто указал, что я сейчас использую ;)


        1. Armleo
          03.11.2016 20:11

          Нет, вы правы, Редис важная вещь ее нельзя упускать (Несмотря на что вы считаете что она не так важна в статье). Статья не идеальна и ей есть куда расти. Обязательно редис будет во второй (возможно третьей) статье :)


    1. Suvitruf
      04.11.2016 05:51

      Плюсую за редис. Мы его используем для токенов и игровых рейтингов. Настраивается быстро, нагрузки держит большие.


  1. Obramko
    04.11.2016 11:31

    Что такое "асинхронные потоки"?


    cluster использует fork().


    1. Armleo
      04.11.2016 11:44

      Под асинхронными потоками подразумевались процессы нода :)


      1. Obramko
        11.11.2016 13:34

        Ну так и напишите "форкнутые процессы node-ы". В чем разница? В корректности.


  1. Aries_ua
    04.11.2016 11:40
    +1

    Спасибо! Статья отличная. Жаль что до всего этого пришлось прийти самостоятельно методом проб и ошибок. С нетерпением ожидаю следующие статьи. Надеюсь автор напишет их в ближайшем будущем.


  1. socaseinpoint
    04.11.2016 11:40

    В worker.js пропущено var cons = require('consolidate'); Спасибо)


    1. Armleo
      04.11.2016 13:52

      Спасибо за замечание!


  1. yogurt1
    04.11.2016 11:42
    -2

    Статься хорошая, но очень много мелких недочетов
    Например в строке app.use(require(...)) мне захотелось ударить вас :-)
    Нужно всегда делать в начале файла const module = require('module') и уже потом использовать его
    Каждый require синхронный и, очевидно, блочит весь процесс


    1. Armleo
      04.11.2016 11:47

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


    1. Armleo
      04.11.2016 13:55

      Недочетов много, потому что первая статья :) Буду рад любым замечаниям.


    1. dale0
      04.11.2016 18:19

      А каким образом require помешает в данном конкретном случае?


  1. wert_lex
    04.11.2016 15:04

    Вопрос. Если все worker-ы симметричные, то не проще было ли писать приложение без cluster (особенно учитывая что усложнять пример не хотелось), а запускаться потом через PM2? Там и gracefull reload, и cluster, и auto restart, и даже логи в реальном времени.


    И таки для request request-promise, которые для тестирования кода, лучше таки --save-dev использовать.


    1. Armleo
      04.11.2016 15:05

      Они нам еще понадобятся в проекте. А именно для каптчи. Забыл записать :)
      Проект для понимания внутренностей. Читайте первый коментарий.


  1. AirWorker
    05.11.2016 13:21

    Кто-нибудь может сказать, зачем писать на express, когда уже давно можно писать на koa?


    И да, коллбеки — отстой.


    1. Armleo
      05.11.2016 14:20

      А зачем писать на koa, если есть express? Различий не увидел, кроме this.req вместо req и все :)
      В основном потому что есть опыт с express ом.


      И да вы где ты коллбеки увидели? Все в проекте на обещаниях… (практически)


      1. dale0
        05.11.2016 16:31

        Разница между ними в основном в том, что на Koa можно писать асинхронный код в синхронном стиле, используя генераторы или асинхронные функции (начиная со второй ветки).
        Ну и ещё Koa — это больше connect, нежели express.


        1. Armleo
          05.11.2016 18:01

          Посмотрел я Koa. Идея хорошая использовать yield next() и прочее вместо классических коллбеков. Хотя сам против таких вещей как yield т.к. они усложняют понимание кода фронт енд разработчикам :).


        1. seryh
          05.11.2016 18:59

          Писать код в синхронном стиле хорошо с использованием async/await.
          Генераторы я вообще не понимаю для кого и зачем сделаны. Их дизайн сделан настолько плохо,
          что вернувшись к функции которую написал 15 минут назад, уже не понимаешь что она делает.
          Это похоже на такой задел под ленивые вычисления, который не довели до конца.


          1. dale0
            05.11.2016 20:51

            Генераторы были сделаны для того, чтобы упростить написание итераторов. Просто они оказались нужнее в другом месте.
            Асинхронные функции пока что всё равно реализуются через них. Не вижу ничего плохого.


            1. Armleo
              05.11.2016 21:48
              +1

              А я вижу. В асинхронном async/await сразу понятно, что происходит. А что происходит с генераторами? Они просто сложны. И сложны они на пустом месте. Я ПРОТИВ генераторов т.к. они НЕ для этого предназначены. А вот async/await веб фреймоворк, да пожалуйста!


            1. seryh
              05.11.2016 21:53

              Генераторы были сделаны для того, чтобы упростить написание итераторов.

              Звучит как масло масляное, что дальше то делать с этими итераторами? Разработчики ES6 этим функционалом предоставили просто аппендикс для работы с ленивыми структурами данных, но в сам язык никакой удобной их поддержки не предоставили. Для декларативного описания обработки коллекций по прежнему проще использовать сторонние либы по типу "Stream.js". Сообщество просто покрутило эту штуку и пристроило куда могло, то что генераторы решают проблему callback hell красивее чем целый ряд изобретенных до этого костылей, сомнительно. В любом случае в Node.js 7.0.0 подвезли async/await и использованию генераторов для написания синхронного кода, нет никакого оправдания.


              1. dale0
                05.11.2016 22:28

                Рано вы про нативную имплементацию заговорили. Нода 7й версии ещё только релизнулась, да и поддержка асинхронных функций работает пока только с флагом.


                1. Armleo
                  05.11.2016 22:36

                  Рано? Самое время… Не хотите флаг используйте обещания. Хотите асинхронности, то только async/await все остальное так-же плохо, как использовать коллбеки если есть промисы.


                  1. dale0
                    05.11.2016 22:40

                    Асинхронные функции удобные, зачем от них отказываться? А что там под капотом транспайлера происходит — без разницы. Когда нода начнёт поддерживать всё это дело без флага, можно будет просто убрать компиляцию и пользоваться уже нативно. При этом не придётся переписывать код, чтобы работать уже с асинхронными функциями.


                    1. Armleo
                      05.11.2016 22:52

                      Если есть возможность использовать Babylon или похожие, то async/await однозначно. Если нет то обещания. Не надо делать костыли. Можно, но плохо.


                      1. dale0
                        05.11.2016 23:02

                        А вы про какой babylon?


                        1. Armleo
                          05.11.2016 23:22

                          Извиняюсь babel. :)