Як я винаходив велосипед, або мій перший MEAN-проект


Сьогодні, у період стрімкого розвитку веб-технологій, досвідченому фронтэнд-розробнику потрібно завжди залишатися в тренді, кожен день поглиблюючи свої знання. А що робити, якщо Ви тільки починаєте свій шлях у світі веб? Ви вже перехворіли версткою і на цьому не хочете зупинятися. Вас тягне в загадковий світ JavaScript! Якщо це про Вас, сподіваюся ця стаття доведеться до речі.
Маючи за плечима півторарічний досвід роботи в якості фронтэнд-розробника, я, стомившись монотонної версткою чергового рядового проекту, задався метою поглибити знання в сфері веб-програмування. У мене виникло бажання створити своє перше single page application. Вибір стека технологій був очевидний, так як я завжди був не байдужий до Node.js методологія MEAN стала тим, що доктор прописав.
Сьогодні в інтернеті існує незліченна кількість різних туториалов, в яких створюють безліч додатків helloworld, todo, management agency і т. д. Але просто бездумно слідувати кроків туториала — не мій вибір. Я ж вирішив створити якусь подобу месенджера: додаток з можливістю реєстрації нових користувачів, створенням діалогів між ними, спілкування з chat-ботом для тестових користувачів. І так, ретельно продумавши план дій, я приступив до роботи.
Далі мій розповідь опише основні моменти створення цього додатка, а для більшої наочності демо я залишу тут.
*Також хочу зазначити, що мета даної статті, може бути, допомогти їм не наступити на граблі, на які свого часу наступила я, і дати можливість більш досвідченим розробникам переглянути код і висловити свою думку в коментарях.
Складемо план дій:
  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 і внесемо в нього першу налаштування — порт, який слухає наше додаток:
{
"порту": 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 dialog to', (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 dialog to отримує масив з повідомленнями користувачів, подія 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 in room 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 dialog to', 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 в даній кімнаті, з яким і передали повідомлення.
Ось і все наше додаток готово!
Висновок
Хех, ви все-таки дочитали?! Не дивлячись на обсяги статті, я не встиг оглянути всі деталі створення програми, так як мої можливості обмежені даним форматом. Але виконуючи цю роботу я не тільки значно поглибив свої знання в сфері веб-програмування, але і отримав море задоволення від виконаної роботи. Хлопців, ніколи не бійтеся братися за щось нове, складне, адже, якщо ретельно підійти до справи, поступово розбираючись з спливаючими питаннями, навіть з нульовим досвідом на старті, можна створити щось дійсно хороше!
Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.