Pull to refresh

Как я изобретал велосипед, или мой первый MEAN-проект

Reading time 12 min
Views 26K

Сегодня, в период стремительного развития веб-технологий, опытному фронтэнд-разработчику нужно всегда оставаться в тренде, каждый день углубляя свои познания. А что делать, если Вы только начинаете свой путь в мире веб? Вы уже переболели вёрсткой и на этом не хотите останавливаться. Вас тянет в загадочный мир JavaScript! Если это про Вас, надеюсь данная статья придётся к стати.


Имея за плечами полуторагодовой опыт работы в качестве фронтэнд-разработчика, я, утомившись монотонной вёрсткой очередного рядового проекта, задался целью углубить познания в сфере веб-программирования. У меня возникло желание создать своё первое single page application. Выбор стека технологий был очевиден, так как я всегда был не равнодушен к Node.js, методология MEAN стала тем, что доктор прописал.


Сегодня в интернете существует бесчисленное количество разных туториалов, в которых создают множество приложений helloworld, todo, management agency и т.д. Но просто бездумно следовать шагам туториала — не мой выбор. Я же решил создать некое подобие мессенджера: приложение с возможностью регистрации новых пользователей, созданием диалогов между ними, общения с chat-ботом для тестовых пользователей. И так, тщательно продумав план действий, я приступил к работе.


Далее мой рассказ опишет основные моменты создания данного приложения, а для большей наглядности демо я оставлю тут (ссылка на github).


*Также хочу отметить, что цель данной статьи, быть может, помочь обучающимся не наступить на грабли, на которые в своё время наступил я, и дать возможность более опытным разработчикам просмотреть код и высказать своё мнение в комментариях.


Составим план действий:


  1. Подготовительные работы
  2. Создание системы авторизации
  3. Чат на Angular2 и Socket.io

Подготовительные работы


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


И так, воспользуемся замечательным модулем nconf. Давайте создадим папку с названием config, а в её индексный файл запишем:


const nconf = require('nconf');
const path = require('path');

nconf.argv()
   .env()
   .file({ file: path.join(__dirname, './config.json') });

module.exports = nconf; 

Далее в этой папке создадим файл с названием config.json и внесём в него первую настройку — порт, который слушает наше приложение:


{
    "port": 2016
}

Чтоб внедрить данную настройку в приложение, нужно всего ничего, написать одну/две строки кода:


const config = require('./config');

let port = process.env.PORT || config.get('port');
app.set('port', port);

Но стоит отметить, это будет работать в случае, если порт будет задан таким образом:


const server = http.createServer(app);
server.listen(app.get('port'));

Следующая наша задача — настроить единую систему логгирования в нашем приложении. Как писал автор статьи "О логгировании в Node.js":


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

Для этой задачи воспользуемся модулем winston:


const winston = require('winston');
const env = process.env.NODE_ENV;

function getLogger(module) {
    let path = module.filename.split('\\').slice(-2).join('/');
    return new winston.Logger({
        transports: [
            new winston.transports.Console({
                level: env == 'development' ? 'debug' : 'error',
                showLevel: true,
                colorize: true,
                label: path
            })
        ]
    });
}

module.exports = getLogger;

Конечно, настройка может быть и более гибкой, но на данном этапе нам этого будет достаточно. Чтоб воспользоваться нашим новоиспечённым логгером, нужно всего-ничего подключить данный модуль в ваш рабочий файл и вызвать его в нужном месте:


const log = require('./libs/log')(module); 
log.info('Have a nice day =)');

Следующей нашей задачей станет настройка правильной обработки ошибок при обычных и ajax запросах. Для этого мы внесём некие изменения в код, который заранее был сгенерирован Express (в примере указан только development error handler):


// development error handler
if (app.get('env') === 'development') {
    app.use(function(err, req, res, next) {
        res.status(err.status || 500);
        if(res.req.headers['x-requested-with'] == 'XMLHttpRequest'){
            res.json(err);
        }  else{         
            // will print stacktrace
            res.render('error', {
                message: err.message,
                error: err
            });
        }
    });
}

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


const mongoose = require('mongoose');
const config = require('../config');

mongoose.connect(config.get('mongoose:uri'), config.get('mongoose:options'));

module.exports = mongoose;

В mongoose.connect мы передаём два аргумента: uri и options, которые я заранее прописал в конфиге (подробнее о них можно прочесть в документации к модулю).


Процесс создания моделей пользователей и диалогов я описывать не буду, так как схожий процесс отлично описал автор веб-ресурса learn.javascript.ru в своём скринкасте по Node.js в видеоуроке "Создаём модель для пользователя / Основы Mongoose", лишь упомяну, что каждый пользователь будет иметь такие свойства, как username, hashedPassword, salt, dialogs и created. Свойство dialogs, в свою очередь, будет возвращать объект: ключ — id собеседника, значение — id диалога.


Если кому-то всё-таки интересно взглянуть на код данных моделей:


users.js
const mongoose  = require('../libs/mongoose');
const Schema = mongoose.Schema;
const crypto = require('crypto');

let userSchema = new Schema({
    username: {
        type: String,
        unique: true, 
        required: true
    },
    hashedPassword: {
        type: String,
        required: true
    },
    salt: {
        type: String,
        required: true
    },
    dialogs: {
        type: Schema.Types.Mixed,
        default: {defaulteDialog: 1}
    },
    created: {
        type: Date,
        default: Date.now
    }
});

userSchema.methods.encryptPassword = function(password){
    return crypto.createHmac('sha1', this.salt).update(password).digest('hex');
};
userSchema.methods.checkPassword = function(password){
    return this.encryptPassword(password) === this.hashedPassword;
}
userSchema.virtual('password')
    .set(function(password){
        this._plainPassword = password;
        this.salt = Math.random() + '';
        this.hashedPassword = this.encryptPassword(password);
    })
    .get(function(){
        return this._plainPassword;
    });

module.exports = mongoose.model('User', userSchema);
dialogs.js
const mongoose  = require('../libs/mongoose');
const Schema = mongoose.Schema;

let dialogSchema = new Schema({
    data: {
        type: [],
        required: true
    } 
})

module.exports = mongoose.model('Dialog', dialogSchema);

Осталось всего-ничего — прикрутить сессии к костяку нашего приложения. Для этого создадим файл session.js и подключим в него такие модули, как express-session, connect-mongo, и созданный нами модуль из файла mongoose.js:


const mongoose = require('./mongoose');
const session = require('express-session');
const MongoStore = require('connect-mongo')(session);

module.exports =  session({
  secret: 'My secret key!',
  resave: false,
  saveUninitialized: true,
  cookie:{
    maxAge: null,
    httpOnly: true,
    path: '/'
  },
  store: new MongoStore({mongooseConnection: mongoose.connection})
})

Выносить данную настройку в отдельный файл важно, но не обязательно. Это предоставит возможность в дальнейшем без особого труда помирить сессии и веб-соккеты между собой. Теперь подключим данный модуль в app.js:


const session = require('./libs/session');
app.use(session);

При чём, app.use(session) обязательно нужно указать после app.use(cookieParser()), чтобы cookie уже успели быть прочитанными. Всё! Теперь мы имеем возможность сохранять сессии в нашу базу данных.


И на этом подготовительные работы — окончены. Пора приступать к самому интересному!


Создание системы авторизации


Создание системы авторизации будет делиться на два основных этапа: фронтэнд и бэкэнд. Так как, затеивая данное приложение, я собирался всё время учить что-то новое, а с Angular1.x я уже имел опыт работы, фронтэнд часть решил организовывать на Angular2. Тот факт, что, когда я создавал приложение, уже была выпущена четвёртая (а сейчас пятая) предрелизная версия данного фреймворка, вселил во мне уверенность, что оф-релиз уже не за горами. И так, собравшись с мыслями, я сел за написание авторизации.


Для ребят, которые ещё не сталкивались с разработкой на Angular2, прошу не удивляться, если в коде ниже вы встретите не известный вам ранее синтаксис javascript. Всё дело в том, что весь Angular2 построен на typescript. И нет, это вовсе не означает, что работать с данным фреймворком используя обычный javascript нельзя! Вот к примеру отличная статья, в ходе которой автор рассматривает разработку на Angular2 с использованием ES6.


Но typescript — это javascript, который масштабируется. Являясь компилируемым надмножеством javascript, этот язык добавляет в него все фичи из ES6 & ES7, настоящее ООП с блэк-джеком и классами, строгую типизацию и ещё много крутейших штук. И пугаться здесь нечего: ведь всё, что валидно в javascript, будет работать и в typescript!


Первым делом создадим файл user-authenticate.service.ts, в нём будет находиться сервис авторизации:


import { Injectable } from '@angular/core';
import { Http, Headers } from '@angular/http';

@Injectable()
export class UserAuthenticateService{
    private authenticated = false;   
    constructor(private http: Http) {}
}

Далее внутри нашего класса создадим несколько методов: login, logout, singup, isLoggedIn. Все эти методы однотипны: каждый выполняет свою задачу по отправке запроса типа post на соответствующий адрес. Не сложно догадаться, какую логическую нагрузку несёт каждый из них. Рассмотрим код метода login:


login(username, password) {
    let self = this;
    let headers = new Headers();
    headers.append('Content-Type', 'application/json');

    return this.http
        .post( 'authentication/login', JSON.stringify({ username, password }), { headers })
        .map(function(res){
            let answer = res.json();
            self.authenticated = answer.authenticated;
            return answer;
        });
}

Чтоб вызвать данный метод из компонента Angular2, нужно внедрить данный сервис в соответствующий компонент:


import { UserAuthenticateService } from '../services/user-authenticate.service';

@Component({ ... })

export class SingInComponent{
    constructor(private userAuthenticateService: UserAuthenticateService, private router: Router){ ... }
    onSubmit() {
        let self = this; 
        let username = this.form.name.value;
        let password = this.form.password.value;

        this.userAuthenticateService
            .login(username, password)
            .subscribe(function(result) {
                self.onSubmitResult(result);
            });
    }
}

Стоит отметить: для получения доступа к одному и тому же экземпляру сервиса из разных компонентов, его нужно внедрять в общий родительский компонент.


И на этом мы оканчиваем фронтэнд этап создания системы авторизации.


Приступая к бэкэнд разработке, рекомендую вам ознакомиться с интересным модулем async (документация к модулю). Он станет мощным инструментом в вашем арсенале для работы с асинхронными функциями javascript.


Давайте создадим файл authentication.js в уже существующей директории routes. Теперь укажем данный middleware в app.js:


const authentication = require('./routes/authentication');
app.use('/authentication', authentication);

Далее просто создадим обработчик для запроса пост на адрес authentication/login. Чтоб не писать длинную простыню из различных if...else воспользуемся методом waterfall из вышеупомянутого модуля async. Данный метод позволяет выполнять коллекцию асинхронных задач по-порядку, передавая результаты предидущей задачи в аргументы следующей, а на выходе выполнить какой-нибудь полезный колбек. Давайте сейчас и напишем данный колбек:


const express = require('express');
const router = express.Router();
const User = require('../models/users');
const Response = require('../models/response');

const async = require('async');
const log = require('../libs/log')(module);

router.post('/login', function (req, res, next) {
    async.waterfall([ ... ], function(err, results){
        let authResponse = new Response(req.session.authenticated, {}, err);
        res.json(authResponse);
    })
}

Для собственного удобства я заранее подготовил конструктор Response:


const Response = function (authenticated, data, authError) {
    this.authenticated = authenticated;
    this.data = data;
    this.authError = authError;
}

module.exports = Response;

Нам осталось только записать функции в нужном нам порядке в массив, переданный первым аргументом в async.waterfall. Давайте создадим эти самые функции:


function findUser(callback){
    User.findOne({username: req.body.username}, function (err, user) {
        if(err) return next(err);
        (user) ? callback(null, user) : callback('username');
    }
}

function checkPassword(user, callback){
    (user.checkPassword(req.body.password)) ? callback(null, user) : callback('password');
}

function saveInSession (user, callback){
    req.session.authenticated = true;
    req.session.userId = user.id;
    callback(null);
}

Вкратце опишу, что здесь происходит: мы ищем пользователя в базе данных, если такового здесь нет, вызываем колбек с ошибкой 'username', в случае удачного поиска передаём пользователя в колбек; вызываем метод checkPassword, опять же, если пароль верный, передаём пользователя в колбек, в противном случае вызываем колбек с ошибкой 'password'; далее сохраняем сессию в базу данных и вызываем завершающий колбек.


Вот и всё! Теперь пользователи нашего приложения имеют возможность авторизации.


Чат на Angular2 и Socket.io


Мы подошли к написанию функции, несущей в себе основную смысловую нагрузку нашего приложения. В данном разделе мы организуем алгоритм подключения к диалогам (chat-rooms) и функцию отправки/получения сообщений. Для этого мы воспользуемся библиотекой Socket.io, которая позволяет очень просто реализовать обмен данными между браузером и сервером в реальном времени.


Создадим файл sockets.js и подключим данный модуль в bin/www (входной файл Express):


const io = require('../sockets/sockets')(server);

Так как Socket.io работает с протоколом web-sockets, нам необходимо придумать способ передать ей сессию текущего пользователя. Для этого в уже созданный нами файл sockets.js запишем:


const session = require('../libs/session');

module.exports = (function(server) {
    const io = require('socket.io').listen(server);

    io.use(function(socket, next) {
        session(socket.handshake, {}, next);
    });

    return io;
});

Socket.io построена таким образом, что браузер и сервер всё время обмениваются различными событиями: браузер генерирует события, на которые реагирует сервер, и на оборот, сервер генерирует события, на которые реагирует браузер. Давайте напишем обработчики событий на стороне клиента:


import { Component } from '@angular/core';
import { Router } from '@angular/router';

declare let io: any;

@Component({ ... })

export class ChatFieldComponent {
    socket: any;
    constructor(private router: Router, private userDataService: UserDataService){
        this.socket = io.connect();

        this.socket.on('connect', () => this.joinDialog());
        this.socket.on('joined to dialog', (data) => this.getDialog(data)); 
        this.socket.on('message', (data) => this.getMessage(data));
    }
}

В коде выше мы создали три обработчика событий: connect, joined to dialog, message. Каждый из них вызывает соответствующую ему функцию. Так, событие connect вызывает функцию joinDialog(), которая в свою очередь генерирует серверное событие join dialog, с которым передаёт id собеседника.


joinDialog(){
    this.socket.emit('join dialog', this.userDataService.currentOpponent._id);
}

Далее всё просто: событие joined to dialog получает массив с сообщениями пользователей, событие message добавляет новые сообщения в выше упомянутый массив.


getDialog(data) => this.dialog = data;
getMessage(data) => this.dialog.push(data);

Чтоб в дальнейшем уже не возвращаться к фронтэнду, давайте создадим функцию, которая будет отправлять сообщения пользователя:


sendMessage($event){
    $event.preventDefault();
    if (this.messageInputQuery !== ''){
        this.socket.emit('message', this.messageInputQuery);
    }
    this.messageInputQuery = '';
}

Данная функция генерирует событие message, с которым и передаёт текст отправленного сообщения.


Дело осталось за малым — написать обработчики событий на стороне сервера!


io.on('connection', function(socket){
    let currentDialog, currentOpponent;

    socket.on('join dialog', function (data) { ... });
    socket.on('message', function(data){ ... });
})

В переменные currentDialog и currentOpponent мы будем сохранять идентификаторы текущего диалога и собеседника.


Приступим к написанию алгоритма подключения к диалогу. Для этого воспользуемся библиотекой async, а именно вышеупомянутым методом watterfall. Очерёдность наших действий:


Покинуть предидущий диалог:
function leaveRooms(callback){
    // Проходим циклом по всем комнатам и покидаем их
    for(let room in socket.rooms){
        socket.leave(room)
    }
    // Переходим к выполнению следующей задачи
    callback(null);
}
Получить из базы данных пользователя и его собеседника:
function findCurrentUsers(callback) {
    // Параллельно выполняем коллекцию асинхронных задач:
    // - поиск текущего пользователя
    // - поиск текущего собеседника
    async.parallel([findCurrentUser, findCurrentOpponent], function(err, results){
        if (err) callback(err);
        // Передаём пользователей в колбэк, переходим к выполнению следующей задачи
        callback(null, results[0], results[1]);
    })
}
Подключиться к существующему/создать новый диалог:
function getDialogId(user, opponent, callback){
    // Проверяем существование диалога между вышеупомянутыми пользователями
    if (user.dialogs[currentOpponent]) {
        let dialogId = user.dialogs[currentOpponent];
        // Передаём в колбек Id диалога, переходим к выполнению следующей задачи
        callback(null, dialogId);
    }   else{
        // Последовательно выполняем коллекцию задач:
        // - создание диалога 
        // - сохранение ссылки на него пользователям 
        async.waterfall([createDialog, saveDialogIdToUser], function(err, dialogId){
            if (err) callback(err);
            // Передаём в колбек Id диалога, переходим к выполнению следующей задачи
            callback(null, dialogId);
        })
    }
}
Получить историю сообщений:
function getDialogData(dialogId, callback){
    // Выполняем поиск диалога в базе данных
    Dialog.findById(dialogId, function(err, dialog){
        if (err) callback('Error in connecting to dialog');     
        // Передаём в колбек диалог, переходим к выполнению глобального колбэка
        callback(null, dialog);
    })
}
Вызов вышеупомянутых функций, глобальный колбек:
// Последовательно выполняем коллекцию задач
async.waterfall([
    leaveRooms,
    findCurrentUsers, 
    getDialogId, 
    getDialogData
    ], 
    // Глобальный колбэк
    function(err, dialog){
        if (err) log.error(err);

        currentDialog = dialog;
        // Подключаемся к данной комнате
        socket.join(currentDialog.id);
        // Генерируем событие joined to dialog, с которым передаём историю сообщений пользователей
        io.sockets.connected[socket.id].emit('joined to dialog', currentDialog.data);
    }
)

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


socket.on('message', function(data){
    let message = data;
    let currentUser = socket.handshake.session.userId;

    let newMessage = new Message(message, currentUser);

    currentDialog.data.push(newMessage);
    currentDialog.markModified('data');

    currentDialog.save(function(err){
        if (err) log.error('Error in saveing dialog =(');
        io.to(currentDialog.id).emit('message', newMessage);
    })
})

В данном примере кода мы сохранили в переменные текст сообщения и идентификатор пользователя, затем с помощью заранее созданного конструктора Message создали объект нового сообщения, добавили его в массив и, сохранив обновлённый диалог в базу данных, сгенерировали событие message в данной комнате, с которым и передали сообщение.


Вот и всё наше приложение готово!


Вывод


Хех, вы всё-таки дочитали?! Не смотря на объёмы статьи, я не успел обозреть все детали создания приложения, так как мои возможности ограничены данным форматом. Но выполняя данную работу я не только значительно углубил свои познания в сфере веб-программирования, но и получил море удовольствия от выполненной работы. Ребят, никогда не бойтесь браться за что-то новое, сложное, ведь, если тщательно подойти к делу, постепенно разбираясь с всплывающими вопросами, даже с нулевым опытом на старте, можно создать что-то действительно хорошее!

Tags:
Hubs:
+29
Comments 9
Comments Comments 9

Articles