從Chrome源碼看WebSocket

WebSocket是爲了解決雙向通訊的問題,由於一方面HTTP的設計是單向的,只能是一邊發另外一邊收。而另外一方面,HTTP等都是創建在TCP鏈接之上的,HTTP請求完就會把TCP給關了,而TCP鏈接自己就是一個長鏈接嗎,只要鏈接雙方不斷關閉鏈接它就會一直鏈接態,因此有必要再搞一個WebSocket的東西嗎?

咱們能夠考慮一下,若是不搞WebSocket怎麼實現長鏈接:javascript

(1)HTTP有一個keep-alive的字段,這個字段的做用是複用TCP鏈接,可讓一個TCP鏈接用來發多個http請求,重複利用,避免新的TCP鏈接又得三次握手。這個keep-alive的時間服務器如Apache的時間是5s,而nginx默認是75s,超過這個時間服務器就會主動把TCP鏈接關閉了,由於不關閉的話會有大量的TCP鏈接佔用系統資源。因此這個keep-alive也不是爲了長鏈接設計的,只是爲了提升http請求的效率,而http請求上面已經提到它是面向單向的,要麼是服務端下發數據,要麼是客戶端上傳數據。html

(2)使用HTTP的輪詢,這也是一種很經常使用的方法,沒有websocket以前,基本上網頁的聊天功能都是這麼實現的,每隔幾秒就向服器發個請求拉取新消息。這個方法的問題就在於它也是須要不斷地創建TCP鏈接,同時HTTP頭部是很大的,效率低下。java

(3)直接和服務器創建一個TCP鏈接,保持這個鏈接不中斷。這個至少在瀏覽器端是作不到的,由於沒有相關的API。因此就有了WebSocket直接和服務器創建一個TCP鏈接。node

TCP鏈接是使用套接字創建的,若是你寫過Linux服務的話,就知道怎麼用系統底層的API(C語言)創建一個TCP鏈接,它是使用的套接字socket,這個過程大概以下,服務端使用socket建立一個TCP監聽:nginx

// 先建立一個套接字,返回一個句柄,相似於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);複製代碼

客戶端也使用的套接字進行鏈接:web

// 客戶端也是建立一個套接字
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表示,和上面那個數據幀格式圖一一對照,就可以解釋這個數據幀是什麼意思,有什麼內容。把它打印成原始二進制表示:

1000000110011010010011000011111101100100011101010010010001011010000010000001100100100011000100110100010000000001001001000101011000010111010101010010010101001100010001000001001100111110010100000000100101010101001011110101001100001101000100000010001001001011

參照報文格式,以下圖所示:

經過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傳輸的穩定性可能又是另一個話題了,包括出錯重連機制,跨中美地區的可能須要使用專線等。

相關文章
相關標籤/搜索