Делаем проект на Node.js с использованием Mongoose, Express, Cluster. Часть 1

в 16:04, , рубрики: javascript, mvc, node.js

Введение

Добрый день, дорогой %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('/'); на


// Используем движок усов
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. Суть проекта объясняется во второй части.

Автор: Armleo

Источник

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


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