數(shù)據(jù)實時:即數(shù)據(jù)庫中的數(shù)據(jù)得到更新,頁面立刻就想得到更新并展示最新的數(shù)據(jù)狀態(tài)。通常使用在大數(shù)據(jù)可視化分析,運(yùn)營數(shù)據(jù)監(jiān)控等場景。
# 數(shù)據(jù)實時方案
Web想要更新頁面,通常都是客戶端發(fā)起Http異步請求,主動向服務(wù)端索取數(shù)據(jù),方案有:
(1)Ajax輪詢,又稱 Ajax短連接:即啟動一個定時器隔一定時間(如1s)發(fā)送一個請求,服務(wù)端收到請求無論如何都直接返回當(dāng)前數(shù)據(jù)庫狀態(tài)數(shù)據(jù)。缺點是實時性不夠,產(chǎn)生很多不必要的請求??捎糜谒⑿骂l率不是很高的場景。
(2)Ajax長連接:客戶端發(fā)起Http請求,并設(shè)置一個長超時時間,服務(wù)端收到請求后,檢查數(shù)據(jù)庫如果沒有更新則阻塞請求,直到有更新或超時為止??蛻舳嗣看问盏巾憫?yīng)后,立即再發(fā)一個請求,Comet就是這種方式。缺點是服務(wù)器的處理線程長時間掛起,極大浪費資源,且網(wǎng)絡(luò)鏈路可能被網(wǎng)關(guān)關(guān)閉,需要如ping數(shù)據(jù)來維持鏈接。
? 以上兩種機(jī)制都治標(biāo)不治本,是否能有一種機(jī)制,由服務(wù)端自己檢測數(shù)據(jù)狀態(tài),有更新主動告知客戶端。好在,HTML5推出了 WebSocket 協(xié)議,解決了這個問題
?
# WebSocket是什么
? WebSocket(以下簡稱 ws)是HTML5提供的一種在單個 TCP 連接上進(jìn)行全雙工通訊的網(wǎng)絡(luò)技術(shù),目的是在瀏覽器和服務(wù)器之間建立一個不受限的雙向通信的通道,讓雙方都可以主動給對方發(fā)消息。
? 雖說ws是H5下新的協(xié)議,但其實也不是全新的。它屬于應(yīng)用層協(xié)議,復(fù)用了HTTP的握手通道。ws協(xié)議與HTTP協(xié)議都是基于TCP的,因此都是可靠的協(xié)議。ws客戶端和服務(wù)器只需要做一個握手的動作,兩者之間就形成了一條快速通道。在建立握手連接時,數(shù)據(jù)是通過http進(jìn)行傳輸?shù)?,但建立之后,真正的?shù)據(jù)傳輸階段就不需要http參與了

?
# WebSocket的優(yōu)點
? ws協(xié)議相比于HTTP協(xié)議,它具有以下優(yōu)勢:
- 全雙工通信能力:支持客戶端和服務(wù)端主動給對方發(fā)送消息
- 高實時性:Ajax輪詢只是不斷的請求,而服務(wù)端檢測到更新主動推送才是真正意義上的實時。
- 高效節(jié)能:HTTP協(xié)議請求一般都會有較長的頭部,而需要實時更新的數(shù)據(jù)可能就一點點,這就造成了帶寬很多不必要的消耗。而ws協(xié)議控制數(shù)據(jù)包的頭部比較小,一般只有十個字節(jié)左右。
- 支持?jǐn)U展: ws協(xié)議定義了擴(kuò)展,用戶可以擴(kuò)展協(xié)議,或?qū)崿F(xiàn)自定義子協(xié)議。
-
沒有跨域限制:不是xhr請求,沒有同源策略的限制
?
# WebSocket的第一次握手
? 雖說ws支持雙向通訊能力,但請求必須是由客戶發(fā)起。由于發(fā)起時是一個http握手,因此格式如下
GET ws://localhost:3000/ws/chat HTTP/1.1
Host: localhost
Upgrade: websocket
Connection: Upgrade
Origin: http://localhost:3000
Sec-WebSocket-Key: w4v7O6xFTi36lq3RNcgctw== // 客戶端隨機(jī)串
Sec-WebSocket-Version: 13
值得注意的是:
(1)其只能發(fā)GET請求,且不再是 http://... 而是換成了 ws://... 開頭的地址
(2)請求頭Upgrade: websocket和Connection: Upgrade表示該連接將要被升級為WebSocket連接;
(3)Sec-WebSocket-Key 標(biāo)識連接的Key串(下方有更多解釋)
(4)Sec-WebSocket-Version 指定了WebSocket的協(xié)議版本。
如果服務(wù)器識別key正確,會接收這個請求,就會響應(yīng)如下:
HTTP/1.1 101 Switching Protocols
Upgrade: websocket
Connection: Upgrade
Sec-WebSocket-Accept: Oy4NRAQ13jhfONC7bP8dTKb4PTU= // 服務(wù)端隨機(jī)串
服務(wù)端Accept串是根據(jù)客戶端隨機(jī)串計算出來的,計算規(guī)則為:(1)與固定串拼接,(2)執(zhí)行sha1算法,(3)轉(zhuǎn)為base64字符串。這對Key/Accept需ws客戶端和服務(wù)端提前約定,目的是為了避免非法ws請求等一些常見的意外情況。并不能確保數(shù)據(jù)安全性,畢竟算法公開且簡單。公式如下:
toBase64( sha1( Sec-WebSocket-Key + 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 ) )
響應(yīng)碼101表示將切換協(xié)議,更改后的協(xié)議就是Upgrade: websocket指定的WebSocket協(xié)議。當(dāng)連接建立成功后,雙方就可以自由通訊消息了。消息一般分兩種:(1)文本,(2)二進(jìn)制數(shù)據(jù)。開發(fā)中會使用JSON文本數(shù)據(jù)比較直觀。
?
# ws為什么能實現(xiàn)全雙工通訊
? 前文多次遇到 全雙工通信 字眼,意思就是客戶端和服務(wù)端能隨時給對方發(fā)送消息。好像理解了但又朦朦朧朧。這里解釋一下:
- 單工: 數(shù)據(jù)傳輸只支持在一個方向上的傳輸,同時只能有一方發(fā)送或接收消息。
- 半雙工:數(shù)據(jù)允許在兩個方向上傳輸,但任一時刻,只允許有一方在傳輸,是一種切換方向的單工通信
- 全雙工:任何時刻都允許兩個方向進(jìn)行數(shù)據(jù)傳輸,不受對方限制。
? HTTP 和WebSocket 都是基于TCP傳輸協(xié)議的,其實TCP本身是支持全雙工通訊的,而HTTP協(xié)議的請求,因為其應(yīng)答機(jī)制限制了全雙工通信。當(dāng)?shù)谝淮挝帐滞瓿珊?,協(xié)議由HTTP切換成了WebSocket,ws連接建立,其實只是簡單規(guī)定了下:后續(xù)通訊不再使用http協(xié)議,雙發(fā)可以互相發(fā)送數(shù)據(jù)了。

?
# 安全的WebSocket通訊
? 與 HTTPS 類似,安全的ws連接使用的是wss://...開頭的請求,它首先會通過https創(chuàng)建安全的連接,升級協(xié)議后,底層通信依然走的 SSL/TLS 協(xié)議
?
# 連接保持 - 心跳
? WebSocket為了保持客戶端與服務(wù)端的實時雙向通訊,需保持TCP通道鏈接沒有斷開。然而長時間沒有數(shù)據(jù)往來的連接,會浪費一些連接資源,網(wǎng)絡(luò)鏈路同樣可能被網(wǎng)關(guān)關(guān)閉,畢竟網(wǎng)關(guān)不是我們能控制的。因此鏈路鏈接就需要提示說明還在使用周期內(nèi),這個提示就是心跳來實現(xiàn)的。
- 發(fā)送方 --> 接收方: ping
- 接收方 --> 發(fā)送方: pong
舉例,ws服務(wù)端向客戶端發(fā)送ping,代碼如下
ws.ping('', false, true)
?
$ WebSocket API
? 理解了WebSocket的概念及相應(yīng)的特征后,來看看怎么上手編寫
# 創(chuàng)建WebSocket實例
? ws提供了WebSocket(url[, protocals])構(gòu)造函數(shù)來返回實例化ws對象。參數(shù)一表示要連接的URL,參數(shù)二表示可接受的子協(xié)議。
let socket= new WebSocket('http://localhost:8080')
? 執(zhí)行以上代碼,瀏覽器就開始嘗試創(chuàng)建連接,與 xhr 的readystatechange 類似的是,ws連接也有一個表示當(dāng)前狀態(tài)的屬性readyState,
# 連接狀態(tài)-readyState 只讀
? 用于返回當(dāng)前WebSocket連接的狀態(tài),其值即含義如下
| 值 | 狀態(tài)含義 |
|---|---|
| 0 | WebSocket.CONNECTING |
| 1 | WebSocket.OPEN |
| 2 | WebSocket.CLOSING |
| 3 | WebSocket.CLOSED |
一個ws連接各個狀態(tài)的執(zhí)行時刻如下
let socket = new WebSocket('http://localhost:8080')
// 正在創(chuàng)建連接
console.log('[readyState]:', socket.readyState) // 0
// 連接建立成功后觸發(fā)onopen回調(diào)
socket.onopen = function() {
console.log('connected,[readyState]:', socket.readyState) // 1
// 發(fā)送消息
socket.send('from client: Hello')
}
// 從服務(wù)端收到信息觸發(fā)onmessage回調(diào)
socket.onmessage = function() {
console.log('received,[readyState]:', socket.readyState) // 1
// 發(fā)送消息
socket.send('from client: Hello')
}
// 連接失敗觸發(fā)onerror回調(diào)
socket.onerror = function() {
console.log('connect error, [readyState]:', socket.readyState) // 3
}
// 調(diào)用關(guān)閉連接,狀態(tài)立刻變成2(正在關(guān)閉)。關(guān)閉成功觸發(fā)onclose變成3
socket.close()
// 連接關(guān)閉觸發(fā)onclose回調(diào),有回調(diào)參數(shù)
socket.onclose = function(event) {
const { code, reason, wasClean } = event
console.log('connect closed, [readyState]:', socket.readyState) // 3
console.log(code, reason, wasClean) // wasClean表示連接是否已經(jīng)關(guān)閉。boolean
}
? 當(dāng)readyState的值從 0 變成 1 后,客戶端和服務(wù)端就可以通訊了。
?
# 方法
- 發(fā)送數(shù)據(jù) send()
? 發(fā)送數(shù)據(jù)一定是伴隨在連接已經(jīng)打開的情況下
socket.addEventListener('open', function(event) {
sokcet.send('hello server')
})
- 關(guān)閉連接 close()
? 關(guān)閉當(dāng)前連接??梢詡?0/1/2 個參數(shù)。code解釋關(guān)閉原因的狀態(tài)碼。reason解釋關(guān)閉原因的描述(限制123個字節(jié))。
sokcet.close([code[, reason]])
如果未傳參數(shù),會默認(rèn)code為1005,意為:無參數(shù),未提供關(guān)閉原因狀態(tài)碼。查看 狀態(tài)碼詳情。如果提供一個無效的狀態(tài)碼,會拋出異常INVALID_ACCESS_ERR。
?
# 事件
- 連接已建立 onopen
socket.addEventListener('open', function(event) {
// TODO: send message
});
- 接收服務(wù)端消息回調(diào) onmessage
? 當(dāng)服務(wù)器向客戶端發(fā)來消息時,WebSocket對象會觸發(fā)message事件。這個message事件與其他傳遞消息的協(xié)議類似,也是把返回的數(shù)據(jù)保存在event.data屬性中
socket.addEventListener('message', function(event) {
var data = event.data;
// TODO:
});
- 關(guān)閉連接的回調(diào) onclose
socket.addEventListener('close', function(event) {
const { code, reason, wasClean } = event
// TODO:
});
- 連接失敗的回調(diào) onerror
socket.addEventListener('error', function(event) {
console.error("WebSocket error observed:", event)
});
?
# 屬性
- 當(dāng)前剩余未發(fā)送數(shù)據(jù) bufferedAmount 只讀
? 用于返回已經(jīng)被send()方法放入隊列但還沒有被發(fā)送到網(wǎng)絡(luò)中的數(shù)據(jù)的字節(jié)數(shù),只有發(fā)送完成它才會被重置為0。如果發(fā)送過程中連接被關(guān)閉不會重置,不斷的調(diào)用send()該值會不斷增長。
if (ws.bufferedAmount === 0){
console.log("發(fā)送已完成");
} else {
console.log("還有", ws.bufferedAmount, "數(shù)據(jù)沒有發(fā)送");
}
- 連接二進(jìn)制類型 binaryType 只讀
? 返回websocket連接所傳輸二進(jìn)制數(shù)據(jù)的類型
const binaryType = socket.binaryType
- 已選擇的擴(kuò)展值 extensions 只讀
? 返回服務(wù)器已選擇的擴(kuò)展值
const extensions = socket.extensions
- 子協(xié)議 protocol 只讀
? 返回服務(wù)器端選中的子協(xié)議的名字;也就是在實例化WebSocket對象時,在參數(shù)protocols中指定的字符串
const protocol = socket.protocol
- 子協(xié)議 url 只讀
? 返回值為當(dāng)構(gòu)造函數(shù)創(chuàng)建WebSocket實例對象時URL的絕對路徑。
const url = socket.url
?
$ 一個服務(wù)端實例
這里提供一個簡單的例子,引入了ws庫實現(xiàn)。也可以使用socket.io
var app = require('express')();
var server = require('http').Server(app);
var WebSocket = require('ws');
var wss = new WebSocket.Server({ port: 8080 });
wss.on('connection', function connection(ws) {
console.log('server: receive connection.');
ws.on('message', function incoming(message) {
console.log('server: received: %s', message);
});
ws.send('world');
});
app.get('/', function (req, res) {
res.sendfile(__dirname + '/index.html');
});
app.listen(3000);
?
結(jié)束語
參考文獻(xiàn)
WebSocket-菜鳥教程
WebSocket-MDN
WebSocket-廖雪峰的官方網(wǎng)站