簡介 WebSocket 使得客戶端和伺服器之間的數據交換變得更加簡單, 允許服務端主動向客戶端推送數據。 在 WebSocket API 中,瀏覽器和伺服器只需要完成一次握手,兩者之間就直接 可以創建持久性的連接,併進行雙向數據傳輸。 現在,很多網站為了實現 推送技術 ,所用的技術都是 Ajax ...
簡介
WebSocket 使得客戶端和伺服器之間的數據交換變得更加簡單,允許服務端主動向客戶端推送數據。在 WebSocket API 中,瀏覽器和伺服器只需要完成一次握手,兩者之間就直接可以創建持久性的連接,併進行雙向數據傳輸。
現在,很多網站為了實現推送技術,所用的技術都是 Ajax 輪詢。輪詢是在特定的的時間間隔(如每1秒),由瀏覽器對伺服器發出HTTP請求,然後由伺服器返回最新的數據給客戶端的瀏覽器。這種傳統的模式帶來很明顯的缺點,即瀏覽器需要不斷的向伺服器發出請求,然而HTTP請求可能包含較長的頭部,其中真正有效的數據可能只是很小的一部分,顯然這樣會浪費很多的帶寬等資源。
HTML5 定義的 WebSocket 協議,能更好的節省伺服器資源和帶寬,並且能夠更實時地進行通訊。
原生socket
socket是在http基礎上,對http進行升級,讓連接用socket來完成。
一個典型的Websocket握手請求如下:
客戶端請求
GET / HTTP/1.1
Upgrade: websocket
Connection: Upgrade
Host: example.com
Origin: http://example.com
Sec-WebSocket-Key: sN9cRrP/n9NdMgdcy2VJFQ==
Sec-WebSocket-Version: 13
伺服器回應
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: fFBooB7FAkLlXgRSz0BT3v4hq5s=
Sec-WebSocket-Location: ws://example.com/
- Connection 必須設置 Upgrade,表示客戶端希望連接升級。
- Upgrade 欄位必須設置 Websocket,表示希望升級到 Websocket 協議。
- Sec-WebSocket-Key 是隨機的字元串,伺服器端會用這些數據來構造出一個 SHA-1 的信息摘要。把 “Sec-WebSocket-Key” 加上一個特殊字元串 “258EAFA5-E914-47DA-95CA-C5AB0DC85B11”,然後計算 SHA-1 摘要,之後進行 BASE-64 編碼,將結果做為 “Sec-WebSocket-Accept” 頭的值,返回給客戶端。如此操作,可以儘量避免普通 HTTP 請求被誤認為 Websocket 協議。
- Sec-WebSocket-Version 表示支持的 Websocket 版本。RFC6455 要求使用的版本是 13,之前草案的版本均應當棄用。
- Origin 欄位是可選的,通常用來表示在瀏覽器中發起此 Websocket 連接所在的頁面,類似於 Referer。但是,與 Referer 不同的是,Origin 只包含了協議和主機名稱。
- 其他一些定義在 HTTP 協議中的欄位,如 Cookie 等,也可以在 Websocket 中使用。
服務端
const http = require('http');
const net = require('net');//TCP 原生socket
const crypto = require('crypto');
let server = net.createServer(socket => {
//握手只有一次
socket.once('data',(data)=>{
console.log('握手開始')
let str = data.toString()
let lines = str.split('\r\n')
//捨棄第一行和最後兩行
lines = lines.slice(1,lines.length-2)
//切開
let headers ={}
lines.forEach(line=>{
let [key,value]= line.split(`: `)
headers[key.toLowerCase()]=value
})
if(headers[`upgrade`]!='websocket'){
console.log('其他協議',headers[`upgrade`])
socket.end()
}else if(headers[`sec-websocket-version`] != '13'){
console.log('版本不對',headers[`upgrade`])
socket.end()
}else {
let key = headers['sec-websocket-key']
let mask = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11'
let hash =crypto.createHash('sha1')
hash.update(key+mask)
let key2 =hash.digest('base64')
socket.write(`HTTP/1.1 101 Switching Protocols\r\nUpgrade: websocket\r\nConnection: Upgrade\r\nSec-WebSocket-Accept: ${key2}\r\n\r\n`)
//伺服器響應,握手結束
//真正的數據
socket.on('data',data1 => {
console.log(data1)//幀
let FIN = data1[0]&0x001//位運算
let opcode=data1[0]&0xF0
})
}
})
//斷開
socket.on('end',() =>{
console.log('客戶端斷開')
})
});
server.listen(8080);
客戶端
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
<script>
let socket = new WebSocket('ws://localhost:8080/')
socket.emit = function(name,...args){
socket.send(JSON.stringify({name,data:args}))
alert(JSON.stringify({name,data:args}))
}
socket.onopen = function (event) {
console.log('WebSocket is open')
socket.emit('msg',12,34)
}
socket.onmessage = function (event) {
console.log('有消息過來')
}
socket.onclose = function () {
console.log('斷開連接')
}
</script>
</head>
<body>
</body>
</html>
socket.io
原生socket較複雜,一般都通過框架來使用websocket,socket.io封裝了websocket。
socket.io文檔
安裝:
npm install socket.io -s
簡單使用
服務端
- 創建服務端IO對象
io = require('socket.io')(httpServer);
- 監視連接
io.on('connection',function(socket))
- 通過emit 、 on
on(name,function(data){})
:綁定監聽emit(name,data)
: 發送消息
let app = require('express')();
let httpServer = require('http').createServer(app);
//得到IO對象
let io = require('socket.io')(httpServer);
//監視連接,(當有一個客戶連接上時回調) io關聯多個socket
io.on('connection',function (socket) {
console.log('socketio connected');
//綁定sendMsg監聽,接受客戶端發送的消息
socket.on('sendMsg',function (data) {
console.log('服務端接受到瀏覽器的信息');
/*io.emit 發送給所有連接伺服器的客戶端,
socket.emit 發送給當前連接的客戶端*/
socket.emit('receiveMsg',data.name + '_'+ data.date);
console.log('伺服器向瀏覽器發送消息',data)
})
})
http.listen(4000,function(){
console.log('listening on:4000')
})
客戶端
- 引入客戶端
socket.io-client
庫 io(url)
連接服務端,得到socket對象(如果不指定url,將會連接預設主機地址)- 通過emit,on實現通信
<script src="https://cdn.bootcss.com/socket.io/2.3.0/socket.io.js"></script>
<script>
//連接伺服器,得到代表連接的socket對象
const socket = io('ws://localhost:4000');
//綁定'receiveMessage的監聽,來接受伺服器發送的數據
socket.on('receiveMsg',function(data){
console.log('瀏覽器端接受消息');
})
//向伺服器發送消息
socket.emit('sendMsg',{name: 'Tom',date: Date.now()})
console.log('瀏覽向伺服器發送消息')
</script>
實現一個簡易的聊天室
上面服務端如果使用socket.emit
實現的是服務端和客戶端的一對一發送數據,那麼如何將服務端收到的數據發送給其他用戶,來實現聊天室效果呢?
這裡就需要io.emit
發送數據給當前連接此伺服器的所有用戶。
服務端
let app = require('express')();
let httpServer = require('http').createServer(app);
//得到IO對象
let io = require('socket.io')(httpServer);
//監視連接.
io.on('connection',function (socket) {
socket.on('chat message', function(msg){
console.log('connected',msg)
io.emit('chat message', msg);
});
socket.on('disconnected',(res)=>{
console.log('disconnected',res)
})
})
http.listen(4000,function(){
console.log('listening on:4000')
})
客戶端
<head>
<script src="https://cdn.bootcss.com/socket.io/2.3.0/socket.io.js"></script>
<script src="https://cdn.bootcss.com/jquery/3.4.1/jquery.min.js"></script>
<script>
$(function () {
let socket = io("ws://localhost:4000");
$('form').submit(function(e){
e.preventDefault(); //阻止刷新
socket.emit('chat message', $('#m').val());
$('#m').val('');
return false;
});
socket.on('chat message', function(msg){
$('#messages').append($('<li>').text(msg));
});
});
</script>
</head>
<body>
<ul id="messages"></ul>
<form action="">
<input id="m" autocomplete="off" /><button>Send</button>
</form>
</body>
接下來根據官網的方案進行優化
Here are some ideas to improve the application:
- Broadcast a message to connected users when someone connects or disconnects.
在服務端,通過io.on('connection')
監聽用戶連接。
socket.on('disconnect')
監聽用戶斷開。
通過回調向客戶端傳遞提示信息。 socket.id
可以用來獨一無二的表示當前會話的客戶端id
//服務端
socket.on('add user',function (msg) { //在用戶第一次加入時,觸發add user,提示所有用戶
io.emit('user joined',{id: socket.id});
})
socket.on('disconnect',(reason)=>{
console.log('disconnect',socket.id,reason);
io.emit('user left',{id: socket.id})
})
//客戶端
socket.on('user joined',function(data){
let {id} = data;
$('#messages').append($('<li>').text(id+'加入聊天室').addClass('log'));
})
socket.on('user left',function(data){
let {id} = data;
$('#messages').append($('<li>').text(id+'離開聊天室').addClass('log'));
})
- Add support for nicknames.
//在客戶端提供添加昵稱的輸入框,當輸入完信息後,傳遞昵稱給服務端
socket.emit('add user', username);
//在服務端重構
socket.on('add user', function (username) {
socket.username = username;
io.emit('user joined', {
username: socket.username
});
})
- Don’t send the same message to the user that sent it himself. Instead, append the message directly as soon as he presses enter.
通過監聽keydown
事件,判定 event.which
的值是否為 13(enter的Unicode碼是13)。如果是則emit 消息
- Add “{user} is typing” functionality.
通過監聽input事件,來更新type信息
//update
$inputMessage.on('input', function () {
updateTyping();
});
function updateTyping() {
if (connected) {
if (!typing) {//如果當前沒在輸入,則更改標誌,併發送正在輸入的消息消息
typing = true;
socket.emit('typing');
}
lastTypingTime = (new Date()).getTime();
setTimeout(function () {
var typingTimer = (new Date()).getTime();
var timeDiff = typingTimer - lastTypingTime;
if (timeDiff >= TYPING_TIMER_LENGTH && typing) {//如果停止輸入超時,則發送停止消息
socket.emit('stop typing');
typing = false;
}
}, TYPING_TIMER_LENGTH);
}
}
//服務端 傳遞當前正在輸入或停止輸入的用戶名,用於讓客戶端顯示或消失 is Typing的信息
socket.on('typing', function () {
io.emit('typing', {
username: socket.username
});
});
socket.on('stop typing', function () {
io.emit('stop typing', {
username: socket.username
});
- Show who’s online.
- Add private messaging.
更多案例在官方倉庫中查找
NameSpaces、rooms
namespace允許用戶去分配路徑,這個的好處是可以減少TCP資源,同時進行通道隔離
預設的namespace是/
通過 of
方法可以自定義namespace
//服務端
const nsp = io.of('/my-namespace');
nsp.on('connection', function(socket){
console.log('someone connected');
});
nsp.emit('hi', 'everyone!');
//客戶端
const socket = io('/my-namespace');
對於每個namespace,都可以定義多個頻道,也就是room,用戶可以 join
和 left
//服務端
io.on('connection', function(socket){
socket.join('some room');
});
//當要向某個房間傳數據時,使用 to
io.to('some room').emit('some event');
有的時候需要將數據從一個進程發送到令一個進程,可以通過redis adapter
//一個服務端 可以應用redis adapter
const io = require('socket.io')(3000);
const redis = require('socket.io-redis');
io.adapter(redis({ host: 'localhost', port: 6379 }));
//另一個服務端可以通過連接給服務,從另一個進程的任意頻道發送
const io = require('socket.io-emitter')({ host: '127.0.0.1', port: 6379 });
setInterval(function(){
io.emit('time', new Date);
}, 5000);
參考鏈接
fork的chat案例