一絮记、相關(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