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