C#实现WebSocket服务器:(02)消息帧分析和代码实现

Posted Anlige

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了C#实现WebSocket服务器:(02)消息帧分析和代码实现相关的知识,希望对你有一定的参考价值。

前文我们介绍了WebSocket的握手:C#实现WebSocket服务器:(01)握手
握手完成后,即可客户端和服务端双方即可进行消息的收发。
WebSocket消息的收发是以帧为单位的。

0、WebSocket的帧

帧类型Op

常用帧类型有以下六种:

类型说明
0x00Continuation后续帧,当一个帧是非结束帧的时候,后续帧会被标记为Continuation,应用程序需要一直读下一个帧,直到读到结束帧。
0x01Text数据帧:文本,说明帧的Payload为文本经UTF8编码后的数据
0x02Binary数据帧:二进制,说明帧的Payload为二进制数据
0x08Close关闭帧,通常需要接收端在收到Close帧的时候,同样响应一个Close帧给发送端
0x09PingPing帧,检测对方是否可继续收发数据(RFC用的词是:是否可响应)
0x0aPongPong帧,通常一端在收到Ping帧后,需要响应一个Pong帧给发送方,确认自己是“可响应”的

帧的数据格式

数据格式的解释一般是相当枯燥无味的,还好WebSocket帧的数据格式相对简单,下面按照我的理解解释帧数据格式。
帧首先可以分为两块:元数据PayloadPayload跟在元数据之后,内容由元数据决定。

元数据

1、第一个字节,按位展开:
位置01234-7
说明标识当前帧是否是结束帧,1-结束帧,0-非结束帧保留保留保留帧类型,对应上面几种类型

对于非结束帧,当前帧结束后的下一个帧,其帧类型为Continuation(0x00),应用程序需要检查帧是否为结束帧。
如果不是,需要继续读下一个帧,直到遇到结束帧,然后把读到的所有帧Payload连接起来,才是完整的Payload数据。

2、第二个字节,按位展开:
位置01-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代表随机字节)说明
文本100x81 0x0a最简单的,两个字节即可以把元数据描述清楚
文本100x81 0x8a 0xXX 0xXX 0xXX 0xXX加了掩码,一定是位于元数据的最后4个字节
文本10000x81 0x7e 0x03 0xe8数据超过了125并且小于65536,需要额外的2个字节表示Payload长度
文本10000x81 0xfe 0x03 0xe8 0xXX 0xXX 0xXX 0xXX有掩码,在前面的基础上再补4个字节即可
文本1000000x81 0x7f 0x00 0x00 0x00 0x00 0x00 0x01 0x86 0xa0数据超过了65535,需要额外的8个字节表示Payload长度
文本1000000x81 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完整的读出。
对于TextBinaryContinuation类型的帧,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方法来生成元数据。
调用FrameOpenWrite方法后,会自动生成帧元数据,并写入到基础流,同时返回一个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关键是帧的解析,充分了解了帧的数据结构后,其实很容易。
我们这里实现的是最基本的WebSocketWebSocket还有更多的功能,像压缩、其他扩展等。

以上是关于C#实现WebSocket服务器:(02)消息帧分析和代码实现的主要内容,如果未能解决你的问题,请参考以下文章

C# 基于websocket实时通信的实现—GoEasy

c# webapi websocket 服务端消息发送

C# - Websocket - 将消息发送回客户端

如何让 C# WebSocketClient 持续监听消息

C# Websocket消息推送---GoEasy

Websockets:从NodeJS websocket服务器到带有WebSocketSharp的C#客户端的多个响应