nodejs+mongoose+websocket搭建xxx聊天室

简介

本文是由nodejs+mongoose+websocket打造的一个即时聊天系统;本来打算开发一个类似于网页QQ类似功能的聊天系统,但是目前只是开发了一个模块功能 --- 类似群聊的,即一对多的聊天模式;因为时间关系,一对一私聊功能还没有开发,敬请期待!

该聊天室整个页面布局是通过bootstrap框架编写,可能很简陋,请大家多多包涵!

源码及作品

作品在线地址:http://chat.hawkzz.com
源码地址:https://github.com/zhuangZhou/chat.io

本地运行方法:

  • 命令下载:npm install
  • 启动node服务器:node app.js
  • 在浏览器中打开:http://localhost:8880

下面为效果图预览:

环境介绍

  • Windows 7 PC
  • nodejs
  • mongoDB
  • websocket
  • boostrap

准备工作

  • 安装node(废话,这肯定是必须的),安装地址:https://nodejs.org/en/
  • 安装mongoDB环境,地址:https://www.mongodb.com/download-center#community
  • 创建一个文件夹(这里你随意);
  • 初始化文件夹,使其变成一个node项目文件夹 npm init 或者 npm init -y;这里讲一下这两种的区别,npm init是初始化但是要自己选择初始化的条件,npm init -y则是默认选择初始化内容;
  • 创建app.js文件,作为整个项目的入口文件;
  • 安装整个项目的依赖,如下:
    1. express框架
    2. mongoose,是mongoDB的一个对象模型工具
    3. cookie-parser
    4. body-parser
    5. swig模板
    6. socket.io

开始工作##

express框架搭建

express是一个基于 Node.js 平台的极简、灵活的 web 应用开发框架,它提供一系列强大的特性,帮助你创建各种 Web 和移动设备应用。接下来我们在上面创建的app.js 里面搭建express框架的搭建

var express = require('experss');//引入express模块

var app =  new express();//实例化

app.listen(8880);//监听端口8880,这里可以自定义端口

根据以上,其实一个node服务器已经搭好了,通过命令‘node app.js’即可运行,地址是localhost:8880;这只是一个简单的服务器,想要加载页面以及页面交互,这些肯定是远远不够,接下来我们一步步开始;

首先,我们创建三个文件夹,分别为views,public,router;

  • views文件夹:存放html模板文件;
  • public文件夹:存放静态文件,如:js,css,image
  • router文件夹:存放页面的路由以及与数据库交互的文件;

然后,设置模板,以及静态文件托管,以及加载body-parser和cookie模块

var express = require('express');
var swig = require('swig');
var bodyParser = require('body-parser');
var cookieParser = require('cookie-parser');

var app = new express();

var server = require('http').createServer(app);

//静态文件托管
app.use('/public', express.static(__dirname + '/public'));

//设置模板
app.engine('html', swig.renderFile);
app.set('views', './views');
app.set('view engine', 'html');
swig.setDefaults({
    cache: false
});

//设置body-parser
app.use(bodyParser.json());
app.use(bodyParser.urlencoded({extended: false}));

//设置cookie
app.use(cookieParser());

//加载路由
app.use('/', require('./routers/index')); //首页
app.use('/login', require('./routers/login')); //登录页面
app.use('/register', require('./routers/register'));//注册页面
app.use('/exit', require('./routers/exit'));//退出

server.listen(8880, function () {
    console.log('服务器连接成功!');
});

mongoDB搭建

首先,我们需要下载安装mongoDB环境,https://www.mongodb.com/download-center#community

然后,创建一个文件夹database,存放本项目所需的mongoDB数据库文件;

接着,我们将mongoDB数据库的存放指向database;

1. 通过cmd找到mongoDB安装路径 -->  Server --> bin ;
2. 输入命令 mongod --dbpath  E://chat/database ;
3. 回车;

这里,我们是使用mongoDB的一个对象模型工具--mongoose来对mongoDB数据库进行操作,所以,这里我们需要了解mongoose的机制;

mongoose是mongoDB的一个对象模型工具,是基于node-mongodb-native开发的mongoDB的nodejs驱动,可以在异步的环境下执行。同时它也是针对mongoDB操作的一个对象模型库,封装了mongoDB对文档的一些增删改查等常用方法,让nodejs操作mongoDB数据库变得更加容易。

如果要通过mongoose创建一个集合并对其进行增删改查,就需要用到Schema,Model;所以接下来我们创建两个文件夹Schema,Model来存储Schema和Model文件(这里我是把这两个分开存放,也可以写在一起);

Schema是一种以文件形式存储的数据库模型骨架,无法直接通往数据库端,也就是说它不具备对数据库的操作能力,仅仅只是数据库模型在程序片段中的一种表现,可以说是数据属性模型(传统意义的表结构),又或者是集合的模型骨架。基本属性类型有字符串、日期型、数值型、布尔型、null、数组、内嵌文档等。

Model由Schema构造生成的模型,除了Schema定义的数据库骨架以外,还具有数据库操作的行为,类似于管理数据属性、行为的类。

到目前为止,我们整个项目的骨架,以及所有目录结构已经搭建完成,如下图:

登录与注册

因为我们要求连接的用户首先要注册一个账号,并且这个账号的用户名是唯一的,不能与别人相同,方便用户区分,以及一些数据库操作;

为此在后台中,我们需要创建数据库连接,以及登录注册页面;

1.在app.js连接数据库

var  mongoose = require('mongoose');

...
...
...

mongoose.connect('mongodb://localhost:27017/chat', function (err, data) {
    if (err) {
        console.log('数据库连接失败!');
    } else {
        server.listen(8880, function () {
            console.log('服务器连接成功!');
        });

    }
});

这里我把数据库连接放在服务器监听外面,是为了当数据库启动后,再启动服务器,避免服务器启动,数据库连接不上,导致整个程序跑不动;

2.在Schema文件夹创建user.js文件,创建user的数据属性模型

const mongose = require('mongoose');

const user = mongose.Schema({
    username: String, //用户姓名
    password: String, //用户密码
    image: String,  //用户图像
    state: Boolean  //用户上学状态
});

module.exports = user;


3.在Model文件夹创建User.js 文件,创建user的对象模型

const mongoose = require('mongoose');
const userSchema = require('../schemas/user');
module.exports = mongoose.model('User', userSchema);

4.在views创建注册页面register.html,以及在router创建register.js

前台:

$('#register').on({
        click: function () {
            var username = $('#username').val();
            var password = $('#password').val();
            var rePassowrd = $('#rePassword').val();
            if (username == '') {
                alert('请填写用户名!');
                return false;
            }
            var reg = /^[a-z0-9_-]{6,18}$/;
            if (!reg.test(password)) {
                alert('请填写6-12位密码!');
                return false;
            }

            if (password !== rePassowrd) {
                alert('两次密码不一致!');
                return false;
            }

            $.post('/register/signUp', {username: username, password: password}, function (res) {
                if (res.success == 1) {
                    location.href = 'login';
                } else {
                    alert(res.err);
                }
            }, 'json')
        }
    });

后台:

const express = require('express');
const User = require('../models/User');

const router = express.Router();

router.get('/', function (req, res) {
    res.render('register.html');
});

router.post('/signUp', function (req, res) {
    var username = req.body.username; //获取前台传过来的数据
    var password = req.body.password;
    var resData = {};
    User.findOne({
        username: username
    }).then(function (userInfo) {
        if (userInfo) {
            resData.success = 0;
            resData.err = "该用户名已被注册!";
            res.json(resData);//给前台返回数据状态
            return false;
        } else {
            var user = new User({
                username: username,
                password: password,
                image: '../public/img/people.png', //初始化图像
                state: false  //上线状态
            });
            return user.save();
        }
    }).then(function () {
        resData.success = 1;
        resData.message = "注册成功!";
        res.json(resData);
    })
});

module.exports = router;

5.在views创建注册页面login.html,以及在router创建login.js

由于登录页面和注册页面差不多,这里就不详细描述了,具体看代码;

主页面

主页面是整个页面的核心,它包括:在线用户统计,用户信息修改,以及聊天模块;

1.在线用户统计

在线用户统计这里可以分成三个小模块,分别是:在线用户展示,用户上线提醒,和用户离开提醒;

后台:


const server = require('http').createServer(app);
const io = require('socket.io').listen(server);
const User = require('./models/User');

io.on('connection', function (socket) {
    socket.on('login', function (data) {//用户登录
        var username = data.username;
        socket.username = username;
        User.find().then(function (data) {
            for (var i = 0; i < data.length; i++) {
                if (!data[i].state) {
                    data.splice(i, 1);
                }
            }
            socket.emit('loginSuccess', data);
            socket.broadcast.emit('user_list', data);
            socket.broadcast.emit('userIn', username);
        });

    });
    socket.on('disconnect', function (data) {//用户离开
        var username = data.username || socket.username;
        User.findOne({
            username: username
        }).then(function (userInfo) {
            if (userInfo) {
                return User.update({
                    _id: userInfo._id
                }, {
                    state: false
                })
            }
        }).then(function () {
            return User.find();
        }).then(function (data) {
            for (var i = 0; i < data.length; i++) {
                if (!data[i].state) {
                    data.splice(i, 1);
                }
            }
            socket.broadcast.emit('user_list', data);
            socket.broadcast.emit('userOut', username);
        })
    });
});

前台:

<span style="color: #f66;">用户上线提醒</span>

var username = $('#username').text();
var socket = io.connect('http://localhost:8880');
socket.emit('login', {username: username});

//有人加入
socket.on('userIn', function (data) {
    var html = '<li class="tip"><div class="text-center">@ ' + data + ' @上线</div></li>';
    $('#MsgList').append(html);
});

当用户登录成功,跳转到主页面后,前台通过io.connect()与服务器建立websocket连接,开始即时通信;并且同时通过socket.emit('login')向服务器发送用户上线的通知;

服务器通过socket.on('login')接收到前台发送的用户上线消息,然后通过 socket.broadcast.emit('userIn', username)向其他在线用户广播发送,该用户上线的消息;其他用户通过socket.on('userIn')接收消息,并向聊天模块上,添加提示;

<span style="color: #f66;">用户离线提醒</span>

前台:

//有人退出
socket.on('userOut', function (data) {
    var html = '<li class="tip"><div class="text-center">@ ' + data + ' @离开</div></li>';
    $('#MsgList').append(html);
});

//退出
$('#exitBtn').on('click', function () {
    var username = $('#username').text();
    location.href = 'exit';
});

在这里我把用户离线分为三种情况:一是,点击“退出”按钮;二是,关闭浏览器;三是,刷新该页面;但是不管是哪一种情况,只要页面改变了,都会触发服务器的socket.on('disconnect'),更改用户状态,然后服务器进行命令发送,这里和用户上线提醒的操作是一样的;

当在写刷新浏览器的时候,遇到了一个问题;我原本的想法是通过js监控浏览器的事件操作,然而发现这根本是不可能的,虽然在网上有很多帖子,但是通过实现都不能实现;

于是,我就想到了一个方法,当页面第一次通过login页面到主页面的时候,设置两个cookie,一个为user,来判断用户登录的;一个为flag,来判断页面已经加载过了;当用户刷新的时候,判断是否存在cookie-flag,如果存在,证明已经加载过了,就直接退出,用户离线;否则就是用户上线;这个方法可能不是很好,希望大神们有什么好方法,来解决这个问题;

后台:

index.js 主页面

router.get('/home', function (req, res) {
    if (!req.cookies.user) {
        res.redirect('/login');
    } else {
        if (!req.cookies.flag) {
            User.findOne({
                username: req.cookies.user
            }).then(function (userInfo) {
                res.render('home', {
                    username: userInfo.username,
                    image: userInfo.image
                });
            });
        } else {
            res.redirect('/exit');
        }

    }
});

eixt.js 退出

router.get('/', function (req, res) {
    User.update({
        username: req.cookies.user
    }, {
        state: false
    }).then(function () {
        res.clearCookie('user');
        res.clearCookie('flag');
        res.redirect('/login');
    });
});

<span style="color: #f66;">在线用户展示</span>

socket.on('loginSuccess', function (data) {
    userUpdate(data);
});

//更新在线人数列表
socket.on('user_list', function (data) {
    userUpdate(data);
});
function userUpdate(data) {
    var len = data.length;
    var str = '';
    for (var i = 0; i < len; i++) {
        str += '<li>';
        str += '![](' + data[i].image + ')';
        str += '<span>' + data[i].username + '</span>'
    }
    $('#peopleList').html(str);
    $('#list-count span').html(len);
}

这里在线用户展示分两种情况,一是,用户刚刚登陆时,需要在侧边栏展示已在线用户;二是,当有新用户登陆时,其他用户更新在线用户;

第一种情况,在服务器收到socket.on('login')时,通过socket.emit('loginSuccess')向新用户发送在线用户;

第二种情况,在服务器收到socket.on('login')时,通过socket.broadcast.emit('user_list')向其他用户发送更新在线用户的命令;

无论是哪种情况,当服务器在发送命令之前,都需要通过User.find()查询在线用户的信息,才能将消息发送给用户;然而当用户收到命令时,虽然是不同的命令,但是所要做的操作是一样的,所以执行相同的方法userUpdate(),展示在侧边栏;

2.聊天模块

当没有写过之前,能够写个向qq一样能即时聊天的功能,感觉好高大上,真牛逼;但是当真正开始写过之后,就感觉也就那么一会事;

闲话不多说;聊天模块的功能无非就是:发送消息和接收消息;当然这里分了发送接收文字信息和图片;虽然分的是两种,但是操作是一样的,又由于我把接收的信息和自己发送信息的样式做了区别,所以我这里把发送信息和接收信息分别封装了两个方法meSendMsg(msg,n)和getMsg(msg, n);参数“msg”是聊天内容,“n”的值为0和1,分类代表发送的是文字消息和图片消息。

前台:

//发送消息
$('#sendBtn').on('click', function () {
    var msg = $('#msgInput').val();
    if (msg == '') {
        alert('发送内容不能为空!');
        return false;
    }
    var username = $('#username').text();
    var img = $('#userImage').attr('src');
    meSendMsg(msg, 0);
    socket.emit('postNewMsg', {msg: msg, username: username, image: img});
    $('#msgInput').val('');
});

//接收消息
socket.on('newMsg', function (data) {
    getMsg(data, 0);
});

//发送照片
$('#addImage').on('click', function (e) {
    var e = e || window.event;
    e.stopPropagation();
    $('#files').trigger('click');
});

function changeFiles(e) {
    var e = e || window.event;
    var files = e.target.files || e.dataTransfer.files;
    var len = files.length;
    if (len === 0) return false;
    for (var i = 0; i < len; i++) {
        var fs = new FileReader();
        fs.readAsDataURL(files[i]);
        fs.onload = function () {
            var username = $('#username').text();
            var img = $('#userImage').attr('src');
            socket.emit('postImg', {imgData: this.result, username: username, image: img});
            meSendMsg(this.result, 1);
        }
    }
}

//接收照片
socket.on('newImg', function (data) {
    getMsg(data, 1);
});
//自己发送消息
function meSendMsg(msg, n) {
    var src = $('#userImage').attr('src');
    var name = $('#username').text();
    var html = ' <li class="me">';
    html += '<div class="row">';
    html += ' <div class="userInfo col-sm-1 col-md-1 pull-right">';
    html += '![](' + src + ')';
    html += '<p class="text-center">' + name + '</p>';
    html += '</div>';
    html += '<div class="msgInfo col-sm-5 col-md-5 pull-right">';
    if (n == 0) {
        html += msg;
    } else if (n == 1) {
        html += '![](' + msg + ')';
    }

    html += '</div></div></li>';
    $('#MsgList').append(html);
    var Li = $('#MsgList li');
    var len = Li.length;
    var LiH = Li.eq(len - 1).height();
    var h = document.getElementById('MsgList').scrollHeight;
    document.getElementById('MsgList').scrollTop = h + LiH;
}

//接收消息
function getMsg(data, n) {
    var html = ' <li >';
    html += '<div class="row">';
    html += ' <div class="userInfo col-sm-1 col-md-1">';
    html += '![](' + data.image + ')';
    html += '<p class="text-center">' + data.username + '</p>';
    html += '</div>';
    html += '<div class="msgInfo col-sm-5 col-md-5 ">';
    if (n == 0) {
        html += data.msg;
    } else if (n == 1) {
        html += '![](' + data.imgData + ')';
    }
    html += '</div></div></li>';
    $('#MsgList').append(html);
    var Li = $('#MsgList li');
    var len = Li.length;
    var LiH = Li.eq(len - 1).height();
    var h = document.getElementById('MsgList').scrollHeight;
    document.getElementById('MsgList').scrollTop = h + LiH;
}

后台:

 socket.on('postNewMsg', function (data) {//接收到新消息
    socket.broadcast.emit('newMsg', data);
});
socket.on('postImg', function (data) {//接收到图片
    socket.broadcast.emit('newImg', data);
});

在这里,我来说说图片上传和发送;图片不同于文字的传递,但是如果将图片转化为字符串形式后,便可以像发送普通文字消息一样发送图片了,只是在展示的时候将其还原为图片就行;

在这之前,我们已经将图片按钮在页面放好了,其实是一个文件类型的input,下面只需在它身上做功夫便可。

用户点击图片按钮后,弹出文件选择窗口供用户选择图片。之后我们可以在JavaScript代码中使用FileReader来将图片读取为base64格式的字符串形式进行发送。而base64格式的图片直接可以指定为图片的src,这样就可以将图片用img标签显示在页面了。

为此我们监听图片按钮的change事件,一但用户选择了图片,便显示到自己的屏幕上同时读取为文本发送到服务器。

//发送照片
$('#addImage').on('click', function (e) {
    var e = e || window.event;
    e.stopPropagation();
    $('#files').trigger('click');
});

function changeFiles(e) {
    var e = e || window.event;
    var files = e.target.files || e.dataTransfer.files;
    var len = files.length;
    if (len === 0) return false;
    for (var i = 0; i < len; i++) {
        var fs = new FileReader();
        fs.readAsDataURL(files[i]);
        fs.onload = function () {
            var username = $('#username').text();
            var img = $('#userImage').attr('src');
            socket.emit('postImg', {imgData: this.result, username: username, image: img});
            meSendMsg(this.result, 1);
        }
    }
}

3.用户修改信息

用户修改信息用到的方法,在上面的编写中都用到了;比如:图片上传和发送,用户列表的更新等,这里我就不详细讲了,大家看代码吧,有什么问题请及时call我;

前台:

//修改信息
$('#editImage').on('click', function (e) {
    var e = e || window.event;
    e.stopPropagation();
    $('#fileImg').trigger('click');
});

$('#editBtn').on('click', function () {
    var newName = $('#newName').val();
    var newImage = $('#editImage').attr('src');
    $('#userImage').attr('src', newImage);
    $('#username').html(newName);
    $('#changeInfo').modal('hide');
    socket.emit('edit', {newName: newName, newImage: newImage, username: username});
});
function editImageFn(e) {
    var e = e || window.event;
    var files = e.target.files || e.dataTransfer.files;
    var fs = new FileReader();
    fs.readAsDataURL(files[0]);
    fs.onload = function () {
        $('#editImage').attr('src', this.result);
    }
}

后台:

socket.on('edit', function (data) {
        var username = data.username || socket.username;
        User.findOne({
            username: username
        }).then(function (userInfo) {
            return User.update({
                _id: userInfo._id
            }, {
                username: data.newName,
                image: data.newImage
            })
        }).then(function () {
            socket.username = data.newName;
            return User.find();
        }).then(function (data) {
            for (var i = 0; i < data.length; i++) {
                if (!data[i].state) {
                    data.splice(i, 1);
                }
            }
            socket.emit('user_list', data);
            socket.broadcast.emit('user_list', data);
        });
    })

小结

到此为止,一个简单的即时聊天室完成了;其实,做这个聊天室的时候,有很多设想,比如发送表情和一对一聊天等,这些都是在其中,虽然现在还没有实现,但是请敬请期待;或者你们自己已经实现了,大家可以交流交流;

在这里说说为什么叫“xxx聊天室”,是因为本人语言能力实在是比较差,想不出什么好名字来(哈哈),大家有什么好名字可以自己添加;

本文可能很多地方用词用句都不是很恰当,也有很多地方讲解不清楚,请大家多多原谅,本人的语言表达能力实在是不怎么样,在以后的文章中,会改善的;

原文: http://blog.hawkzz.com/2017/06/01/xxx聊天室/  作者: hawk_zz

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,324评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,303评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,192评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,555评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,569评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,566评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,927评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,583评论 0 257
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,827评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,590评论 2 320
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,669评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,365评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,941评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,928评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,159评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,880评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,399评论 2 342

推荐阅读更多精彩内容