写这个系列写到第三篇了。第一篇处理了TCP监听和Accept循环,第二篇完成了HTTP Upgrade握手,到这一步客户端和服务器之间已经建立起了真正的WebSocket连接。接下来面对的问题很直接:怎么把消息干净地收进来、发出去,并且不让上层业务代码去碰那些晦涩的帧字节。这就是今天要聊的话题:消息收发的封装。
如果只是写个Demo,收发消息确实可以在握手后的循环里直接读字节、按帧拼数据,但一旦要面对多连接、心跳、分片、广播、异常断开,裸处理代码很快就会变成一团乱麻。这篇我会把RFC 6455里帧格式、掩码、分片这些必须掌握的东西讲透,再给出一个适合中小项目的C#封装思路,包含可运行的代码片段和实际踩坑经验。适合正在用C#做接入服务,或者想搞明白WebSocket底层到底在传什么的同学参考。
1. 为什么要把消息收发单独封装一层
1.1 直接处理WebSocket帧有多麻烦
很多新手在握手成功后会陷入一种错觉:连接已经建立,接下来岂不是可以直接用Encoding.UTF8.GetString把收到的字节变成字符串?实际写两步就会发现问题。
WebSocket协议不是裸字符串流,它有一套自己的帧格式。客户端发来一句话,网络包里面除了那句话的UTF-8字节,还带着FIN标志、Opcode、掩码标志、负载长度,甚至可能带4字节的掩码密钥。服务器往外发消息时同样得自己组装帧头。这还不算分片、ping/pong、close帧这些控制逻辑。
如果每个业务入口都自己处理这一套,最典型的后果是:一个连接循环里塞满了位运算和字节数组拼接,出了bug很难排查,来了新需求又不敢动。比如原本只支持文本消息,突然要支持二进制消息,所有读取消息的地方都得改一遍。封装的核心目标不是炫技,而是把协议细节收口到一个类里,让调用方只接触SendText、OnMessage这样的东西。
换个生活化的类比:装电脑的时候,如果每个电源线都从电源仓直接拉到主板、显卡、硬盘上,机箱内部会乱得没法看。模组电源加理线槽,就是把这些线的“收口”工作集中起来。消息收发的封装,做的就是WebSocket协议层里的“理线”。
1.2 封装后调用方应该只看到什么
我理想中的上层接口应该是这样的:
- 收到完整文本消息时,触发一个事件,参数直接是string。
- 收到二进制消息时,触发另一个事件,参数是byte[]。
- 连接关闭时,触发Close事件,参数里带关闭状态码和原因。
- 调用SendTextAsync,传一个字符串进去就行。
- 调用SendBinaryAsync,传byte[]或者ReadOnlyMemory 就行。
所有关于掩码、长度、分片、控制帧的细节,都应该在封装层内部消化掉。这样上层做业务时,根本不需要关心协议,出问题也只会在封装层集中排查。
1.3 造轮子之前先想清楚
这里必须提一句:.NET自带的System.Net.WebSockets已经实现了服务端WebSocket协议,直接用就好了,大多数人不需要自己实现。那为什么还要写这套封装?几个常见场景:
- 做教学和协议研究,想彻底看懂底层。
- 出于裁剪或定制需求,不想要太重的依赖。
- 在自研网关、嵌入式网关、内网通信场景里,需要极简实现。
- 想通过实现一遍来排查线上遇到的帧解析、分片、心跳问题。
所以这篇的定位不是“替代System.Net.WebSockets”,而是“理解它的底层逻辑后自己也能写出一套可用的收发封装”。理解了之后再去用官方库,也能更快定位问题。
2. 核心设计:从裸字节到消息的转换层
2.1 RFC 6455帧格式速览
在写封装之前,必须先把帧格式刻在脑子里。WebSocket帧长这样:
| 字段 | 长度 | 说明 |
|---|---|---|
| FIN | 1 bit | 1表示这是消息的最后一帧,0表示后面还有分片 |
| RSV1-3 | 各1 bit | 扩展协商用,没有扩展时必须是0 |
| Opcode | 4 bit | 标识帧类型,0x1文本、0x2二进制、0x8关闭、0x9Ping、0xA Pong、0x0续帧 |
| MASK | 1 bit | 客户端发给服务器的帧必须为1,服务器发给客户端必须是0 |
| Payload len | 7 bit / 16 bit / 64 bit | 负载长度,7位里126表示扩展2字节,127表示扩展8字节 |
| Masking key | 4字节 | 仅当MASK=1时存在,用于对Payload做XOR解码 |
| Payload data | 可变 | 实际业务数据,控制帧的Payload长度不能超过125字节 |
关于长度位需要特别注意:如果第二字节的低7位是0~125,那就是实际长度;如果是126,后面2字节才是真正的长度,并且按网络字节序(大端)解析;如果是127,后面8字节才是真正的长度。这个设计是为了短消息省字节,长消息可以表达64位长度。
Opcode这里有一个容易混淆的点:文本帧的Opcode是0x1,但如果一条大消息被分片,那么第一帧的Opcode是0x1或0x2,且FIN=0;中间所有的分片Opcode都是0x0(续帧);最后一帧Opcode也是0x0,但FIN=1。也就是说,是一条消息的“第一帧”决定它是文本还是二进制,后续分片都靠续帧聚合。
掩码更是个经典坑:协议规定客户端发给服务器的帧必须带掩码,服务器如果收到不带掩码的帧,应该按协议错误处理并关闭连接。反过来,服务器发给客户端的帧不允许带掩码。很多自写客户端时忽略这个规则,导致服务端直接拒绝消息。
2.2 接收侧的状态机设计
TCP是流协议,不是消息协议,所以WebSocket帧可能在一次Read里同时到好几帧,也可能一帧只到了一半。这是封装层要处理的最核心的状态问题。
一个比较稳的接收循环是这样设计的:
- 先把连接对应的Socket或Stream包一层。
- 建立一个接收循环,永远尝试解析“一帧”的数据。
- 如果数据不够一帧,继续读,直到凑齐。
- 如果缓冲区里已经有了超过一帧的数据,解析完第一帧后,剩余的继续留在缓冲区,等待下一轮解析。
- 解析出的内容按照Opcode交给对应的处理逻辑。
我见过很多第一次写WebSocket服务的人,栽在“一次Read就是一帧”这个错误假设上。实际上一次stream.ReadAsync可能返回一个字节,也可能返回几百个字节,具体取决于TCP分段和接收缓冲区。所以封装层必须提供类似ReadExactAsync的方法,保证目标长度的字节读满了才返回。
从代码结构上说,我倾向于把“帧解析”和“消息组装”分成两层。帧解析只负责把原始字节变成Frame结构体,消息组装负责处理分片、判断消息类型、控制帧拦截。这样每个方法只干一件事,测试也更容易覆盖。
2.3 收发API的异步封装
C#做网络库,异步是标配。这里有两个关键点:
第一,所有IO方法尽量返回ValueTask而不是Task,特别是在低内存分配场景下。ValueTask避免了异步方法分配Task对象,对高并发的连接数有明显好处。当然,如果连接数不大,Task也无所谓,图省事就用Task。
第二,发送端必须串行化。WebSocket连接底层的TCP Stream不支持并发写,如果两个线程同时调用WriteAsync,会出现帧数据交错,接收方解析必然失败。所以每个Session内部我放了一个SemaphoreSlim(1,1),所有发送操作都先进锁,保证一帧一帧往外写。
在高并发的广播场景下,SemaphoreSlim可能会成为瓶颈。更好的方案是每个Session一个发送Channel,发送线程只往Channel里塞消息,后台单独有一个写循环来消费,天然串行,还能结合背压控制。这个可以根据项目量级自行选择,中小项目直接上锁就够用。
3. 具体实现:消息接收解析与发送封装
3.1 基础数据结构的定义
我建议先定义几个最小的类型,避免到处用Tuple和裸byte[]。
第一个是接收消息的类型,包含Opcode和Payload:
public readonly struct WebSocketMessage { public int Opcode { get; } public byte[] Payload { get; } public WebSocketMessage(int opcode, byte[] payload) { Opcode = opcode; Payload = payload; } public bool IsText => Opcode == 0x1; public bool IsBinary => Opcode == 0x2; public bool IsClose => Opcode == 0x8; public bool IsPing => Opcode == 0x9; public bool IsPong => Opcode == 0xA; }为什么用byte[]而不是string?因为文本和二进制都能用byte[]表达,上层需要字符串时再Encoding转换。如果封装层直接给string,收到二进制帧的时候就没办法处理了。
第二个是内部使用的帧头解析结果:
private readonly struct WebSocketFrame { public bool Fin { get; } public int Opcode { get; } public byte[] Payload { get; } public WebSocketFrame(bool fin, int opcode, byte[] payload) { Fin = fin; Opcode = opcode; Payload = payload; } }这个类型是内部用的,不暴露给上层。
然后是连接的Session封装。我常用的Session类大致长这样:
public sealed class WebSocketSession { private readonly Stream _stream; private readonly SemaphoreSlim _sendLock = new(1, 1); private readonly CancellationTokenSource _cts = new(); public event Func<WebSocketSession, string, Task>? TextMessageReceived; public event Func<WebSocketSession, byte[], Task>? BinaryMessageReceived; public event Func<WebSocketSession, int, string, Task>? Closed; public WebSocketSession(Stream stream) { _stream = stream; } }事件用Func委托而不是EventHandler,是为了支持async事件处理器。EventHandler配合async void很容易让异常逃逸到线程池,排查起来非常难受。
3.2 帧解析代码:读取头部与掩码处理
核心解析方法负责从Stream中读取一帧。首先是“读满指定字节”的工具方法:
private static async ValueTask ReadExactAsync( Stream stream, Memory<byte> target, CancellationToken ct) { int totalRead = 0; while (totalRead < target.Length) { int read = await stream.ReadAsync(target.Slice(totalRead), ct); if (read == 0) { throw new EndOfStreamException("连接已关闭"); } totalRead += read; } }这个循环很重要,千万不要用一次ReadAsync去读target.Length个字节,因为底层返回的字节数可能比需要的少。
然后是读取一帧的方法:
private async ValueTask<WebSocketFrame> ReadFrameAsync( Stream stream, CancellationToken ct) { var header = new byte[2]; await ReadExactAsync(stream, header, ct); byte b0 = header[0]; byte b1 = header[1]; bool fin = (b0 & 0x80) != 0; int opcode = b0 & 0x0F; bool masked = (b1 & 0x80) != 0; long payloadLength = b1 & 0x7F; // 扩展长度 if (payloadLength == 126) { var lengthBytes = new byte[2]; await ReadExactAsync(stream, lengthBytes, ct); payloadLength = (lengthBytes[0] << 8) | lengthBytes[1]; } else if (payloadLength == 127) { var lengthBytes = new byte[8]; await ReadExactAsync(stream, lengthBytes, ct); payloadLength = BinaryPrimitives.ReadInt64BigEndian(lengthBytes); } // 服务端接收的帧必须带掩码 if (!masked) { throw new WebSocketProtocolException(1002, "客户端帧必须带掩码"); } // 读取掩码 var maskKey = new byte[4]; await ReadExactAsync(stream, maskKey, ct); // 读取负载 byte[] payload = new byte[payloadLength]; await ReadExactAsync(stream, payload, ct); // 掩码解码 for (int i = 0; i < payload.Length; i++) { payload[i] ^= maskKey[i & 3]; } return new WebSocketFrame(fin, opcode, payload); }这里有几个细节要解释一下。
BinaryPrimitives.ReadInt64BigEndian需要using System.Buffers.Binary,用它的好处是不用手动处理字节排序。注意服务端接收到的帧长度可能是64位,但实际不允许超过int.MaxValue吗?理论上协议允许64位长度,实践上我建议设置一个MaxMessageSize,比如16MB,超过直接拒绝。否则恶意客户端发一个2GB长度的帧头,服务器会尝试分配2GB内存,直接OOM。这个检查必须加在分配payload之前。
掩码解码用的是maskKey[i & 3],因为掩码循环使用4字节,索引按4取模即可。也可以用i % 4,但位运算快一些且意图明确。
关于“客户端帧必须带掩码”这一点,如果是服务器接收,直接踢掉;如果是客户端接收,则恰好相反,服务器帧必须不带掩码。这套封装如果将来也要做客户端连接,需要把这段逻辑用方向标志区分开。
3.3 消息组装:分片、控制帧与事件触发
拿到Frame之后,不能直接触发上层事件,因为可能遇到分片。分片处理的常见做法是在Session内部维护当前片段:
private List<byte[]>? _fragmentPayloads; private int? _fragmentedOpcode;然后在接收循环里做聚合逻辑:
public async ValueTask ReceiveLoopAsync(CancellationToken ct) { try { while (!ct.IsCancellationRequested) { WebSocketFrame frame = await ReadFrameAsync(_stream, ct); if (frame.Opcode == 0x9) // Ping { await WriteFrameAsync(0xA, frame.Payload, true, ct); continue; } if (frame.Opcode == 0xA) // Pong { // 可以刷新心跳时间,这里先忽略 continue; } if (frame.Opcode == 0x8) // Close { int statusCode = 1000; string reason = ""; if (frame.Payload.Length >= 2) { statusCode = (frame.Payload[0] << 8) | frame.Payload[1]; reason = Encoding.UTF8.GetString(frame.Payload, 2, frame.Payload.Length - 2); } await WriteFrameAsync(0x8, frame.Payload, true, ct); Closed?.Invoke(this, statusCode, reason); return; } if (frame.Opcode == 0x1 || frame.Opcode == 0x2) { if (_fragmentPayloads != null) { throw new WebSocketProtocolException(1002, "消息已经开始,不能出现新的数据帧"); } if (frame.Fin) { await DispatchMessageAsync(frame.Opcode, frame.Payload); } else { _fragmentPayloads = new List<byte[]> { frame.Payload }; _fragmentedOpcode = frame.Opcode; } continue; } if (frame.Opcode == 0x0) { if (_fragmentPayloads == null) { throw new WebSocketProtocolException(1002, "收到无效的续帧"); } _fragmentPayloads.Add(frame.Payload); if (frame.Fin) { byte[] fullPayload = CombineFragments(_fragmentPayloads); int opcode = _fragmentedOpcode.Value; _fragmentPayloads = null; _fragmentedOpcode = null; await DispatchMessageAsync(opcode, fullPayload); } continue; } throw new WebSocketProtocolException(1002, $"不支持的Opcode: {frame.Opcode}"); } } catch (EndOfStreamException) { // 客户端断开 } catch (Exception ex) { // 记录日志,触发异常关闭 } finally { _cts.Cancel(); _sendLock.Dispose(); } }这里有几个关键点。
第一,Ping和Pong是控制帧,控制帧不能在分片中间被业务层看到,Protocol规定控制帧可以插在分片之间。上面代码对Ping的处理是立即回Pong,不暴露给上层,这是最常用的策略。
第二,Close帧的Payload前两个字节是关闭状态码,后面的字节是UTF-8原因。收到Close后,按照规范应该回一个Close帧,然后关闭TCP连接。我这里的写法是直接回同一份Payload,通常回1000空Payload也可以。
第三,CombineFragments方法用Array.Copy把所有分片拼到一起:
private static byte[] CombineFragments(List<byte[]> fragments) { int total = 0; foreach (var fragment in fragments) total += fragment.Length; byte[] result = new byte[total]; int offset = 0; foreach (var fragment in fragments) { Buffer.BlockCopy(fragment, 0, result, offset, fragment.Length); offset += fragment.Length; } return result; }对于大消息,这样拼会有一次O(N)的复制,但中小项目足够。如果想优化,可以改用分段List然后给上层提供ReadOnlySequence,但复杂度会明显上升。
DispatchMessageAsync就是触发事件的地方:
private async ValueTask DispatchMessageAsync(int opcode, byte[] payload) { if (opcode == 0x1) { string text = Encoding.UTF8.GetString(payload); if (TextMessageReceived != null) await TextMessageReceived(this, text); } else if (opcode == 0x2) { if (BinaryMessageReceived != null) await BinaryMessageReceived(this, payload); } }文本帧这里直接用了Encoding.UTF8.GetString,严格来说应该做UTF-8合法性校验。后面常见问题部分我会单独提。
3.4 发送封装:构造帧头与分片发送
发送端的核心是WriteFrameAsync。先看构造帧头的逻辑:
private static byte[] BuildHeader(int opcode, int payloadLength, bool fin) { byte[] header; if (payloadLength < 126) { header = new byte[2]; header[1] = (byte)payloadLength; } else if (payloadLength <= ushort.MaxValue) { header = new byte[4]; header[1] = 126; BinaryPrimitives.WriteUInt16BigEndian(header.AsSpan(2), (ushort)payloadLength); } else { header = new byte[10]; header[1] = 127; BinaryPrimitives.WriteInt64BigEndian(header.AsSpan(2), payloadLength); } header[0] = (byte)((fin ? 0x80 : 0x00) | opcode); return header; }服务端发送时掩码位必须是0,所以第二字节最高位是0。上面代码只设置了低7位,正好满足要求。
实际发送方法:
public async ValueTask WriteFrameAsync( int opcode, ReadOnlyMemory<byte> payload, bool fin, CancellationToken ct) { byte[] header = BuildHeader(opcode, payload.Length, fin); await _sendLock.WaitAsync(ct); try { await _stream.WriteAsync(header, ct); if (payload.Length > 0) { await _stream.WriteAsync(payload, ct); } await _stream.FlushAsync(ct); } finally { _sendLock.Release(); } }注意这里用了语义锁,确保整个帧头和负载是连续写入的,不会和其他线程的发送交错。
然后提供更上层的API:
public ValueTask SendTextAsync(string text, CancellationToken ct = default) { byte[] payload = Encoding.UTF8.GetBytes(text); return WriteFrameAsync(0x1, payload, true, ct); } public ValueTask SendBinaryAsync(byte[] payload, CancellationToken ct = default) { return WriteFrameAsync(0x2, payload, true, ct); } public ValueTask SendPingAsync(byte[] payload = null, CancellationToken ct = default) { return WriteFrameAsync(0x9, payload ?? Array.Empty<byte>(), true, ct); }发送大数据时,如果直接塞进一个帧,会遇到两个问题:一是大块内存分配和拷贝,二是某些代理和中间件可能对单帧大小有限制。协议层面没有强制要求分片,但实践上我建议超过64KB的消息做分片。
分片发送的伪代码思路:
public async ValueTask SendBigBinaryAsync( ReadOnlyMemory<byte> payload, CancellationToken ct) { const int chunkSize = 64 * 1024; int offset = 0; bool first = true; while (offset < payload.Length) { int length = Math.Min(chunkSize, payload.Length - offset); var chunk = payload.Slice(offset, length); int opcode = first ? 0x2 : 0x0; bool fin = (offset + length) >= payload.Length; await WriteFrameAsync(opcode, chunk, fin, ct); first = false; offset += length; } }第一帧的Opcode是0x2(二进制),中间续帧的Opcode全部是0x0,最后一帧Fin=true。注意这里循环里每次都调用WriteFrameAsync,而WriteFrameAsync内部有锁,没问题。但更高效的做法是只锁一次然后循环写,否则大消息分100个片段就要加锁100次,有一定开销。如果想优化,可以把锁粒度提高到整个分片发送过程。
3.5 心跳和被动关闭
WebSocket本身没有强制心跳,但许多真实场景下,如果一段时间没有消息,中间代理或NAT设备会静默切断连接。所以服务器侧必须主动发Ping,期待客户端回Pong。
我在Session里放了一个定时器:
private readonly Timer _heartbeatTimer; // 每隔30秒发一次Ping _heartbeatTimer = new Timer(HeartbeatCallback, null, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30));HeartbeatCallback里维护一个“最后收到数据”的时间戳,如果超过60秒没有收到包括Pong在内的任何数据,就主动关闭连接。实现很简单:
private DateTime _lastReceiveTime = DateTime.UtcNow; private void HeartbeatCallback(object? state) { if ((DateTime.UtcNow - _lastReceiveTime).TotalSeconds > 60) { // 超时,主动关闭 _ = CloseAsync(1001, "Heartbeat timeout"); return; } _ = SendPingAsync(); }在ReceiveLoop里,每次成功读取一帧后更新_lastReceiveTime:
_lastReceiveTime = DateTime.UtcNow;这样心跳逻辑就闭环了。注意Timer的回调里不能做太重的事,SendPingAsync本身是异步的,但Timer回调是void,所以用了_ =忽略Task。更好的做法是直接把SendPingAsync放到一个后台发送队列里,避免Timer回调里调用异步IO引发异常无法捕获。
4. 实操中的常见问题与排查技巧
4.1 粘包、半包和ReadAsync返回长度不足
这是WebSocket服务里最容易踩、也最隐蔽的坑。
如果没有ReadExactAsync这类“读满指定长度”的工具,直接拿第一次ReadAsync的结果去解析帧头,大概率会遇到半包。尤其在高延迟或弱网环境下,一帧数据可能分多次到达。正确做法是严格按照“先读2字节帧头,不够就继续读;再按长度扩展,不够继续读;最后读Payload”的步骤来。
我在调试时习惯在帧解析入口处打一个十六进制日志,把每次读到的前几个字节打出来。比如客户端发来64字节数据,头两个字节是0x81 0x3E,那说明这是一个FIN=1的文本帧,Payload长度是62,正好和内容长度吻合。这种日志对定位粘包问题和掩码异常非常有用。
另外,TCP层可能会一次收到多个WebSocket帧,收完一帧以后,剩下的数据要留在缓冲区,下一次继续解析。如果直接用单个Stream的ReadAsync,没有缓冲区的话,剩下数据会丢失。最简单的处理是加一个缓冲流,或者用System.IO.Pipelines的PipeReader来做。Pipelines内部处理了缓冲、解耦、反压,适合高性能需求。中小项目里,用一个MemoryStream做缓冲也能跑,但自己处理边界容易出错。
4.2 并发发送导致帧交错
客户端和服务端之间是同一个TCP连接,如果业务里有多个线程同时调用SendTextAsync,底层会同时向Stream写入数据。TCP保证字节流有序,但不会帮你保护“帧”的原子性。两个线程各自写了帧头和负载,交错后接收方解析出来的就是乱掉的帧。
我遇到过最典型的情况:服务器收到一条消息后,在事件处理里同时发起广播和回复,结果广播线程往Session A写一条消息,回复线程也往Session A写一条消息,两个线程并发写,Session A的接收方就收到了一个混合的无效帧,表现成偶发的“客户端收到乱码,连接被关闭”。
解决方案就是在Session内部用SemaphoreSlim串行化发送,或者用单个后台发送线程加Channel队列。前者简单,后者能支持更多流量。这个一定要在封装层做,不能指望上层业务自觉加锁。
4.3 大消息分片导致的内存膨胀
接收端如果完全不限制消息大小,客户端可以一直发分片,服务端把所有分片都保存在内存里,直到收到FIN=1的续帧。如果分片永远不来,内存就被垃圾数据占满了。所以我强烈建议在Session里设置MaxMessageSize,默认16MB或1MB,在CombineFragments前检查总长度,超过直接关闭连接并返回1009(Message Too Big)。
发送端的另一个大坑是使用字符串插值直接拼JSON,然后GetBytes,导致大字符串临时对象的分配。如果性能敏感,建议用Utf8JsonWriter直接写UTF-8字节,或者至少用ArrayBufferWriter 复用缓冲区。当然,这些优化在连接量低时不需要做。
4.4 文本帧的UTF-8校验
RFC 6455规定文本帧必须是合法的UTF-8。直接调用Encoding.UTF8.GetString,遇到非法字节序列时会把非法字节替换成U+FFFD。这在业务上可能掩盖问题:客户端发送了坏数据,服务端照样返回JSON解析错误,但问题根源在协议层。
更好的做法是使用严格校验模式:
var strictUtf8 = new UTF8Encoding(false, true); try { string text = strictUtf8.GetString(payload); } catch (DecoderFallbackException) { throw new WebSocketProtocolException(1007, "文本帧包含非法UTF-8"); }这样才算符合协议要求。
4.5 被动关闭时的优雅处理
收到Close帧后,先回应Close帧,然后再关闭底层Stream或Socket。但TCP关闭是个“双方向”的过程,直接Dispose可能让回应Close帧还没发出去就被中断。我习惯在写Close帧后加一个短暂延时,比如50毫秒,或者等待对端Close帧主动到达,超时后强制关闭。这个细节在弱网环境里很重要,处理不好会导致客户端看到1006 Abnormal Closure。
4.6 常用调试工具与日志技巧
- 调试阶段用浏览器控制台
new WebSocket("ws://127.0.0.1:8080")做客户端,能覆盖握手、文本、二进制、关闭流程。 - 自写测试客户端时,一定要确认帧带掩码,否则服务端会按协议错误断开。
- 在服务端开启一个“帧日志开关”,记录每个连接的收发帧头:FIN、Opcode、长度、掩码标记。问题基本都能从这个日志里找出来。
- 多连接压力测试时,注意观察线程数和内存占用。如果内存持续上涨,优先怀疑接收循环里有大对象没有释放,或者分片列表没有被清理。
5. 写在最后的一点经验
消息收发封装做完之后,我最大的感触是:WebSocket协议本身并不复杂,复杂的是把边界情况处理精确。帧长度扩展、掩码、分片、控制帧、并发锁、心跳超时,每一个点单独拎出来都是小问题,但全部融在一起后,就需要靠分层来保持清晰。
我建议你在自己的实现里保持这个分层:流读取负责读满字节,帧解析负责把字节变成Frame,消息组装负责处理分片和控制帧,业务事件负责对接上层逻辑。每一层只关心自己的事,出了问题也知道去哪里修。不要一上来就把所有逻辑揉进一个while循环里。
如果接下来还要继续扩展这个服务器,我会优先考虑几个方向:一是把System.IO.Pipelines用起来,替换手动读流,性能和缓冲管理都会上一个台阶;二是给Session加上消息排队和背压,避免某个慢客户端拖垮整个服务器;三是支持消息的路由和拦截器,让上层业务写起来更像ASP.NET Core中间件。
但这些都是锦上添花。最基础的消息收发封装稳定之后,这个服务器才算是真正“能干活”了。后面再遇到问题,也都有了可以下手的着力点。