使用nodeJs+web Socket构建即时通讯(WebIM)
2017-09-07 15:22
387 查看
在文章的开头,我要解释一下,为什么不直接使用web Socket实现即时通讯,因为一部分浏览器并不兼容web Socket,导致即时通讯在这些浏览器上无法正常使用,所以才需要用到nodeJs里封装好webSocket协议的socket.io包模块来提供不兼容websocket浏览器的解决方案并接管客户与服务器端交互的IO流。(一般处理不兼容websocket的浏览器的方法就是考虑 commit 方式,或者用 Flash sockect.)
这个即时通讯的demo包括最基本的聊天室功能和私聊功能,其他附加的功能就是发送图片和表情及改变字体颜色,实现最基本的对话聊天功能。需要其他功能的可以懂得原理后自行添加模块进来。话不多说,直接用代码说话。(其中将会使用到express和socket.io两个包模块,一定要先安装好。PS: express是node.js中管理路由响应请求的模块,根据请求的URL返回相应的HTML页面。这里我们使用一个事先写好的静态页面返回给客户端,只需使用express指定要返回的页面的路径即可。如果不用这个包,我们需要将HTML代码与后台JavaScript代码写在一起进行请求的响应,不太方便。socket.io是Node.js中使用socket的一个包。使用它可以很方便地建立服务器到客户端的sockets连接,发送事件与接收特定事件。)
首先,我们来看服务器端代码:
学过JS的小伙伴们肯定都能看得懂大概的意思吧,如果不明白socket.io中的服务器推技术是怎么实现的,可以百度一下相关文档,这里就不多加赘述了。现在我们来看客户端的代码实现:
最后我们来看看聊天展示页面的html代码吧~
至此,一个简单的WebIM就完成了。(这个代码经过测试,可以直接复制运行,至于样式,自己做一下吧,哈哈~)
谢谢大大的文章给我的启发:http://www.cnblogs.com/Wayou/p/hichat_built_with_nodejs_socket.html
在此感谢!!!小伙伴们有什么宝贵的意见也可以在评论区跟我交流,感激不尽哦~
最后,为了让我们更清楚即时通讯其中的工作原理,我们来了解几个web通信中比较重要的概念知识吧:(摘自博主hoojo的http://www.cnblogs.com/hoojo/p/longPolling_comet_jquery_iframe_ajax.html。)
Web 通信 之 长连接、长轮询(long polling)
基于HTTP的长连接,是一种通过长轮询方式实现”服务器推”的技术,它弥补了HTTP简单的请求应答模式的不足,极大地增强了程序的实时性和交互性。
一、什么是长连接、长轮询?
用通俗易懂的话来说,就是客户端不停的向服务器发送请求以获取最新的数据信息。这里的“不停”其实是有停止的,只是我们人眼无法分辨是否停止,它只是一种快速的停下然后又立即开始连接而已。
二、长连接、长轮询的应用场景
长连接、长轮询一般应用与WebIM、ChatRoom和一些需要及时交互的网站应用中。其真实案例有:WebQQ、Hi网页版、Facebook IM等。
如果你对服务器端的反向Ajax感兴趣,可以参考这篇文章 DWR 反向Ajax 服务器端推的方式:http://www.cnblogs.com/hoojo/category/276235.html
三、优缺点
轮询:客户端定时向服务器发送Ajax请求,服务器接到请求后马上返回响应信息并关闭连接。
优点:后端程序编写比较容易。
缺点:请求中有大半是无用,浪费带宽和服务器资源。
实例:适于小型应用。
长轮询:客户端向服务器发送Ajax请求,服务器接到请求后hold住连接,直到有新消息才返回响应信息并关闭连接,客户端处理完响应信息后再向服务器发送新的请求。
优点:在无消息的情况下不会频繁的请求,耗费资源小。
缺点:服务器hold连接会消耗资源,返回数据顺序无保证,难于管理维护。
实例:WebQQ、Hi网页版、Facebook IM。
长连接:在页面里嵌入一个隐蔵iframe,将这个隐蔵iframe的src属性设为对一个长连接的请求或是采用xhr请求,服务器端就能源源不断地往客户端输入数据。
优点:消息即时到达,不发无用请求;管理起来也相对方便。
缺点:服务器维护一个长连接会增加开销。
实例:Gmail聊天
Flash Socket:在页面中内嵌入一个使用了Socket类的 Flash 程序JavaScript通过调用此Flash程序提供的Socket接口与服务器端的Socket接口进行通信,JavaScript在收到服务器端传送的信息后控制页面的显示。
优点:实现真正的即时通信,而不是伪即时。
缺点:客户端必须安装Flash插件;非HTTP协议,无法自动穿越防火墙。
实例:网络互动游戏。
四、实现原理
所谓长连接,就是要在客户端与服务器之间创建和保持稳定可靠的连接。其实它是一种很早就存在的技术,但是由于浏览器技术的发展比较缓慢,没有为这种机制的实现提供很好的支持。所以要达到这种效果,需要客户端和服务器的程序共同配合来完成。通常的做法是,在服务器的程序中加入一个死循环,在循环中监测数据的变动。当发现新数据时,立即将其输出给浏览器并断开连接,浏览器在收到数据后,再次发起请求以进入下一个周期,这就是常说的长轮询(long-polling)方式。如下图所示,它通常包含以下几个关键过程:
![](https://img-blog.csdn.net/20170907142358297?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvc2luYXRfMjk2NzM0MDM=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast)
1. 轮询的建立
建立轮询的过程很简单,浏览器发起请求后进入循环等待状态,此时由于服务器还未做出应答,所以HTTP也一直处于连接状态中。
2. 数据的推送
在循环过程中,服务器程序对数据变动进行监控,如发现更新,将该信息输出给浏览器,随即断开连接,完成应答过程,实现“服务器推”。
3. 轮询的终止
轮询可能在以下3种情况时终止:
3.1. 有新数据推送
当循环过程中服务器向浏览器推送信息后,应该主动结束程序运行从而让连接断开,这样浏览器才能及时收到数据。
3.2. 没有新数据推送
循环不能一直持续下去,应该设定一个最长时限,避免WEB服务器超时(Timeout),若一直没有新信息,服务器应主动向浏览器发送本次轮询无新信息的正常响应,并断开连接,这也被称为“心跳”信息。
3.3. 网络故障或异常
由于网络故障等因素造成的请求超时或出错也可能导致轮询的意外中断,此时浏览器将收到错误信息。
4. 轮询的重建
浏览器收到回复并进行相应处理后,应马上重新发起请求,开始一个新的轮询周期。
五、程序设计
1、普通轮询 Ajax方式
客户端代码片段
客户端实现的就是用一种普通轮询的结果,比较简单。利用setInterval不间断的刷新来获取服务器的资源,这种方式的优点就是简单、及时。缺点是链接多数是无效重复的;响应的结果没有顺序(因为是异步请求,当发送的请求没有返回结果的时候,后面的请求又被发送。而此时如果后面的请求比前面的请求要先返回结果,那么当前面的请求返回结果数据时已经是过时无效的数据了);请求多,难于维护、浪费服务器和网络资源。
服务器端代码
服务器端实现,这里就模拟下程序监控数据的变化。上面代码属于SpringMVC 中controller中的一个方法,相当于Servlet中的一个doPost/doGet方法。如果没有程序环境适应servlet即可,将方法体中的代码copy到servlet的doGet/doPost中即可。
服务器端在进行长连接的程序设计时,要注意以下几点:
1. 服务器程序对轮询的可控性
由于轮询是用死循环的方式实现的,所以在算法上要保证程序对何时退出循环有完全的控制能力,避免进入死循环而耗尽服务器资源。
2. 合理选择“心跳”频率
从图1可以看出,长连接必须由客户端不停地进行请求来维持,所以在客户端和服务器间保持正常的“心跳”至为关键,参数POLLING_LIFE应小于WEB服务器的超时时间,一般建议在10~20秒左右。
3. 网络因素的影响
在实际应用时,从服务器做出应答,到下一次循环的建立,是有时间延迟的,延迟时间的长短受网络传输等多种因素影响,在这段时间内,长连接处于暂时断开的空档,如果恰好有数据在这段时间内发生变动,服务器是无法立即进行推送的,所以,在算法设计上要注意解决由于延迟可能造成的数据丢失问题。
4. 服务器的性能
在长连接应用中,服务器与每个客户端实例都保持一个持久的连接,这将消耗大量服务器资源,特别是在一些大型应用系统中更是如此,大量并发的长连接有可能导致新的请求被阻塞甚至系统崩溃,所以,在进行程序设计时应特别注意算法的优化和改进,必要时还需要考虑服务器的负载均衡和集群技术。
![](https://img-blog.csdn.net/20170907142247396?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvc2luYXRfMjk2NzM0MDM=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast)
上图是返回的结果,可以看到先发出请求,不一定会最先返回结果。这样就不能保证顺序,造成脏数据或无用的连接请求。可见对服务器或网络的资源浪费。
2、普通轮询 iframe方式
这里的客户端程序是利用隐藏的iframe向服务器端不停的拉取数据,将iframe获取后的数据填充到页面中即可。同ajax实现的基本原理一样,唯一不同的是当一个请求没有响应返回数据的情况下,下一个请求也将开始,这时候前面的请求将被停止。如果要使程序和上面的ajax请求一样也可以办到,那就是给每个请求分配一个独立的iframe即可。下面是返回的结果:
![](https://img-blog.csdn.net/20170907142211657?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvc2luYXRfMjk2NzM0MDM=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast)
其中红色是没有成功返回请求就被停止(后面请求开始)掉的请求,黑色是成功返回数据的请求。
3、长连接iframe方式
这个轮询方式就是把刚才上面的稍微改下,每个请求都有自己独立的一个iframe,当这个iframe得到响应的数据后就把数据push到当前页面上。使用此方法已经类似于ajax的异步交互了,这种方法也是不能保证顺序的、比较耗费资源、而且总是有一个加载的条在地址栏或状态栏附件(当然要解决可以利用htmlfile,Google的攻城师们已经做到了,网上也有封装好的lib库),但客户端实现起来比较简单。
![](https://img-blog.csdn.net/20170907142142035?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvc2luYXRfMjk2NzM0MDM=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast)
如果要保证有序,可以不使用setInterval,将创建iframe的方法放在load事件中即可,即使用递归方式。调整后的代码片段如下:
这种方式虽然保证了请求的顺序,但是它不会处理请求延时的错误或是说很长时间没有返回结果的请求,它会一直等到返回请求后才能创建下一个iframe请求,总会和服务器保持一个连接。和以上轮询比较,缺点就是消息不及时,但保证了请求的顺序。
4、ajax实现长连接
上面这段代码就是才有Ajax的方式完成长连接,主要优点就是和服务器始终保持一个连接。如果当前连接请求成功后,将更新数据并且继续创建一个新的连接和服务器保持联系。如果连接超时或发生异常,这个时候程序也会创建一个新连接继续请求。这样就大大节省了服务器和网络资源,提高了程序的性能,从而也保证了程序的顺序。
![](https://img-blog.csdn.net/20170907141955503?watermark/2/text/aHR0cDovL2Jsb2cuY3Nkbi5uZXQvc2luYXRfMjk2NzM0MDM=/font/5a6L5L2T/fontsize/400/fill/I0JBQkFCMA==/dissolve/70/gravity/SouthEast)
六、总结
现代的浏览器都支持跨域资源共享(Cross-Origin Resource Share,CORS)规范,该规范允许XHR执行跨域请求,因此基于脚本的和基于iframe的技术已成为了一种过时的需要。
把Comet做为反向Ajax的实现和使用的最好方式是通过XMLHttpRequest对象,该做法提供了一个真正的连接句柄和错误处理。当然你选择经由HTTP长轮询使用XMLHttpRequest对象(在服务器端挂起的一个简单的Ajax请求)的Comet模式,所有支持Ajax的浏览器也都支持该种做法。
基于HTTP的长连接技术,是目前在纯浏览器环境下进行即时交互类应用开发的理想选择,随着浏览器的快速发展,html5将为其提供更好的支持和更广泛的应用。在html5中有一个websocket 可以很友好的完成长连接这一技术,网上也有相关方面的资料,这里也就不再做过多介绍。
这个即时通讯的demo包括最基本的聊天室功能和私聊功能,其他附加的功能就是发送图片和表情及改变字体颜色,实现最基本的对话聊天功能。需要其他功能的可以懂得原理后自行添加模块进来。话不多说,直接用代码说话。(其中将会使用到express和socket.io两个包模块,一定要先安装好。PS: express是node.js中管理路由响应请求的模块,根据请求的URL返回相应的HTML页面。这里我们使用一个事先写好的静态页面返回给客户端,只需使用express指定要返回的页面的路径即可。如果不用这个包,我们需要将HTML代码与后台JavaScript代码写在一起进行请求的响应,不太方便。socket.io是Node.js中使用socket的一个包。使用它可以很方便地建立服务器到客户端的sockets连接,发送事件与接收特定事件。)
首先,我们来看服务器端代码:
var express = require('express'), app = express(), server = require('http').createServer(app), io = require('socket.io').listen(server); //指定静态HTML文件的位置 app.use('/', express.static(__dirname + '/www')); server.listen(3000);//监听端口是否有来自客户端的请求 var onlineUsers = {};//在线用户列表 var socketList = {};//每个用户所持有的与服务器交互的socket列表 var onlineCount = 0;//在线人数 //处理socket事件 io.sockets.on('connection', function(socket) { //新用户登陆 socket.on('login', function(obj) { if (onlineUsers.hasOwnProperty(obj.userid)) { socket.emit('userExisted'); } else { socket.name = obj.userid; socketList[obj.userid] = socket; //检查在线用户列表,如果不存在,则将该用户加入在线用户表 if(!onlineUsers.hasOwnProperty(obj.userid)) { onlineUsers[obj.userid] = obj.nickname; //在线人数+1 onlineCount++; } socket.emit('loginSuccess',{onlineUsers:onlineUsers, onlineCount:onlineCount, user:obj}); io.sockets.emit('system', obj, onlineCount, 'login'); }; }); //用户离线 socket.on('disconnect', function() { //将退出的用户从在线列表中删除 if(onlineUsers.hasOwnProperty(socket.name)) { //退出用户的信息 var obj = {userid:socket.name, nickname:onlineUsers[socket.name]}; //删除 delete onlineUsers[socket.name]; delete socketList[socket.name]; //在线人数-1 onlineCount--; //向所有客户端广播用户退出 socket.broadcast.emit('system', obj, onlineCount, 'logout'); } }); //接收新信息 socket.on('postMsg', function(msg, color) { socket.broadcast.emit('newMsg', onlineUsers[socket.name], msg, color); }); //接收新私信(P2P) socket.on('privateMsg', function(msg, color, userid) { socketList[userid].emit('newMsg', onlineUsers[socket.name], msg, color); }); //接收新图片 socket.on('img', function(imgData, color) { socket.broadcast.emit('newImg', onlineUsers[socket.name], imgData, color); }); //接收新私人图片(P2P) socket.on('privateimg', function(imgData, color, userid) { socketList[userid].emit('newImg', onlineUsers[socket.name], imgData, color); }); });
学过JS的小伙伴们肯定都能看得懂大概的意思吧,如果不明白socket.io中的服务器推技术是怎么实现的,可以百度一下相关文档,这里就不多加赘述了。现在我们来看客户端的代码实现:
window.onload = function() { var rdChat = new RdChat(); rdChat.init(); }; var RdChat = function() { this.socket = null; }; RdChat.prototype = { init: function() { var that = this 4000 ; var userList = {};//用户列表 var userCount = null;//用户数 this.socket = io.connect(); this.socket.on('connect', function() {//用户登录 document.getElementById('info').textContent = 'get yourself a nickname :)'; document.getElementById('nickWrapper').style.display = 'block'; document.getElementById('nicknameInput').focus(); }); this.socket.on('nickExisted', function() { document.getElementById('info').textContent = '!nickname is taken, choose another pls'; }); this.socket.on('loginSuccess', function(o) { document.title = 'RdChat | ' + document.getElementById('nicknameInput').value; this.userList = o.onlineUsers; that._initUserList(o.onlineUsers); document.getElementById('loginWrapper').style.display = 'none'; document.getElementById('messageInput').focus(); }); this.socket.on('error', function(err) { if (document.getElementById('loginWrapper').style.display == 'none') { document.getElementById('status').textContent = '!fail to connect :('; } else { document.getElementById('info').textContent = '!fail to connect :('; } }); this.socket.on('system', function(obj, userCount, type) { var msg = obj.nickname + (type == 'login' ? ' joined' : ' left'); that._displayNewMsg('system ', msg, 'red'); if(type == 'login' && !this.userList.hasOwnProperty(obj.userid)){ that._updateUserList(obj); } if(document.getElementById('userlist').value == ""){ document.getElementById('status').textContent = userCount + (userCount > 1 ? ' users' : ' user') + ' online'; }else{ if(document.getElementById('userlist').value == obj.userid){ document.getElementById('status').textContent = obj.nickname + " " + type; } } }); this.socket.on('newMsg', function(user, msg, color) { that._displayNewMsg(user, msg, color); }); this.socket.on('newImg', function(user, img, color) { that._displayImage(user, img, color); }); document.getElementById('loginBtn').addEventListener('click', function() {//监听登录按钮的click事件 var nickName = document.getElementById('nicknameInput').value; var userid = that._getUid(); if (nickName.trim().length != 0) { that.socket.emit('login', {userid:userid, nickname:nickName}); } else { document.getElementById('nicknameInput').focus(); }; }, false); document.getElementById('nicknameInput').addEventListener('keyup', function(e) {//监听回车键事件 if (e.keyCode == 13) { var nickName = document.getElementById('nicknameInput').value; var userid = that._getUid(); if (nickName.trim().length != 0) { that.socket.emit('login', {userid:userid, nickname:nickName}); }; }; }, false); document.getElementById('sendBtn').addEventListener('click', function() {//监听消息发送的click事件 var messageInput = document.getElementById('messageInput'), msg = messageInput.value, color = document.getElementById('colorStyle').value; messageInput.value = ''; messageInput.focus(); if (msg.trim().length != 0) { if(document.getElementById('userlist').value == ''){//判断是广播消息还是私聊 that.socket.emit('postMsg', msg, color); that._displayNewMsg('me', msg, color); return; }else{ that.socket.emit('privateMsg', msg, color, document.getElementById('userlist').value); that._displayNewMsg('me', msg, color); return; } }; }, false); document.getElementById('messageInput').addEventListener('keyup', function(e) {//监听键盘事件(回车发送消息) var messageInput = document.getElementById('messageInput'), msg = messageInput.value, color = document.getElementById('colorStyle').value; if (e.keyCode == 13 && msg.trim().length != 0) { messageInput.value = ''; if(document.getElementById('userlist').value == ''){ that.socket.emit('postMsg', msg, color); that._displayNewMsg('me', msg, color); }else{ that.socket.emit('privateMsg', msg, color, document.getElementById('userlist').value); that._displayNewMsg('me', msg, color); } }; }, false); document.getElementById('clearBtn').addEventListener('click', function() { document.getElementById('historyMsg').innerHTML = ''; }, false); document.getElementById('sendImage').addEventListener('change', function() {//发送文件按钮监听事件 if (this.files.length != 0) { var file = this.files[0], reader = new FileReader(), color = document.getElementById('colorStyle').value; if (!reader) { that._displayNewMsg('system', '!your browser doesn\'t support fileReader', 'red'); this.value = ''; return; }; reader.onload = function(e) { this.value = ''; if(document.getElementById('userlist').value == ""){ that.socket.emit('img', e.target.result, color); that._displayImage('me', e.target.result, color);//在聊天窗口显示自己发送的图片 }else{ that.socket.emit('privateimg', e.target.result, color, document.getElementById('userlist').value); that._displayImage('me', e.target.result, color); } }; reader.readAsDataURL(file); }; }, false); this._initialEmoji();//初始化聊天表情 document.getElementById('emoji').addEventListener('click', function(e) {//发送聊天表情 var emojiwrapper = document.getElementById('emojiWrapper'); emojiwrapper.style.display = 'block'; e.stopPropagation(); }, false); document.body.addEventListener('click', function(e) { var emojiwrapper = document.getElementById('emojiWrapper'); if (e.target != emojiwrapper) { emojiwrapper.style.display = 'none'; }; }); document.getElementById('emojiWrapper').addEventListener('click', function(e) { var target = e.target; if (target.nodeName.toLowerCase() == 'img') { var messageInput = document.getElementById('messageInput'); messageInput.focus(); messageInput.value = messageInput.value + '[emoji:' + target.title + ']'; }; }, false); }, _initialEmoji: function() {//创建表情的html内容 var emojiContainer = document.getElementById('emojiWrapper'), docFragment = document.createDocumentFragment(); for (var i = 69; i > 0; i--) { var emojiItem = document.createElement('img'); emojiItem.src = '../content/emoji/' + i + '.gif'; 18189 emojiItem.title = i; docFragment.appendChild(emojiItem); }; emojiContainer.appendChild(docFragment); }, _displayNewMsg: function(user, msg, color) {//展示新信息 var container = document.getElementById('historyMsg'), msgToDisplay = document.createElement('p'), date = new Date().toTimeString().substr(0, 8), //判断消息中是否包含表情 msg = this._showEmoji(msg); msgToDisplay.style.color = color || '#000'; msgToDisplay.innerHTML = user + '<span class="timespan">(' + date + '): </span>' + msg; container.appendChild(msgToDisplay); container.scrollTop = container.scrollHeight; }, _displayImage: function(user, imgData, color) { var container = document.getElementById('historyMsg'), msgToDisplay = document.createElement('p'), date = new Date().toTimeString().substr(0, 8); msgToDisplay.style.color = color || '#000'; msgToDisplay.innerHTML = user + '<span class="timespan">(' + date + '): </span> <br/>' + '<a href="' + imgData + '" target="_blank"><img src="' + imgData + '"/></a>'; container.appendChild(msgToDisplay); container.scrollTop = container.scrollHeight; }, _showEmoji: function(msg) { var match, result = msg, reg = /\[emoji:\d+\]/g, emojiIndex, totalEmojiNum = document.getElementById('emojiWrapper').children.length; while (match = reg.exec(msg)) { emojiIndex = match[0].slice(7, -1); if (emojiIndex > totalEmojiNum) { result = result.replace(match[0], '[X]'); } else { result = result.replace(match[0], '<img class="emoji" src="../content/emoji/' + emojiIndex + '.gif" />');//todo:fix this in chrome it will cause a new request for the image }; }; return result; }, _getUid:function(){//得到用户标识 return new Date().getTime()+""+Math.floor(Math.random()*899+100); }, _updateUserList:function(obj){//更新用户列表 var container = document.getElementById('userlist'), optDisplay = document.createElement('option'); optDisplay.style.color = '#000'; optDisplay.value = obj.userid; optDisplay.innerHTML = obj.nickname; container.appendChild(optDisplay); container.scrollTop = container.scrollHeight; }, _initUserList:function(userList){//初始化用户列表 var container = document.getElementById('userlist'); for(key in userList) { if(userList.hasOwnProperty(key)){ var optDisplay = document.createElement('option'); optDisplay.style.color = '#000'; optDisplay.value = key; optDisplay.innerHTML = userList[key]; container.appendChild(optDisplay); container.scrollTop = container.scrollHeight; } } } };
最后我们来看看聊天展示页面的html代码吧~
<!doctype html> <html> <head> <meta charset="utf-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge,chrome=1"> <meta name="author" content="Xj"> <meta name="description" content="RdChat"> <meta name="viewport" content="width=device-width, initial-scale=1"> <title>RdChat</title> <link rel="stylesheet" href="styles/main.css"> <link rel="shortcut icon" href="favicon.ico" type="image/x-icon"> <link rel="icon" href="favicon.ico" type="image/x-icon"> </head> <body> <div class="wrapper"> <div class="left"> <div class="banner"> <span id="status"></span> </div> <div id="historyMsg"> </div> <div class="controls" > <div class="items"> <input id="colorStyle" type="color" placeHolder='#000' title="font color" /> <input id="emoji" type="button" value="emoji" title="emoji" /> <label for="sendImage" class="imageLable"> <input type="button" value="image" /> <input id="sendImage" type="file" value="image"/> </label> <input id="clearBtn" type="button" value="clear" title="clear screen" /> </div> <textarea id="messageInput" placeHolder="enter to send"></textarea> <input id="sendBtn" type="button" value="SEND"> <div id="emojiWrapper"> </div> </div> </div> <!--右侧联系人列表--> <div class="right"> <select name="select" id="userlist" size="15" id="select"> </select> </div> </div> <div id="loginWrapper"> <p id="info">connecting to server...</p> <div id="nickWrapper"> <input type="text" placeHolder="nickname" id="nicknameInput" /> <input type="button" value="OK" id="loginBtn" /> </div> </div> <script src="/socket.io/socket.io.js"></script><!--一定要这个js--> <script src="scripts/rdChat.js"></script> </body> </html>
至此,一个简单的WebIM就完成了。(这个代码经过测试,可以直接复制运行,至于样式,自己做一下吧,哈哈~)
谢谢大大的文章给我的启发:http://www.cnblogs.com/Wayou/p/hichat_built_with_nodejs_socket.html
在此感谢!!!小伙伴们有什么宝贵的意见也可以在评论区跟我交流,感激不尽哦~
最后,为了让我们更清楚即时通讯其中的工作原理,我们来了解几个web通信中比较重要的概念知识吧:(摘自博主hoojo的http://www.cnblogs.com/hoojo/p/longPolling_comet_jquery_iframe_ajax.html。)
Web 通信 之 长连接、长轮询(long polling)
基于HTTP的长连接,是一种通过长轮询方式实现”服务器推”的技术,它弥补了HTTP简单的请求应答模式的不足,极大地增强了程序的实时性和交互性。
一、什么是长连接、长轮询?
用通俗易懂的话来说,就是客户端不停的向服务器发送请求以获取最新的数据信息。这里的“不停”其实是有停止的,只是我们人眼无法分辨是否停止,它只是一种快速的停下然后又立即开始连接而已。
二、长连接、长轮询的应用场景
长连接、长轮询一般应用与WebIM、ChatRoom和一些需要及时交互的网站应用中。其真实案例有:WebQQ、Hi网页版、Facebook IM等。
如果你对服务器端的反向Ajax感兴趣,可以参考这篇文章 DWR 反向Ajax 服务器端推的方式:http://www.cnblogs.com/hoojo/category/276235.html
三、优缺点
轮询:客户端定时向服务器发送Ajax请求,服务器接到请求后马上返回响应信息并关闭连接。
优点:后端程序编写比较容易。
缺点:请求中有大半是无用,浪费带宽和服务器资源。
实例:适于小型应用。
长轮询:客户端向服务器发送Ajax请求,服务器接到请求后hold住连接,直到有新消息才返回响应信息并关闭连接,客户端处理完响应信息后再向服务器发送新的请求。
优点:在无消息的情况下不会频繁的请求,耗费资源小。
缺点:服务器hold连接会消耗资源,返回数据顺序无保证,难于管理维护。
实例:WebQQ、Hi网页版、Facebook IM。
长连接:在页面里嵌入一个隐蔵iframe,将这个隐蔵iframe的src属性设为对一个长连接的请求或是采用xhr请求,服务器端就能源源不断地往客户端输入数据。
优点:消息即时到达,不发无用请求;管理起来也相对方便。
缺点:服务器维护一个长连接会增加开销。
实例:Gmail聊天
Flash Socket:在页面中内嵌入一个使用了Socket类的 Flash 程序JavaScript通过调用此Flash程序提供的Socket接口与服务器端的Socket接口进行通信,JavaScript在收到服务器端传送的信息后控制页面的显示。
优点:实现真正的即时通信,而不是伪即时。
缺点:客户端必须安装Flash插件;非HTTP协议,无法自动穿越防火墙。
实例:网络互动游戏。
四、实现原理
所谓长连接,就是要在客户端与服务器之间创建和保持稳定可靠的连接。其实它是一种很早就存在的技术,但是由于浏览器技术的发展比较缓慢,没有为这种机制的实现提供很好的支持。所以要达到这种效果,需要客户端和服务器的程序共同配合来完成。通常的做法是,在服务器的程序中加入一个死循环,在循环中监测数据的变动。当发现新数据时,立即将其输出给浏览器并断开连接,浏览器在收到数据后,再次发起请求以进入下一个周期,这就是常说的长轮询(long-polling)方式。如下图所示,它通常包含以下几个关键过程:
1. 轮询的建立
建立轮询的过程很简单,浏览器发起请求后进入循环等待状态,此时由于服务器还未做出应答,所以HTTP也一直处于连接状态中。
2. 数据的推送
在循环过程中,服务器程序对数据变动进行监控,如发现更新,将该信息输出给浏览器,随即断开连接,完成应答过程,实现“服务器推”。
3. 轮询的终止
轮询可能在以下3种情况时终止:
3.1. 有新数据推送
当循环过程中服务器向浏览器推送信息后,应该主动结束程序运行从而让连接断开,这样浏览器才能及时收到数据。
3.2. 没有新数据推送
循环不能一直持续下去,应该设定一个最长时限,避免WEB服务器超时(Timeout),若一直没有新信息,服务器应主动向浏览器发送本次轮询无新信息的正常响应,并断开连接,这也被称为“心跳”信息。
3.3. 网络故障或异常
由于网络故障等因素造成的请求超时或出错也可能导致轮询的意外中断,此时浏览器将收到错误信息。
4. 轮询的重建
浏览器收到回复并进行相应处理后,应马上重新发起请求,开始一个新的轮询周期。
五、程序设计
1、普通轮询 Ajax方式
客户端代码片段
<%@ page language="java" import="java.util.*" pageEncoding="UTF-8" isELIgnored="false" %> <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"> <html> <head> <meta http-equiv="pragma" content="no-cache"> <meta http-equiv="cache-control" content="no-cache"> <meta http-equiv="author" content="hoojo & http://hoojo.cnblogs.com"> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <%@ include file="/tags/jquery-lib.jsp"%> <script type="text/javascript"> $(function () { window.setInterval(function () { $.get("${pageContext.request.contextPath}/communication/user/ajax.mvc", {"timed": new Date().getTime()}, function (data) { $("#logs").append("[data: " + data + " ]<br/>"); }); }, 3000); }); </script> </head> <body> <div id="logs"></div> </body> </html>
客户端实现的就是用一种普通轮询的结果,比较简单。利用setInterval不间断的刷新来获取服务器的资源,这种方式的优点就是简单、及时。缺点是链接多数是无效重复的;响应的结果没有顺序(因为是异步请求,当发送的请求没有返回结果的时候,后面的请求又被发送。而此时如果后面的请求比前面的请求要先返回结果,那么当前面的请求返回结果数据时已经是过时无效的数据了);请求多,难于维护、浪费服务器和网络资源。
服务器端代码
@RequestMapping("/ajax") public void ajax(long timed, HttpServletResponse response) throws Exception { PrintWriter writer = response.getWriter(); Random rand = new Random(); // 死循环 查询有无数据变化 while (true) { Thread.sleep(300); // 休眠300毫秒,模拟处理业务等 int i = rand.nextInt(100); // 产生一个0-100之间的随机数 if (i > 20 && i < 56) { // 如果随机数在20-56之间就视为有效数据,模拟数据发生变化 long responseTime = System.currentTimeMillis(); // 返回数据信息,请求时间、返回数据时间、耗时 writer.print("result: " + i + ", response time: " + responseTime + ", request time: " + timed + ", use time: " + (responseTime - timed)); break; // 跳出循环,返回数据 } else { // 模拟没有数据变化,将休眠 hold住连接 Thread.sleep(1300); } } }
服务器端实现,这里就模拟下程序监控数据的变化。上面代码属于SpringMVC 中controller中的一个方法,相当于Servlet中的一个doPost/doGet方法。如果没有程序环境适应servlet即可,将方法体中的代码copy到servlet的doGet/doPost中即可。
服务器端在进行长连接的程序设计时,要注意以下几点:
1. 服务器程序对轮询的可控性
由于轮询是用死循环的方式实现的,所以在算法上要保证程序对何时退出循环有完全的控制能力,避免进入死循环而耗尽服务器资源。
2. 合理选择“心跳”频率
从图1可以看出,长连接必须由客户端不停地进行请求来维持,所以在客户端和服务器间保持正常的“心跳”至为关键,参数POLLING_LIFE应小于WEB服务器的超时时间,一般建议在10~20秒左右。
3. 网络因素的影响
在实际应用时,从服务器做出应答,到下一次循环的建立,是有时间延迟的,延迟时间的长短受网络传输等多种因素影响,在这段时间内,长连接处于暂时断开的空档,如果恰好有数据在这段时间内发生变动,服务器是无法立即进行推送的,所以,在算法设计上要注意解决由于延迟可能造成的数据丢失问题。
4. 服务器的性能
在长连接应用中,服务器与每个客户端实例都保持一个持久的连接,这将消耗大量服务器资源,特别是在一些大型应用系统中更是如此,大量并发的长连接有可能导致新的请求被阻塞甚至系统崩溃,所以,在进行程序设计时应特别注意算法的优化和改进,必要时还需要考虑服务器的负载均衡和集群技术。
上图是返回的结果,可以看到先发出请求,不一定会最先返回结果。这样就不能保证顺序,造成脏数据或无用的连接请求。可见对服务器或网络的资源浪费。
2、普通轮询 iframe方式
<%@ page language="java" import="java.util.*" pageEncoding="UTF-8" isELIgnored="false" %> <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"> <html> <head> <meta http-equiv="pragma" content="no-cache"> <meta http-equiv="cache-control" content="no-cache"> <meta http-equiv="expires" content="0"> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <%@ include file="/tags/jquery-lib.jsp"%> <script type="text/javascript"> $(function () { window.setInterval(function () { $("#logs").append("[data: " + $($("#frame").get(0).contentDocument).find("body").text() + " ]<br/>"); $("#frame").attr("src", "${pageContext.request.contextPath}/communication/user/ajax.mvc?timed=" + new Date().getTime()); // 延迟1秒再重新请求 window.setTimeout(function () { window.frames["polling"].location.reload(); }, 1000); }, 5000); }); </script> </head> <body> <iframe id="frame" name="polling" style="display: none;"></iframe> <div id="logs"></div> </body> </html>
这里的客户端程序是利用隐藏的iframe向服务器端不停的拉取数据,将iframe获取后的数据填充到页面中即可。同ajax实现的基本原理一样,唯一不同的是当一个请求没有响应返回数据的情况下,下一个请求也将开始,这时候前面的请求将被停止。如果要使程序和上面的ajax请求一样也可以办到,那就是给每个请求分配一个独立的iframe即可。下面是返回的结果:
其中红色是没有成功返回请求就被停止(后面请求开始)掉的请求,黑色是成功返回数据的请求。
3、长连接iframe方式
<%@ page language="java" import="java.util.*" pageEncoding="UTF-8" isELIgnored="false" %> <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"> <html> <head> <meta http-equiv="pragma" content="no-cache"> <meta http-equiv="cache-control" content="no-cache"> <meta http-equiv="author" content="hoojo & http://hoojo.cnblogs.com"> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <%@ include file="/tags/jquery-lib.jsp"%> <script type="text/javascript"> $(function () { window.setInterval(function () { var url = "${pageContext.request.contextPath}/communication/user/ajax.mvc?timed=" + new Date().getTime(); var $iframe = $('<iframe id="frame" name="polling" style="display: none;" src="' + url + '"></iframe>'); $("body").append($iframe); $iframe.load(function () { $("#logs").append("[data: " + $($iframe.get(0).contentDocument).find("body").text() + " ]<br/>"); $iframe.remove(); }); }, 5000); }); </script> </head> <body> <div id="logs"></div> </body> </html>
这个轮询方式就是把刚才上面的稍微改下,每个请求都有自己独立的一个iframe,当这个iframe得到响应的数据后就把数据push到当前页面上。使用此方法已经类似于ajax的异步交互了,这种方法也是不能保证顺序的、比较耗费资源、而且总是有一个加载的条在地址栏或状态栏附件(当然要解决可以利用htmlfile,Google的攻城师们已经做到了,网上也有封装好的lib库),但客户端实现起来比较简单。
如果要保证有序,可以不使用setInterval,将创建iframe的方法放在load事件中即可,即使用递归方式。调整后的代码片段如下:
<script type="text/javascript"> $(function () { (function iframePolling() { var url = "${pageContext.request.contextPath}/communication/user/ajax.mvc?timed=" + new Date().getTime(); var $iframe = $('<iframe id="frame" name="polling" style="display: none;" src="' + url + '"></iframe>'); $("body").append($iframe); $iframe.load(function () { $("#logs").append("[data: " + $($iframe.get(0).contentDocument).find("body").text() + " ]<br/>"); $iframe.remove(); // 递归 iframePolling(); }); })(); }); </script>
这种方式虽然保证了请求的顺序,但是它不会处理请求延时的错误或是说很长时间没有返回结果的请求,它会一直等到返回请求后才能创建下一个iframe请求,总会和服务器保持一个连接。和以上轮询比较,缺点就是消息不及时,但保证了请求的顺序。
4、ajax实现长连接
<%@ page language="java" import="java.util.*" pageEncoding="UTF-8" isELIgnored="false" %> <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01 Transitional//EN"> <html> <head> <meta http-equiv="pragma" content="no-cache"> <meta http-equiv="cache-control" content="no-cache"> <meta http-equiv="expires" content="0"> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <%@ include file="/tags/jquery-lib.jsp"%> <script type="text/javascript"> $(function () { (function longPolling() { $.ajax({ url: "${pageContext.request.contextPath}/communication/user/ajax.mvc", data: {"timed": new Date().getTime()}, dataType: "text", timeout: 5000, error: function (XMLHttpRequest, textStatus, errorThrown) { $("#state").append("[state: " + textStatus + ", error: " + errorThrown + " ]<br/>"); if (textStatus == "timeout") { // 请求超时 longPolling(); // 递归调用 // 其他错误,如网络错误等 } else { longPolling(); } }, success: function (data, textStatus) { $("#state").append("[state: " + textStatus + ", data: { " + data + "} ]<br/>"); if (textStatus == "success") { // 请求成功 longPolling(); } } }); })(); }); </script> </head> <body>
上面这段代码就是才有Ajax的方式完成长连接,主要优点就是和服务器始终保持一个连接。如果当前连接请求成功后,将更新数据并且继续创建一个新的连接和服务器保持联系。如果连接超时或发生异常,这个时候程序也会创建一个新连接继续请求。这样就大大节省了服务器和网络资源,提高了程序的性能,从而也保证了程序的顺序。
六、总结
现代的浏览器都支持跨域资源共享(Cross-Origin Resource Share,CORS)规范,该规范允许XHR执行跨域请求,因此基于脚本的和基于iframe的技术已成为了一种过时的需要。
把Comet做为反向Ajax的实现和使用的最好方式是通过XMLHttpRequest对象,该做法提供了一个真正的连接句柄和错误处理。当然你选择经由HTTP长轮询使用XMLHttpRequest对象(在服务器端挂起的一个简单的Ajax请求)的Comet模式,所有支持Ajax的浏览器也都支持该种做法。
基于HTTP的长连接技术,是目前在纯浏览器环境下进行即时交互类应用开发的理想选择,随着浏览器的快速发展,html5将为其提供更好的支持和更广泛的应用。在html5中有一个websocket 可以很友好的完成长连接这一技术,网上也有相关方面的资料,这里也就不再做过多介绍。
相关文章推荐
- Node.js权威指南 (14) - 使用Express构建Web应用程序
- 使用Node.js + Mongodb构建基于Cloud Foundry的项目
- 使用Node.js构建命令行工具
- 在Mac OS上安装使用Node.js的项目自动化构建工具Gulp
- 在Maven+Spring项目中使用Node.js的Gulp进行前端自动化构建
- 使用基于Node.js的构建工具Grunt来发布ASP.NET MVC项目
- 使用 Node.js 和 IBM Mobile Web Push 构建一个实时的营销应用程序
- 第8章-使用Express.js和Hapi构建Node.js-REST-API服务-8.3.使用Express和Mongoskin实现REST API服务器
- 使用electron构建跨平台Node.js桌面应用
- Linux环境使用Node.js构建访问静态网页的服务
- 使用electron构建跨平台Node.js桌面应用
- 使用Node.js + Mongodb构建基于Cloud Foundry的项目
- 第8章-使用Express.js和Hapi构建Node.js-REST-API服务-8.4.重构:使用Hapi搭建REST API服务器
- [转载]使用electron构建跨平台Node.js桌面应用经验分享
- 【Node.js学习笔记】使用Gulp项目自动化构建工具
- 第8章-使用Express.js和Hapi构建Node.js-REST-API服务-8.5.小结
- 在Maven+Spring项目中使用Node.js的Gulp进行前端自动化构建
- 第8章-使用Express.js和Hapi构建Node.js-REST-API服务-8.1.REST(表述性状态传递)Representational State Transfer
- 使用 Node.js、Express、AngularJS 和 MongoDB 构建一个Web程序
- 在Maven+Spring项目中使用Node.js的Gulp进行前端自动化构建