前言
最近在構建兩個系統的實時通訊部分,總結一下所學。
這是一個系列文章,暫時主要構思四個部分
- 深入淺出Websocket(一)Websocket協議
- 深入淺出Websocket(二)分散式Websocket伺服器叢集
- 深入淺出Websocket(三)分頻道的Websocket(分析socket.io原始碼以及ws-wrapper)以及斷線重連(socket.io)
正文
本文主要介紹Websocket是什麼以及其協議內容。
WebSocket 協議實現在受控環境中執行不受信任程式碼的一個客戶端到一個從該程式碼已經選擇加入通訊的遠端主機之間的全雙工通訊。該協議包括一個開啟階段握手規定以及通訊時基本訊息幀的定義。其基於TCP之上。此技術的目標是為基於瀏覽器的應用程式提供一種機制,這些應用程式需要與伺服器進行雙向通訊,而不依賴於開啟多個HTTP連線(例如,使用XMLHttpRequest或<iframe>和長輪詢
)。
Websocket能做什麼
過去,建立需要在客戶端和服務之間雙向通訊(例如,即時訊息和遊戲應用)的web應用,需要通過HTTP來輪詢伺服器來獲取更新然後如果是推送訊息則傳送另一個請求(現在很多應用也依舊採用這種方式)。這樣做會存在一些問題。
- 伺服器端被迫提供兩類介面,一類提供給客戶端輪詢新訊息,一類提供給客戶端推送訊息給伺服器端。
- HTTP協議有較多的額外費用(overhead),每次傳送訊息都會有一個HTTP header資訊,而且如果不用Keep-Alive每次還都要握手。
- 客戶端的指令碼比如JS可能還需要跟蹤整個過程,也就是說我傳送一個訊息後,我可能需要跟蹤這個訊息的返回。
一個簡單的辦法是使用單個TCP連線雙向傳輸。這是為什麼提供WebSocket 協議。與WebSocket API結合[WSAPI],它提供了一個HTTP輪詢的替代來進行從web 頁面到遠端伺服器的雙向通訊。
協議內容
Websocket協議主要包括兩個部分,一個是握手的規則,另一個是資料傳輸的方式及載體格式。這裡給個網上找的例子(點這裡),可以開發者工具看看Network裡面的內容。
一旦客戶端和伺服器握手成功後,資料傳輸部分就開始了,這是一個全雙工的通訊。客戶端與伺服器之間互相傳輸資料的的基本單位根據規格說明書裡我們稱為“Messages”。在實際網路中,這些Message由一個或多個Frames組成,Websocket的Message裡的frame和計算機網路裡說的的frame並不是對應關係,後面會詳細介紹Frame的結構。
握手
開啟階段握手目的是相容基於HTTP的伺服器軟體和中介軟體,以便單個埠可以用於與伺服器交流的HTTP客戶端和與伺服器交流的WebSocket客戶端。所以WebSocket客戶端的握手是一個HTTP Upgrade請求(Http status code 101):
這裡關於欄位就講幾個欄位以及它們的考量
Origin(請求頭)
Origin
用來指明請求的來源,Origin
頭部主要用於保護Websocket伺服器免受非授權的跨域指令碼呼叫Websocket API的請求。也就是不想沒被授權的跨域訪問與伺服器建立連線,伺服器可以通過這個欄位來判斷來源的域並有選擇的拒絕。
Sec-WebSocket-Key(請求頭)以及Sec-WebSocket-Accept(響應頭)
另一方面,Websocket協議需要保證客戶端發起的Websocket連線請求只會被能理解Websocket協議的伺服器所識別。
Really, as you are mentioned, if you are aware of websockets (that is what to be checked), you could pretend to be a websocket server by sending correct response. But then, if you will not act correctly (e.g. form frames correctly), it will be considered as a protocol violation. Actually, you can write a websocket server that is incorrect, but there will be not much use in it.
And another purpose is to prevent clients accidentally requesting websockets upgrade not expecting it (say, by adding corresponding headers manually and then expecting smth else). Sec-WebSocket-Key and other related headers are prohibited to be set using setRequestHeader method in browsers.
資料傳輸
下面介紹下Frame的結構
之前也說過,客戶端與伺服器之間互相傳輸資料的的基本單位根據規格說明書裡我們稱為“Messages”。在實際網路中,這些Message由一個或多個Frames組成。
FIN
,指明Frame是否是一個Message裡最後Frame(之前說過一個Message可能又多個Frame組成)RSV1-3
,必須是0,除非有擴充套件定義了非零值的意義。Opcode
,這個比較重要,有如下取值是被協議定義的-
%x0 denotes a continuation frame
-
%x1 表示一個text frame
-
%x2 表示一個binary frame
-
%x3-7 are reserved for further non-control frames
-
%x8 表示連線關閉
-
%x9 表示 ping (心跳檢測相關,後面會講)
-
%xA 表示 pong (心跳檢測相關,後面會講)
-
%xB-F are reserved for further control frames
-
Mask
,這個是指明“payload data”是否被計算掩碼。這個和後面的Masking-key
有關Payload len
,資料的長度,不贅述了。Masking-key
,這裡不贅述了,給一個Websocket中掩碼的意義Payload data
,幀真正要傳送的資料,可以是任意長度,但儘管理論上幀的大小沒有限制,但傳送的資料不能太大,否則會導致無法高效利用網路頻寬,正如上面所說Websocket提供分片。
動手算一下 下面是charles裡面擷取的一段內容
// 十六進位制
81 84 3a a6 ac e4 51 c3 c7 81
// 二進位制
10000001 10000100 00111010 10100110 10101101 11100100 01010001 11010011 11010111 10000001
複製程式碼
opcode為0001,0x1表示一個Text frame
payload len為0000100,0x4表示長度為4位元組
掩碼是 00111010 10100110 10101101 11100100
payload是 01010001 11010011 11010111 10000001
具體的處理可以參考Node.js ws的原始碼 其中的buffer-utils
Websocket的使用及API
講完Websocket協議部分,現在說說如何相關的Web API。
// 客戶端
var ws = new WebSocket('wss://example.com/socket'); ➊
ws.onerror = function (error) { ... } ➋
ws.onclose = function () { ... } ➌
ws.onopen = function () { ➍
ws.send("Connection established. Hello server!"); ➎
}
ws.onmessage = function(msg) { ➏
if(msg.data instanceof Blob) { ➐
processBlob(msg.data);
} else {
processText(msg.data);
}
}
複製程式碼
- 開啟新的安全 WebSocket 連線(wss)
- 可選的回撥,在連線出錯時呼叫
- 可選的回撥,在連線終止時呼叫
- 可選的回撥,在 WebSocket 連線建立時呼叫
- 客戶端先向伺服器傳送一條訊息
- 回撥函式,伺服器每發回一條訊息就呼叫一次
- 根據接收到的訊息,決定呼叫二進位制還是文字處理邏輯
心跳檢測
在使用websocket的過程中,有時候會遇到客戶端網路關閉的情況,而這時候在服務端並沒有觸發onclose事件。這樣會:
- 多餘的連線
- 服務端會繼續給客戶端發資料,這些資料會丟失
所以就需要一種機制來檢測客戶端和服務端是否處於正常連線的狀態。心跳檢測就是這樣的一種機制,一般來說客戶端每過一定時間
ws模組對心跳的處理
ws模組如何通過心跳檢測去檢測和關閉壞掉的連線
const WebSocket = require('ws');
const wss = new WebSocket.Server({ port: 8080 });
function noop() {}
function heartbeat() {
this.isAlive = true;
}
wss.on('connection', function connection(ws) {
ws.isAlive = true;
ws.on('pong', heartbeat);
});
const interval = setInterval(function ping() {
wss.clients.forEach(function each(ws) {
if (ws.isAlive === false) return ws.terminate();
ws.isAlive = false;
ws.ping(noop);
});
}, 30000);
複製程式碼
根據規範,當接收到Ping訊息後Pong響應訊息會自動傳送。
解決ws與wss共存
下面是我的nginx配置,順帶加了負載均衡。測了可用,就是證照由於是自簽名的所以有點問題。
Websocket怎麼做身份認證
大體上Websocket的身份認證都是發生在握手階段,通過請求中的內容來認證。一個常見的例子是在url中附帶引數。
new WebSocket("ws://localhost:3000?token=xxxxxxxxxxxxxxxxxxxx");
複製程式碼
淘寶的直播彈幕也是用這種方式做的身份認證
以npm的ws模組實現為例,其建立Websocket伺服器時提供了verifyClient方法。
const wss = new WebSocket.Server({
host: SystemConfig.WEBSOCKET_server_host,
port: SystemConfig.WEBSOCKET_server_port,
// 驗證token識別身份
verifyClient: (info) => {
const token = url.parse(info.req.url, true).query.token
let user
console.log('[verifyClient] start validate')
// 如果token過期會爆TokenExpiredError
if (token) {
try {
user = jwt.verify(token, publicKey)
console.log(`[verifyClient] user ${user.name} logined`)
} catch (e) {
console.log('[verifyClient] token expired')
return false
}
}
// verify token and parse user object
if (user) {
info.req.user = user
return true
} else {
info.req.user = {
name: `遊客${parseInt(Math.random() * 1000000)}`,
mail: ''
}
return true
}
}
})
複製程式碼
相關的ws原始碼位於ws/websocket-server
// ...
if (this.options.verifyClient) {
const info = {
origin: req.headers[`${version === 8 ? 'sec-websocket-origin' : 'origin'}`],
secure: !!(req.connection.authorized || req.connection.encrypted),
req
};
if (this.options.verifyClient.length === 2) {
this.options.verifyClient(info, (verified, code, message) => {
if (!verified) return abortHandshake(socket, code || 401, message);
this.completeUpgrade(extensions, req, socket, head, cb);
});
return;
}
if (!this.options.verifyClient(info)) return abortHandshake(socket, 401);
}
this.completeUpgrade(extensions, req, socket, head, cb);
}
複製程式碼
後記
參考資料:
《rfc6455》 The WebSocket Protocol
《High Performance Browser Networking》- 【加】Ilya Grigorik