使用socket.io打造公共聊天室
最近的计算机网络课上老师开始讲socket,tcp相关的知识,当时脑袋里就蹦出一个想法,那就是打造一个聊天室。实现方式也挺多的,常见的可以用C++或者Java进行socket编程来构建这么一个聊天室。当然,我毫不犹豫选择了node来写,node有一个名叫socket.io的框架已经很完善的封装了socket相关API,所以无论是学习还是使用都是非常容易上手的,在这里强烈推荐!demo已经做好并放到我的个人网站了,大家可以试试,挺好玩的。
进去试试 -> http://www.yinxiangyu.com:9000 (改编了socket.io官方提供的例子)
源码 -> https://github.com/yxy19950717/js-practice-demo/tree/master/2016-4/chat
在梳理整个demo之前,先来看看聊天室构建所要用到的原理性的东西。
何为socket
首先要很明确web聊天室客户端是如何与服务器进行通信的。没错,正是socket(套接字)对这样的通信负责。打个比方,如果你正使用你的计算机浏览页面,并且打开了1个telnet和1个ssh会话,那样你就有3个应用进程。当你的计算机中的运输层(tcp,udp)从底层的网络层接收数据时,它需要将接收到的数据定向到三个进程中的一个。而每个进程都有一个或多个套接字,它相当于从网络向进程传递数据和从进程向网络传递数据的门户。
如上图,在接收端,运输层检查报文段中的字段,标识出接收套接字,进而将报文定向该套接字。这样将运输层报文段中的数据交付到正确的套接字的工作称为多路分解。同样在源主机从不同套接字中收集数据块,并为每个数据封装上首部信息(用于分解)从而生成报文段,然后将报文段传递到网络层,这样的工作叫做多路复用。
WebSocket与HTTP
了解完socket套接字的基本原理,可以知道socket始终不是应用层的东西,它是连接应用层与传输层的一个桥梁,那从实现角度上考虑,我们应该如何来编写聊天室这样一个应用呢?
HTTP是无状态的协议,何为无状态?就是指HTTP服务器并不保存关于客户的任何信息。因为TCP为HTTP提供了可靠数据传输服务,意味着一个客户进程发出的每个HTTP请求报文都能完整地到达服务器。HTTP的无状态的特点源于分层体系结构,它的优点也很明显,不用担心数据丢失。但也会出现这样的现象:服务器向客户发送被请求的文件,而不存储任何关于该客户的状态信息。也就是说当一个客户端接连两次请求同一个文件,服务器并不会因为刚刚为该客户提供了该文件而不再做出反应,而是重新发送,HTTP不记得之前做过什么事了!
当然在传统的HTTP应用中,客户端和服务器端时而需要在一个相当长的时间内进行通信,通常会带上cookie进行认证通信,而长时间保持一个连接,会耗费时间和带宽,这样一来,性能会不是很好,而聊天室需要的是实时通信,所以我们更需要WebSocket这样的协议。(部分浏览器还不支持WebSocket,在不是很追求实时的情况下,仍然可以采用HTTP中ajax的方式进行通信)。
WebSocket是html5的一个新协议,它的出现主要是为了解决ajax轮询和long poll时给服务器带来的压力。在HTTP中,通过ajax轮询和Long poll是不断监听服务器是否有新消息,而在WebSocket中,每当服务器有新消息时才会推送,而且它能与代理服务器(一般来说是nginx或者apache)保持长久连接,但与HTTP不同的是,它只需要一次请求即可保持连接。
而对于socket.io这个框架,它兼容了WebSocket以及HTTP两种协议的使用,在部分不能使用WebSocket协议的浏览器中,采用ajax轮询方式进行消息交换。
若想对WebSocket做更多了解,可以阅读此文: WebSocket 是什么原理?为什么可以实现持久连接?
使用socket.io
socket.io是一个完全由JavaScript实现、基于Node.js、支持WebSocket的协议用于实时通信、跨平台的开源框架,它包括了客户端的JavaScript和服务器端的Node.js。Socket.IO除了支持WebSocket通讯协议外,还支持许多种轮询(Polling)机制以及其它实时通信方式,并封装成了通用的接口,并且在服务端实现了这些实时机制的相应代码。Socket.IO实现的Polling通信机制包括Adobe Flash Socket、AJAX长轮询、AJAX multipart streaming、持久Iframe、JSONP轮询等。Socket.IO能够根据浏览器对通讯机制的支持情况自动地选择最佳的方式来实现网络实时应用。
有了这样一个框架,对于了解socket编程的你相信运用起来会非常容易上手了。socket.io的API可以在以下两个网站上进行学习
github: https://github.com/socketio/socket.io
要打造一个聊天室应用,首先确定聊天中服务器需要接收的几个事件响应,分为如下几点:
1.新用户进来时 ('add user')
2.用户正在输入时 ('typing')
3.用户停止输入时 ('stop typing')
4.用户发送消息时 ('new message')
5.用户离开时 ('disconnect')
其次是客户端的用户(们)需要接收到的事件响应:
1.我进来了 ('login')
2.有人进来了 ('user joined')
3.有人正在输入 ('typing')
4.有人停止了输入 ('stop typing')
5.有人发送了新消息 ('new message')
6.有人离开了 ('user left')
接下来我们需要用socket的on和emit接口进行编写,服务器端代码如下:
index.js:
1 // Setup basic express server 2 var express = require('express'); 3 var app = express(); 4 var server = require('http').createServer(app); 5 var io = require('socket.io')(server); 6 var port = process.env.PORT || 9000; 7 8 server.listen(port, function () { 9 console.log('Server listening at port %d', port); 10 }); 11 12 //路由,链接到public,访问时直接访问到index.html 13 app.use(express.static(__dirname + '/public')); 14 15 // Chatroom 16 17 // 在线人数 18 var numUsers = 0; 19 20 // 连接打开 21 io.on('connection', function (socket) { 22 var addedUser = false; 23 24 // when the client emits 'new message', this listens and executes 25 // 接收到客户端发送的new message 26 socket.on('new message', function (data) { 27 socket.pic = data.pic; 28 // we tell the client to execute 'new message' 29 // 广播发送new message 到客户端 30 socket.broadcast.emit('new message', { 31 username: socket.username, 32 message: data.message, 33 pic: socket.pic 34 }); 35 }); 36 37 // when the client emits 'add user', this listens and executes 38 // 有新用户进入时 39 socket.on('add user', function (username) { 40 if (addedUser) return; 41 42 // we store the username in the socket session for this client 43 // 将名字保存在socket的session中 44 socket.username = username; 45 ++numUsers; 46 addedUser = true; 47 socket.emit('login', { 48 numUsers: numUsers 49 }); 50 // echo globally (all clients) that a person has connected 51 // 广播发送user joined到客户端 52 socket.broadcast.emit('user joined', { 53 username: socket.username, 54 numUsers: numUsers 55 }); 56 }); 57 58 // when the client emits 'typing', we broadcast it to others 59 // 接收到xxx输入的消息 60 socket.on('typing', function (data) { 61 // 广播发送typing到客户端 62 socket.broadcast.emit('typing', { 63 username: socket.username, 64 pic: data.pic 65 }); 66 }); 67 68 // when the client emits 'stop typing', we broadcast it to others 69 socket.on('stop typing', function () { 70 socket.broadcast.emit('stop typing', { 71 username: socket.username 72 }); 73 }); 74 75 // when the user disconnects.. perform this 76 socket.on('disconnect', function () { 77 if (addedUser) { 78 --numUsers; 79 80 // echo globally that this client has left 81 socket.broadcast.emit('user left', { 82 username: socket.username, 83 numUsers: numUsers 84 }); 85 } 86 }); 87 });
在客户端,也必须有接收发送消息的脚本
main.js:
1 $(function() { 2 var FADE_TIME = 150; // ms 3 var TYPING_TIMER_LENGTH = 400; // ms 4 var COLORS = [ 5 '#e21400', '#91580f', '#f8a700', '#f78b00', 6 '#58dc00', '#287b00', '#a8f07a', '#4ae8c4', 7 '#3b88eb', '#3824aa', '#a700ff', '#d300e7' 8 ]; 9 // Initialize variables 10 var $document = $(document); 11 var $usernameInput = $('.usernameInput'); // Input for username 12 var $messages = $('.messages'); // Messages area 13 var $inputMessage = $('.inputMessage'); // Input message input box 14 15 var $loginPage = $('.login.page'); // The login page 16 var $chatPage = $('.chat.page'); // The chatroom page 17 18 // 选头像 19 20 var $headPic = $('.headPic li'); 21 22 // Prompt for setting a username 23 var username; 24 var connected = false; 25 var typing = false; 26 var lastTypingTime; 27 var yourHeadPic; 28 // 直接聚焦到输入框 29 var $currentInput = $usernameInput.focus(); 30 31 var socket = io(); 32 33 function addParticipantsMessage (data) { 34 var message = ''; 35 if (data.numUsers === 1) { 36 message += "there's 1 participant"; 37 } else { 38 message += "there are " + data.numUsers + " participants"; 39 } 40 log(message); 41 } 42 43 // Sets the client's username 44 function setUsername () { 45 username = cleanInput($usernameInput.val().trim()); 46 47 // If the username is valid 48 if (username) { 49 $loginPage.fadeOut(); 50 $chatPage.show(); 51 $loginPage.off('click'); 52 $currentInput = $inputMessage.focus(); 53 54 // Tell the server your username 55 socket.emit('add user', username); 56 } 57 } 58 59 // Sends a chat message 60 function sendMessage () { 61 var message = $inputMessage.val(); 62 // Prevent markup from being injected into the message 63 message = cleanInput(message); 64 // if there is a non-empty message and a socket connection 65 // 显示自己 66 if (message && connected) { 67 $inputMessage.val(''); 68 addChatMessage({ 69 pic: yourHeadPic, 70 username: username, 71 message: message, 72 owner: true 73 }); 74 // tell server to execute 'new message' and send along one parameter 75 socket.emit('new message', { 76 message: message, 77 pic: yourHeadPic 78 }); 79 } 80 } 81 82 83 84 // Log a message 85 function log (message, options) { 86 var $el = $('<li>').addClass('log').text(message); 87 addMessageElement($el, options); 88 } 89 90 // Adds the visual chat message to the message list 91 function addChatMessage (data, options) { 92 // Don't fade the message in if there is an 'X was typing' 93 var $typingMessages = getTypingMessages(data); 94 options = options || {}; 95 if ($typingMessages.length !== 0) { 96 options.fade = false; 97 $typingMessages.remove(); 98 } 99 // 选中的头像 100 if(data.owner) { 101 //自己的话在右边 102 var $img = $('<span class="myHeadPicRight"><img src='+data.pic+'.png></span>'); 103 104 var $usernameDiv = $('<span class="yourUsername"/>') 105 .text(data.username) 106 .css('color', getUsernameColor(data.username)); 107 var $messageBodyDiv = $('<span class="messageBody">') 108 .css('float', 'right') 109 .css('padding-right', '15px') 110 .text(data.message); 111 112 var $rightDiv = $('<p style="float:right; width:90%">') 113 .append($usernameDiv, $messageBodyDiv); 114 var typingClass = data.typing ? 'typing' : ''; 115 var $messageDiv = $('<li class="message clearfix"/>') 116 .data('username', data.username) 117 .addClass(typingClass) 118 .append($img, $rightDiv); 119 120 addMessageElement($messageDiv, options); 121 }else{ 122 var $img = $('<span class="myHeadPic"><img src='+data.pic+'.png></span>'); 123 124 var $usernameDiv = $('<span class="username"/>') 125 .text(data.username) 126 .css('color', getUsernameColor(data.username)); 127 var $messageBodyDiv = $('<span class="messageBody">') 128 .text(data.message); 129 130 var $rightDiv = $('<p style="float:left; width:90%">') 131 .append($usernameDiv, $messageBodyDiv); 132 var typingClass = data.typing ? 'typing' : ''; 133 var $messageDiv = $('<li class="message clearfix"/>') 134 .data('username', data.username) 135 .addClass(typingClass) 136 .append($img, $rightDiv); 137 138 addMessageElement($messageDiv, options); 139 } 140 } 141 142 // Adds the visual chat typing message 143 function addChatTyping (data) { 144 data.typing = true; 145 data.message = '正在输入...'; 146 addChatMessage(data); 147 } 148 149 // Removes the visual chat typing message 150 function removeChatTyping (data) { 151 getTypingMessages(data).fadeOut(function () { 152 $(this).remove(); 153 }); 154 } 155 156 // Adds a message element to the messages and scrolls to the bottom 157 // el - The element to add as a message 158 // options.fade - If the element should fade-in (default = true) 159 // options.prepend - If the element should prepend 160 // all other messages (default = false) 161 function addMessageElement (el, options) { 162 var $el = el; 163 164 // Setup default options 165 if (!options) { 166 options = {}; 167 } 168 if (typeof options.fade === 'undefined') { 169 options.fade = true; 170 } 171 if (typeof options.prepend === 'undefined') { 172 options.prepend = false; 173 } 174 175 // Apply options 176 if (options.fade) { 177 $el.hide().fadeIn(FADE_TIME); 178 } 179 if (options.prepend) { 180 $messages.prepend($el); 181 } else { 182 $messages.append($el); 183 } 184 $messages[0].scrollTop = $messages[0].scrollHeight; 185 } 186 187 // Prevents input from having injected markup 188 function cleanInput (input) { 189 return $('<div/>').text(input).text(); 190 } 191 192 // Updates the typing event 193 function updateTyping () { 194 if (connected) { 195 if (!typing) { 196 typing = true; 197 socket.emit('typing',{ 198 pic: yourHeadPic 199 }); 200 } 201 lastTypingTime = (new Date()).getTime(); 202 203 setTimeout(function () { 204 var typingTimer = (new Date()).getTime(); 205 var timeDiff = typingTimer - lastTypingTime; 206 if (timeDiff >= TYPING_TIMER_LENGTH && typing) { 207 socket.emit('stop typing'); 208 typing = false; 209 } 210 }, TYPING_TIMER_LENGTH); 211 } 212 } 213 214 // Gets the 'X is typing' messages of a user 215 function getTypingMessages (data) { 216 return $('.typing.message').filter(function (i) { 217 return $(this).data('username') === data.username; 218 }); 219 } 220 221 // Gets the color of a username through our hash function 222 // hash确定名字颜色 223 function getUsernameColor (username) { 224 // Compute hash code 225 var hash = 7; 226 for (var i = 0; i < username.length; i++) { 227 hash = username.charCodeAt(i) + (hash << 5) - hash; 228 } 229 // Calculate color 230 var index = Math.abs(hash % COLORS.length); 231 return COLORS[index]; 232 } 233 234 // Keyboard events 235 $document.on('keydown',function (event) { 236 // Auto-focus the current input when a key is typed 237 // 按ctrl,alt,meta以外的键可以键入文字字母数字等... 238 if (!(event.ctrlKey || event.metaKey || event.altKey)) { 239 $currentInput.focus(); 240 } 241 // When the client hits ENTER on their keyboard 242 if (event.which === 13 ) { 243 // username已存在,已经登录 244 if (username) { 245 sendMessage(); 246 socket.emit('stop typing'); 247 typing = false; 248 } else if(!yourHeadPic) { 249 // 没有选择头像 250 alert('请选择头像!'); 251 return false; 252 } else { 253 // 首次登录 254 setUsername(); 255 } 256 } 257 }); 258 259 // 输入框一旦change就发送消息 260 $inputMessage.on('input', function() { 261 updateTyping(); 262 }); 263 264 // Click events 265 266 // Focus input when clicking anywhere on login page 267 $loginPage.click(function () { 268 $currentInput.focus(); 269 }); 270 271 // Focus input when clicking on the message input's border 272 $inputMessage.click(function () { 273 $inputMessage.focus(); 274 }); 275 276 277 // 选择头像 278 $headPic.on('click', function() { 279 var which = parseInt($(this).attr('class').slice(3))-1; 280 $('.chosePic li').each(function(i, item) { 281 $(item).children().remove(); 282 yourHeadPic = undefined; 283 }); 284 $('.chosePic li:eq(' + which + ')').append($('<span></span>')); 285 yourHeadPic = which + 1; 286 }); 287 288 // Socket events 289 290 // 客户端socket接收到Login指令 291 // Whenever the server emits 'login', log the login message 292 socket.on('login', function (data) { 293 connected = true; 294 // Display the welcome message 295 var message = "welcome to sharlly's chatroom"; 296 //传给Log函数 297 log(message, { 298 prepend: true 299 }); 300 addParticipantsMessage(data); 301 }); 302 303 // Whenever the server emits 'new message', update the chat body 304 socket.on('new message', function (data) { 305 addChatMessage(data); 306 }); 307 308 // Whenever the server emits 'user joined', log it in the chat body 309 socket.on('user joined', function (data) { 310 log(data.username + ' joined'); 311 addParticipantsMessage(data); 312 }); 313 314 // Whenever the server emits 'user left', log it in the chat body 315 socket.on('user left', function (data) { 316 log(data.username + ' left'); 317 addParticipantsMessage(data); 318 removeChatTyping(data); 319 }); 320 321 // Whenever the server emits 'typing', show the typing message 322 socket.on('typing', function (data) { 323 addChatTyping(data); 324 }); 325 326 // Whenever the server emits 'stop typing', kill the typing message 327 socket.on('stop typing', function (data) { 328 removeChatTyping(data); 329 }); 330 });
了解socket运行只需关注socket.on,socket.broadcast.emit这几个函数。socket.on提供了接收消息的方法,接收到后,其第二个参数就是回调函数,而socket.broadcast.emit是广播发送,向每个用户发送一个对象或一个字符串。到这里你可能会觉得socket.io非常简单,当然这只是它的一些功能,更多用法大家可以自行学习。
刚刚提供的这个例子改编于socket.io的官方实例,博主在写的时候对前端界面增加了头像选择,以及第一人称第三人称文字的排版布局改动,所以在main.js中可以代码有些繁杂(所以只用关注有socket.的地方),完整代码请到我的github上下载: socket.io打造的公共聊天室
最后,欢迎大家无聊的时候来我的聊天室聊天哦!