基于node+socket.io+redis的多房間多進程聊天室

一絮记、相關(guān)技術(shù)介紹:

消息實時推送肝集,指的是將消息實時地推送到瀏覽器,用戶不需要刷新瀏覽器就可以實時獲取最新的消息钧椰,實時聊天室的技術(shù)原理也是如此粹断。傳統(tǒng)的Web站點為了實現(xiàn)推送技術(shù),所用的技術(shù)都是輪詢嫡霞,這種傳統(tǒng)的模式帶來很明顯的缺點瓶埋,即瀏覽器需要不斷的向服務(wù)器發(fā)出請求。
短輪詢(Polling)


客戶端需要定時忘瀏覽器輪詢發(fā)送請求诊沪,且只有當(dāng)服務(wù)有數(shù)據(jù)更新后养筒,客戶端的下一次輪詢請求才能拿到更新后的數(shù)據(jù),在數(shù)據(jù)更新前的多次請求相當(dāng)于無效端姚。這對帶寬資源造成了極大的浪費晕粪,若提高輪詢定時,又會有數(shù)據(jù)更新不及時的煩惱渐裸。
commet
為了解決短輪詢的弊端巫湘,一種基于http長連接的"服務(wù)器推"方式被hack出來。其于短輪詢的區(qū)別主要是昏鹃,采用commet時尚氛,客戶端與服務(wù)端保持一個長連接,當(dāng)數(shù)據(jù)發(fā)生改變時洞渤,服務(wù)端主動將數(shù)據(jù)推送到客戶端阅嘶。Comet 又可以被細分為兩種實現(xiàn)方式,一種是長輪詢機制载迄,一種是流技術(shù)讯柔。

  • 長輪詢

    長輪詢跟短輪詢不同的地方是,客戶端往服務(wù)端發(fā)送請求后宪巨,服務(wù)端判斷是否有數(shù)據(jù)更新磷杏,若沒有,則將請求hold住捏卓,等待數(shù)據(jù)更新時极祸,才返回響應(yīng)慈格。這樣則避免了無效的http請求,但即使采用長輪詢方式遥金,接受數(shù)據(jù)的最小時間間隔還是為2*RTT(往返時間)浴捆。
  • 流技術(shù)

    流技術(shù)(http stream)基于iframe實現(xiàn)。通過HTML標簽iframe src指向服務(wù)端稿械,建立一個長連接选泻。當(dāng)有數(shù)據(jù)推送,則往客戶度 返回美莫,無須再請求页眯。但流技術(shù)有個缺點就是,在瀏覽器頂部會一直出現(xiàn)頁面未加載完成的loading標示厢呵。

websocket


為了解決服務(wù)端如何更快得實時推送數(shù)據(jù)到客戶端以及以上推送方式技術(shù)的不足窝撵,HTML5中定義了Websocket協(xié)議,它是一種在單個TCP連接上進行全雙工通訊的協(xié)議襟铭。與http協(xié)議不同的請求/響應(yīng)模式不同碌奉,Websocket在建立連接之前有一個Handshake(Opening Handshake)過程,建立連接之后寒砖,雙方即可雙向通信赐劣。當(dāng)然,由于websocket是html5新特性哩都,在部分瀏覽器(IE10以下)是不支持的魁兼。
我們來看下websocket的握手報文:


請求報文:

GET /chat HTTP/1.1
Host: server.example.com
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==
Sec-WebSocket-Protocol: chat
Sec-WebSocket-Version: 13
Origin: http://example.com
  • "Upgrade ","Connection": 告訴服務(wù)器這個請求是一個websocket協(xié)議,需要區(qū)別處理茅逮。
  • "Upgrade: websocket": 表明這是一個 WebSocket 類型請求璃赡,意在告訴 server 需要將通信協(xié)議切換到 WebSocket
  • "Sec-WebSocket-Key: *": 是 client 發(fā)送的一個 base64 編碼的密文判哥,要求 server 必須返回一個對應(yīng)加密的 "Sec-WebSocket-Accept" 應(yīng)答献雅,否則 client 會拋出 "Error during WebSocket handshake" 錯誤,并關(guān)閉連接
  • "Sec-WebSocket-Protocol":一個用戶定義的字符串塌计,用來區(qū)分同URL下挺身,不同的服務(wù)所需要的協(xié)議
  • "Sec-WebSocket-Version":Websocket Draft (協(xié)議版本)

響應(yīng)報文:

HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=
Sec-WebSocket-Protocol: chat
  • "Sec-WebSocket-Accept": 這個則是經(jīng)過服務(wù)器確認,并且加密過后的 Sec-WebSocket-Key锌仅。加密方式為將Sec-WebSocket-Key與一段固定的 GUID 字符串進行連接章钾,然后進行SHA-1 hash,接著base64編碼得到热芹。

****socket.io(http://socket.io)****
是一個完全由JavaScript實現(xiàn)贱傀,基于Node.js、支持WebSocket的協(xié)議用于實時通信伊脓、跨平臺的開源框架府寒。Socket.IO除了支持WebSocket通訊協(xié)議外,還支持許多種輪詢機制以及其它實時通信方式,并封裝成了通用的接口株搔,并能夠根據(jù)瀏覽器對通訊機制的支持情況自動地選擇最佳的方式來實現(xiàn)網(wǎng)絡(luò)實時應(yīng)用剖淀。

首先,我們創(chuàng)建一個socket.io server對象纤房,指定監(jiān)聽80端口纵隔。并且指定收到message消息,以及socket端口的監(jiān)聽方法炮姨。接著捌刮,當(dāng)socket建立連接后,通過socket.emit方法舒岸,可以往客戶端發(fā)送消息糊啡。

 var io = require('socket.io')();
 io.on('connection', function(socket) {
    //接受消息
    socket.on('message', function (msg) {
        console.log('receive messge : ' + msg );
    });
    
    //發(fā)送消息
    socket.emit('message', 'hello');
    
    //斷開連接回調(diào)
    socket.on('disconnect', function () { 
        console.log('socket disconnect');
    });
});
io.listen(80);

客戶端的代碼也非常簡單,只要引入socket.io對應(yīng)的客戶端庫(https://github.com/socketio/socket.io-client)吁津。
在socket建立連接的回調(diào)中棚蓄,使用socket.emit以及socket.on就可以分別做消息的發(fā)送以及監(jiān)聽了。

<script>
  var socket = io('http://localhost/');
  socket.on('connect', function () {
    socket.emit('message', 'hi, i am client!');

    socket.on('message', function (msg) {
      console.log('msg received from server');
    });
  });
</script>

二碍脏、多節(jié)點集群架構(gòu)設(shè)計

若只是單機部署應(yīng)用梭依,單純使用socket.io的消息事件監(jiān)聽處理即可滿足我們的需求。但隨著業(yè)務(wù)的擴大典尾,我們需要考慮多機集群部署役拴,客戶端可以連接到任一節(jié)點,并發(fā)送消息钾埂。這時如何做到多節(jié)點的同時推送河闰,我們需要一套多節(jié)點之間的消息分發(fā)/訂閱架構(gòu)。這時我們引入redis的pub/sub功能褥紫。

****redis****
redis是一個key-value存儲系統(tǒng)姜性,在該項目中主要起到一個消息分發(fā)中心的作用。用戶通過socket.io namespace 訂閱房間號后髓考,socket.io server則往redis訂閱(subscribe)該房間號channel部念。當(dāng)在該房間中的某一用戶發(fā)送消息時,則通過redis的publish功能往該房間號channel推送用戶發(fā)送消息氨菇。這樣所有訂閱該房間號channel的websocket連接則會收到消息回調(diào)儡炼,然后推送給客戶端。

****nginx****
由于采用了集群架構(gòu)查蓉,則需要nginx來做反向代理乌询。需要注意的是,websocket的支持需要nginx1.3以上版本豌研。并且我們需要通過配置ip_hash做粘性會話(ip_hash)處理妹田,避免在低版本瀏覽器socket.io使用兼容方案輪詢請求竣灌,請求到不同機器,造成session異常秆麸。

三初嘹、架構(gòu)設(shè)計圖


客戶端通過socket.io namespace 指定對應(yīng)roomid,請求到nginx沮趣。nginx根據(jù)ip_hash反向代理到對應(yīng)機器的某一端口的socket.io server 進程屯烦。建立websocket連接,并往redis訂閱對應(yīng)到房間(roomid)channel房铭。到這個時候驻龟,一個訂閱了某一房間的websocket通道建立完成。
當(dāng)用戶發(fā)送消息時缸匪,socket.io server捕獲到該房間到消息后翁狐,即往redis對應(yīng)房間id的channel publish消息光绕。這時所有訂閱了該房間id channel的socket.io server就會收到訂閱響應(yīng)详幽,接著找到對應(yīng)房間id的webscoket通道,并將消息推送到客戶端读宙。

四砂心、代碼示例(多房間實時聊天室):

nginx配置(nginx版本須>1.3):
在http{}里配置定義upstream懈词,并設(shè)置ip_hash。使同一個ip的請求能夠落在同一個機器同一個進程中辩诞。 如果改節(jié)點掛了坎弯,則自動重連到另外一個節(jié)點。

upstream io_nodes {
 ip_hash;
 server 127.0.0.1:6001;
 server 127.0.0.1:6002;
 server 127.0.0.1:6003;
 server 127.0.0.1:6004;
 server 127.0.0.1:6005;
 server 127.0.0.1:6006;
 server 127.0.0.1:6007;
 server 127.0.0.1:6008;
 server 10.x.x.x:6001;
 server 10.x.x.x:6002;
 server 10.x.x.x:6003;
 server 10.x.x.x:6004;
 server 10.x.x.x:6005;
 server 10.x.x.x:6006;
 server 10.x.x.x:6007;
 server 10.x.x.x:6008;
 }

在server中译暂,配置location:

location / {
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_set_header X-Forwarded-For  $proxy_add_x_forwarded_for;
    proxy_set_header Host $host;
    proxy_http_version 1.1;
    proxy_pass http://io_nodes;
    proxy_redirect off;
}

cluster.js
我們采用了多進程的設(shè)計抠忘,充分利用cpu多核優(yōu)勢。通過主進程統(tǒng)一管理維護子進程外永,每個進程監(jiān)聽一個端口崎脉。

var cupNum = require('os').cpus().length,
    workerArr = [],
    roomInfo = [];
var connectNum = 0;

for (var i = 0; i < cupNum; i++) {
    workerArr.push(fork('./fork_server.js', [6001 + i]));

    workerArr[i].on('message', function(msg) {
        if (msg.cmd && msg.cmd === 'client connect') {
            connectNum++;
            console.log('socket server connectnum:' + connectNum);
        }
        if (msg.cmd && msg.cmd === 'client disconnect') {
            connectNum--;
            console.log('socket server connectnum:' + connectNum);
        }
    });

fork_server.js

var process = require('process');

var io = require('socket.io')();

var num = 0;

var redis = require('redis');
var redisClient = redis.createClient;

//建立redis pub、sub連接
var pub = redisClient({port:13800, host: '127.0.0.1', password:'xxxx'});
var sub = redisClient({port: 13800, host:'127.0.0.1', password:'xxxx'});

var roomSet = {};

//獲取父進程傳遞端口
var port = parseInt(process.argv[2]);

//當(dāng)websocket連接時
io.on('connection', function(socket) {

    //客戶端請求ws URL:  http://127.0.0.1:6001?roomid=k12_webcourse_room_1
    var roomid = socket.handshake.query.roomid;

    console.log('worker pid: ' + process.pid  + ' join roomid: '+ roomid);
    
    socket.on('join', function (data) {

        socket.join(roomid);    //加入房間
         
        // 往redis訂閱房間id
        if(!roomSet[roomid]){
            roomSet[roomid] = {};
            console.log('sub channel ' + roomid);
            sub.subscribe(roomid);
        }

      roomSet[roomid][socket.id] = {};
      reportConnect();
      console.log(data.username + ' join, IP: ' + socket.client.conn.remoteAddress);
      roomSet[roomid][socket.id].username = data.username;
      // 往該房間id的reids channel publish用戶進入房間消息
      pub.publish(roomid, JSON.stringify({"event":'join',"data": data}));
  });
  
  //用戶發(fā)言 推送消息到redis
  socket.on('say', function (data) {
    console.log("Received Message: " + data.text);
    pub.publish(roomid, JSON.stringify({"event":'broadcast_say',"data": {
      username: roomSet[roomid][socket.id].username,
      text: data.text
    }}));
  });


    socket.on('disconnect', function() {
        num--;
        console.log('worker pid: ' + process.pid + ' clien disconnection num:' + num);
        process.send({
            cmd: 'client disconnect'
        });

        if (roomSet[roomid] && roomSet[roomid][socket.id] && roomSet[roomid][socket.id].username) {
      console.log(roomSet[roomid][socket.id].username + ' quit');
      pub.publish(roomid, JSON.stringify({"event":'broadcast_quit',"data": {
        username: roomSet[roomid][socket.id].username
      }}));
    }
    roomSet[roomid] && roomSet[roomid][socket.id] && (delete roomSet[roomid][socket.id]);

    });
});

/**
 * 訂閱redis 回調(diào)
 * @param  {[type]} channel [頻道]
 * @param  {[type]} count   [數(shù)量]  
 * @return {[type]}         [description]
 */
sub.on("subscribe", function (channel, count) {
    console.log('worker pid: ' + process.pid + ' subscribe: ' + channel);
});

/**
 * 收到redis publish 對應(yīng)channel的消息
 * @param  {[type]} channel  [description]
 * @param  {[type]} message
 * @return {[type]}          [description]
 */
sub.on("message", function (channel, message) {
    console.log("message channel " + channel + ": " + message);
    //往對應(yīng)房間廣播消息
    io.to(channel).emit('message', JSON.parse(message));
});

/**
 * 上報連接到master進程 
 * @return {[type]} [description]
 */
var reportConnect = function(){
    num++;
    console.log('worker pid: ' + process.pid + ' client connect connection num:' + num);
    process.send({
        cmd: 'client connect'
    });
};


io.listen(port);

console.log('worker pid: ' + process.pid + ' listen port:' + port);

客戶端:

<script src="static/socket.io.js"></script>
<script>
    var roomid = (function () {
        return prompt('請輸入房間號','')
    })();

    var userInfo = {
        username: (function () {
            return prompt('請輸入rtx昵稱', '');
        })()
    };

    if(roomid != null && roomid != "") {
        var socket = io.connect('http://10.244.146.2?roomid='+ roomid);

        socket.emit('join', {
            username: userInfo.username
        });

        socket.on('message', function(msg){ 
            switch (msg.event) {
                case 'join':
                if (msg.data.username) {
                    console.log(msg.data.username + '加入了聊天室');
                    var data = {
                        text: msg.data.username + '加入了聊天室'
                    };
                    showNotice(data);
                }
                break;
                /*收到消息廣播后象迎,顯示消息*/
                case 'broadcast_say':
                    if(msg.data.username!==userInfo.username) {
                        console.log(msg.data.username + '說: ' + msg.data.text);
                        showMessage(msg.data);
                    }
                break;
/*離開聊天室廣播后荧嵌,顯示消息*/
                case 'broadcast_quit':
                    if (msg.data.username) {
                        console.log(msg.data.username + '離開了聊天室');
                        var data = {
                            text: msg.data.username + '離開了聊天室'
                        };
                        showNotice(data);
                    }
                    break;
            }
        })

    }



    /*點擊發(fā)送按鈕*/
    document.getElementById('send').onclick = function () {
        var keywords = document.getElementById('keywords');
        if (keywords.value === '') {
            keywords.focus();
            return false;
        }
        var data = {
            text: keywords.value,
            type: 0,
            username: userInfo.username
        };
        /*向服務(wù)器提交一個say事件,發(fā)送消息*/
        socket.emit('say', data);

        showMessage(data);
        keywords.value = "";
        keywords.focus();
    };
    /*展示消息*/
    function showMessage(data) {
        var itemArr = [];
        itemArr.push('<dd class="'+(data.type === 0 ? "me" : "other")+'">');
        itemArr.push('<ul>');
        itemArr.push('<li class="nick-name">' + data.username + '</li>');
        itemArr.push('<li class="detail">');
        itemArr.push('<div class="head-icon"></div>');
        itemArr.push('<div class="text">' + data.text + '</div>');
        itemArr.push('</li>');
        itemArr.push('</ul>');
        itemArr.push('</dd>');

        document.getElementById('list').innerHTML += itemArr.join('');
    }
    /*展示通知*/
    function showNotice(data) {
        var item = '<dd class="tc"><span>' + data.text + '</span><dd>';
        document.getElementById('list').innerHTML += item;
    }

    /*回車事件*/
    document.onkeyup = function (e) {
        if (!e) e = window.event;
        if ((e.keyCode || e.which) == 13) {
            document.getElementById('send').click();
        }
    }

</script>

原文鏈接:http://imweb.io/topic/584412459be501ba17b10a7b
gihub源碼地址:https://github.com/493326889/node-multiple-rooms-chat

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末砾淌,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子谭网,更是在濱河造成了極大的恐慌汪厨,老刑警劉巖,帶你破解...
    沈念sama閱讀 216,470評論 6 501
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件愉择,死亡現(xiàn)場離奇詭異劫乱,居然都是意外死亡织中,警方通過查閱死者的電腦和手機,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 92,393評論 3 392
  • 文/潘曉璐 我一進店門衷戈,熙熙樓的掌柜王于貴愁眉苦臉地迎上來狭吼,“玉大人,你說我怎么就攤上這事殖妇〉篌希” “怎么了?”我有些...
    開封第一講書人閱讀 162,577評論 0 353
  • 文/不壞的土叔 我叫張陵谦趣,是天一觀的道長疲吸。 經(jīng)常有香客問我,道長前鹅,這世上最難降的妖魔是什么摘悴? 我笑而不...
    開封第一講書人閱讀 58,176評論 1 292
  • 正文 為了忘掉前任,我火速辦了婚禮舰绘,結(jié)果婚禮上蹂喻,老公的妹妹穿的比我還像新娘。我一直安慰自己捂寿,他們只是感情好叉橱,可當(dāng)我...
    茶點故事閱讀 67,189評論 6 388
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著者蠕,像睡著了一般窃祝。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上踱侣,一...
    開封第一講書人閱讀 51,155評論 1 299
  • 那天粪小,我揣著相機與錄音,去河邊找鬼抡句。 笑死探膊,一個胖子當(dāng)著我的面吹牛,可吹牛的內(nèi)容都是我干的待榔。 我是一名探鬼主播逞壁,決...
    沈念sama閱讀 40,041評論 3 418
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼锐锣!你這毒婦竟也來了腌闯?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 38,903評論 0 274
  • 序言:老撾萬榮一對情侶失蹤雕憔,失蹤者是張志新(化名)和其女友劉穎姿骏,沒想到半個月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體斤彼,經(jīng)...
    沈念sama閱讀 45,319評論 1 310
  • 正文 獨居荒郊野嶺守林人離奇死亡分瘦,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,539評論 2 332
  • 正文 我和宋清朗相戀三年蘸泻,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學(xué)時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片嘲玫。...
    茶點故事閱讀 39,703評論 1 348
  • 序言:一個原本活蹦亂跳的男人離奇死亡悦施,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出去团,到底是詐尸還是另有隱情抡诞,我是刑警寧澤,帶...
    沈念sama閱讀 35,417評論 5 343
  • 正文 年R本政府宣布渗勘,位于F島的核電站沐绒,受9級特大地震影響,放射性物質(zhì)發(fā)生泄漏旺坠。R本人自食惡果不足惜乔遮,卻給世界環(huán)境...
    茶點故事閱讀 41,013評論 3 325
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望取刃。 院中可真熱鬧蹋肮,春花似錦、人聲如沸璧疗。這莊子的主人今日做“春日...
    開封第一講書人閱讀 31,664評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽崩侠。三九已至漆魔,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間却音,已是汗流浹背改抡。 一陣腳步聲響...
    開封第一講書人閱讀 32,818評論 1 269
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留系瓢,地道東北人阿纤。 一個月前我還...
    沈念sama閱讀 47,711評論 2 368
  • 正文 我出身青樓,卻偏偏與公主長得像夷陋,于是被迫代替她去往敵國和親欠拾。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點故事閱讀 44,601評論 2 353

推薦閱讀更多精彩內(nèi)容