使用Node.js+Socket.IO搭建WebSocket實時應用
Web領(lǐng)域的實時推送技術(shù),也被稱作Realtime技術(shù)。這種技術(shù)要達到的目的是讓用戶不需要刷新瀏覽器就可以獲得實時更新。它有著廣泛的應用場景,比如在線聊天室、在線客服系統(tǒng)、評論系統(tǒng)、WebIM等。
WebSocket簡介
談到Web實時推送,就不得不說WebSocket。在WebSocket出現(xiàn)之前,很多網(wǎng)站為了實現(xiàn)實時推送技術(shù),通常采用的方案是輪詢 (Polling)和Comet技術(shù),Comet又可細分為兩種實現(xiàn)方式,一種是長輪詢機制,一種稱為流技術(shù),這兩種方式實際上是對輪詢技術(shù)的改進,這些 方案帶來很明顯的缺點,需要由瀏覽器對服務器發(fā)出HTTP request,大量消耗服務器帶寬和資源。面對這種狀況,HTML5定義了WebSocket協(xié)議,能更好的節(jié)省服務器資源和帶寬并實現(xiàn)真正意義上的實 時推送。
WebSocket協(xié)議本質(zhì)上是一個基于TCP的協(xié)議,它由通信協(xié)議和編程API組成,WebSocket能夠在瀏覽器和服務器之間建立雙向連接, 以基于事件的方式,賦予瀏覽器實時通信能力。既然是雙向通信,就意味著服務器端和客戶端可以同時發(fā)送并響應請求,而不再像HTTP的請求和響應。
為了建立一個WebSocket連接,客戶端瀏覽器首先要向服務器發(fā)起一個HTTP請求,這個請求和通常的HTTP請求不同,包含了一些附加頭信 息,其中附加頭信息”Upgrade: WebSocket”表明這是一個申請協(xié)議升級的HTTP請求,服務器端解析這些附加的頭信息然后產(chǎn)生應答信息返回給客戶端,客戶端和服務器端的 WebSocket連接就建立起來了,雙方就可以通過這個連接通道自由的傳遞信息,并且這個連接會持續(xù)存在直到客戶端或者服務器端的某一方主動的關(guān)閉連 接。
一個典型WebSocket客戶端請求頭:
前面講到WebSocket是HTML5中新增的一種通信協(xié)議,這意味著一部分老版本瀏覽器(主要是IE10以下版本)并不具備這個功能, 通過百度統(tǒng)計的公開數(shù)據(jù)顯示,IE8 目前仍以33%的市場份額占據(jù)榜首,好在chrome瀏覽器市場份額逐年上升,現(xiàn)在以超過26%的市場份額位居第二,同時微軟前不久宣布停止對IE6的技 術(shù)支持并提示用戶更新到新版本瀏覽器,這個曾經(jīng)讓無數(shù)前端工程師為之頭疼的瀏覽器有望退出歷史舞臺,再加上幾乎所有的智能手機瀏覽器都支持HTML5,所 以使得WebSocket的實戰(zhàn)意義大增,但是無論如何,我們實際的項目中,仍然要考慮低版本瀏覽器的兼容方案:在支持WebSocket的瀏覽器中采用 新技術(shù),而在不支持WebSocket的瀏覽器里啟用Comet來接收發(fā)送消息。
WebSocket實戰(zhàn)
本文將以多人在線聊天應用作為實例場景,我們先來確定這個聊天應用的基本需求。
需求分析
1、兼容不支持WebSocket的低版本瀏覽器。
2、允許客戶端有相同的用戶名。
3、進入聊天室后可以看到當前在線的用戶和在線人數(shù)。
4、用戶上線或退出,所有在線的客戶端應該實時更新。
5、用戶發(fā)送消息,所有客戶端實時收取。
在實際的開發(fā)過程中,為了使用WebSocket接口構(gòu)建Web應用,我們首先需要構(gòu)建一個實現(xiàn)了 WebSocket規(guī)范的服務端,服務端的實現(xiàn)不受平臺和開發(fā)語言的限制,只需要遵從WebSocket規(guī)范即可,目前已經(jīng)出現(xiàn)了一些比較成熟的 WebSocket服務端實現(xiàn),比如本文使用的Node.js+Socket.IO。為什么選用這個方案呢?先來簡單介紹下他們兩。
Node.js
Node.js采用C++語言編寫而成,它不是Javascript應用,而是一個Javascript的運行環(huán)境,據(jù)Node.js創(chuàng)始人 Ryan Dahl回憶,他最初希望采用Ruby來寫Node.js,但是后來發(fā)現(xiàn)Ruby虛擬機的性能不能滿足他的要求,后來他嘗試采用V8引擎,所以選擇了 C++語言。
Node.js支持的系統(tǒng)包括*nux、Windows,這意味著程序員可以編寫系統(tǒng)級或者服務器端的Javascript代碼,交給 Node.js來解釋執(zhí)行。Node.js的Web開發(fā)框架Express,可以幫助程序員快速建立web站點,從2009年誕生至今,Node.js的 成長的速度有目共睹,其發(fā)展前景獲得了技術(shù)社區(qū)的充分肯定。
Socket.IO
Socket.IO是一個開源的WebSocket庫,它通過Node.js實現(xiàn)WebSocket服務端,同時也提供客戶端JS庫。Socket.IO支持以事件為基礎的實時雙向通訊,它可以工作在任何平臺、瀏覽器或移動設備。
Socket.IO支持4種協(xié)議:WebSocket、htmlfile、xhr-polling、jsonp-polling,它會自動根據(jù)瀏覽 器選擇適合的通訊方式,從而讓開發(fā)者可以聚焦到功能的實現(xiàn)而不是平臺的兼容性,同時Socket.IO具有不錯的穩(wěn)定性和性能。
編碼實現(xiàn)
本文一開始的的插圖就是效果演示圖:可以點擊這里查看在線演示,整個開發(fā)過程非常簡單,下面簡單記錄了開發(fā)步驟:
安裝Node.js
根據(jù)自己的操作系統(tǒng),去Node.js官網(wǎng)下載安裝即可。如果成功安裝。在命令行輸入node -v
和npm -v
應該能看到相應的版本號。
node -v v0.10.26 npm -v 1.4.6
搭建WebSocket服務端
這個環(huán)節(jié)我們盡可能的考慮真實生產(chǎn)環(huán)境,把WebSocket后端服務搭建成一個線上可以用域名訪問的服務,如果你是在本地開發(fā)環(huán)境,可以換成本地ip地址,或者使用一個虛擬域名指向本地ip。
先進入到你的工作目錄,比如 /workspace/wwwroot/plhwin/realtime.plhwin.com
,新建一個名為 package.json
的文件,內(nèi)容如下:
- {
- "name": "realtime-server",
- "version": "0.0.1",
- "description": "my first realtime server",
- "dependencies": {}
- }
接下來使用npm
命令安裝express
和socket.io
npm install --save express npm install --save socket.io
安裝成功后,應該可以看到工作目錄下生成了一個名為node_modules
的文件夾,里面分別是express
和socket.io
,接下來可以開始編寫服務端的代碼了,新建一個文件:index.js
- var app = require('express')();
- var http = require('http').Server(app);
- var io = require('socket.io')(http);
- app.get('/', function(req, res){
- res.send('<h1>Welcome Realtime Server</h1>');
- });
- http.listen(3000, function(){
- console.log('listening on *:3000');
- });
命令行運行node index.js
,如果一切順利,你應該會看到返回的listening on *:3000
字樣,這說明服務已經(jīng)成功搭建了。此時瀏覽器中打開http://localhost:3000
應該可以看到正常的歡迎頁面。
如果你想要讓服務運行在線上服務器,并且可以通過域名訪問的話,可以使用Nginx做代理,在nginx.conf中添加如下配置,然后將域名(比如:realtime.plhwin.com)解析到服務器IP即可。
- server
- {
- listen 80;
- server_name realtime.plhwin.com;
- location / {
- proxy_pass http://127.0.0.1:3000;
- }
- }
完成以上步驟,http://realtime.plhwin.com:3000
的后端服務就正常搭建了。
服務端代碼實現(xiàn)
前面講到的index.js
運行在服務端,之前的代碼只是一個簡單的WebServer歡迎內(nèi)容,讓我們把WebSocket服務端完整的實現(xiàn)代碼加入進去,整個服務端就可以處理客戶端的請求了。完整的index.js
代碼如下:
- var app = require('express')();
- var http = require('http').Server(app);
- var io = require('socket.io')(http);
- app.get('/', function(req, res){
- res.send('<h1>Welcome Realtime Server</h1>');
- });
- //在線用戶
- var onlineUsers = {};
- //當前在線人數(shù)
- var onlineCount = 0;
- io.on('connection', function(socket){
- console.log('a user connected');
- //監(jiān)聽新用戶加入
- socket.on('login', function(obj){
- //將新加入用戶的唯一標識當作socket的名稱,后面退出的時候會用到
- socket.name = obj.userid;
- //檢查在線列表,如果不在里面就加入
- if(!onlineUsers.hasOwnProperty(obj.userid)) {
- onlineUsers[obj.userid] = obj.username;
- //在線人數(shù)+1
- onlineCount++;
- }
- //向所有客戶端廣播用戶加入
- io.emit('login', {onlineUsers:onlineUsers, onlineCount:onlineCount, user:obj});
- console.log(obj.username+'加入了聊天室');
- });
- //監(jiān)聽用戶退出
- socket.on('disconnect', function(){
- //將退出的用戶從在線列表中刪除
- if(onlineUsers.hasOwnProperty(socket.name)) {
- //退出用戶的信息
- var obj = {userid:socket.name, username:onlineUsers[socket.name]};
- //刪除
- delete onlineUsers[socket.name];
- //在線人數(shù)-1
- onlineCount--;
- //向所有客戶端廣播用戶退出
- io.emit('logout', {onlineUsers:onlineUsers, onlineCount:onlineCount, user:obj});
- console.log(obj.username+'退出了聊天室');
- }
- });
- //監(jiān)聽用戶發(fā)布聊天內(nèi)容
- socket.on('message', function(obj){
- //向所有客戶端廣播發(fā)布的消息
- io.emit('message', obj);
- console.log(obj.username+'說:'+obj.content);
- });
- });
- http.listen(3000, function(){
- console.log('listening on *:3000');
- });
客戶端代碼實現(xiàn)
進入客戶端工作目錄/workspace/wwwroot/plhwin/demo.plhwin.com/chat
,新建一個index.html
:
- <!DOCTYPE html>
- <html>
- <head>
- <meta charset="utf-8">
- <meta name="format-detection" content="telephone=no"/>
- <meta name="format-detection" content="email=no"/>
- <meta content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=0" name="viewport">
- <title>多人聊天室</title>
- <link rel="stylesheet" type="text/css" href="./style.css" />
- <!--[if lt IE 8]><script src="./json3.min.js"></script><![endif]-->
- <script src="http://realtime.plhwin.com:3000/socket.io/socket.io.js"></script>
- </head>
- <body>
- <div id="loginbox">
- <div style="width:260px;margin:200px auto;">
- 請先輸入你在聊天室的昵稱
- <br/>
- <br/>
- <input type="text" style="width:180px;" placeholder="請輸入用戶名" id="username" name="username" />
- <input type="button" style="width:50px;" value="提交" onclick="CHAT.usernameSubmit();"/>
- </div>
- </div>
- <div id="chatbox" style="display:none;">
- <div style="background:#3d3d3d;height: 28px; width: 100%;font-size:12px;">
- <div style="line-height: 28px;color:#fff;">
- <span style="text-align:left;margin-left:10px;">Websocket多人聊天室</span>
- <span style="float:right; margin-right:10px;"><span id="showusername"></span> |
- <a href="javascript:;" onclick="CHAT.logout()" style="color:#fff;">退出</a></span>
- </div>
- </div>
- <div id="doc">
- <div id="chat">
- <div id="message" class="message">
- <div id="onlinecount" style="background:#EFEFF4; font-size:12px; margin-top:10px; margin-left:10px; color:#666;">
- </div>
- </div>
- <div class="input-box">
- <div class="input">
- <input type="text" maxlength="140" placeholder="請輸入聊天內(nèi)容,按Ctrl提交" id="content" name="content">
- </div>
- <div class="action">
- <button type="button" id="mjr_send" onclick="CHAT.submit();">提交</button>
- </div>
- </div>
- </div>
- </div>
- </div>
- <script type="text/javascript" src="./client.js"></script>
- </body>
- </html>
上面的html內(nèi)容本身沒有什么好說的,我們主要看看里面的4個文件請求:
1、realtime.plhwin.com:3000/socket.io/socket.io.js
2、style.css
3、json3.min.js
4、client.js
第1個JS是Socket.IO提供的客戶端JS文件,在前面安裝服務端的步驟中,當npm安裝完socket.io并搭建起WebServer后,這個JS文件就可以正常訪問了。
第2個style.css文件沒什么好說的,就是樣式文件而已。
第3個JS只在IE8以下版本的IE瀏覽器中加載,目的是讓這些低版本的IE瀏覽器也能處理json,這是一個開源的JS,詳見:http://bestiejs.github.io/json3/
第4個client.js
是完整的客戶端的業(yè)務邏輯實現(xiàn)代碼,它的內(nèi)容如下:
- (function () {
- var d = document,
- w = window,
- p = parseInt,
- dd = d.documentElement,
- db = d.body,
- dc = d.compatMode == 'CSS1Compat',
- dx = dc ? dd: db,
- ec = encodeURIComponent;
- w.CHAT = {
- msgObj:d.getElementById("message"),
- screenheight:w.innerHeight ? w.innerHeight : dx.clientHeight,
- username:null,
- userid:null,
- socket:null,
- //讓瀏覽器滾動條保持在最低部
- scrollToBottom:function(){
- w.scrollTo(0, this.msgObj.clientHeight);
- },
- //退出,本例只是一個簡單的刷新
- logout:function(){
- //this.socket.disconnect();
- location.reload();
- },
- //提交聊天消息內(nèi)容
- submit:function(){
- var content = d.getElementById("content").value;
- if(content != ''){
- var obj = {
- userid: this.userid,
- username: this.username,
- content: content
- };
- this.socket.emit('message', obj);
- d.getElementById("content").value = '';
- }
- return false;
- },
- genUid:function(){
- return new Date().getTime()+""+Math.floor(Math.random()*899+100);
- },
- //更新系統(tǒng)消息,本例中在用戶加入、退出的時候調(diào)用
- updateSysMsg:function(o, action){
- //當前在線用戶列表
- var onlineUsers = o.onlineUsers;
- //當前在線人數(shù)
- var onlineCount = o.onlineCount;
- //新加入用戶的信息
- var user = o.user;
- //更新在線人數(shù)
- var userhtml = '';
- var separator = '';
- for(key in onlineUsers) {
- if(onlineUsers.hasOwnProperty(key)){
- userhtml += separator+onlineUsers[key];
- separator = '、';
- }
- }
- d.getElementById("onlinecount").innerHTML = '當前共有 '+onlineCount+' 人在線,在線列表:'+userhtml;
- //添加系統(tǒng)消息
- var html = '';
- html += '<div class="msg-system">';
- html += user.username;
- html += (action == 'login') ? ' 加入了聊天室' : ' 退出了聊天室';
- html += '</div>';
- var section = d.createElement('section');
- section.className = 'system J-mjrlinkWrap J-cutMsg';
- section.innerHTML = html;
- this.msgObj.appendChild(section);
- this.scrollToBottom();
- },
- //第一個界面用戶提交用戶名
- usernameSubmit:function(){
- var username = d.getElementById("username").value;
- if(username != ""){
- d.getElementById("username").value = '';
- d.getElementById("loginbox").style.display = 'none';
- d.getElementById("chatbox").style.display = 'block';
- this.init(username);
- }
- return false;
- },
- init:function(username){
- /*
- 客戶端根據(jù)時間和隨機數(shù)生成uid,這樣使得聊天室用戶名稱可以重復。
- 實際項目中,如果是需要用戶登錄,那么直接采用用戶的uid來做標識就可以
- */
- this.userid = this.genUid();
- this.username = username;
- d.getElementById("showusername").innerHTML = this.username;
- this.msgObj.style.minHeight = (this.screenheight - db.clientHeight + this.msgObj.clientHeight) + "px";
- this.scrollToBottom();
- //連接websocket后端服務器
- this.socket = io.connect('ws://realtime.plhwin.com:3000');
- //告訴服務器端有用戶登錄
- this.socket.emit('login', {userid:this.userid, username:this.username});
- //監(jiān)聽新用戶登錄
- this.socket.on('login', function(o){
- CHAT.updateSysMsg(o, 'login');
- });
- //監(jiān)聽用戶退出
- this.socket.on('logout', function(o){
- CHAT.updateSysMsg(o, 'logout');
- });
- //監(jiān)聽消息發(fā)送
- this.socket.on('message', function(obj){
- var isme = (obj.userid == CHAT.userid) ? true : false;
- var contentDiv = '<div>'+obj.content+'</div>';
- var usernameDiv = '<span>'+obj.username+'</span>';
- var section = d.createElement('section');
- if(isme){
- section.className = 'user';
- section.innerHTML = contentDiv + usernameDiv;
- } else {
- section.className = 'service';
- section.innerHTML = usernameDiv + contentDiv;
- }
- CHAT.msgObj.appendChild(section);
- CHAT.scrollToBottom();
- });
- }
- };
- //通過“回車”提交用戶名
- d.getElementById("username").onkeydown = function(e) {
- e = e || event;
- if (e.keyCode === 13) {
- CHAT.usernameSubmit();
- }
- };
- //通過“回車”提交信息
- d.getElementById("content").onkeydown = function(e) {
- e = e || event;
- if (e.keyCode === 13) {
- CHAT.submit();
- }
- };
- })();
至此所有的編碼開發(fā)工作全部完成了,在瀏覽器中打開http://demo.plhwin.com/chat/就可以看到效果了。
上面所有的客戶端和服務端的代碼可以從Github上獲得,地址:https://github.com/plhwin/nodejs-socketio-chat
git clone https://github.com/plhwin/nodejs-socketio-chat.git
下載本地后有兩個文件夾 client
和 server
,client
文件夾是客戶端源碼,可以放在Nginx/Apache的WebServer中,也可以放在Node.js的WebServer中。后面的server
文件夾里的代碼是websocket服務端代碼,放在Node.js環(huán)境中,使用npm安裝完 express
和 socket.io
后,node index.js
啟動后端服務就可以了。
本例只是一個簡單的Demo,留下2個有關(guān)項目擴展的思考:
1、假設是一個在線客服系統(tǒng),里面有許多的公司使用你的服務,每個公司自己的用戶可以通過一個專屬URL地址進入該公司的聊天室,聊天是一對一的,每個公司可以新建多個客服人員,每個客服人員可以同時和客戶端的多個用戶聊天。
2、又假設是一個在線WebIM系統(tǒng),實現(xiàn)類似微信,qq的功能,客戶端可以看到好友在線狀態(tài),在線列表,添加好友,刪除好友,新建群組等,消息的發(fā)送除了支持基本的文字外,還能支持表情、圖片和文件。
有興趣的同學可以繼續(xù)深入研究。