1. 为什么消息收发必须单独封装:Socket字节流和业务消息之间隔着一道鸿沟
先明确一下这篇文章在系列里的位置。前面两篇我们搞定了HTTP Upgrade握手和连接生命周期管理,也就是从一个普通TCP连接变成合法的WebSocket连接。到了这一步,很多照着例子写代码的朋友会下意识觉得"最难的部分已经过去了",剩下的无非就是ReceiveAsync读字节、SendAsync写字节。但实际动手写一个能用的服务器,你会发现真正的麻烦才刚刚开始。
原生Socket的API说到底只提供两个能力:往连接里塞字节流、从连接里读字节流。而WebSocket对应用层暴露的语义是什么?是一条完整的消息。什么叫 "完整"?客户端发来一个1MB的文本消息,底层网络可能把它拆成几十个TCP包,每个包可能只带几百字节的WebSocket帧数据;反过来,客户端连续发10条小消息,这几个消息的帧可能被打包在同一个TCP段里一次性抵达服务器。如果不做任何封装,你的业务代码将面对的是"一段段毫无边界的字节",你得自己从里面抠出帧边界、判断帧类型、处理半包、缓存未完成的数据,这一套逻辑如果散落在每个连接的处理代码里,那维护成本简直是灾难。
我在项目里见过不少直接把解析逻辑写在ProcessReceivedBuffer里的代码,几百行if-else堆在一起,最后连原作者自己都说不清某个分支什么时候会触发。所以这个系列到第三篇,我认为必须停下来,专门把"消息收发"这一层做成独立模块。它要解决的不只是"能收发",而是:
- 接收端能准确地把字节流切成一个个帧,再把分片消息完整重组,最后只把"完整消息"抛给上层业务;
- 发送端能从业务层接一条消息(字符串或字节数组),自动完成帧头构造、长度编码、掩码处理、必要时自动分片;
- 连接层、协议层、业务层各管各的事,不要互相纠缠。
简单说,你要在Socket之上再造一个"消息语义层",这一层的质量直接决定这个服务器能撑多少并发、代码能不能维护下去。
2. 接收方向:帧解析器的逐位拆解与实现细节
帧解析是整个封装里最基础也最容易写错的部分。WebSocket的帧格式本身不算复杂,但因为它涉及位操作、变长整数、掩码算法,一不留神就会踩坑。我建议把你对RFC 6455的依赖降低——不要凭印象写,把帧头那几段规范打印出来贴墙上,边写边对照。
2.1 帧头的三个字节:一位一位抠出来
一个WebSocket帧的头两个字节是固定的元信息,第二个字节之后可能是扩展长度字段,扩展长度字段之后是掩码键(如果有的话),再往后才是真正的Payload数据。规范里定义得很清楚,我用C#代码拆给你看:
public readonly struct FrameHeader { public readonly bool IsFinal; // FIN位 public readonly bool HasMask; // MASK位 public readonly byte Opcode; // 4位opcode public readonly ulong PayloadLength; // 7位/16位/64位长度 public readonly byte MaskKey0, MaskKey1, MaskKey2, MaskKey3; } public static bool TryParseHeader(ReadOnlySpan<byte> buffer, out FrameHeader header, out int headerLength) { header = default; headerLength = 0; if (buffer.Length < 2) return false; byte b0 = buffer[0]; byte b1 = buffer[1]; header.IsFinal = (b0 & 0x80) != 0; byte opcode = (byte)(b0 & 0x0F); header.Opcode = opcode; header.HasMask = (b1 & 0x80) != 0; ulong payloadLen = (ulong)(b1 & 0x7F); int offset = 2; if (payloadLen == 126) { if (buffer.Length < 4) return false; payloadLen = (ulong)((buffer[2] << 8) | buffer[3]); offset = 4; } else if (payloadLen == 127) { if (buffer.Length < 10) return false; // 64位长度按网络字节序(大端)读取 payloadLen = 0; for (int i = 0; i < 8; i++) payloadLen = (payloadLen << 8) | buffer[2 + i]; offset = 10; } if (header.HasMask) { if (buffer.Length < offset + 4) return false; header.MaskKey0 = buffer[offset]; header.MaskKey1 = buffer[offset + 1]; header.MaskKey2 = buffer[offset + 2]; header.MaskKey3 = buffer[offset + 3]; offset += 4; } header.PayloadLength = payloadLen; headerLength = offset; return true; }这段代码里我认为最值得说清楚的有两个位置。
第三字节的126和127是"长度格式标记",不是长度本身。如果长度在0到125之间,直接用7位存;如果超过125,第一个长度字节填126,再用接下来2个字节表示真实长度;如果2个字节也不够(超过65535),第一个长度字节填127,再用8个字节表示真实长度。这个设计说白了就是为了绝大多数小消息省字节,同时又能支持超大帧。很多人第一次写容易直接if (payloadLen > 125),然后不管三七二十一把长度按8字节读,导致所有中等长度消息全部解析失败。
64位长度必须自己用移位拼出来。C#里的BinaryPrimitives.ReadUInt64BigEndian可以读,但如果你的目标框架版本比较老,或者你在Unity这种环境里写,建议直接用循环移位。顺带提醒一句:规范的最终版里明确规定,最前面的最高位必须为0,也就是说超过2^63的长度是非法的,遇到可以直接断开连接,别客气。
2.2 掩码算法:客户端到服务器的帧必须反转
掩码不是加密,它的作用仅仅是防止某些代理服务器把WebSocket帧误判成HTTP请求,以及避免缓存污染攻击。规范要求:客户端发往服务器的帧必须设置MASK位并使用掩码键;服务器发往客户端的帧不允许设置MASK位。
接收端要做的事情就是把掩码键和Payload逐字节做异或。算法很简单:
public static void ApplyMask(Span<byte> payload, byte k0, byte k1, byte k2, byte k3) { // 掩码键按 i % 4 循环使用 for (int i = 0; i < payload.Length; i++) { byte key = i % 4 switch { 0 => k0, 1 => k1, 2 => k2, _ => k3 }; payload[i] ^= key; } }性能上有个小优化点:这个循环是纯CPU密集的,而且访存模式非常规整,完全可以向量化。如果帧很大(比如几百KB以上的二进制消息),用System.Numerics.Vector<byte>把Payload按16字节一组批量异或,可以省下不少时间。我在实际项目中测试过,16字节一组做掩码比逐字节循环快大概4到5倍,这个优化几乎是零成本的,值得做。
2.3 接收缓冲区:怎么避免"数据还没到齐就开始解析"
这是新手最容易踩的坑。Socket.ReceiveAsync返回的字节数并不保证恰好是一个完整帧,更不保证恰好是一条完整消息。它只是告诉你"这次网络层递给我了这么多字节"。这三个事实之间没有任何等价关系。
所以接收方向的第一原则是:永远不要在缓冲区不够一个完整帧头时强行解析,也不要在一个帧的Payload没收齐时提前消费。我在封装里用了一个简单的"缓冲队列"思路:
public sealed class FrameBuffer { private readonly List<byte> _buffer = new List<byte>(); private readonly byte[] _temp; public void Append(ReadOnlySpan<byte> data) { // 把socket读到的一块数据追加进来 } public bool TryReadFrame(out ReadOnlyMemory<byte> frameData) { // 1. 先TryParseHeader,若缓冲区不够头部长度则返回false // 2. 若头部都齐了,检查Payload是否已经全部到达 // 3. 若Payload没收齐,返回false // 4. 收齐了,从缓冲区“切走”帧头+Payload } public void Compact() { // 把已消费的数据从缓冲区头部移除 } }注意一个细节:判断"Payload是否收齐"与"掩码是否已经应用"的顺序。我建议先把Payload字节原样保留在缓冲区,等到确认整个帧完整后,一次性做解析和掩码处理,不要在收到一半的时候就提前异或,否则一旦后续数据没到齐,你会面对一个被部分掩码、无法继续处理的"脏数据"。
3. 消息重组:分片、控制帧与状态机设计
如果你只需要处理小消息,帧解析做完其实已经可以交付了。但真实的WebSocket场景里一定会遇到分片,因为协议允许发送方把一条大消息分成多个帧逐块发送。分片的触发条件通常是消息超过某个阈值(比如1MB)或发送方显式使用流式接口。接收端必须把一组连续的data帧拼回一条完整消息,并在重组过程中正确处理穿插进来的控制帧。
3.1 分片规则:首帧决定类型,尾帧FIN=1
具体规则是这样的:一条分片消息的第一个帧的opcode是实际消息类型(1文本或2二进制),并且FIN=0;中间的若干个帧opcode为0(continuation),FIN=0;最后一个帧opcode仍然为0,但FIN=1。如果中途来了一个新的非0帧,那说明协议状态出错了——你正在重组一条还没收尾的消息,结果又来了新消息的开头,这基本是对方实现有bug或者故意攻击,应当直接断开连接。
3.2 状态机设计:三态足够
我实现重组状态机时只用了三态:
- Ready:当前没有未完成的消息,可以接受任何新消息首帧;
- Fragmented:正在重组一条分片消息,只接受opcode=0的后续帧,以及控制帧;
- Error:协议违规,直接触发连接关闭。
public enum MessageReassemblyState { Ready, Fragmented, ProtocolError } public sealed class MessageReassembler { private MessageReassemblyState _state = MessageReassemblyState.Ready; private byte _messageType; // 首帧的opcode private readonly List<byte[]> _fragments = new List<byte[]>(); public bool TryPushFrame(FrameHeader header, ReadOnlyMemory<byte> payload, out WebSocketMessage message) { message = default; // 控制帧不参与重组,直接放行 if (header.Opcode == 0x8 || header.Opcode == 0x9 || header.Opcode == 0xA) { // 让上层单独处理,这里不改变状态 return false; } if (_state == MessageReassemblyState.Ready) { if (header.IsFinal) { // 单个帧就是完整消息 message = new WebSocketMessage(header.Opcode, payload.ToArray()); return true; } _messageType = header.Opcode; _fragments.Add(payload.ToArray()); _state = MessageReassemblyState.Fragmented; return false; } if (_state == MessageReassemblyState.Fragmented) { if (header.Opcode != 0x0) { _state = MessageReassemblyState.ProtocolError; throw new ProtocolViolationException("分片未结束时收到新消息首帧"); } _fragments.Add(payload.ToArray()); if (header.IsFinal) { var fullMessage = ConcatenateFragments(_fragments); _fragments.Clear(); _state = MessageReassemblyState.Ready; message = new WebSocketMessage(_messageType, fullMessage); return true; } return false; } throw new InvalidOperationException("协议状态错误"); } }这里有个容易被忽略的点:控制帧可以在任何时刻插入,即使正在重组分片消息。最常见的就是Ping帧。客户端可能在发送一个大文件的流式过程中,中途插入一个Ping来确认连接还活着。你的重组状态机不能因为"当前处于分片状态"就把Ping帧丢掉,或者直接报错。正确做法是控制帧永远旁路,单独走自己的处理通道。
另外要注意"未完成消息的最大累积量"——如果对方持续发送FIN=0的帧却始终不结束,你的_fragments列表会无限增长,内存直接被拖垮。规范虽然没有强制要求,但服务器必须设置一个上限(比如累计不超过16MB或64MB),超了就主动断连,这是防资源耗尽的基本手段。
4. 发送方向:从应用层消息到Wire Frame的封装
发送和接收虽然干的是相反的事,但封装的对称性非常重要。接收端有FrameParser和MessageReassembler,发送端就应该有对应的FrameBuilder和MessageSender,两边API一一对应,这样整个协议层的心智模型才完整。
4.1 帧构造:一个入口函数解决所有情况
发送的核心工作是把一条消息变成"一个或多个帧",并正确写入帧头。我建议把帧构造做成一个静态方法,它接收消息类型、Payload、是否分片等参数,返回一个byte[]直接可写Socket:
public static byte[] BuildFrame(byte opcode, ReadOnlySpan<byte> payload, bool isFinal) { long length = payload.Length; int headerLength; if (length < 126) headerLength = 2; else if (length <= ushort.MaxValue) headerLength = 4; else headerLength = 10; // 服务器给客户端发数据不设置掩码,所以头里没有MaskKey var frame = new byte[headerLength + length]; frame[0] = (byte)((isFinal ? 0x80 : 0x00) | opcode); if (length < 126) { frame[1] = (byte)length; } else if (length <= ushort.MaxValue) { frame[1] = 126; frame[2] = (byte)(length >> 8); frame[3] = (byte)(length & 0xFF); } else { frame[1] = 127; // 大端写入8字节长度 for (int i = 0; i < 8; i++) frame[2 + i] = (byte)(length >> (56 - i * 8)); } payload.CopyTo(frame.AsSpan(headerLength)); return frame; }这个函数完全不需要考虑掩码,因为服务端发送就是不带MASK。如果你参照的是网上某些通用库的代码,他们可能会统一加掩码,那是为了同时兼容客户端模式,但纯服务端场景加上纯属浪费CPU。
4.2 分片发送策略:什么时候该分片
分片发送的核心动机有两个:一是消息太大,一次写进Socket缓冲区可能造成长时间阻塞;二是发送方希望一边生成数据一边发送,不想等全部数据都准备好了再发。但是WebSocket协议本身并没有"必须分片"的强制要求,服务器完全可以把所有数据打包成一个帧发出去。
我的经验是设一个阈值,比如单条消息超过128KB就自动分片。分片时首帧opcode为消息类型且FIN=0,中间帧和尾帧opcode为0。注意分片的语义是"I am sending a fragmented message",不是"I am chunking by TCP",帧内的Payload分多大完全由你决定,跟TCP分段无关。实际上大部分情况下,消息不超过1MB时不分片,网络层也会帮你处理,反而少了一层状态管理。只有在写超大消息或者做实时流式推送时,才需要主动分片。
4.3 发送缓冲与背压:别让一个慢客户端拖垮你的服务器
这一节容易被忽略。假如你有1000个连接,其中999个都在正常接收数据,只有1个客户端因为网络拥堵迟迟消费不完。如果你对每条消息都立刻SendAsync,底层Socket的发送缓冲区会慢慢堆满,然后你的写操作开始阻塞。如果不加控制,阻塞会蔓延到业务线程池,最后整个服务器的吞吐量都被这一个慢客户端拖垮。
封装发送方向时,我强烈建议给每个连接维护一个发送队列:
public sealed class OutgoingMessageQueue { private readonly Queue<byte[]> _pending = new Queue<byte[]>(); private bool _isSending; public void EnqueueAndTrySend(byte[] frameBytes) { _pending.Enqueue(frameBytes); if (!_isSending) StartSendLoop(); } }发送循环的核心逻辑是:一次只从队列取一个帧写入Socket,写完一个立刻写下一个,但如果写操作返回WouldBlock或出现可重试错误,就停下来等下次触发。配合一个高水位阈值,当待发送字节数超过阈值时,对应用户的"发送窗口"关闭——业务层可以感知到这个背压信号,决定是丢弃消息还是暂存磁盘。这一步不做,你的服务器并发上限会非常低,而且极不稳定。
5. 收发调度的线程模型:一个连接一条读循环,还是一组连接共用?
线程模型是消息收发封装里绕不开的话题。C#里实现WebSocket服务器有两种主流方式:一种是一连接一线程模型(每个连接用一个独立线程跑接收循环),一种是基于异步事件循环模型(通过SocketAsyncEventArgs或async/await实现非阻塞IO)。我实话说:在.NET这边,现代推荐方案一定是异步事件驱动,一连接一线程在连接数超过几百时就会被线程上下文切换压垮。
具体到消息收发封装,我采用的模型是:
- 每个连接有一个
Task作为接收循环,它不断调用ReceiveAsync读取数据,然后把数据交给FrameBuffer和FrameParser消费; - 每条消息解析成功后,触发一个事件/回调让业务层处理;
- 发送不依赖接收循环,通过
OutgoingMessageQueue自己维护一个写循环,两者互不阻塞。
这里有个非常微妙的问题:Socket的SendAsync和ReceiveAsync是否可以同时进行?答案是可以。一个Socket在内核层面接收缓冲区和发送缓冲区是独立的,你可以在接收循环正阻塞读的同时,另外一个线程在写。这也是为什么我把接收循环和发送循环分开封装,而不是在同一个方法里先读再写。很多人写的简易服务器是"读一条消息,回一条响应"这种请求-响应模式,这在WebSocket这种全双工协议里是完全错误的心智模型。
不过异步模型有个代价:回调/事件的执行上下文经常跨线程。你解析出来的消息在某个线程池线程上触发,但业务代码可能期望在特定上下文(比如UI线程)运行。这个属于业务层的调度问题,协议层不必关心,但你需要提供一个SynchronizationContext注入点,否则后续扩展会很痛苦。
5.1 发送并发控制:为什么发送必须串行化
多条业务线程同时往一个连接写帧,会出现什么问题?两个线程各自调用SendAsync,底层的数据可能会交叉拼接:线程A写了一部分帧头,线程B也写了自己的帧头,然后线程A继续写Payload,最终形成的字节流完全错乱,对方解析出一个坏帧。Socket本身的Send在操作系统中被保证为原子操作吗?严格说,Send调用会被串行化,但如果你把一条消息拆成多次写入(比如SendAsync写帧头再SendAsync写Payload就可能是两次),就完全无法保证原子性。
所以发送方向必须串行化,最干净的方式就是我前面说的发送队列。所有发送请求只把数据丢进队列,由唯一一个发送循环负责"一次取一帧、一次写一个完整帧"。这样不管多少线程调Send,落到Socket上的顺序都是确定的,每个帧内部也不会被切碎。这一点,我在所有代码评审里都会主动检查。
6. 协议层抽象设计:让业务代码远离字节
收发机制的封装做完之后,它对外呈现的接口决定了业务代码的复杂度。我认为一个"能用"的协议层封装至少应该对外提供这几个能力,这些也是我在重构中反复打磨的。
6.1 事件接口:OnMessage是标配,OnFrame是扩展
大多数人对接WebSocket库希望的是这样的体验:
server.OnMessage += (conn, message) => { if (message.IsText) { string text = message.AsText(); // 业务逻辑 } else { byte[] binary = message.AsBinary(); // 业务逻辑 } };而不是让业务层自己处理字节切片、掩码、分片。所以协议层的最低标准就是提供一个OnMessage事件,参数是一个已经重组完整的WebSocketMessage对象。至于更底层的OnFrame事件,适合做协议分析器或者测试工具,普通业务不该接触。
6.2 文本与二进制的区分
WebSocket消息类型在opcode上就区分了1(文本)和2(二进制)。对业务层来说,文本消息应当以字符串的形式直接提供,二进制以byte[]提供。但注意:文本消息的Payload在Wire上永远是UTF-8编码,这是规范强制要求的。无论发送方本身是什么编码,到了WebSocket层都必须以UTF-8传输。接收方拿到Payload后直接Encoding.UTF8.GetString即可,不要再自作聪明地猜测其他编码。
我在设计消息对象时用了这样一个结构:
public sealed class WebSocketMessage { public byte Opcode { get; } public bool IsText => Opcode == 0x1; public bool IsBinary => Opcode == 0x2; public ReadOnlyMemory<byte> Payload { get; } public string AsText() => Encoding.UTF8.GetString(Payload.Span); public byte[] AsBinary() => Payload.ToArray(); }注意ReadOnlyMemory<byte>而不是byte[],这样在解析时可避免无意义的拷贝——虽然我们现在的实现里为了简化用了ToArray(),但接口层面别把自己锁死,将来做零拷贝优化时不需要改调用方的代码。
6.3 控制帧的默认处理
协议层的隐藏职责之一是自动响应Ping/Pong。面对一个Ping帧,协议层应该立刻回一个Pong帧,Payload回显Ping的Payload。这个逻辑不属于业务,不该让业务代码操心。但你要注意回Pong的时机——如果正在重组分片消息,Pong可以立即发,它不参与重组状态机。我的实现里专门有一个ControlFrameHandler来统一处理这三类控制帧:
- Close帧:回Close帧后主动关闭连接;
- Ping帧:回Pong帧;
- Pong帧:通常什么都不用做,但可以用来实现心跳超时判断——如果客户端久久不回Pong,说明连接半死,可以主动断开。
这层默认逻辑做扎实了,上层心跳保活就非常简单。我见过一些库把Ping处理也抛给业务层,结果每个业务代码都在重复实现回Pong的逻辑,这是抽象层次没搭好导致的重复劳动。
7. 实测中遇到的问题与纠偏:从一次半包事故说起
封装写完不等于能用,至少要在真实网络环境下跑一遍。我在自测过程中遇到过几个典型的坑,这里逐个说,希望能让你少走弯路。
7.1 半包事故:一个帧被拆成两次到达
现象是这样的:客户端发来一条长度为200字节的消息,但在网络状况较差时,服务器端第一次ReceiveAsync只收到60字节,剩余140字节过了几毫秒才到达。如果我在解析代码里写的是"头解析成功就直接读Payload",那第二次数据来之前,解析出来的Payload长度明显不够,直接把ReadOnlyMemory交给业务层,业务层拿到的就是一个不完整消息。这种bug特别隐蔽,因为大部分时间网络正常,很难复现。
正确做法就是我前面说过的:FrameBuffer只有在确认"帧头+完整Payload"全部到位后,才会把整帧交付。不要看到帧头就急着往下走,必须把Payload长度作为最低接收量的判断基准。
7.2 粘包问题:多条消息挤在一个TCP段里
反过来的场景是,客户端瞬间发送了"hello"和"world"两条消息,服务器一次ReceiveAsync把两条的帧数据全放进缓冲区。如果你只允许每次解析出一个帧,那第二个帧就留在缓冲区里等待下次读取——只要逻辑正确不会丢,但如果你不小心把缓冲区在解析一个帧后直接清空,第二条消息就永久丢失了。所以FrameBuffer必须支持"一次可以解析出多个帧,直到缓冲区不足"的循环。一个典型的TryReadAllFrames可以这么做:
public List<WebSocketMessage> DrainAllMessages() { var result = new List<WebSocketMessage>(); while (TryReadFrame(out var frameHeader, out var framePayload)) { var message = _reassembler.Process(frameHeader, framePayload); if (message != null) result.Add(message); } return result; }通过在一个循环里反复尝试读帧,才能处理一次TCP读取中包含多个帧的情况。
7.3 关于API设计的几个教训
最后说几个纯经验层面的教训,这些不写下来,等你自己踩一遍通常会花很长时间。
第一个教训:不要把"网络字节"和"消息对象"混在一个类里。我初版封装曾让一个Frame类既负责解析又负责承载消息,结果业务层和协议层共享同一类型,后续改了帧结构结果业务代码跟着遭殃。后来彻底分开成FrameHeader、WebSocketMessage、FrameParser、MessageReassembler四个独立单元,改动才变得可控。
第二个教训:异常别吞。很多关于Socket的示例代码里喜欢try { ... } catch { },这在接收循环里是灾难。一旦出现协议错误,最正确的响应是记录日志、发Close帧(如果还发得出去)、断开连接。如果吞掉异常然后继续循环,下一次读取可能又触发同样错误,最后整个连接进入一个不断空转的病态状态。
第三个教训:性能优化要按部就班。消息收发封装初期最该关心的是正确性和可测试性,而不是一上来就搞Buffer Pool、零拷贝、Channel队列。先把功能跑通,用单元测试把帧解析的各种边界(0长度、126边界、127边界、分片中断、控制帧穿插)覆盖到位,然后再考虑性能。我在这个项目里先实现了简单的List<byte>缓冲,功能验证完毕之后才优化成池化的ArrayPool<byte>,每一步都有测试兜底,心里才踏实。
这篇讲的是消息收发封装的主干逻辑。实际项目里还会涉及TLS支持、代理协议、压缩扩展(permessage-deflate)等进阶话题,但那些都是在"帧解析和消息重组"这个地基上做的扩展。地基打牢,后面无论怎么加功能都不会心虚。下一篇我打算聊连接保活与心跳超时的实现细节,结合这次封装的接收循环,看看怎么把空闲连接及时清理干净。