爲什麼需要 WebSocket?
我們已經有HTTP協議了,爲什麼還需要另一個協議?它能給我們帶來什麼好處呢?
原因是:HTTP協議有一個缺陷:通信只能由客戶端發起,做不到服務器主動向客戶端推送信息。這種單向請求的特點意味着如果服務器有連續的狀態變化,客戶端獲取就會變的非常麻煩,只能使用輪詢的方法獲取,其中最典型的就是聊天場景了。而websocket是服務器可以主動向客戶端推送信息,客戶端也可以主動向服務器發送信息,是真正的雙向平等對話。
短輪詢(Polling)
短輪詢的實現思路是瀏覽器端每隔幾秒鐘向服務器端發送HTTP請求,服務端在收到請求後,不論是否有數據更新,都直接進行響應。服務端響應完成,關閉這個TCP連接,代碼實現也簡單,如下所示:
setInterval(function() {
// ajax請求
}, 3000);
優點:實現簡單。
缺點:會導致數據在一小段時間內不同步和大量無效的請求,安全性差、浪費資源。
長輪詢(Long-Polling)
長輪詢的實現思路是客戶端發送請求後服務器端不會立即返回數據,服務器端會阻塞請求連接不會立即斷開,直到服務器端有數據更新或者是連接超時才返回,客戶端纔再次發出請求新建連接、如此反覆獲取最新數據。如下圖所示:
- 優點:在短輪詢基礎上做了優化,有較好的時效性。
- 缺點:保持連接掛起會消耗資源,服務器沒有返回有效數據,程序超時。
短輪詢和長輪詢, 都是先由客戶端發起Ajax請求,才能進行通信,走的是HTTP協議,服務器端無法主動向客戶端推送信息。
websocket是什麼
websocket是一種網絡通信協議,我們知道http協議只能從客戶端主動發起,不能從服務端推送數據到客戶端,websocket是一種不僅能從客戶端發送數據到服務端,也可主動從服務的推送數據給客戶端的一種協議。我們先來看一張經典圖:
從上述圖中我們可知:
- http請求是客戶端發起請求,服務端響應,然後斷開連接,客戶端發起,服務端響應的一種循環。
- websocket協議是客戶端發起連接後,就會一直保持連接,期間客戶端和服務端都可以向對方發送數據,直到連接關閉。
websocket特點
- 建立在TCP協議之上,服務器端的實現比較容易
- 與HTTP協議有着良好的兼容性。默認端口也是80和443,並且握手階段採用HTTP協議,因此握手時不容易屏蔽,能通過各種HTTP代理服務器
- 數據格式比較輕量,性能開銷小,通信高效
- 可以發送文本,也可以發送二進制數據
- 沒有同源限制,客戶端可以與任意服務器通信
- 協議標識符是ws(如果加密,是wss),服務器網址就是URL
websocket通信原理
當客戶端和服務端建立WebSocket連接時,在客戶端和服務器的握手過程中,客戶端首先會向服務端發送一個 HTTP 請求,包含一個Upgrade請求頭來告知服務端客戶端想要建立一個WebSocket連接。
// 請求頭
Accept-Encoding: gzip, deflate, br
Accept-Language: zh-CN,zh;q=0.9
Cache-Control: no-cache
Connection: Upgrade // 表示該連接要升級協議
Cookie: _hjMinimizedPolls=358479; ts_uid=7852621249; CNZZDATA1259303436=1218855313-1548914234-%7C1564625892; csrfToken=DPb4RhmGQfPCZnYzUCCOOade; JSESSIONID=67376239124B4355F75F1FC87C059F8D; _hjid=3f7157b6-1aa0-4d5c-ab9a-45eab1e6941e; acw_tc=76b20ff415689655672128006e178b964c640d5a7952f7cb3c18ddf0064264
Host: localhost:9000
Origin: http://localhost:9000
Pragma: no-cache
Sec-WebSocket-Extensions: permessage-deflate; client_max_window_bits
Sec-WebSocket-Key: 5fTJ1LTuh3RKjSJxydyifQ== // 與響應頭 Sec-WebSocket-Accept 相對應
Sec-WebSocket-Version: 13 // 表示 websocket 協議的版本
Upgrade: websocket // 表示要升級到 websocket 協議
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_14_0) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/76.0.3809.132 Safari/537.36
// 響應頭
Connection: Upgrade
Sec-WebSocket-Accept: ZUip34t+bCjhkvxxwhmdEOyx9hE=
Upgrade: websocket
// General中
Request URL: ws://localhost:9000
Request Method: GET
Status Code: 101 Switching Protocols
// status code是101 Switching Protocols ,表示該連接已經從HTTP協議轉換爲 WebSocket通信協議。 轉換成功之後,該連接並沒有中斷,而是建立了一個全雙工通信,後續發送和接收消息都會走這個連接通道。
注:請求頭中Sec-WebSocket-Key字段,和響應頭中的Sec-WebSocket-Accept是配套對應的,作用是提供基本的防護,比如惡意的連接或無效的連接。Sec-WebSocket-Key是客戶端隨機生成的一個base64編碼,服務器會使用這個編碼,並根據一個固定的算法。
GUID = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; // 一個固定的字符串
accept = base64(sha1(key + GUID)); // key是Sec-WebSocket-Key,accept是 Sec-WebSocket-Accept
// 其中GUID字符串是RFC6455 官方定義的一個固定字符串,不得修改。
客戶端拿到服務端響應的Sec-WebSocket-Accept後,會拿自己之前生成的Sec-WebSocket-Key用相同算法算一次,如果匹配,則握手成功。然後判斷 HTTP Response 狀態碼是否爲 101(切換協議),如果是,則建立連接。
應用場景
我們通過一個例子來想下,比如我們平常生活中買東西,在支付時,支付成功後,需要給用戶反饋一個支付成功的提示,那麼在websocket應用之前,我們一般使用輪詢的方法處理,即客戶端定時向服務端發送請求,看有沒有收到支付金額,沒有就一直髮送,收到了再停止。
在發送請求的過程中,浪費了大量的資源,而且響應也不是及時的,比如我是每隔1秒請求一次,但是並不能立刻得到支付成功的狀態。這時如果使用websocket的方式,就會及時響應支付狀態,websocket一般用在一些能及時響應的場景中。主要應用在如下幾種場景中:
- 社交訂閱
有時我們需要及時收到訂閱消息,比如開獎通知、在線邀請,支付結果等
- 多玩家遊戲
很多遊戲都是協同作戰的,玩家的操作和狀態需要及時同步給所有玩家
- 協同編輯文檔
同一份文檔,編輯狀態需要同步到所有參與的用戶界面上
- 數據流狀態
比如上傳下載文件,文件進度,文件是否上傳成功等
- 多人聊天
很多場景下都需要多人蔘與討論聊天,用戶發送的消息得第一時間同步到所有用戶
- 股票虛擬貨幣價格
股票和虛擬貨幣的價格都是實時波動的,價格跟用戶的操作息息相關,及時推送對用戶跟盤有很大的幫助
websocket客戶端的API
- WebSocket 構造函數
// WebSocket對象作爲一個構造函數,用於新建WebSocket實例。
var ws = new WebSocket('ws://localhost:8080');
// 執行完上面語句之後,客戶端就會與服務器進行連接
- webSocket.readyState
readyState屬性返回實例對象的當前狀態,共有四種:
- CONNECTING:值爲0,表示正在連接。
- OPEN:值爲1,表示連接成功,可以通信了。
- CLOSING:值爲2,表示連接正在關閉。
- CLOSED:值爲3,表示連接已經關閉,或者打開連接失敗。
switch (ws.readyState) {
case WebSocket.CONNECTING:
// TODO
break;
case WebSocket.OPEN:
// TODO
break;
case WebSocket.CLOSING:
// TODO
break;
case WebSocket.CLOSED:
// TODO
break;
default:
// this never happens
break;
}
- webSocket.onopen
實例對象的onopen屬性,用於指定連接成功後的回調函數
ws.onopen = function () {
ws.send('Hello Server!');
}
如果要指定多個回調函數,可以使用addEventListener方法
ws.addEventListener('open', function (event) {
ws.send('Hello Server!');
});
- webSocket.onclose
實例對象的onclose屬性,用於指定連接關閉後的回調函數。
ws.onclose = function(event) {
var code = event.code;
var reason = event.reason;
var wasClean = event.wasClean;
// TODO
};
// 如果要指定多個回調函數,同上面的寫法
- webSocket.onmessage
實例對象的onmessage屬性,用於指定收到服務器數據後的回調函數。
ws.onmessage = function(event) {
var data = event.data;
// TODO
};
注:服務器數據可能是文本,也可能是二進制數據(blob對象或Arraybuffer對象)
ws.onmessage = function(event){
if(typeof event.data === String) {
console.log("Received data string");
}
if(event.data instanceof ArrayBuffer){
var buffer = event.data;
console.log("Received arraybuffer");
}
}
除了動態判斷收到的數據類型,也可使用binaryType屬性,指定收到的二進制數據類型。
// 收到blob數據
ws.binaryType = "blob";
ws.onmessage = function(e) {
console.log(e.data.size);
};
// 收到ArrayBuffer數據
ws.binaryType = "arraybuffer";
ws.onmessage = function(e) {
console.log(e.data.byteLength);
};
- webSocket.send()
實例對象的send()方法用於向服務器發送數據。
// 發送文本
ws.send('hello javascript');
// 發送Blob對象
var file = document.querySelector('input[type="file"]').files[0];
ws.send(file);
// 發送ArrayBuffer對象
var img = canvas_context.getImageData(0, 0, 400, 320);
var binary = new Uint8Array(img.data.length);
for (var i = 0; i < img.data.length; i++) {
binary[i] = img.data[i];
}
ws.send(binary.buffer);
- webSocket.bufferedAmount
實例對象的bufferedAmount屬性,表示還有多少字節的二進制數據沒有發送出去。它可以用來判斷髮送是否結束
var data = new ArrayBuffer(10000000);
socket.send(data);
if (socket.bufferedAmount === 0) {
// 發送完畢
} else {
// 發送沒結束
}
- webSocket.onerror
實例對象的onerror屬性,用於指定報錯時的回調函數。
socket.onerror = function(event) {
// TODO
};
實現一個簡單聊天
實現一個一對一的單聊天功能:
客戶端
function connectWebsocket() {
ws = new WebSocket('ws://localhost:9000');
// 監聽連接成功
ws.onopen = () => {
ws.send(JSON.stringify(msgData)); // 給服務端發送消息
};
// 監聽服務端消息(接收消息)
ws.onmessage = (msg) => {
let message = JSON.parse(msg.data);
console.log('收到的消息:', message);
};
// 監聽連接失敗
ws.onerror = () => {
// 連接失敗,正在重連...
connectWebsocket();
};
// 監聽連接關閉
ws.onclose = () => {
console.log('連接關閉');
};
};
connectWebsocket();
服務端
const path = require('path');
const express = require('express');
const app = express();
const server = require('http').Server(app);
const WebSocket = require('ws');
const wss = new WebSocket.Server({ server: server });
wss.on('connection', (ws) => {
// 監聽客戶端發來的消息
ws.on('message', (message) => {
console.log(wss.clients.size);
let msgData = JSON.parse(message);
if (msgData.type === 'open') {
// 初始連接時標識會話
ws.sessionId = `${msgData.fromUserId}-${msgData.toUserId}`;
} else {
let sessionId = `${msgData.toUserId}-${msgData.fromUserId}`;
wss.clients.forEach(client => {
if (client.sessionId === sessionId) {
client.send(message); // 給對應的客戶端連接發送消息
}
})
}
})
// 連接關閉
ws.on('close', () => {
console.log('連接關閉');
});
server.listen(9000, function () {
console.log('http://localhost:9000');
});
});
最終效果圖如下:
總結:
- websocket是一種類似http的一種通訊協議
- websocket最大特點是客戶端和服務端能相互給對方發送消息
- websocket廣泛應用在需要實時通訊的一些場景上面
- websocket沒有同源限制,而且性能開銷小,通信高效
- websocket幾乎所有瀏覽器都支持
- websocket通過send()方法發送消息,onmessage事件接收消息,然後對消息進行處理顯示在頁面上。 onerror 事件(監聽連接失敗)觸發時,最好進行執行重連,以保持連接不中斷。