Продвинутый чат на Node.JS

Да, в интернете полно реализаций банального чата, но все-же мне они не по душе. Представляю Вам мою реализацию чата, с блекджеком и сами знаете чем.

Итак, сразу ссылка на демо для нетерпеливых.
(Сервер уже уложили)

Особенности


  • Сохранение сообщений в БД
  • Авторизация
  • Команды чата
  • Соединение с сервером по WebSocket



Как оно работает



Ну тут всё просто


Как такое сделать



Установка зависимостей


sudo yum install nodejs
sudo yum install mongodb
npm install ws
npm install mongodb


Программируем


Для начала сделаем клиентскую часть, пока устанавливается node.js и все остальное, необходимое для серверной.

HTML весьма лаконичен

<!DOCTYPE html>
<html>
	<head>
		<link href='http://fonts.googleapis.com/css?family=Ubuntu&subset=latin,cyrillic' rel='stylesheet' type='text/css'>
		<link href="main.css" rel="stylesheet" />
		<script src="main.js" defer></script>
		<meta charset="UTF-8">
	</head>
	<body>
		<form id="loginform" class="unauthorized">
			<input id="login" placeholder="Логин"><br>
			<input id="password" placeholder="Пароль">
			<div>* Если аккаунт не существует, то будет создан</div>
		</form>
		<output id="messages"></output>
		<div>
			<div contenteditable id="input"></div>
		</div>
	</body>
</html>


Благодаря атрибуту defer у тега script, javascript запустится только после прогрузки всей страницы. Это гораздо удобнее, чем событие window.onload

Чтобы уменьшить код, сокращаем document.getElementById до $

function $(a){return document.getElementById(a)}


Открываем соединение с сервером и ждём входящих сообщений

ws = new WebSocket ('ws://x.cloudx.cx:9000');

ws.onmessage = function (message) {
	// приводим ответ от сервера в пригодный вид 
	var event = JSON.parse(message.data);
	
	// проверяем тип события и выбираем, что делать
	switch (event.type) {
		case 'message':
			// рендерим само сообщение
			
			var name = document.createElement('div');
			var icon = document.createElement('div');
			var body = document.createElement('div');
			var root = document.createElement('div');
			
			name.innerText = event.from;
			body.innerText = specials_in(event);
			
			root.appendChild(name);
			root.appendChild(icon);
			root.appendChild(body);
			
			$('messages').appendChild (root);
			
			break;
		case 'authorize':
			// ответ на запрос об авторизации
			if (event.success) {
				$('loginform').classList.remove('unauthorized');
			}
			break;
		default: 
			// если сервер спятил, то даем об себе этом знать
			console.log ('unknown event:', event)
			break;
	}
}


Возможно вы заметили, что входящие сообщения сначала обрабатываются функцией specials_in. Эта функция ищет в тексте сообщения специальные фрагменты, расширяющие функционал чата.

function specials_in (event) {
	var message = event.message;
	var moment = new Date(event.time);
	
        // получаем время в пригодном виде
	var time = (moment.getHours()<10)? '0'+moment.getHours() : moment.getHours();
		time = (moment.getMinutes()<10)? time+':0'+moment.getMinutes() : time+':'+moment.getMinutes();
		time = (moment.getSeconds()<10)? time+':0'+moment.getSeconds() : time+':'+moment.getSeconds();
	var date = (moment.getDate()<10)? '0'+moment.getDate() : moment.getDate();
		date = (moment.getMonth()<10)? date+'.0'+moment.getMinutes()+'.'+moment.getFullYear() : date+':'+moment.getMonth()+'.'+moment.getFullYear()
	
	
	message = message.replace(/\[time\]/gim, time);
	message = message.replace(/\[date\]/gim, date);
	
	return message;
}


Подобная функция для исходящих сообщений

function specials_out(message) {
	// /me
	message = message.replace(/\s*\/me\s/, $('login').value+' ');
	
	return message;
}


Остальной код клиентской части, тут ничего необычного
// по нажатию Enter в поле ввода пароля  
$('password').onkeydown = function (e) {
    if (e.which == 13) {
        // отправляем серверу событие authorize
		ws.send (JSON.stringify ({
			type: 'authorize',
			user: $('login').value,
			password: $('password').value
		}));
    }
}
// по нажатию Enter в поле ввода текста
$('input').onkeydown = function (e) {
	// если человек нажал Ctrl+Enter или Shift+Enter, то просто создаем новую строку. 
	if (e.which == 13 && !e.ctrlKey && !e.shiftKey) {
        // отправляем серверу событие message
		ws.send (JSON.stringify ({
			type: 'message',
			message: specials_out($('input').innerText)
		})); 
		$('input').innerText = ''; // чистим поле ввода
    }
}
// скроллим вниз при новом сообщении
var observer = new MutationObserver(function(mutations) {
	mutations.forEach(function(mutation) {
		var objDiv = $('messages');
		objDiv.scrollTop = objDiv.scrollHeight;
	}); 
}).observe($('messages'), { childList: true });



Теперь приступим к серверной части

Соединяемся с БД и ждем соединения по вебсокету на 9000 порту

// создаем сервер
var WebSocketServer = require('ws').Server,
	wss = new WebSocketServer({port: 9000});

// соединение с БД
var MongoClient = require('mongodb').MongoClient,
	format = require('util').format;   

var userListDB, chatDB;

// подсоединяемся к БД
MongoClient.connect('mongodb://127.0.0.1:27017', function (err, db) {
	if (err) {throw err}
	
	// записываем ссылки на таблицы (коллекции) в глобальные переменные
	userListDB = db.collection('users');
	chatDB = db.collection('chat');
});


Авторизация и регистрация сделаны как можно более простыми для пользователя. Если аккаунта нет, то он будет создан.

// проверка пользователя на предмет существования в базе данных
function existUser (user, callback) {
	userListDB.find({login: user}).toArray(function (error, list) {
		callback (list.length !== 0);
	});
}
// эта функция отвечает целиком за всю систему аккаунтов
function checkUser (user, password, callback) {
	// проверяем, есть ли такой пользователь
	existUser(user, function (exist) {
		// если пользователь существует
		if (exist) {
			// то найдем в БД записи о нем
			userListDB.find({login: user}).toArray(function (error, list) {
				// проверяем пароль
				callback (list.pop().password === password);
			});
		} else {
			// если пользователя нет, то регистрируем его
			userListDB.insert ({login: user, password: password}, {w:1}, function (err) {
				if (err) {throw err}
			});
			// не запрашиваем авторизацию, пускаем сразу
			callback (true);
		}
	});
}


Отправка сообщения всем участникам чата
Для работы этой функции ссылки на соединения с каждым участником лежат в массиве peers

// функция отправки сообщения всем
function broadcast (by, message) {
	
	// запишем в переменную, чтоб не расходилось время
	var time = new Date().getTime();
	
	// отправляем по каждому соединению
	peers.forEach (function (ws) {
		ws.send (JSON.stringify ({
			type: 'message',
			message: message,
			from: by,
			time: time
		}));
	});
	
	// сохраняем сообщение в истории
	chatDB.insert ({message: message, from: by, time: time}, {w:1}, function (err) {
		if (err) {throw err}
	});
}


Обработка новых соединений и сообщений

// при новом соединении 
wss.on('connection', function (ws) {	
	// проинициализируем переменные
	var login = '';
	var registered = false;
	
	// при входящем сообщении
	ws.on('message', function (message) {
		// получаем событие в пригодном виде
		var event = JSON.parse(message);
		
		// если человек хочет авторизироваться, проверим его данные
		if (event.type === 'authorize') {
			// проверяем данные
			checkUser(event.user, event.password, function (success) {
				// чтоб было видно в другой области видимости
				registered = success;
				
				// подготовка ответного события
				var returning = {type:'authorize', success: success};
				
				// если успех, то
				if (success) {
					// добавим к ответному событию список людей онлайн
					returning.online = lpeers;
					
					// добавим самого человека в список людей онлайн
					lpeers.push (event.user);
					
					// добавим ссылку на сокет в список соединений
					peers.push (ws);
					
					// чтобы было видно в другой области видимости
					login = event.user;
					
					//  если человек вышел
					ws.on ('close', function () {
						peers.exterminate(ws);
						lpeers.exterminate(login);
					});
				}
				
				// ну и, наконец, отправим ответ
				ws.send (JSON.stringify(returning));
			
				// отправим старые сообщения новому участнику
				if (success) {
					sendNewMessages(ws);
				}
			});
		} else {
			// если человек не авторизирован, то игнорим его
			if (registered) {
				// проверяем тип события
				switch (event.type) {
					// если просто сообщение
					case 'message':
						// рассылаем его всем
						broadcast (login, event.message)
						break;
					// если сообщение о том, что он печатает сообщение
					case 'type':
						// то пока я не решил, что делать в таких ситуациях
						break;
				}	
			}
		}
	});
});


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

// список участников чата (их логины)
var lpeers = [];

// функция отправки старых сообщений новому участнику чата
function sendNewMessages (ws) {
	chatDB.find().toArray(function(error, entries) {
		if (error) {throw error}
		entries.forEach(function (entry){
			entry.type = 'message';
			ws.send (JSON.stringify (entry));
		});
	});
}

// убрать из массива элемент по его значению
Array.prototype.exterminate = function (value) {
	this.splice(this.indexOf(value), 1);
}


Чат готов!

Исходники можно взять тут
(Сервер регулярно лежит)

Можно запускать

su -
mongod --smallfiles > /dev/null &
node path/to/server.js > /dev/null &


TODO


  • Защита от флуда
  • Больше команд чата и специальных фрагментов
  • Загрузка аватарок
  • Звуковые уведомления
  • «Комнаты»
  • Удаление сообщений
  • Отправка изображений, аудио и видео
Ads
AdBlock has stolen the banner, but banners are not teeth — they will be back

More

Comments 27

    +1
    Что-то ссылка на демо уже не работает :)
      0
      Асинхронный производительный event-driven сервер, легковесная nosql БД и гибкие расширяемые облачные технологии в действии.
      Кавычки расставить по вкусу :D
        0
        Слабину дал не Node.JS и не MongoDB, а мой относительно новый HDD.
        Какие тут еще «облачные» технологии? Всё на рабочем компе.
      0
      function $(a){return document.getElementById(a)}
      

      А почему не так?
      var $ = document.getElementById.bind(document);
      
        +6
        А зачем всё усложнять?
        +3
        о, исходники в архиве, как в старые добрые времена =)
        P.S. почему не стильный, модный, молодежный github/bitbucket/etc?
          +6
          Пишем на досуге распределенную отказоустойчивую систему нотификаций и декомпозиции сообщений. Можно как и очень навороченный чат использовать. Используется для внутренних нужд.

          Схема работы (немного не правильная):
          github.com/Kluge-Inc/talkwut-docs

          Веб (можно использовать как чат с комнатами + рассылка на почту):
          github.com/Kluge-Inc/talkwut-web

          Ядро:
          github.com/Kluge-Inc/talkwut-core

          Desktop клиент:
          github.com/Kluge-Inc/talkwut-notifier

          Если кому-то интересно — то можем написать статью.
            +4
            мне интересно
              0
              Через неделю — две, думаю, напишем.

              Пока в божеский вид приведем.

              Еще хотим github.com/Kluge-Inc/spectator на него завязать (для этого нужна версионность сообщений, минорные изменения протокола и хотелось бы diff вордовских документов)

              В перспективе там должен быть базовый campfire.
              0
              Очень интересно, напишите пожалуйста!
              –1
              Делал как-то в свободное время чат на node.js, плюс к нему игру викторина, вот что получилось: vk.com/appquiz :)
                +1


                Оригинальный интерфейс. Где кнопка входа-то?
                  –1
                  Enter нажать не пробовал? Зачем лишние кнопки?
                    +3
                    Пробовал, похоже такой ник уже использовался и ничего не произошло, хотя выглядело это, будто что-то отвалилось. Я бы предпочёл наличие кнопочки входа.
                      +3
                      Как юзеру далекому от IT я за кнопку «Войти»!
                  0
                  Ссылки выдаюи 404. Звлейте исходники на гитхаб. За статью спасибо, плюсанул.
                    +1
                    Сейчас залью, только кто-то постарался почистить мне комп примерно вот так:

                    rm -rf /var/www/x
                      –1
                      А вот с Гитхабом у них так, скорее всего, не получится сделать.
                    0
                    Для интересующихся этой темой могу порекомендовать видео канал Ильи Кантора на youtube. Там все это до мелочей разбирается.
                    На счет статьи, я так и не понял, где здесь продвинутый чат???
                      +1
                      Возможно функционал функции specials_in…
                      0
                      Демо не работает, не могу залогинится. Поправьте, пожалуйста
                        0
                        Как вы собираетесь решать проблему ограничения на 4гб использования оперативной памяти для сервера nodejs? Как этот продвинутый чат масштабировать? Если запустить второй инстанс — память не перебросится между серверами. Половина запросов проходить не будет.
                          0
                          Статье то уже 7 лет как) Нода сильно изменилась с тех пор, да и я был куда моложе. Убрал бы в черновики, но пусть лучше лежит тут для истории. «Продвинутый» он только для своего времени.

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

                          Для масштабирования следовало бы использовать какой-нибудь Redis или любой другой удобный pub/sub. Но нужно было ли это вообще? Максимальный онлайн был на порядки ниже, чем вынес бы один инстанс.
                            0
                            я не о том что запросы пропадать будут. я о том что часть запросов будет идти на первый сервер, часть на второй. И это не запоминается никак. То есть если я залогинился на первом сервере — второй меня видит как анонима. и наоборот
                              0
                              ладно, понял.
                              Кста редис не пройдет. Надо как-то коннекты помнить. редис работает со строками, перевод в JSON не проходит с ошибкой циклической зависимости в объекте.
                                0
                                Первого и второго просто не будет. Второй не встанет рядом сам по себе, порты по своей природе используются эксклюзивно, и если два приложения захотят слушать на одном, то какому-то из них не суждено.

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

                                И редис пройдет. На всякий случай, объясняю:
                                Можно повесить несколько инстансов на разных портах за балансировщиком (nginx, к примеру), и между ними, в таком случае, будут распределяться клиенты (websocket'ы), но ни коим образом не сообщения в этих самых сокетах. Сокет однозначно связывает один инстанс сервера с одним инстансом клиента. В таком случае без изменений кода будет работать всё, за исключением мгновенных уведомлений. Выйдет так, что уведомление о новом сообщении получат только те же клиенты что связаны с тем же инстансом сервера. Это решается рассылкой уведомлений (событий нового сообщения) между инстансами серверов через Redis pub/sub.
                          0
                          /del

                          Only users with full accounts can post comments. Log in, please.