C#实现WebSocket服务器:(02)消息帧分析和代码实现
Posted Anlige
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C#实现WebSocket服务器:(02)消息帧分析和代码实现相关的知识,希望对你有一定的参考价值。
前文我们介绍了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
还有更多的功能,像压缩、其他扩展等。
以上是关于C#实现WebSocket服务器:(02)消息帧分析和代码实现的主要内容,如果未能解决你的问题,请参考以下文章