關於Socket,看我這幾篇就夠了(三)原來你是這樣的Websocket

chouheiwa發表於2019-02-11

期刊列表

  1. 關於Socket,看我這幾篇就夠了(一)
  2. 關於Socket,看我這幾篇就夠了(二)之HTTP
  3. 關於Socket,看我這幾篇就夠了(三)原來你是這樣的Websocket

在上一篇中,我們介紹了HTTP協議。HTTP協議是一種無狀態、無連線的協議。

在HTTP 1.1 版本之前,客戶端到伺服器的TCP/IP連線是使用完畢便斷開的,而伺服器的TCP/IP的socket層是有開銷的,而客戶端又很可能請求多次連線,每次建立連線都需要進行三次握手,斷開連線需要進行四次揮手,我們便可以思考如何簡化這些步驟。

於是,HTTP 1.1的版本中,便正式增加了一系列頭部欄位如Connection: keep-alive等等,使得客戶端到伺服器的socket連線可以維持一定時間不被銷燬。因此客戶端到伺服器的每一次請求便不必都重新建立一次socket連線了,可以在已經建立的連線上直接傳送資料了。

HTTP協議的缺點

即便是HTTP協議已經進化到可以複用連線了,它依然是有許多部分讓人不滿意:

1. HTTP請求的無關內容(協議相關內容)開銷大

我們上一篇文章中講過 HTTP協議中 我們操作的部分一般是body,也有一部分的header

這裡我們按照位元組Byte來簡述下:

這裡假設我們需要定時重新整理一個GET介面獲取資訊(我們只分析傳送請求),則我們請求的資料文字結構便為如下結構:

GET / HTTP/1.1\r\n
Host: www.example.com\r\n
\r\n
複製程式碼

可能有人會覺得,這個資料並不多啊。

這裡我們需要注意,開銷大並不是一個絕對的含義,它是一種相對的。我們可以觀察一下,在這樣的一個簡單請求中,我們究竟傳送了多少位元組,一共是42個位元組。也就是說,每次我們執行這個請求都需要傳送這42個位元組,其中用於格式相關的便佔有14個位元組(HTTP/1.1\r\n)。這些資料每次請求都需要重複傳送,我們也可以說,HTTP請求相對較重

2. HTTP請求只能單向傳送

HTTP請求採用的是請求-應答模式,即客戶端發出請求,伺服器給出迴應。這樣就產生了一個弊端,伺服器只能被動迴應資料,無法主動推送資料。

我們雖然可以主動輪詢請求,但是這就又引發了問題1,HTTP請求的開銷很大,伺服器又是資源緊缺型的

因此這就導致了Websocket的產生:

Websocket

Websocket是一種在建立在TCP連線上進行的全雙工通訊的協議

全雙工 指的是通訊的兩端都具有主動傳送資料的能力

WebSocket使得客戶端和伺服器之間的資料交換變得更加簡單,允許服務端主動向客戶端推送資料。在WebSocket API中,瀏覽器和伺服器只需要完成一次額外握手,兩者之間就直接可以建立永續性的連線,並進行雙向資料傳輸。

協議

連線建立

我們所說的連線建立都是已經建立在TCP/IP三次握手後。

Websocket 在連線建立後 需要額外進行一次HTTP握手,目的是確定通訊雙方都可以支援 此協議(防止誤訪問)。

  1. 客戶端發起協議升級請求

客戶端需要先傳送一個HTTP頭(包含Websocket指定資訊,與其他頭部資訊如cookie等),客戶端頭部結構如下所示:

GET /訪問路徑 HTTP/1.1\r\n 
Host: www.example.com\r\n
Connection: Upgrade\r\n
Upgrade: websocket\r\n
Sec-WebSocket-Version: 13\r\n
Sec-WebSocket-Key: mgZ6+kXU1+mEgOXWDPPsBg==\r\n
\r\n
複製程式碼

上述為websocket規定的固定的頭部資訊

  • Connection欄位必須為Upgrade,用以標誌著客戶端需要連線升級
  • Upgrade欄位必須為websocket,標誌著客戶端需要由http請求升級成websocket
  • Sec-WebSocket-Version欄位為13,代表著當前協議的版本號(目前一般採用13)
  • Sec-WebSocket-Key欄位為必填項,值一般為16個位元組的隨機資料轉成base64字串。該欄位用以提供給伺服器做頭部返回憑證校驗(用於客戶端確定伺服器是否支援websocket)

Websocket的請求頭欄位與標準的HTTP並無兩樣,但是協議規定,Websocket請求只能為GET型別,其餘頭部欄位可由伺服器與客戶端雙方協商增加。

Sec-WebSocket-Key主要是用於客戶端確定伺服器是否支援,因為客戶端有可能因為某些原因錯誤的訪問了一個HTTP伺服器,該伺服器並不支援Websocket,但是可以響應對應的GET請求,這個時候,客戶端便可以通過伺服器對應的返回欄位確定是否應該繼續建立連線或者是關閉連線

  1. 伺服器響應請求資料

當伺服器收到客戶端的請求頭的時候,便需要作出響應,響應資料也為標準的HTTP請求頭

HTTP/1.1 101 Switch Protocol\r\n
Connection: Upgrade\r\n
Upgrade: websocket\r\n
Sec-WebSocket-Accept: qIs5tRK57T9vTjEtFfTLOSe3K3w=\r\n
\r\n
複製程式碼

伺服器首先要返回狀態碼101,用以表明服務端切換協議了,以後的資料解析協議將不再是HTTP超文字協議

伺服器同樣也要返回對應的ConnectionUpgrade 欄位,同時伺服器需要對客戶端傳入Sec-WebSocket-Key進行一定的處理,將處理結果返回至Sec-WebSocket-Accept中供客戶端校驗。

  • Sec-WebSocket-Key處理方法:

Sec-WebSocket-Key拼接字串 258EAFA5-E914-47DA-95CA-C5AB0DC85B11 然後將其進行sha1計算hash,最後將得出的hash進行base64轉碼成字串,放入至Sec-WebSocket-Accept

當客戶端收到對應的Sec-WebSocket-Accept時,用自己傳的Sec-WebSocket-Key進行同樣的處理,並比較伺服器返回結果,如果結果一致則客戶端認為伺服器支援請求。當比較不一致時,按照協議要求,客戶端應該主動斷開連線。


我們可以看到,Websocket連線建立事實上就相當於客戶端向伺服器發起了一次普通的Body為空的HTTP請求,而伺服器做出了同樣的響應

Websocket如此做,是為了相容標準的HTTP協議,因為對於一臺伺服器應用而言,它不必同時監聽多個埠,就可以同時滿足充當HTTP伺服器和Websocket伺服器。

同樣Websocket請求也可以支援Cookie等等的HTTP頭部規定。

在這裡我們還看不出來Websocket如何解決HTTP的缺點的,因為這個只是Websocket的額外握手過程,並非真正資料傳送。

資料傳送

這裡就要講到Websocket最重要的環節了

首先我們需要明確兩個定義ByteBit:

  • Byte:計算機儲存與傳輸的標準單位(位元組),轉成非負整數能支援最大的數為(2^8 - 1) = 255,一個Byte轉成二進位制位的時候:0 0 0 0 0 0 0 0 由8個可以為0或1的組成,其中每個0或1均為1個Bit
  • Bit:二進位制數系統中,每個0或1就是一個位(bit),位是資料儲存的最小單位。1 Byte = 8 Bit

接下來還是要講Websocket的資料傳送結構,我們習慣稱每一次完整的資料包為一

幀的資料結構:

Websocket幀資料結構

在上圖中,我們是以Bit為單位,但是在真實資料處理過程中,我們操作記憶體的最小單位也就是Byte,也就是8*Bit,在Swift中我們可以使用UInt8將Byte轉為無整形進行處理。

我們可以看出來,Websocket的資料包的協議相關部分只佔2-10個位元組,如果算上相關掩碼,也最多佔用14個位元組,和http相比,這也就是說,Websocket的額外消耗小。

這裡我們開始按照順序開始講解協議相關內容:

  • FIN:

該位是整個幀的首位,用以標誌該幀是否為連續幀的結束

0: 連續資料包尚未結束

1: 當前幀為資料包的最後一幀

  • RSV1-RSV3:

用於子協議,或者其他相關。官方要求這3位均為0,子協議可以對此進行擴充。當這三位中有1-3位為1的時候,如果接收端不能正確理解相關資料,則應關閉相關連線

關閉:並非指TCP/IP層的連線關閉,而是Websocket協議層定義的關閉,接下來的所有關閉都是如此,我們將在接下來解釋關閉含義

  • 操作碼(opcode):

操作碼佔用4個Bit,所以操作碼的一共有2^4=16種可能

下面我將以16進位制列舉情況:

  1. 0:代表著當前幀是一個繼續幀
  2. 1:代表著當前幀是一個文字幀(傳輸資料為UTF8編碼的文字)
  3. 2:代表著當前幀是一個二進位制資料流幀(Swift中為Data)
  4. 3-7:用於未來的非控制幀
  5. 8:代表著當前幀是一個關閉幀
  6. 9:代表著當前幀是一個心跳檢測Ping幀
  7. A:代表著當前幀是一個心跳檢測回覆Pong幀
  8. B-F:用於未來的控制幀

在這裡,一個有兩種情況,控制幀非控制幀

控制幀

控制幀有一定的特殊要求:

  1. 控制幀不能處於一個連續的資料幀中
  2. 控制幀的真實傳送資料大小不能超過125位元組
  3. 控制幀的FIN(終止位)必須是1

控制幀意味著,當收到對應幀的時候,接收方應該做出一定的響應或者操作。

8:關閉幀

當接收方收到關閉幀的時候,有如下兩種情況:

  1. 若接收方之前尚未傳送過關閉幀

如果此時接收方正在傳送連續的資料幀過程中,則可以繼續傳送資料幀(此時無法確定另一方還會繼續處理資料)。隨後應該回復一個關閉幀,隨後完成斷開TCP/IP連線操作。

  1. 若接收方之前已經傳送過關閉幀

接收方在傳送關閉幀之後不應再傳送任何資料幀,當收到關閉幀後,斷開TCP/IP連線

  1. 關閉幀為控制幀,因此可以攜帶不超過125個位元組的資料,該幀攜帶的資料前兩個位元組為錯誤碼,隨後的位元組為對應的描述原因(UTF8編碼文字)

關閉: 若一方發起關閉,則該方主動傳送關閉幀,並最終執行關閉TCP/IP連線的一整套流程被稱為關閉

9:Ping

Ping為Websocket的心跳包機制幀,主要用於確認另一方未因為異常關閉連線,當我們接收到Ping幀時,我們應該響應Pong幀作為迴應。若長時間未收到迴應,我們應該考慮主動關閉連線

A:Pong

Pong幀為Websocket的心跳包機制幀中的響應幀。

其餘控制幀

在現有協議中未做定性要求,可能在未來Websocket升級增加(或者子協議中定義)

如果接收方未定義該幀的相應處理方法,則應該關閉連線

非控制幀

非控制幀也就是我們通常意義上的資料幀,主要是用於雙方傳送資料,也是我們平時用的最多的

0:繼續幀(分片)

分片

分片的主要目的是允許當訊息開始但不必緩衝該訊息時傳送一個未知大小的消 息。如果訊息不能被分片,那麼端點將不得不緩衝整個訊息以便在首位元組發生之 前統計出它的長度。對於分片,伺服器或中介軟體可以選擇一個合適大小的緩衝, 當緩衝滿時,寫一個片段到網路。

第二個分片的用例是用於多路複用,一個邏輯通道上的一個大訊息獨佔輸出通道 是不可取的,因此多路複用需要可以分割訊息為更小的分段來更好的共享輸出通道。

資料分片傳送的要求:

  1. 資料的首幀與過程幀的FIN位為0
  2. 資料的首幀的操作碼必須為對應的非控制幀操作碼,且不能為繼續幀
  3. 資料的過程幀與終止幀的操作碼必須為繼續幀
  4. 資料的終止幀的操作碼必須為1

我們可以這樣理解:

首先當我們需要傳送分片資料的時候,我們最開始肯定要告訴對方,我們的這個資料是什麼型別的,同時我們肯定不能在傳送過程中告訴對方,資料傳送完了。同時在傳送過程中,我們得告訴對方,我們的資料還沒有傳送完成,這個資料是其中的一部分。當傳送到最後一個的時候,我們又需要告訴對方,傳送完了。

其實簡化來說,規則如下:

  1. 傳送開始確定資料型別,過程與結尾均不可更改
  2. 傳送截止告訴對方資料完成

對應的接收處理方式也如上面所說,先解析首幀,確定資料型別,然後接收中間資料,最後接收尾幀,資料處理完成。過程中如果接收到不符合分片傳送的資料要求,則應該關閉連線

1:文字幀

文字幀就是標誌著,傳輸的資料是使用UTF8編碼的文字,當我們使用的時候,就需要將資料轉換為UTF8字串,當轉換失敗的時候我們需要關閉連線

2:二進位制幀

二進位制幀代表著傳送的資料為二進位制檔案

3-7: 其餘非控制幀

用以在未來協議升級,或者子協議擴充

操作碼算是整個協議頭裡很關鍵的部分,它定義了資料的處理方式,與一些其他的操作

掩碼(MASK)

掩碼佔位1個Bit 用以標誌著該欄位傳送是否使用了掩碼,以及是否需要對真實資料進行解碼。

若掩碼位為1: 則標誌著存在掩碼,並需要進行轉碼

為什麼要設計掩碼?

協議規定,客戶端到伺服器資料傳送必須包含掩碼,伺服器返回資料不能攜帶掩碼

資料長度(Payload Len)

資料長度佔用7個Bit(可能更多),所以該段最大有可能2^7 - 1 = 127,但是真實的傳送資料可能遠遠超過這個值,應該怎麼處理呢?

所以協議制定者在這裡規定了:

  1. 當該值小於等於125時表示真正的資料長度(Byte)
  2. 當該值等於126時,我們需要取接下來的16個Bit(2個Byte)作為長度,使得長度可以支援到2^16 - 1 = 65535(Byte)
  3. 當該值等於127時,我們需要取接下來的64個Bit(8個Byte)作為長度,使得長度可以支援到2^64 - 1 = 很大的一個數

如果還不夠怎麼辦?

可以考慮分片傳送了-_-

Masking-Key(真實掩碼)

真實掩碼一共佔用32個Bit(4個Byte)

該欄位是我們根據上述掩碼標誌位獲取的,如果掩碼標誌位為1,則該欄位存在;為0則該位為空。

協議規定,真實掩碼應該是我們使用不可預測的演算法得出的隨機32個Bit(4個Byte)

在Swift中我們可以使用Security.SecRandomCopyBytes()方法獲取隨機值

當我們擁有掩碼與真實資料後,我們需要按照如下操作對真實資料進行處理(直接展示Swift程式碼)

func maskData(payloadData: Data, maskingKey: Data) -> Data {
    let finalData = Data(count: payloadData.count)
    // 轉化Data為指標,方便處理
    let payloadPointer: UnsafePointer<UInt8> = payloadData.withUnsafeBytes({$0})
    let maskPointer: UnsafePointer<UInt8> = maskingKey.withUnsafeBytes({$0})
    let finalPointer: UnsafeMutablePointer<UInt8> = finalData.withUnsafeBytes({UnsafeMutablePointer(mutating: $0)})


    for index in 0..<payloadData.count {
        let indexMod = index % 4
        // 對應位異或XOR(^)
        (finalPointer + index).pointee = (payloadPointer + index).pointee ^ (maskPointer + indexMod).pointee
    }

    return finalData
}
複製程式碼

掩碼與解碼均是按照此演算法進行計算

真實資料(Payload Data)

也可以稱作負載資料(或許應該被稱為負載資料而不是真實資料,不過沒什麼關係),也就是我們主要使用的資料。也就不再多說了。

其他

關於Websocket還有一些東西我們尚未講述,如子協議之類的,這些東西作者還需要再進行深入研究。因此,在以後將會以補充文章進行講述。

什麼時候需要使用Websocket

作為iOS開發人員,我們使用這個的機會不多。但是當我們希望伺服器能主動推送資料到我們這,同時又不希望再進行自行開發上層協議的時候我們可以考慮這個協議,還是很好用的。

為什麼要寫這篇文章?

作者最近正在研究這個協議,同時正在使用純swift語言開發一個Websocket客戶端三方庫: SwiftAsyncWebsocket,目前正處於開發階段。覺得對Websocket有一定的研究心得,故此寫下這篇文章

結尾

我們現在前行的每一步,都是前人為我們鋪好的道路。

文章中如果有錯誤,還請各位評論指出

PS: 又用PPT畫了一張圖,感覺好費勁啊,-_-

參考:

SocketRocket原始碼

RFC 6455

相關文章