网页聊天室之Socket.io实战

从10月1号到13号,用了大概80%的时间基本实现了一个聊天室应该具备的基本功能:

  • 用户登录注册
  • 添加好友 (点击添加双方可同时添加对方)
  • 选择好友聊天和聊天信息的存储
  • 若对方离线则提示用户对方不在线
  • 若对方正在与其他用户聊天,则提示对方忙碌,对方提示收到其他聊天请求
  • 存在未读消息可显示在用户名旁,且可在聊天页面直接显示未读消息
  • 查看与某个好友的聊天记录

好了,基本就是这些功能。总的实现过程来讲,会操作socket.io和mongodb即可。

先来看下界面吧。

聊天记录界面还很粗糙

登录
聊天界面
1
实现过程

1. 登录注册就不用讲了,直接把建博客时的拿过来了。

2. 数据结构的定义

//用户表
var userSchema = new Schema({
    username: String,
    password: String,
    email:    String,
    address:  String,
    userImg: String,
    meta: {
        updateAt: {type:Date, default: Date.now()},
        createAt: {type:Date, default: Date.now()}
    }
});

/* 好友表 */
var friendSchema = new Schema({
    uid: {type: ObjectId, ref: 'User'},
    fid: {type: ObjectId, ref: 'User'},
    meta: {
        updateAt: {type:Date, default: Date.now()},
        createAt: {type:Date, default: Date.now()}
    }
});

/*/聊天信息表  */
var messageSchema = new Schema({
    from: {type: ObjectId, ref: 'User'},
    to: {type: ObjectId, ref: 'User'},
    content: String,
    status: String,
    meta: {
        updateAt: {type:Date, default: Date.now()},
        createAt: {type:Date, default: Date.now()}
    }
});

用户表:增加friends数组,包含friendIdunreadunread是后来考虑到要显示未读消息时加进去的,一开始friends只是个保持好友_id的数组,加了unread后就要重写取出用户好友等相关函数,很麻烦,这是个教训,所以以后加数组的时候应该多定义一个,不管之后有没有用。
聊天信息表很简单,from发送方idto接收方idstatus是消息读取状态,已读为1,未读为0

重新定义好用户表后就可以进行拉取全部用户数组和用户好友数组了。
//取出用户
  exports.searchAllUsers = function (req, cb) {
    var $user = {};
    User.find({}, function (err, data) {
        var userList = new Array();
        for (var i = 0; i < data.length; i++) {
            userList.push(data[i].toObject());
        }
        // console.log(categoryList);
        $user.results = userList;
        $user.count = userList.length;
        cb(true, $user);
    })
};

//取出用户好友
exports.matchUser = function (id, cb) {
  User.findById(id, function (err, data) {
      if(err) {
          console.log(err);
      } else {
          var user = (data !== null) ? data.toObject() : '';
          cb(true, user);
      }
  }).populate('friends.friendId', 'username');
};

侧边栏的html内容

<div class="sidebar" id="chat-sidebar">
    <div class="sidebar-wrapper" id="sidebar-wrapper">
        <div class="panel-group">
            <div class="panel panel-default">
                <div class="panel-heading">
                    <h4 class="panel-title">
                        <a data-toggle="collapse" data-parent="#admin-box" href="#friend">好友列表</a>
                    </h4>
                </div>
                <div id="friend" class="panel-collapse collapse">
                    <div class="panel-body">
                        <ul class="nav">
                            {{#if user.friends}}
                                {{#each user.friends}}
                                    <li class="users-list">
                                        <div class="username">
                                            <a href="/p/chatRoom/{{_id}}">{{friendId.username}} {{unread}}</a>
                                        </div>
                                    </li>
                                {{/each}}
                            {{/if}}
                        </ul>
                    </div>
                </div>
            </div>
            <div class="panel panel-default">
                <div class="panel-heading">
                    <h4 class="panel-title">
                        <a data-toggle="collapse" data-parent="#admin-box" href="#stranger">用户列表 ({{userCount}})</a>
                    </h4>
                </div>
                <div id="stranger" class="panel-collapse collapse">
                    <div class="panel-body">
                        <ul class="nav">
                            {{#each entries}}
                                    <li data-toggle="select" data-id="{{_id}}" class="users-list">
                                        <div class="username">{{username}}</div>
                                        <div class="users-list-r">
                                            <a href="#" class="add-user"><i class="fa fa-plus"></i>添加</a>
                                        </div>
                                    </li>
                            {{/each}}
                        </ul>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>

ChatRoom的html内容

<div class="wrapper">
    <div class="chat-room" data-id="{{user._id}}">
        <div class="chat-top">
            <div class="username-to" data-id="{{friend._id}}">{{friend.username}}</div>
            <div class="back-btn">
                <a href="/p/historyMessages/{{friend._id}}" class="btn btn-primary btn-md">查看聊天记录</a>
            </div>
        </div>
        <div class="chat-ctn">
            <ul id="messages">
                {{#each message}}
                    <li class="chat-box-l"><img class="chat-user-img" src="/images/mb2.jpg"><p class="chat-p">{{content}}</p></li>
                {{/each}}
            </ul>
        </div>
        <form action="">
            <div class="send-box">
                <div class="msg-input">
                    <input id="m" autocomplete="off" class="input-ctn" placeholder="请输入...">
                </div>
                <div class="send-btn">
                    <button type="submit" class="btn btn-primary pull-right">发送</button>
                </div>
            </div>
        </form>
    </div>
</div>
<script src="/chat/chatRoom.js"></script>

让我们来看下router的内容

//进入index后读取全部用户和重新读取好友列表,以便添加好友后即可在好友列表看到该好友
var express = require('express');
var router = express.Router();
var config = require('../config');
var dbHelper = require('../db/dbHelper');
var formidable = require('formidable');
var entries = require('../db/jsonRes');
var fs = require('fs');

/* GET home page. */
router.get('/index', function(req, res, next) {
    var id = req.session.user._id;
    dbHelper.searchAllUsers(req, function (success, data) {
        dbHelper.getFriends(id, function (success, doc) {
            res.render('new', {
                entries: data.results,
                userCount: data.count,
                user: req.session.user,
                friendList: doc
            });
        });
    });
});
//获取历史聊天记录
router.post('/getHistoryMsg', function (req, res, next) {
    dbHelper.findHistoryMsg(req.body, function (success, doc) {
        res.send(doc);
    })
});
//添加好友
router.post('/addFriend', function(req, res, next) {
    dbHelper.addFriend(req.body, function (success, doc) {
        res.send(doc);
    });
});
//
router.post('/addMessage', function(req, res, next) {
    dbHelper.addMessage(req.body, function (success, doc) {
        res.send(doc);
    });
});

router.post('/getUnreadMsg', function(req, res, next) {
    dbHelper.getUnreadMsg(req.body, function (success, doc) {
        res.send(doc);
    });
});
router.post('/updateMsgStatus', function (req, res, next) {
    dbHelper.updateMsgStatus(req.body, function (success, doc) {
        res.send(doc);
    });
});

//上传图片
router.post('/uploadImg', function(req, res, next) {
    console.log("开始上传");
    // var io = global.io;

    var form = new formidable.IncomingForm();
    var path = "";
    var fields = [];

    form.encoding = 'utf-8';                    //上传文件编码格式
    form.uploadDir = "public/uploadFile";     //上传文件保存路径(必须在public下新建)
    form.keepExtensions = true;                 //保持上传文件后缀
    form.maxFieldsSize = 30000 * 1024 * 1024;   //上传文件格式最大值


    var uploadprogress = 0;
    console.log("start:upload----"+uploadprogress);

    form.parse(req);

    form.on('field', function(field, value) {
        console.log(field + ":" + value);       //上传的参数数据
    })
        .on('file', function(field, file) {
            path = '\\' + file.path;            //上传的文件数据
        })
        .on('progress', function(bytesReceived, bytesExpected) {

            uploadprogress = (bytesReceived / bytesExpected * 100).toFixed(0);  //计算进度
            console.log("upload----"+ uploadprogress);
            // io.sockets.in('sessionId').emit('uploadProgress', uploadprogress);
        })
        .on('end', function() {
            //上传完发送成功的json数据
            console.log('-> upload done\n');
            entries.code = 0;
            entries.data = path;
            res.writeHead(200, {
                'content-type': 'text/json'
            });
            res.end(JSON.stringify(entries));
        })
        .on("err",function(err){
            var callback="<script>alert('"+err+"');</script>";
            res.end(callback);//这段文本发回前端就会被同名的函数执行
        }).on("abort",function(){
        var callback="<script>alert('"+ttt+"');</script>";
        res.end(callback);
    });

});
//上传截图
router.post('/upload', function(req, res, next){
    //接收前台POST过来的base64
    var imgData = req.body.img;
    //过滤data:URL
    var base64Data = imgData.replace(/^data:image\/\w+;base64,/, "");
    var dataBuffer = new Buffer(base64Data, 'base64');
    var fileName = req.body.fileName;
    console.log(dataBuffer);

    fs.writeFile("./public/uploadFile/upload_" + fileName +".jpg", dataBuffer, function(err) {
        if(err){
            res.send(err);
        }else{
            var path = "\\public\\uploadFile\\upload_" + fileName +".jpg";
            entries.code = 0;
            entries.data = path;
            res.end(JSON.stringify(entries));
        }
    });
});

module.exports = router;

好了,接下去就是聊天相关操作了

3. 添加好友

参数data包含userIdfriendId,查找到_id: userId后把friendIdpush进去,对friendId做同样处理,从而实现两端同时添加好友

exports.addFriend = function (data, cb) {
    entries.code = 0;
    console.log(data);
    User.findById(data.user, function (err, user) {
        User.findOne({"_id": data.user, "friends.friendId": data.friend}, function (err, doc) {
            if(err) {
                console.log(err);
            } else if(doc != null) {
                entries.code = 98;
                entries.msg = '该好友已添加!';
                cb(false, entries);
            } else if(doc == null) {
                user.friends.push({
                    friendId: data.friend
                });
                user.save(function (err, doc) {
                    if(err) {
                        entries.code = 99;
                        console.log(err);
                    } else {
                        console.log("好友添加成功");
                        cb(true, entries);
                    }
                })
            }
        });
        
    });
    User.findById(data.friend, function (err, user) {
        User.findOne({"_id": data.friend, "friends.friendId": data.user}, function (err, doc) {
            if(err) {
                console.log(err);
            } else if(doc != null) {
                console.log("对方已添加!");
            } else if(doc == null) {
                user.friends.push({
                    friendId: data.user
                });
                user.save(function (err, doc) {
                    if(err) {
                        console.log(err);
                    } else {
                        console.log("both add!")
                    }
                })
            }
        });

    });
};

4 Socket.io聊天

根据之前写的那篇Socket.io API,来看下实际环境该如何操作。

服务端代码
var io = require('socket.io').listen(server);
var userId = {};
var toChat = {};
io.on('connection', function(socket){
  socket.on('add user', function (data) {
    socket.name = data.from;
    toChat[data.from] = data.to;//保存用户聊天对象
    userId[data.from] = socket;//每次进入都要socket.id都要变一次
  });
  socket.on('chat message', function(msg){
    if(toChat[toChat[socket.name]] == socket.name && userId[msg.to]) {
      var data = {
        msg: msg.content,
        ctn: msg.content,
        status: 1
      };
      userId[msg.to].emit('chat message', data);
    } else if(userId[msg.to] && toChat[toChat[socket.name]] != socket.name) {
      var data = {
        msg: "系统:你收到其他用户的聊天信息!",
        _msg: "对方正在和其他人聊天!",
        ctn: msg.content,
        status: 2
      };
      userId[msg.from].emit('chat busy', data);
      userId[msg.to].emit('chat message', data);
    } else {
      var data = {
        msg: "抱歉,当前用户不在线!",
        ctn: msg.content,
        status: 0
      };
      userId[msg.from].emit('chat message', data);
    }
  });
  socket.on('disconnect', function () {
    //用户退出提醒对方
    if(userId[toChat[socket.name]] && toChat[toChat[socket.name]] == socket.name) {//判断对方聊天的对象是否是自己
      userId[toChat[socket.name]].emit('user left', "对方退出!");
    }
    delete userId[socket.name];
    delete toChat[socket.name];
  });
});
客户端js代码
var socket = io();
//传递给服务器用户Id

var data = {
    from: $('.chat-room').attr("data-id"),
    to: $('.username-to').attr("data-id")
};
var msg = {
    from:   $('.chat-room').attr("data-id"),
    to: $('.username-to').attr("data-id"),
    content: ''
};
socket.emit('add user', data);

updateMsgStatus();//消息标记为已读

$('form').submit(function(){
    var ctn = $('#m').val();
    if(ctn != '') {
        var html = '<li class="chat-box-r"><p class="chat-p">'+ ctn +'</p><img class="chat-user-img" src="/images/mb2.jpg"></li>';
        $('#messages').append(html);
        msg.content = ctn;
        socket.emit('chat message', msg);
        // doAddMsg();
    }
    $('#m').val('');
    return false;
});
socket.on('chat busy', function (data) {
    var html = '<li class="chat-box-l"><img class="chat-user-img" src="/images/mb2.jpg"><p class="chat-p">' + data._msg +'</p></li>';
    $('#messages').append(html);
    doAddMsg(0, data.ctn, msg.from, msg.to);
});
socket.on('chat message', function(data){
    var html = '<li class="chat-box-l"><img class="chat-user-img" src="/images/mb2.jpg"><p class="chat-p">' + data.msg +'</p></li>';
    $('#messages').append(html);
    // doAddMsg(data.status, data.ctn);
    if(data.status == 1) {
        doAddMsg(1, data.ctn, msg.to, msg.from);
    } else if(data.status == 0) {
        doAddMsg(0, data.ctn, msg.from, msg.to);
    }
});
socket.on('user left', function (msg) {
    var html = '<li class="chat-box-l"><img class="chat-user-img" src="/images/mb2.jpg"><p class="chat-p">' + msg +'</p></li>';
    $('#messages').append(html);
});

function doAddMsg(status, ctn, from, to) {
    $.ajax({
        type: "POST",
        url: "/p/addMessage",
        contentType: "application/json",
        dataType: "json",
        data: JSON.stringify({
            'from': from,
            'to': to,
            'content': ctn,
            'status': status
        }),
        success: function(result) {
            if (result.code == 99) {
                alert("发送失败");
            }
        }
    })
}
function updateMsgStatus() {
    $.ajax({
        type: "POST",
        url: "/p/updateMsgStatus",
        contentType: "application/json",
        dataType: "json",
        data: JSON.stringify({
            'from': msg.to,
            'to': msg.from
        }),
        success: function(result) {
            if (result.code == 99) {
                alert(result.msg);
            } else {
                console.log("消息已全部阅读!");
            }
        }
    })
}

注释:A客户端主要就是emit()消息,当emit(add user)后服务器就会保存该用户socket.name为该用户ID,保存到userList数组里,相当于userList [ ID ]代表当前用户,之后发送接受就有这个来处理。接着服务器接收到客户端出来的消息后,有 3 种情况要处理:

  1. 对方不在线,即userList [ 对方ID ]NULL,则emit给用户对方不在线,并且用户本地保存消息,标记消息为未读,status为 0
  2. 对方B在线,但正在与其他用户C聊天,chatTo数组保存聊天对象的Id,判断对方的聊天对象是否是自己,写成代码即为 chatTo[chatTo[A]] == A,其中chatTo [ A ]就是B,如果false则emit回本地告诉用户对方正忙,status为0,保存消息进数据库
    3.对方在线且聊天对象正是自己,则emit消息给对方,status为 1,保存消息进数据库

5. 对未读消息的处理(未读消息显示数量和打开聊天页面直接把未读消息取出显示)

  1. 当保存的一条消息status为0时,则相应的用户下的好友属性unread + 1
  1. 进入聊天页面时如果对方的unread不为 0,则从message中找到对方发给自己的status为 0的消息,放进数组返回
//保存消息
exports.addMessage = function (data, cb) {
    console.log(data);
    var message= new Message({
        from: data.from,
        to: data.to,
        content: data.content,
        status: data.status
    });
    message.save(function (err, doc) {
        if(err) {
            entries.code = 99;
            cb(false, entries);
            console.log("add message fail !");
        }
    });
    //如果是未读消息,则
    if(data.status == 0) {
        User.findById(data.to, function (err, user) {
            for(var i = 0; i < user.friends.length; i++) {
                var item = user.friends[i];
                if(item.friendId.toString() == data.from) {
                    item.unread++;
                    break;
                }
            }
            user.save(function (err, doc) {
                if(err) {
                    console.log("add unread fail !");
                }
            })
        })
    }
};
//进入聊天页面时获取对方名字,存在未读消息则取出显示
exports.findFriend = function (userId, friendId, cb) {
    User.findOne({"_id": userId, "friends._id": friendId})
        .populate('friends.friendId', 'username')
        .exec(function (err, data) {
            var user = (data !== null) ? data.toObject() : '';
            for(var i =0; i < data.friends.length; i++) {
                var item = data.friends[i];
                if(item._id.toString() == friendId){
                    user = item;
                    break;
                }
            }
            // console.log(user.friendId);
            var $unreadMsg = {};
            var messageList = new Array();
            $unreadMsg.results = messageList;
            $unreadMsg.friend = user.friendId;
            if(user.unread != 0) {
                // var $unreadMsg = {};
                Message.find({"from": user.friendId._id, "to": userId, "status": 0}, function (err, data) {
                    // var messageList = new Array();
                    for (var i = 0; i < data.length; i++) {
                        messageList.push(data[i].toObject());
                    }
                    $unreadMsg.results = messageList;
                    // $unreadMsg.friend = user.friendId;
                    // console.log(messageList);
                })
            }
            cb(true, $unreadMsg);
            // console.log($unreadMsg);
            // else {
            //     cb(true, user.friendId);
            // }
        })
};
取出未读消息显示后,就该把未读改为已读了
//将对应消息的status改为1,对应好友的unread改为0
exports.updateMsgStatus = function (data, cb) {
    Message.find({"from": data.from, "to": data.to, "status": 0}, function (err, message) {
        for(var i =0; i < message.length; i++) {
            message[i].status = 1;
            message[i].save(function (err, doc) {
                if(err) {
                    console.log(err);
                } else {
                    console.log("成功修改消息status为1");
                }
            })
        }
    });
    //在用户对应的好友未处理消息改为0
    User.findById(data.to, function (err, user) {
        for(var i = 0; i < user.friends.length; i++) {
            var item = user.friends[i];
            if(item.friendId.toString() == data.from) {
                item.unread = 0;
                break;
            }
        }
        user.save(function (err, doc) {
            if(err) {
                console.log("update unread fail !");
            }
        })
    })
};

由于update只能对一条数据进行操作,在更新一块我将status为 0的放进数组,利用for循环更改每一条符合条件的message,批量处理。

6. 查找聊天记录

由于message定义的时候只有简单的fromtocontent,现在只能取出要么是对方发给自己的聊天记录,要么是自己发给对方的聊天记录,无法实现对话式的完整聊天记录提取。我想到的是同时取出双方的记录,再按照时间排序,依次存入数组返回。所以提取聊天记录要改一下。

//查看历史消息记录
exports.findHistoryMsg = function (from, to, cb) {
    Message.find({"from": from, "to": to})
        .populate("from to", "username username")
        .exec(function (err, data) {
            var $message = {};
            var messageList = new Array();
            for(var i =0; i < data.length; i++) {
                messageList.push(data[i].toObject());
            }
            // console.log(messageList);
            var name = messageList[0].from.username;
            $message.results = messageList;
            $message.name = name;
            cb(true, $message);
        })
};

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念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

推荐阅读更多精彩内容

  • 点击查看原文 Web SDK 开发手册 SDK 概述 网易云信 SDK 为 Web 应用提供一个完善的 IM 系统...
    layjoy阅读 13,652评论 0 15
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,580评论 18 139
  • 1.一个浏览器同时登录多个用户session互相冲突如何解决? 由于同一个浏览器默认会共享session,所以无法...
    淡就加点盐阅读 851评论 0 3
  • Android 自定义View的各种姿势1 Activity的显示之ViewRootImpl详解 Activity...
    passiontim阅读 171,392评论 25 707
  • 作为一个生活在几十亿人口地球村的普通一员,怎样能在众多的竞争对手中脱颖而出?怎样能跻身年入百万的高端行列呢?201...
    牛奋大叔阅读 313评论 0 0