從Chrome源碼看WebSocket
WebSocket是為了解決雙向通信的問題,因為一方面HTTP的設計是單向的,只能是一邊發另一邊收。而另一方面,HTTP等都是建立在TCP連接之上的,HTTP請求完就會把TCP給關了,而TCP連接本身就是一個長連接嗎,只要連接雙方不斷關閉連接它就會一直連接態,所以有必要再搞一個WebSocket的東西嗎?
我們可以考慮一下,如果不搞WebSocket怎么實現長連接:
(1)HTTP有一個keep-alive的字段,這個字段的作用是復用TCP連接,可以讓一個TCP連接用來發多個http請求,重復利用,避免新的TCP連接又得三次握手。這個keep-alive的時間服務器如 Apache 的時間是5s,而 nginx 默認是75s,超過這個時間服務器就會主動把TCP連接關閉了,因為不關閉的話會有大量的TCP連接占用系統資源。所以這個keep-alive也不是為了長連接設計的,只是為了提高http請求的效率,而http請求上面已經提到它是面向單向的,要么是服務端下發數據,要么是客戶端上傳數據。
(2)使用HTTP的輪詢,這也是一種很常用的方法,沒有websocket之前,基本上網頁的聊天功能都是這么實現的,每隔幾秒就向服器發個請求拉取新消息。這個方法的問題就在于它也是需要不斷地建立TCP連接,同時HTTP頭部是很大的,效率低下。
(3)直接和服務器建立一個TCP連接,保持這個連接不中斷。這個至少在瀏覽器端是做不到的,因為沒有相關的API。所以就有了WebSocket直接和服務器建立一個TCP連接。
TCP連接是使用套接字建立的,如果你寫過Linux服務的話,就知道怎么用系統底層的API(C語言)建立一個TCP連接,它是使用的套接字socket,這個過程大概如下,服務端使用socket創建一個TCP監聽:
// 先創建一個套接字,返回一個句柄,類似于setTimout返回的tId
// AF_INET是指使用IPv4地址,SOCK_STREAM表示建立TCP連接(相對于UDP)
int sockfd = socket(AF_INET, SOCK_STREAM, 0));
// 把這個套接字句柄綁定到一個地址,如localhost:9000
bind(sockfd, servaddr, sizeof(servaddr));
// 開始使用這個套接字監聽,最大pending的連接數為100
listen(sockfd, 100);
客戶端也使用的套接字進行連接:
// 客戶端也是創建一個套接字
int sockfd = socket(AF_INET, SOCK_STREAM, 0));
// 用這個套接字連接到一個serveraddr
connect(sockfd, servaddr, sizeof(servaddr));
// 向這個套接字發送數據
send(sockfd, sendline, strlen(sendline), 0);
// 關閉連接
close(sockfd);
也就是說TCP和UDP連接都是使用套接字創建的,所以WebSocket的名字就是這么來的,本質上它就是一個套接字,并變成了一個標準,瀏覽器器開放了API,讓網頁開發人員也能直接創建套接字和服務端進行通信,并且這個套接字什么時候要關閉了由你們去決定,而不像http一樣請求完了瀏覽器或者服務器就自動把TCP的套接字連接關了。
所以說WebSocket并不是一個什么神奇的東西,它就是一個套接字。同時,WebSocket得借助于現有的網絡基礎,如果它再從頭搞一套建立連接的標準代價就會很大。在它之前能夠和服務連接的就只有http請求,所以它得借助于http請求來建立一個原生的socket連接,因此才有了協議轉換的那些東西。
瀏覽器建立一個WebSocket連接非常簡單,只需要幾行代碼:
// 創建一個套接字
const socket = new WebSocket('ws://192.168.123.20:9090');
// 連接成功
socket.onopen = function (event) {
console.log('opened');
// 發送數據
socket.send('hello, this is from client');
};
因為瀏覽器已經按照文檔實現好了,而要創建一個WebSocket的服務端應該怎么寫呢?這里我們先拋開Chrome源碼,先研究服務端的實現,然后再反過來看瀏覽器客戶端的實現。準備用Node.js實現一個WebSocket的服務端,來研究整一個連接建立和接收發送數據的過程是怎么樣的。
WebSocket已經在 RFC 6455 里面進行了標準化,我們只要按照文檔的規定進行實現就能和瀏覽器進行對接,這個文檔的說明比較有趣,特別是第1部分,有興趣的讀者可以看看,并且我們發現WebSocket的實現非常簡單,讀者如果有時間的話可以先嘗試自己實現一個,然后再回過頭來,對比本文的實現。
1. 連接建立
使用Node.js創建一個hello, world的http服務,如下代碼index.js所示:
let http = require("http");
const hostname = "192.168.123.20"; // 或者是localhost
const port = "9090";
// 創建一個http服務
let server = http.createServer((req, res) => {
// 收到請求
console.log("recv request");
console.log(req.headers);
// 進行響應,發送數據
// res.write('hello, world');
// res.end();
});
// 開始監聽
server.listen(port, hostname, () => {
// 啟動成功
console.log(`Server running at ${hostname}:${port}`);
});
注意到這里沒有任何的出錯和異常處理,被省略了,在實際的代碼里面為了提高程序的穩健性需要有異常處理,特別是這種server類的服務,不能讓一個請求就把整個server搞掛了。相關出錯處理可以參考Node.js的文檔。
保存文件,執行node index.js啟動這個服務。
然后寫一個index.html,請求上面寫的服務:
<!DOCType html>
<html>
<head>
<meta charset="utf-8">
</head>
<body>
<script>
!function() {
const socket = new WebSocket('ws://192.168.123.20:9090');
socket.onopen = function (event) {
console.log('opened');
socket.send('hello, this is from client');
};
}();
</script>
</body>
</html>
但是我們發現,Node.js代碼里的請求響應回調函數并不會執行,查了文檔發現是因為Node.js有另外一個upgrade的事件:
// 協議升級
server.on("upgrade", (request, socket, head) => {
console.log(request.headers);
});
因為WebSocket需要先協議升級,在upgrade里面就能收到升級的請求。把收到的請求頭打印出來,如下所示:
{ host: ‘192.168.123.20:9090’,
connection: ‘ Upgrade ‘,
pragma: ‘no-cache’,
‘cache-control’: ‘no-cache’,
upgrade: ‘websocket’,
origin: ‘http://127.0.0.1:8080’,
‘sec-websocket-version’: ’13’,
‘user-agent’: ‘Mozilla/5.0 (Macintosh; Intel Mac OS X 10_13_4) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/66.0.3359.181 Safari/537.36’,
‘accept-encoding’: ‘gzip, deflate’,
‘accept-language’: ‘en,zh-CN;q=0.9,zh;q=0.8,zh-TW;q=0.7’,
‘ sec-websocket-key ‘: ‘KR6cP3rhKGrnmIY2iu04Uw==’,
‘sec-websocket-extensions’: ‘permessage-deflate; client_max_window_bits’ }
這是我們建立連接收到的第一個請求,里面有兩個關鍵的字段,一個是connection: ‘Upgrade’表示它是一個升級協議請求,另外一個是sec-websocket-key,這是一個用來確認對方身份的隨機的base64字符串,下面將會用到。
我們需要對這個請求進行響應,按照文檔的說明,需要包含以下字段:
server.on("upgrade", (request, socket, head) => {
let base64Value = '';
// 第一行是響應行(Response line),返回狀態碼101
socket.write('HTTP/1.1 101 Web Socket Protocol Handshake\r\n' +
// http響應頭部字段用\r\n隔開
'Upgrade: WebSocket\r\n' +
'Connection: Upgrade\r\n' +
// 這是一個給瀏覽器確認身份的字符串
`Sec-WebSocket-Accept: ${base64Value}\r\n` +
'\r\n');
});
響應報文需要按照http規定的格式,第一行是響應行,包含了http的版本號,狀態碼101,狀態碼的解釋。每個頭部字段用\r\n隔開,這里面最關鍵的一個是Sec-WebSocket-Accept,它需要計算一下返回瀏覽器。怎么計算呢?文檔是這么規定的:
GUID(Globally_Unique_Identifier) = ‘258EAFA5-E914-47DA-95CA-C5AB0DC85B11’
Sec-WebSocket-Accept = base64(sha1(Sec-Websocket-key + GUID))
使用瀏覽器給我的sec-websocket-key值,拼上一個固定的字符串,這個字符串叫全局唯一標志符,然后取它的sha1值,再進行base64編碼,返回給瀏覽器。如果瀏覽器發現這個值不對的話,就會拋異常,拒絕下一步的連接操作:
因為它發現你是一個假的WebSocket服務,起碼不是按照文檔實現的,所以不是同一個世界,沒有共同語言,下面的交流就沒有必要了。
為了計算這個值需要引入一個sha1庫,base64轉換可以使用Node.js的Buffer轉換,如下代碼所示:
let sha1 = require('sha1');
// 協議升級
server.on("upgrade", (request, socket, head) => {
// 取出瀏覽器發送的key值
let secKey = request.headers['sec-websocket-key'];
// RFC 6455規定的全局標志符(GUID)
const UNIQUE_IDENTIFIER = '258EAFA5-E914-47DA-95CA-C5AB0DC85B11';
// 計算sha1和base64值
let shaValue = sha1(secKey + UNIQUE_IDENTIFIER),
base64Value = Buffer.from(shaValue, 'hex').toString('base64');
socket.write('HTTP/1.1 101 Web Socket Protocol Handshake\r\n' +
'Upgrade: WebSocket\r\n' +
'Connection: Upgrade\r\n' +
`Sec-WebSocket-Accept: ${base64Value}\r\n` +
'\r\n');
});
使用上面瀏覽器發送的key計算得到的accept值為:
RWMSYL3Zmo91ZR+r39JVM2+PxXc=
把這個值發給瀏覽器,Chrome就不會報剛剛那個檢驗出錯了,確認過眼神,遇上對的人。這樣WebSocket連接就建立了,沒錯就是這么簡單。Chrome開發者工具Network面板里的websocket連接將會從pending狀態變成101狀態,如果連接關閉了就會變成200狀態。
上面瀏覽器的代碼在建立連接完成之后還send了一個數據過來:
socket.send('hello, this is from client');
怎么讀取這個數據呢?
2. 接收數據
數據的傳送,文檔規定了WebSocket數據幀格式,長這個樣子:
不要被這個嚇到,一個個拆解來看的話,還是挺簡單的。可以分成兩個部分,幀頭字段和有效內容或者叫有效負載(Payload Data),幀頭字段主要的作用是為了解釋這個幀的,如第1位(bit) FIN 如果置為1就表示它是一個結束幀,如果數據比較長就會被拆成幾個幀發送,FIN為1表示它是當前數據流的最后一個幀。第4到第7倍的 opcode 是用來做一些指令控制的,如果值為1話就表示Payload Data是文本格式的,2則表示二進制內容,8表示連接關閉。第9位到第15位共7位 Payload Len 表示有效負載的字節數,7位二進制數最大表示127,如果有效負載字節數大于127的話就需要用到Extended payload length部分。
第8位的 Mask 如果設置為1就表示這個幀的有效負載內容被掩碼處理過了,客戶端向服務端發送的幀需要進行掩碼,而服務端向客戶端發送的數據幀不需要掩碼。為什么要使用掩碼,這個掩碼計算又是怎么進行的呢?掩碼計算很簡單,就是把要發送的數據和另一個數字異或一下再放到Payload Data, 這個數字就是上面數據幀里的 Masking-key ,它是一個32位的數字。接收方把Payload Data再和這個數異或一下就能得到原始的數據,因為和同一個數異或兩次等于原本的數,即:
a ^ b ^ b = a
并且每個幀里的Making-key要求都是隨機的,不可被(代理)服務所預測的,為什么要這樣呢?文檔里面是這么說的:
The unpredictability of the masking key is essential to prevent authors of malicious applications from selecting the bytes that appear on the wire
這個解釋有點含糊, Stackoverflow 上有人說是為了避免代理緩存中毒攻擊,具體可參考 Http Cache Poinsing .
所以我們需要從這個幀里面取出掩碼的key值,還原原始的paylod數據。
數據的發送和傳輸都要靠socket對象,因為它不是走的http請求,所以在http的響應函數里面是收不到數據的,在upgrade事件里面可以拿到這個socket,監聽這個socket對象的data事件,就可以得到接收的數據:
socket.on('data', buffer => {
console.log('buffer len = ', buffer.length);
console.log(buffer);
});
返回的數據類型是Node.js里的Buffer對象,把這個buffer打印出來:
buffer len = 32 <Buffer 81 9a 4c 3f 64 75 24 5a 08 19 23 13 44 01 24 56 17 55 25 4c 44 13 3e 50 09 55 2f 53 0d 10 22 4b>
這個buffer就是websocket客戶端給我們發送的數據幀了,總共有32個字節,上面的打印是用的16進制表示,可以改二進制0101表示,和上面那個數據幀格式圖一一對照,就能夠解釋這個數據幀是什么意思,有什么內容。把它打印成原始二進制表示:
參照報文格式,如下圖所示:
通過opcode可以知道它是一個文本數據的幀,payload len得到文本長度為26個字節,這個剛好等于上面發送的內容長度:
同時掩碼Mask是打開的,掩碼key值存放范圍是[16, 16 + 32],因為這里不需要使用擴展字段,所以Masking-key就直接跟在Payload len后面了,再往后就是Payload Data,范圍是[48, 48 + 26 * 8].
這就是一個完整的數據幀了,還需要把payload data用掩碼異或一下,還原原始數據。在Node.js里面進行處理。Node.js里面的Buffer類只能操作字節級別,如讀取第n個字節的內容,沒辦法直接操作位,如讀取第n位的數據。所以額外引入一個庫,網上找了一個BitBuffer,但是它的實現好像有問題,所以自已實現了一個。
如下代碼所示,實現一個能夠讀取任意位的BitBuffer:
class BitBuffer {
// 構造函數傳一個Buffer對象
constructor (buffer) {
this.buffer = buffer;
}
// 獲取第offset個位的內容
_getBit (offset) {
let byteIndex = offset / 8 >> 0,
byteOffset = offset % 8;
// readUInt8可以讀取第n個字節的數據
// 取出這個數的第m位即可
let num = this.buffer.readUInt8(byteIndex) & (1 << (7 - byteOffset));
return num >> (7 - byteOffset);
}
}
原理很簡單,先調Node.js的Buffer的readUInt8讀取第n個字節的數據,然后計算一下所要讀取的位數在這個字節的第幾位,通過與運算,把這個位取出來,更多位運算可以參考: 巧用JS位運算 。
用這個代碼取出第8位的Mask Flag是否有設置,如下代碼:
socket.on('data', buffer => {
let bitBuffer = new BitBuffer(buffer);
let maskFlag = bitBuffer._getBit(8);
console.log('maskFlag = ' + maskFlag);
});
打印maskFlag = 1。那么怎么取出連續的n位呢,如opcode,是從第4位到7位。這個也好辦就是把第4位到第7位分別取出來拼成一個數就好了:
getBit (offset, len = 1) {
let result = 0;
for (let i = 0; i < len; i++) {
result += this._getBit(offset + i) << (len - i - 1);
}
return result;
}
這個代碼的效率不是很高,但是容易理解。有個小坑就是JS的位移只支持32位整數的操作,1 << 31會變成一個負數,具體不展開討論。用這個函數取32位的掩碼值就會有問題。
可以利用這個函數取出opcode和payload len:
socket.on('data', buffer => {
let bitBuffer = new BitBuffer(buffer);
let maskFlag = bitBuffer.getBit(8),
opcode = bitBuffer.getBit(4, 4),
payloadLen = bitBuffer.getBit(9, 7);
console.log('maskFlag = ' + maskFlag);
console.log('opcode = ' + opcode);
console.log('payloadLen = ' + payloadLen);
});
打印如下:
maskFlag = 1 opcode = 1 payloadLen = 26
取掩碼值單獨實現一下,這個掩碼是拆成4個數使用的,一個字節表示一個數,借助上面的getBit函數,代碼如下:
getMaskingKey (offset) {
const BYTE_COUNT = 4;
let masks = [];
for (let i = 0; i < BYTE_COUNT; i++) {
masks.push(this.getBit(offset + i * 8, 8));
}
return masks;
}
這個例子的掩碼值是從第16位開始,所以offset是16:
let maskKeys = bitBuffer.getMaskingKey(16);
console.log('maskKey = ' + maskKeys);
打印出來的maskKey為:
maskKeys = 76, 63, 100, 117
怎么用這個Mask Key進行異或呢,文檔里面是這么規定的:
j = i MOD 4 transformed-octet-i = original-octet-i XOR masking-key-octet-j
也就是把Payload Data里面的第n,n + 1,n + 2,n + 3個字節內容分別與makKey數組的第0,1,2,3進行異或即可,所以這個實現也比較簡單,如下代碼所示:
getXorString (byteOffset, byteCount, maskingKeys) {
let text = '';
for (let i = 0; i < byteCount; i++) {
let j = i % 4;
// 通過異或得到原始的utf-8編碼
let transformedByte = this.buffer.readUInt8(byteOffset + i)
^ maskingKeys[j];
// 把編碼值轉成對應的字符
text += String.fromCharCode(transformedByte);
}
return text;
}
異或操作之后就可以得到編碼值,再借助String.fromCharCode就能得到對應的文本,如根據ASCII表,97就會被還原成字母’a’。
這個例子的payload data的偏移是第6個字節開始的,這里我們先直接寫死:
let payloadLen = bitBuffer.getBit(9, 7),
maskKeys = bitBuffer.getMaskingKey(16);
let payloadText = bitBuffer.getXorString(48 / 8, payloadLen, maskKeys);
console.log('payloadText = ' + payloadText);
打印的文本內容為:
payloadText = hello, this is from client
到這里,就把接收的數據還原出來了。如果想要發送數據,就是把讀取的過程逆一下,按照幀格式去拼一個符合規范的幀發送給對方,區別是服務端的幀數據是不需要Mask的,如果你Mask了,Chrome會報一個異常,說數據不需要Mask,拒絕解析接收到的數據。
我們再從Chrome源碼看Websocket客戶端的實現,來補充一些細節。
Chrome的websockets代碼是在src/net/websockets,例如Chrome在握手的時候是怎么生成一個隨機的sec-websocket-key?如下代碼所示:
std::string GenerateHandshakeChallenge() {
std::string raw_challenge(websockets::kRawChallengeLength, '\0');
crypto::RandBytes(base::string_as_array(&raw_challenge),
raw_challenge.length());
std::string encoded_challenge;
base::Base64Encode(raw_challenge, &encoded_challenge);
return encoded_challenge;
}
它是用的一個crypto::RandBytes生成隨機字節,而在檢驗sec-websocket-accept也是用的同樣的計算方法:
std::string ComputeSecWebSocketAccept(const std::string& key) {
std::string accept;
std::string hash = base::SHA1HashString(key + websockets::kWebSocketGuid);
base::Base64Encode(hash, &accept);
return accept;
}
而在使用掩碼計算的時候也是用的一樣的方法:
inline void MaskWebSocketFramePayloadByBytes(
const WebSocketMaskingKey& masking_key,
size_t masking_key_offset,
char* const begin,
char* const end) {
for (char* masked = begin; masked != end; ++masked) {
*masked ^= masking_key.key[masking_key_offset++];
if (masking_key_offset == WebSocketFrameHeader::kMaskingKeyLength)
masking_key_offset = 0;
}
}
其它的還有deflate壓縮、cookie、擴展extensions等,本文不再展開討論。
另外還有一個問題,使用一個WebSocket就需要操持一個TCP連接,如果有1000個用戶同時在線,那么服務端就得保持1000個TCP連接,而一個TCP連接通常需要占用一個獨立的線程,而線程的開銷是很大的,所以WebSocket對服務端的壓力特別大?其實也不見得有那么大,因為Linux有一個epoll的服務模型,它是一個事件驅動機制的,能夠讓一個核支持并發的很多個連接。
最后一個問題,由于連接是一直操持的,如果連接雙方有一方異常退出了,沒有發送一個關閉連接的包通知對方,那么對方就會傻傻地操持著這個沒用的連接,所以WebSocket又引入了一個ping/pong的消息幀,幀頭里的opcode為0x9就表示是一個ping幀,0x10表示pong的響應幀。所以可以讓客戶端不斷地ping,如每隔30秒就ping一次,服務收到了ping就知道當前客戶端還活著,給一個pong的響應,如果服務端太久沒收到ping了如1分鐘,那么就認為這個客戶端已經走了直接關閉連接。而客戶端如果沒收到pong響應那么就認為當前連接已經斷了,需要重連。瀏覽器JS的API沒有開放ping/pong,需要自已實現一個消息類型。
本篇主要討論了WebSocket存在的意義,給瀏覽器開放一個socket的API,并進行標準化,除了瀏覽器,APP等也都可以按照這個標準實現,彌補了HTTP單向傳輸的缺點。還討論了WebSocket報文幀的格式,以及怎么用Node.js讀取這個報文幀,客戶端會把它發送的內容進行掩碼處理,服務端收到的也需要進行掩碼還原。我們發現Chrome客戶端的實現有很多地方是類似的。
怎么保證WebSocket傳輸的穩定性可能又是另外一個話題了,包括出錯重連機制,跨中美地區的可能需要使用專線等。
Post Views: 8
來自:https://www.yinchengli.com/2018/05/27/chrome-websocket/