Как я изобретал велосипед, или мой первый MEAN-проект
Сегодня, в период стремительного развития веб-технологий, опытному фронтэнд-разработчику нужно всегда оставаться в тренде, каждый день углубляя свои познания. А что делать, если Вы только начинаете свой путь в мире веб? Вы уже переболели вёрсткой и на этом не хотите останавливаться. Вас тянет в загадочный мир JavaScript! Если это про Вас, надеюсь данная статья придётся к стати.
Имея за плечами полуторагодовой опыт работы в качестве фронтэнд-разработчика, я, утомившись монотонной вёрсткой очередного рядового проекта, задался целью углубить познания в сфере веб-программирования. У меня возникло желание создать своё первое single page application. Выбор стека технологий был очевиден, так как я всегда был не равнодушен к Node.js, методология MEAN стала тем, что доктор прописал.
Сегодня в интернете существует бесчисленное количество разных туториалов, в которых создают множество приложений helloworld, todo, management agency и т.д. Но просто бездумно следовать шагам туториала — не мой выбор. Я же решил создать некое подобие мессенджера: приложение с возможностью регистрации новых пользователей, созданием диалогов между ними, общения с chat-ботом для тестовых пользователей. И так, тщательно продумав план действий, я приступил к работе.
Далее мой рассказ опишет основные моменты создания данного приложения, а для большей наглядности демо я оставлю тут.
*Также хочу отметить, что цель данной статьи, быть может, помочь обучающимся не наступить на грабли, на которые в своё время наступил я, и дать возможность более опытным разработчикам просмотреть код и высказать своё мнение в комментариях.
Составим план действий:
- Подготовительные работы
- Создание системы авторизации
- Чат на 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 диалога.
Если кому-то всё-таки интересно взглянуть на код данных моделей:
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);
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 в данной комнате, с которым и передали сообщение.
Вот и всё наше приложение готово!
Вывод
Хех, вы всё-таки дочитали?! Не смотря на объёмы статьи, я не успел обозреть все детали создания приложения, так как мои возможности ограничены данным форматом. Но выполняя данную работу я не только значительно углубил свои познания в сфере веб-программирования, но и получил море удовольствия от выполненной работы. Ребят, никогда не бойтесь браться за что-то новое, сложное, ведь, если тщательно подойти к делу, постепенно разбираясь с всплывающими вопросами, даже с нулевым опытом на старте, можно создать что-то действительно хорошее!