使用Node.js+Socket.IO搭建WebSocket實時應用

Web領域的實時推送技術,也被稱作Realtime技術。這種技術要達到的目的是讓用戶不需要刷新瀏覽器就可以獲得實時更新。它有著廣泛的應用場景,比如在線聊天室、在線客服系統、評論系統、WebIM等。

WebSocket簡介

談到Web實時推送,就不得不說WebSocket。在WebSocket出現之前,很多網站為了實現實時推送技術,通常采用的方案是輪詢(Polling)和Comet技術,Comet又可細分為兩種實現方式,一種是長輪詢機制,一種稱為流技術,這兩種方式實際上是對輪詢技術的改進,這些方案帶來很明顯的缺點,需要由瀏覽器對服務器發出HTTP request,大量消耗服務器帶寬和資源。面對這種狀況,HTML5定義了WebSocket協議,能更好的節省服務器資源和帶寬并實現真正意義上的實時推送。

WebSocket協議本質上是一個基于TCP的協議,它由通信協議和編程API組成,WebSocket能夠在瀏覽器和服務器之間建立雙向連接,以基于事件的方式,賦予瀏覽器實時通信能力。既然是雙向通信,就意味著服務器端和客戶端可以同時發送并響應請求,而不再像HTTP的請求和響應。

為了建立一個WebSocket連接,客戶端瀏覽器首先要向服務器發起一個HTTP請求,這個請求和通常的HTTP請求不同,包含了一些附加頭信息,其中附加頭信息”Upgrade: WebSocket”表明這是一個申請協議升級的HTTP請求,服務器端解析這些附加的頭信息然后產生應答信息返回給客戶端,客戶端和服務器端的WebSocket連接就建立起來了,雙方就可以通過這個連接通道自由的傳遞信息,并且這個連接會持續存在直到客戶端或者服務器端的某一方主動的關閉連接。

一個典型WebSocket客戶端請求頭:

前面講到WebSocket是HTML5中新增的一種通信協議,這意味著一部分老版本瀏覽器(主要是IE10以下版本)并不具備這個功能, 通過百度統計的公開數據顯示,IE8目前仍以33%的市場份額占據榜首,好在chrome瀏覽器市場份額逐年上升,現在以超過26%的市場份額位居第二,同時微軟前不久宣布停止對IE6的技術支持并提示用戶更新到新版本瀏覽器,這個曾經讓無數前端工程師為之頭疼的瀏覽器有望退出歷史舞臺,再加上幾乎所有的智能手機瀏覽器都支持HTML5,所以使得WebSocket的實戰意義大增,但是無論如何,我們實際的項目中,仍然要考慮低版本瀏覽器的兼容方案:在支持WebSocket的瀏覽器中采用新技術,而在不支持WebSocket的瀏覽器里啟用Comet來接收發送消息。

WebSocket實戰

本文將以多人在線聊天應用作為實例場景,我們先來確定這個聊天應用的基本需求。

需求分析

1、兼容不支持WebSocket的低版本瀏覽器。
2、允許客戶端有相同的用戶名。
3、進入聊天室后可以看到當前在線的用戶和在線人數。
4、用戶上線或退出,所有在線的客戶端應該實時更新。
5、用戶發送消息,所有客戶端實時收取。

在實際的開發過程中,為了使用WebSocket接口構建Web應用,我們首先需要構建一個實現了 WebSocket規范的服務端,服務端的實現不受平臺和開發語言的限制,只需要遵從WebSocket規范即可,目前已經出現了一些比較成熟的WebSocket服務端實現,比如本文使用的Node.js+Socket.IO。為什么選用這個方案呢?先來簡單介紹下他們兩。

Node.js

Node.js采用C++語言編寫而成,它不是Javascript應用,而是一個Javascript的運行環境,據Node.js創始人Ryan Dahl回憶,他最初希望采用Ruby來寫Node.js,但是后來發現Ruby虛擬機的性能不能滿足他的要求,后來他嘗試采用V8引擎,所以選擇了C++語言。

Node.js支持的系統包括*nux、Windows,這意味著程序員可以編寫系統級或者服務器端的Javascript代碼,交給Node.js來解釋執行。Node.js的Web開發框架Express,可以幫助程序員快速建立web站點,從2009年誕生至今,Node.js的成長的速度有目共睹,其發展前景獲得了技術社區的充分肯定。

Socket.IO

Socket.IO是一個開源的WebSocket庫,它通過Node.js實現WebSocket服務端,同時也提供客戶端JS庫。Socket.IO支持以事件為基礎的實時雙向通訊,它可以工作在任何平臺、瀏覽器或移動設備。

Socket.IO支持4種協議:WebSocket、htmlfile、xhr-polling、jsonp-polling,它會自動根據瀏覽器選擇適合的通訊方式,從而讓開發者可以聚焦到功能的實現而不是平臺的兼容性,同時Socket.IO具有不錯的穩定性和性能。

編碼實現

先上演示效果圖:


可以點擊這里查看在線演示。整個開發過程非常簡單,下面簡單記錄了開發步驟:

安裝Node.js

根據自己的操作系統,去Node.js官網下載安裝即可。如果成功安裝。在命令行輸入node -vnpm -v應該能看到相應的版本號。

<pre>
node -v
v0.10.26
npm -v
1.4.6
</pre>

搭建WebSocket服務端

這個環節我們盡可能的考慮真實生產環境,把WebSocket后端服務搭建成一個線上可以用域名訪問的服務,如果你是在本地開發環境,可以換成本地ip地址,或者使用一個虛擬域名指向本地ip。

先進入到你的工作目錄,比如 /workspace/wwwroot/plhwin/realtime.plhwin.com,新建一個名為 package.json的文件,內容如下:
<pre>
{
"name": "realtime-server",
"version": "0.0.1",
"description": "my first realtime server",
"dependencies": {}
}
</pre>

接下來使用npm命令安裝expresssocket.io
<pre>
npm install --save express
npm install --save socket.io
</pre>
安裝成功后,應該可以看到工作目錄下生成了一個名為node_modules的文件夾,里面分別是expresssocket.io,接下來可以開始編寫服務端的代碼了,新建一個文件:index.js

<pre>
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');
});
</pre>

命令行運行node index.js,如果一切順利,你應該會看到返回的listening on *:3000字樣,這說明服務已經成功搭建了。此時瀏覽器中打開http://localhost:3000應該可以看到正常的歡迎頁面。

如果你想要讓服務運行在線上服務器,并且可以通過域名訪問的話,可以使用Nginx做代理,再nginx.conf中添加如下配置,然后將域名(比如:realtime.plhwin.com)解析到服務器IP即可。
<pre>
server
{
listen 80;
server_name realtime.plhwin.com;
location / {
proxy_pass http://127.0.0.1:3000;
}
}
</pre>

完成以上步驟,http://realtime.plhwin.com:3000的后端服務就正常搭建了。

服務端代碼實現

前面講到的index.js運行在服務端,之前的代碼只是一個簡單的WebServer歡迎內容,讓我們把WebSocket服務端完整的實現代碼加入進去,整個服務端就可以處理客戶端的請求了。完整的index.js代碼如下:

<pre>
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 = {};
//當前在線人數
var onlineCount = 0;

io.on('connection', function(socket){
console.log('a user connected');

//監聽新用戶加入
socket.on('login', function(obj){
    //將新加入用戶的唯一標識當作socket的名稱,后面退出的時候會用到
    socket.name = obj.userid;
    
    //檢查在線列表,如果不在里面就加入
    if(!onlineUsers.hasOwnProperty(obj.userid)) {
        onlineUsers[obj.userid] = obj.username;
        //在線人數+1
        onlineCount++;
    }
    
    //向所有客戶端廣播用戶加入
    io.emit('login', {onlineUsers:onlineUsers, onlineCount:onlineCount, user:obj});
    console.log(obj.username+'加入了聊天室');
});

//監聽用戶退出
socket.on('disconnect', function(){
    //將退出的用戶從在線列表中刪除
    if(onlineUsers.hasOwnProperty(socket.name)) {
        //退出用戶的信息
        var obj = {userid:socket.name, username:onlineUsers[socket.name]};
        
        //刪除
        delete onlineUsers[socket.name];
        //在線人數-1
        onlineCount--;
        
        //向所有客戶端廣播用戶退出
        io.emit('logout', {onlineUsers:onlineUsers, onlineCount:onlineCount, user:obj});
        console.log(obj.username+'退出了聊天室');
    }
});

//監聽用戶發布聊天內容
socket.on('message', function(obj){
    //向所有客戶端廣播發布的消息
    io.emit('message', obj);
    console.log(obj.username+'說:'+obj.content);
});

});

http.listen(3000, function(){
console.log('listening on *:3000');
});
</pre>

客戶端代碼實現

進入客戶端工作目錄/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" />
``
<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="width: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="請輸入聊天內容,按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內容本身沒有什么好說的,我們主要看看里面的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是完整的客戶端的業務邏輯實現代碼,它的內容如下:

<pre>
(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();
    },
    //提交聊天消息內容
    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);
    },
    //更新系統消息,本例中在用戶加入、退出的時候調用
    updateSysMsg:function(o, action){
        //當前在線用戶列表
        var onlineUsers = o.onlineUsers;
        //當前在線人數
        var onlineCount = o.onlineCount;
        //新加入用戶的信息
        var user = o.user;
            
        //更新在線人數
        var userhtml = '';
        var separator = '';
        for(key in onlineUsers) {
            if(onlineUsers.hasOwnProperty(key)){
                userhtml += separator+onlineUsers[key];
                separator = '、';
            }
        }
        d.getElementById("onlinecount").innerHTML = '當前共有 '+onlineCount+' 人在線,在線列表:'+userhtml;
        
        //添加系統消息
        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){
        //客戶端根據時間和隨機數生成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});
        
        //監聽新用戶登錄
        this.socket.on('login', function(o){
            CHAT.updateSysMsg(o, 'login');  
        });
        
        //監聽用戶退出
        this.socket.on('logout', function(o){
            CHAT.updateSysMsg(o, 'logout');
        });
        
        //監聽消息發送
        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();
    }
};

})();
</pre>

至此所有的編碼開發工作全部完成了,在瀏覽器中打開http://demo.plhwin.com/chat/就可以看到效果了,后續我會把演示代碼提交到Github上。

本例只是一個簡單的Demo,留下2個有關項目擴展的思考:

1、假設是一個在線客服系統,里面有許多的公司使用你的服務,每個公司自己的用戶可以通過一個專屬URL地址進入該公司的聊天室,聊天是一對一的,每個公司可以新建多個客服人員,每個客服人員可以同時和客戶端的多個用戶聊天。

2、又假設是一個在線WebIM系統,實現類似微信,qq的功能,客戶端可以看到好友在線狀態,在線列表,添加好友,刪除好友,新建群組等,消息的發送除了支持基本的文字外,還能支持表情、圖片和文件。

有興趣的同學可以繼續深入研究。

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 229,732評論 6 539
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,214評論 3 426
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 177,781評論 0 382
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,588評論 1 316
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,315評論 6 410
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,699評論 1 327
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,698評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 42,882評論 0 289
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,441評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,189評論 3 356
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,388評論 1 372
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 38,933評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,613評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,023評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,310評論 1 293
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,112評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,334評論 2 377

推薦閱讀更多精彩內容