從Chrome原始碼看WebSocket

人人網FED發表於2018-05-27

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表示,和上面那個資料幀格式圖一一對照,就能夠解釋這個資料幀是什麼意思,有什麼內容。把它列印成原始二進位制表示:

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


相關文章