前文我們介紹了WebSocket的握手:C#實作WebSocket服務器:(01)握手
握手完成后,即可客戶端和服務端雙方即可進行訊息的收發,
WebSocket訊息的收發是以幀為單位的,
0、WebSocket的幀
幀型別Op
常用幀型別有以下六種:
| 值 | 型別 | 說明 |
|---|---|---|
| 0x00 | Continuation | 后續幀,當一個幀是非結束幀的時候,后續幀會被標記為Continuation,應用程式需要一直讀下一個幀,直到讀到結束幀, |
| 0x01 | Text | 資料幀:文本,說明幀的Payload為文本經UTF8編碼后的資料 |
| 0x02 | Binary | 資料幀:二進制,說明幀的Payload為二進制資料 |
| 0x08 | Close | 關閉幀,通常需要接收端在收到Close幀的時候,同樣回應一個Close幀給發送端 |
| 0x09 | Ping | Ping幀,檢測對方是否可繼續收發資料(RFC用的詞是:是否可回應) |
| 0x0a | Pong | Pong幀,通常一端在收到Ping幀后,需要回應一個Pong幀給發送方,確認自己是“可回應”的 |
幀的資料格式
資料格式的解釋一般是相當枯燥無味的,還好WebSocket幀的資料格式相對簡單,下面按照我的理解解釋幀資料格式,
幀首先可以分為兩塊:元資料和Payload,Payload跟在元資料之后,內容由元資料決定,
元資料
1、第一個位元組,按位展開:
| 位置 | 0 | 1 | 2 | 3 | 4-7 |
|---|---|---|---|---|---|
| 說明 | 標識當前幀是否是結束幀,1-結束幀,0-非結束幀 | 保留 | 保留 | 保留 | 幀型別,對應上面幾種型別 |
對于非結束幀,當前幀結束后的下一個幀,其幀型別為Continuation(0x00),應用程式需要檢查幀是否為結束幀,
如果不是,需要繼續讀下一個幀,直到遇到結束幀,然后把讀到的所有幀Payload連接起來,才是完整的Payload資料,
2、第二個位元組,按位展開:
| 位置 | 0 | 1-7 |
|---|---|---|
| 說明 | 標識幀Payload資料是否經過掩碼處理,1-經過掩碼處理,0-未經過掩碼處理 | Payload長度標識 |
3、關于Payload長度標識
如果標識值小于126,代表Payload長度就是Payload長度標識的值,
如果標示值等于126,代表Payload長度為后面緊接著的2個位元組代表的無符號整數值,
如果標示值等于127,代表Payload長度為后面緊接著的8個位元組代表的無符號長整型值,
對,確實沒有4位元組,就是2或8,
4、關于掩碼
如果幀經過掩碼處理,那么緊接著的四個位元組為掩碼值,
如果沒有掩碼,緊接著的就是Payload了,
5、對于Payload長度標識和掩碼舉幾個簡單的例子,
| 是否結束幀 | 幀型別 | 有無掩碼 | Payload長度 | 元資料編碼(0xXX代表隨機位元組) | 說明 |
|---|---|---|---|---|---|
| 是 | 文本 | 無 | 10 | 0x81 0x0a | 最簡單的,兩個位元組即可以把元資料描述清楚 |
| 是 | 文本 | 有 | 10 | 0x81 0x8a 0xXX 0xXX 0xXX 0xXX | 加了掩碼,一定是位于元資料的最后4個位元組 |
| 是 | 文本 | 無 | 1000 | 0x81 0x7e 0x03 0xe8 | 資料超過了125并且小于65536,需要額外的2個位元組表示Payload長度 |
| 是 | 文本 | 有 | 1000 | 0x81 0xfe 0x03 0xe8 0xXX 0xXX 0xXX 0xXX | 有掩碼,在前面的基礎上再補4個位元組即可 |
| 是 | 文本 | 無 | 100000 | 0x81 0x7f 0x00 0x00 0x00 0x00 0x00 0x01 0x86 0xa0 | 資料超過了65535,需要額外的8個位元組表示Payload長度 |
| 是 | 文本 | 有 | 100000 | 0x81 0xff 0x00 0x00 0x00 0x00 0x00 0x01 0x86 0xa0 0xXX 0xXX 0xXX 0xXX | 有掩碼,在前面的基礎上再補4個位元組即可 |
當然,Payload為10,我們完全可以按照126或127的模式編碼,
同理,Payload為1000,也可以按照127的模式編碼,
但對于大于65535的長度,只能按照127模式編碼了,
6、掩碼運算
掩碼的運算,就是根據掩碼按位進行異或運算,
例如:
掩碼為:0x01 0x02 0x03 0x04
Payload原文:0x01 0x02 0x03 0x04 0x05 0x06 0x07 0x08 0x09
實際傳輸的Payload編碼方式:(0x01 ^ 0x01) (0x02 ^ 0x02) (0x03 ^ 0x03) (0x04 ^ 0x04) (0x05 ^ 0x01) (0x06 ^ 0x02) (0x07 ^ 0x03) (0x08 ^ 0x04) (0x09 ^ 0x01)
可以看出來,掩碼是回圈使用的,第四個位元組用完后,從第一個位元組開始繼續編碼,直到Payload被編碼完畢,
位元組編碼的數字,均以網路位元組序(大端)傳輸,即高位在前,低位在后,
至此,元資料結束,緊跟元資料后面就是Payload了,Payload長度在元資料中我們已經獲取了,
下圖是RFC中對幀的描述:

Payload
根據元資料中獲取到的Payload長度,可以將Payload完整的讀出,
對于Text、Binary和Continuation型別的幀,Payload沒什么特別的,下面介紹下其他幾個幀的Payload,
1、Close關閉幀
發送關閉幀的一方,可能會在關閉幀的Payload里面附帶狀態碼和關閉原因,也可能只帶狀態碼而沒有原因,
| 狀態碼 | 關閉原因 |
|---|---|
| 2個位元組標識的無符號整數,>=1000 | 除了狀態碼前兩個位元組,剩余的所有Payload位元組均為原因 |
接收方收到Close幀后,通常需要回應一個Close幀給發送方,并且通常也會把發送方給出的狀態碼和原因原樣回傳給發送方,
2、Ping幀
接收方在收到Ping幀后,需要回復一個Pong幀給發送發,同時把Ping幀的Payload原樣帶上,
(就是在打乒乓球,球永遠是那個球,Payload永遠是那個Payload~~~~~~~)
1、實作幀的決議
元資料決議
話不多說了,直接上原始碼,根據上面講的元資料格式決議,
Frame類的定義實作:https://github.com/hooow-does-it-work/http/blob/main/src/WebSocket/Frame.cs
同時我們也實作了常用的控制幀:https://github.com/hooow-does-it-work/http/tree/main/src/WebSocket/Frames
public static Frame NextFrame(Stream baseStream)
{
byte[] buffer = new byte[2];
ReadPackage(baseStream, buffer, 0, 2);
Frame frame = new Frame();
//處理第一位元組
//第一位,如果為1,代表幀為結束幀
frame.Fin = buffer[0] >> 7 == 1;
//三個保留位,我們不用
frame.Rsv1 = (buffer[0] >> 6 & 1) == 1;
frame.Rsv2 = (buffer[0] >> 5 & 1) == 1;
frame.Rsv3 = (buffer[0] >> 4 & 1) == 1;
//5-8位,代表幀型別
frame.OpCode = (OpCode)(buffer[0] & 0xf);
//處理第二個位元組
//第一位,如果為1,代表Payload經過掩碼處理
frame.Mask = buffer[1] >> 7 == 1;
//2-7位,Payload長度標識
int payloadLengthMask = buffer[1] & 0x7f;
//如果值小于126,那么這個值就代表的是Payload實際長度
if (payloadLengthMask < 126)
{
frame.PayloadLength = payloadLengthMask;
}
//126代表緊跟著的2個位元組保存了Payload長度
else if (payloadLengthMask == 126)
{
frame.PayloadLengthBytesCount = 2;
}
//126代表緊跟著的8個位元組保存了Payload長度,對,就是沒有4個位元組,
else if (payloadLengthMask == 127)
{
frame.PayloadLengthBytesCount = 8;
}
//如果沒有掩碼,并且不需要額外的位元組去確定Payload長度,直接回傳
//后面只要根據PayloadLength去讀Payload即可
if (!frame.Mask && frame.PayloadLengthBytesCount == 0)
{
return frame;
}
//把保存長度的2或8位元組讀出來即可
//如果有掩碼,需要繼續讀4個位元組的掩碼
buffer = frame.Mask
? new byte[frame.PayloadLengthBytesCount + 4]
: new byte[frame.PayloadLengthBytesCount];
//讀取Payload長度資料和掩碼(如果有的話)
ReadPackage(baseStream, buffer, 0, buffer.Length);
//如果有掩碼,提取出來
if (frame.Mask)
{
frame.MaskKey = buffer.Skip(frame.PayloadLengthBytesCount).Take(4).ToArray();
}
//從位元組資料中,獲取Payload的長度
if (frame.PayloadLengthBytesCount == 2)
{
frame.PayloadLength = buffer[0] << 8 | buffer[1];
}
else if (frame.PayloadLengthBytesCount == 8)
{
frame.PayloadLength = ToInt64(buffer);
}
//至此所有表示幀元資訊的資料都被讀出來
//Payload的資料我們會用流的方式讀出來
//有些特殊幀,再Payload還會有特定的資料格式,后面單獨介紹
return frame;
}
Payload讀取
讀完幀元資料后,呼叫Frame靜態方法OpenRead打開一個讀取流來讀取Payload,
這里的讀取Payload是通用方法,并沒有分析特殊幀(像Close)的Payload資料,
FrameReadStream內部會自動對有掩碼的Payload解碼,
注意:Frame類也有個非靜態方法
OpenRead,這里打開的Stream只能讀當前Frame幀的Payload,無法讀取Continuation幀的資料,
/// <summary>
/// 靜態方法,從Frame打開一個流
/// </summary>
/// <param name="frame"></param>
/// <param name="stream"></param>
/// <returns>如果frame的FIN標識為1,直接回傳FrameReadStream;否則回傳一個MultipartFrameReadStream,MultipartFrameReadStream可以將后續frame都讀完,直到FIN標識為0</returns>
public static Stream OpenRead(Frame frame, Stream stream) {
if (frame.Fin) return frame.OpenRead(stream);
return new MultipartFrameReadStream(frame, stream, true);
}
2、幀封裝
幀的封裝和決議是一個相反的程序,就不具體講了,我們在Frame類里面實作了一個CreateMetaBytes方法來生成元資料,
呼叫Frame的OpenWrite方法后,會自動生成幀元資料,并寫入到基礎流,同時回傳一個FrameWriteStream流用于向Frame寫入資料,
注意:
FrameWriteStream內部暫沒實作分幀發送大資料,后續會實作,
3、測驗
下面開始測驗我們的邏輯,
直接從Git上拉取我們的前端測驗代碼:https://github.com/hooow-does-it-work/http/tree/main/bin/Release/web
撰寫測驗服器,之前我們OnWebSocket沒有任何實作,只是單純的關閉流,現在我們實作幀的讀寫,
我們設定服務器的WebRoot為Git上拉取的web目錄,實作WebSocket和普通HTTP服務同時運行,
測驗服務器簡單粗暴,直接while回圈從客戶端讀幀,分析幀,
后續可以作一些封裝作業,將邏輯封裝到具體的幀內部,像Close幀狀態碼和關閉原因的讀取,
測驗服務器代碼
https://github.com/hooow-does-it-work/http/blob/main/demo/WebSocketTest.cs
public class HttpServer : HttpServerBase
{
public HttpServer() : base()
{
//設定根目錄
WebRoot = Path.GetFullPath(Path.Combine(AppDomain.CurrentDomain.BaseDirectory, "web"));
}
protected override void OnWebSocket(HttpRequest request, Stream stream)
{
while (true)
{
Frame frame = null;
try
{
frame = Frame.NextFrame(stream);
}
catch (IOException)
{
Console.WriteLine("客戶端連接斷開");
break;
}
Console.WriteLine($"幀型別:{frame.OpCode},是否有掩碼:{frame.Mask},幀長度:{frame.PayloadLength}");
//讀出所有Payload
byte[] payload = null;
using (Stream input = Frame.OpenRead(frame, stream))
{
using MemoryStream output = new MemoryStream();
input.CopyTo(output);
payload = output.ToArray();
}
//收到關閉幀,需要必要情況下需要向客戶端回復一個關閉幀,
//關閉幀比較特殊,客戶端可能會發送狀態碼或原因給服務器
//可以從payload里面把狀態碼和原因分析出來
//前兩個位元組位狀態碼,unsigned int;緊跟著狀態碼的是原因,
if (frame.OpCode == OpCode.Close)
{
int code = 0;
string reason = null;
if(payload.Length >= 2) {
code = payload[0] << 8 | payload[1];
reason = Encoding.UTF8.GetString(payload, 2, payload.Length - 2);
Console.WriteLine($"關閉原因:{code},{reason}");
}
//正常關閉WebSocket,回復關閉幀
//其他Code直接退出回圈關倍訓礎流
if (code <= 1000)
{
CloseFrame response = new CloseFrame(code, reason);
response.OpenWrite(stream);
}
break;
}
//收到Ping幀,需要向客戶端回復一個Pong幀,
//如果有payload,同時發送給客戶端
if (frame.OpCode == OpCode.Ping)
{
PongFrame response = new PongFrame(payload);
response.OpenWrite(stream);
continue;
}
//收到Binary幀,列印下內容
//這里可以使用流的方式,把幀資料保存到檔案或其他應用
if(frame.OpCode == OpCode.Binary)
{
Console.WriteLine(string.Join(", ", payload));
//為了測驗,我們發送測驗內容給客戶端
TextFrame response = new TextFrame($"服務器收到二進制資料,長度:{payload.Length}");
response.OpenWrite(stream);
continue;
}
//收到文本,列印出來
if (frame.OpCode == OpCode.Text)
{
string message = Encoding.UTF8.GetString(payload);
Console.WriteLine(message);
//為了測驗,我們把資訊再發回客戶端
TextFrame response = new TextFrame($"服務器接收到文本資料:{message}");
response.OpenWrite(stream);
}
}
stream.Close();
}
}
運行服務器,瀏覽器訪問:http://127.0.0.1:4189/websocket.html
點擊連接按鈕,連接成功后會展示如下表單,
輸入一些資料,分別點“文本模式發送”和“二進制模式發送”,查看控制臺輸出,
可以查看到服務正確決議了瀏覽器發送的資料,瀏覽器也顯示了服務器回傳的資料,
點擊“斷開連接”,
服務器收到了Close幀,幀長度為0,說明瀏覽器沒有發送狀態碼和原因,
下一篇文章:C#實作WebSocket服務器:(03)代碼封裝
會對訊息進行了封裝,能更直觀的進行WebSocket測驗
4、總結
WebSocket關鍵是幀的決議,充分了解了幀的資料結構后,其實很容易,
我們這里實作的是最基本的WebSocket,WebSocket還有更多的功能,像壓縮、其他擴展等,
轉載請註明出處,本文鏈接:https://www.uj5u.com/ruanti/299494.html
標籤:其他
上一篇:一張網頁帶你了解中秋節的前世今生
