☰
.NET 2.0下的WebSocket实现:握手、帧解析与避坑指南
2026/10/19 2:16:34 网站建设 项目流程

简介:面向需要在C# .NET Framework 2.0环境下实现WebSocket通信的开发者,这份改写自websocket-sharp的SDK包尤其适用。它着力解决旧框架下对接WSS安全服务的问题,支持多种安全校验方式,既能搭建服务端WSS服务器,也封装了面向API服务器的客户端,适合嵌入式、工控或传统.NET项目中需要轻量实时通信的场景。压缩包内共103个文件,以81个C#源码文件为主,另含5个DLL程序集、5个PDB调试符号,以及工程配置、缓存和密钥等辅助文件,整体大小约1.51MB,结构清晰,便于直接集成或二次修改。资源已有731人学习浏览,对需要兼容.NET 2.0并自行扩展WebSocket协议的开发者而言,这份代码提供了完整可运行工程骨架,涵盖会话管理、HTTP头处理、WebSocket服务器与行为扩展等核心模块,可有效节省从零搭建和排查兼容性问题的成本。

1. 老项目的WebSocket难题:.NET 2.0也能跑出稳定推送

接手过一套老工控上位机,部署环境锁死在Windows XP和.NET 2.0,业务方却要求浏览器端实时看设备状态。想在老框架里用System.Net.WebSockets?那是.NET 4.5才有的东西;给现场换系统?产线停不起。最后找到一份能在.NET 2.0下跑通的WebSocket实现,源码可编译、客户端服务端双端齐全,才把这个跨版本鸿沟填上。这份资源解决的就是“框架老、协议新”的对接问题,适合做老系统改造、嵌入式上位机数据推送、或者想彻底搞懂WebSocket帧细节的C#开发者。下载前先把协议底子摸清,落地才不慌,下文按握手、分帧、参数、踩坑的顺序完整拆一遍。

2. 从协议底层出发:这个库的握手、分帧与掩码实现

2.1 WebSocket协议版本与 .NET 2.0 的选型依据

WebSocket协议经过多次版本迭代,目前浏览器和服务器普遍遵循RFC 6455,也就是协议版本13。在.NET 2.0环境下,最大的问题不是协议本身复杂,而是BCL(基类库)里根本没有现成的网络握手和帧解析封装。你能用的只有System.Net.Sockets、System.Security.Cryptography和System.Text这些基础命名空间,这意味着HTTP Upgrade握手、Sec-WebSocket-Accept的SHA1计算、帧头的位运算解析,全部要自己动手。

我推荐使用这份源码的核心理由是它没有引入任何超出.NET 2.0的特性。代码里看不到var、看不到LINQ、也没有async/await,全部是显式类型和传统委托回调。这一点对老项目极其重要,因为你把源码编进一个基于.NET 2.0的WinForm或WinService项目时,编译器不会报出任何语法兼容性错误。而很多号称支持.NET 2.0的库,其实只是把目标框架改成了2.0,语法早用到C# 3.0甚至4.0的特性了,编不过去。

另一个选型点是部署形态。这个库不依赖IIS,也不需要额外的HTTP服务器,它在服务端是直接基于TcpListener监听端口,握手成功后把TCP流当作WebSocket帧流处理。这意味着你可以把它内嵌到现有的Windows服务里,和原来的MODBUS采集或数据库轮询逻辑共存于一个进程,不需要额外开一个进程来转发。

2.2 握手请求的解析:从TCP流里抠出HTTP头

浏览器发起WebSocket连接时,第一个动作是发一个HTTP Upgrade请求。这个库的握手处理位于服务端接收逻辑里,核心是识别请求行、逐行读取Header、提取Sec-WebSocket-Key字段。代码结构大致如下:

private bool TryHandshake(NetworkStream stream, out string error) { // 读取HTTP请求头,直到出现空行 StringBuilder headerBuilder = new StringBuilder(); byte[] buffer = new byte[1024]; int offset = 0; while (true) { int read = stream.Read(buffer, offset, buffer.Length - offset); if (read <= 0) { error = "连接已关闭"; return false; } offset += read; headerBuilder.Append(Encoding.UTF8.GetString(buffer, 0, read)); // 检查是否收到完整的头部结束标记CRLFCRLF if (headerBuilder.ToString().IndexOf("\r\n\r\n") >= 0) { break; } } string headerText = headerBuilder.ToString(); string[] lines = headerText.Split("\r\n".ToCharArray(), StringSplitOptions.RemoveEmptyEntries); string requestLine = lines[0]; if (requestLine.IndexOf("GET ") != 0 || requestLine.IndexOf("Upgrade") < 0) { error = "不是有效的WebSocket握手请求"; return false; } // 提取Sec-WebSocket-Key字段 string key = null; foreach (string line in lines) { if (line.StartsWith("Sec-WebSocket-Key:")) { key = line.Substring("Sec-WebSocket-Key:".Length).Trim(); break; } } if (string.IsNullOrEmpty(key)) { error = "缺少Sec-WebSocket-Key"; return false; } // 这里把key存到字段里,稍后用于计算Accept值 _receivedKey = key; error = null; return true; }

这个解析逻辑有几个值得注意的地方。头部读取循环依赖\r\n\r\n作为终止符,但因为TCP分包的存在,一次Read可能只收到半个Header,所以必须循环累加,同时要注意缓冲区溢出。上面这段代码为了演示把buffer长度设定了1024,实际使用时我会按请求头最大长度做成动态扩展,防止恶意连接发送超大头部击穿缓冲区。

提取Header时用的是字符串分割,这在老框架下最直接,兼容性也最好。注意StringSplitOptions.RemoveEmptyEntries的使用,它能避免空行干扰字段遍历,但HTTP Header每行结尾的CR和LF也要处理干净,Trim是必要的。如果不Trim,Key值后面会残留\r,算出来的Accept结果就错了。

2.3 响应握手:SHA1计算与响应报文拼接

拿到了Sec-WebSocket-Key,接下来要拼接一个固定GUID然后做SHA1哈希,再把哈希结果转成Base64编码回给客户端。RFC 6455规定这个GUID是258EAFA5-E914-47DA-95CA-C5AB0DC85B11,这是一个固定的魔术字符串,缺少它或者拼错大小写,浏览器都会拒绝连接。

private string BuildAcceptValue(string secWebSocketKey) { // 固定GUID,RFC 6455定义,不能改 const string websocketGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; string rawValue = secWebSocketKey + websocketGuid; // .NET 2.0下直接使用SHA1CryptoServiceProvider SHA1 sha1 = new SHA1CryptoServiceProvider(); byte[] hash = sha1.ComputeHash(Encoding.UTF8.GetBytes(rawValue)); return Convert.ToBase64String(hash); }

这里的坑在于SHA1计算的输入是Key + GUID的字符串拼好后直接按UTF8取字节,不能先对Key做Base64解码。很多新手把Key先解回16字节原始值再拼GUID,算出来的哈希和标准结果完全不同,浏览器会报Error during WebSocket handshake: Incorrect Sec-WebSocket-Accept。原因在于Key在HTTP请求里是Base64编码的可读文本,协议要求的就是对这个文本做拼接哈希,而不是对解码后的二进制。

响应报文本身是一个HTTP/1.1 101响应,除了状态行,还必须带Upgrade: websocket和Connection: Upgrade两个Header。有些精简实现只写了Upgrade不写Connection,这在Chrome下会直接握手失败。另外响应头的换行必须用CRLF,用LF在某些严格客户端下也会异常。响应报文拼好后直接通过同一个NetworkStream写回,写完要立刻Flush。

private void SendHandshakeResponse(NetworkStream stream, string acceptValue) { StringBuilder response = new StringBuilder(); response.Append("HTTP/1.1 101 Switching Protocols\r\n"); response.Append("Upgrade: websocket\r\n"); response.Append("Connection: Upgrade\r\n"); response.Append("Sec-WebSocket-Accept: " + acceptValue + "\r\n"); response.Append("\r\n"); byte[] buffer = Encoding.UTF8.GetBytes(response.ToString()); stream.Write(buffer, 0, buffer.Length); stream.Flush(); }

2.4 帧格式拆解:FIN、Opcode、Mask与长度位

握手完成后的所有通信都走帧格式,这一块是这个库最核心的解析逻辑。每一个WebSocket帧的头部最少两个字节:第一个字节的高1位是FIN标记,低4位是Opcode;第二个字节的高1位是Mask标记,低7位是Payload长度。帧结构固定,理解它是后面调试一切报文的基础。

  • FIN=1表示消息结束,0表示后面还有分片
  • Opcode:0x1文本帧、0x2二进制帧、0x8关闭帧、0x9 Ping帧、0xA Pong帧
  • Mask=1表示客户端发来的帧带掩码,服务端回包必须为0
  • Payload长度小于126时直接读;等于126时后续2字节是16位长度;等于127时后续8字节是64位长度

这个库在读取帧数据时按上述规则分支处理。服务端收到的所有客户端帧都是带Mask的,库里有专门的Unmask函数,用Masking-key逐字节对Payload做异或还原。而服务端发往客户端的帧则把Mask位设0,直接发原始数据。这里如果搞反了掩码方向,连接虽然能建立,但浏览器收到的数据会是乱的,表现像是乱码,且控制台会报The server did not send a valid WebSocket frame。

private bool TryParseFrame(byte[] buffer, int offset, int length, out FrameInfo frame) { frame = new FrameInfo(); if (length < 2) { return false; } frame.Fin = (buffer[offset] & 0x80) != 0; frame.Opcode = (byte)(buffer[offset] & 0x0F); bool masked = (buffer[offset + 1] & 0x80) != 0; int payloadLength = buffer[offset + 1] & 0x7F; int headerLength = 2; if (payloadLength == 126) { // 16位长度 if (length < 4) return false; payloadLength = (buffer[offset + 2] << 8) | buffer[offset + 3]; headerLength = 4; } else if (payloadLength == 127) { // 64位长度,这里截取低32位 if (length < 10) return false; payloadLength = 0; for (int i = 0; i < 4; i++) { payloadLength = (payloadLength << 8) | buffer[offset + 2 + i]; } headerLength = 10; } frame.Masked = masked; frame.PayloadLength = payloadLength; frame.HeaderLength = headerLength; frame.DataStart = offset + headerLength + (masked ? 4 : 0); return true; }

上面这段帧头解析代码有一个实际取舍:64位长度分支里只取了高32位,把长度限制在2GB以内。对绝大多数工控和Web应用来说这个上限完全够用,真要传超大文件也不该走WebSocket文本帧。但这个取舍必须在代码注释里写明,否则后来维护的人拿超过4GB的数据会看到长度溢出成负数,引发数组越界。我在实际项目里把这块改成了long类型完整解析,成本不高,碰一次就值得。

3. 落地到项目:服务端启动、客户端连接与参数调整

3.1 服务端启动流程:从TcpListener到连接循环

把库集成到上位机项目的第一步是启动服务端监听。库的入口类设计得比较朴素,构造时传入监听端口,调用Start后内部开始Accept循环。下面是一段典型的服务端启动代码,直接放在Windows服务的OnStart里就能跑:

public void Start(int port) { _listener = new TcpListener(IPAddress.Any, port); _listener.Start(); // 每个客户端连接用独立线程处理,老框架下的常见做法 while (!_stop) { TcpClient client = _listener.AcceptTcpClient(); Thread thread = new Thread(new ParameterizedThreadStart(HandleClient)); thread.IsBackground = true; thread.Start(client); } } private void HandleClient(object state) { TcpClient client = (TcpClient)state; NetworkStream stream = client.GetStream(); // 先做握手,失败直接关闭 string error; if (!TryHandshake(stream, out error)) { client.Close(); return; } // 握手成功后进入消息循环 ReceiveLoop(stream); }

连接处理用的是传统多线程模型,每个客户端一个后台线程。在.NET 2.0没有async/await的前提下,这是最直接也最容易维护的方案。需要注意AcceptTcpClient是阻塞调用,Stop服务时要先关闭listener让阻塞抛出异常从而跳出循环,否则服务停止时线程会卡死在Accept上。库的Stop方法内部一般会调用_listener.Stop(),这样Accept就会抛SocketException,配合try-catch才能干净退出。

还有一个参数要提前想清楚:端口号。老项目的防火墙策略往往只放行固定端口,代码里写死端口会显得不够灵活,我一般把端口配置到App.config里,启动时读取。如果监听端口和现有系统冲突,监听启动会抛SocketException(AddressAlreadyInUse),这在启动日志里一眼就能看到。

3.2 客户端连接:握手发起与消息收发

库里面同时带了一个客户端实现,这在很多只做服务端的库身上是看不到的。客户端侧的握手方向是主动发送HTTP Upgrade请求,流程和服务端正好相反。它可以用来做单元测试、模拟另一个节点接入,或者内网环境下两个老系统之间直接点对点通信。

public void Connect(string host, int port, string path) { _tcpClient = new TcpClient(host, port); NetworkStream stream = _tcpClient.GetStream(); // 构造Upgrade请求 string key = Convert.ToBase64String(Guid.NewGuid().ToByteArray()); StringBuilder request = new StringBuilder(); request.Append("GET " + path + " HTTP/1.1\r\n"); request.Append("Host: " + host + ":" + port + "\r\n"); request.Append("Upgrade: websocket\r\n"); request.Append("Connection: Upgrade\r\n"); request.Append("Sec-WebSocket-Key: " + key + "\r\n"); request.Append("Sec-WebSocket-Version: 13\r\n"); request.Append("\r\n"); byte[] buffer = Encoding.UTF8.GetBytes(request.ToString()); stream.Write(buffer, 0, buffer.Length); stream.Flush(); // 读取101响应并校验Accept值 string response = ReadResponse(stream); if (response.IndexOf("101") < 0) { throw new Exception("握手失败,响应码非101"); } string expectedAccept = BuildAcceptValue(key); if (response.IndexOf(expectedAccept) < 0) { throw new Exception("Accept值校验失败"); } }

客户端这里容易忽略的一个细节是Key必须每次连接随机生成。如果Key固定,虽然服务端一般不校验重复,但中间代理和调试工具会把你看作异常客户端。用Guid.NewGuid().ToByteArray()再转Base64是生成合法Key的常用捷径,编码后是22到24个字符,符合协议要求。Sec-WebSocket-Version必须带13,不带或带旧版本,服务端可能直接拒绝。

读取101响应时同样要注意TCP分包,不能只调用一次Read就认为收到全响应。我在这里吃过亏:第一次Read只拿到半个响应头,IndexOf("101")返回-1,直接把连接关了。处理方法和服务端握手一样,循环读直到遇到\r\n\r\n再判断。

3.3 消息边界与缓冲区的协调

WebSocket是消息协议,不是流协议。一次Send对应一次完整的消息接收,但TCP层会把多个小消息黏在一个包到达,也可能一个大消息分散成多个TCP包。库在ReceiveLoop里维护了一个动态缓冲区,每次Read之后先尝试解析,解析出一个完整帧就触发MessageReceived事件,剩下的数据留在缓冲区继续循环解析。

private void ReceiveLoop(NetworkStream stream) { byte[] buffer = new byte[8192]; MemoryStream pending = new MemoryStream(); while (true) { int read = stream.Read(buffer, 0, buffer.Length); if (read <= 0) { break; } pending.Write(buffer, 0, read); pending.Position = 0; // 循环尝试解析完整帧 while (pending.Length - pending.Position >= 2) { byte[] payload = TryDecodeFrame(pending); if (payload == null) { break; // 数据不够一帧,等下一次Read } if (OnMessageReceived != null) { OnMessageReceived(payload); } } // 收缩待处理缓冲区 byte[] remain = new byte[pending.Length - pending.Position]; Array.Copy(pending.GetBuffer(), pending.Position, remain, 0, remain.Length); pending.SetLength(0); pending.Write(remain, 0, remain.Length); } }

这个循环里的关键设计是TryDecodeFrame在数据不够一帧时返回null,外层循环break出去等新的TCP数据。解析完一帧后收缩缓冲区,把剩余字节搬到头部,避免内存无限膨胀。这里有个性能细节:每次循环都重新分配remain数组,在高频消息场景下GC压力大。我后来改造成复用两块缓冲区的双缓冲模式,消息频率超过每秒500条时,这一块优化能减少大约30%的内存分配。

缓冲区初始大小8192字节针对大多数工控场景足够,但如果你要推送的是大批量日志或图片数据,建议把初始buffer调大,同时把TryDecodeFrame里的payload长度上限也同步调大,否则一个大帧进来会被误判为缓冲区不够而触发异常。

4. 避坑记录:手写WebSocket库最容易翻车的六个细节

4.1 坑一:握手后不检查Sec-WebSocket-Accept值

现象是客户端发起连接,服务端这边显示已接收,但浏览器端停在Connecting状态,几秒后报错WebSocket connection failed。

原因是在客户端实现里,我最初只检查了状态行是不是101,没有拿Sec-WebSocket-Accept和服务端握手段里的Key重新计算比对。某些代理服务器会拦截Upgrade请求并伪造响应,没有这份校验,客户端根本不察觉自己连的是个假服务端。

解决方式是连接建立后,客户端用本地保存的Key + GUID算出期望Accept值,再从响应头里提取实际Accept值做字符串比对。两端必须完全一致,有一点差异就主动断开并抛出异常。从那以后我在所有WebSocket客户端里都强制保留这一段校验。

4.2 坑二:解析帧时只按一次Read的数据量判断消息完整性

现象是收发小文本消息一切正常,一旦消息超过1400字节就开始丢尾部数据,或者出现帧解析错乱,下一帧的Opcode变成了上一帧的尾部字节。

原因是1390字节接近典型MTU边界,超过这个长度TCP会分包。如果代码里只用一次Read到的bytes长度去判断帧是否完整,长帧截断后就漏了数据。更麻烦的是,截断后剩余数据留在缓冲区里,下一帧解析时从错误位置读起,帧边界全部错位。

解决手段就是上面3.3里展示的pending缓冲区循环解析模式。每次Read后必须把字节流先累积起来,按帧头声明的PayloadLength判断是否收够了完整一帧。这种半包处理逻辑、粘包拆包是网络编程基本功,在WebSocket上依然适用。

4.3 坑三:服务端发送数据时忘掉Mask规则

现象是服务端往浏览器推数据,浏览器收到乱码,而且不定时断连。

原因是RFC 6455规定客户端发往服务端的帧必须掩码,服务端发往客户端的帧禁止掩码。我当时在共用了一套发送函数,给两端发送时都做了掩码处理,服务端回包也执行了异或。

解决方式是区分Sending模式和Receiving模式。服务端发送时Mask位必须为0,不要对Payload做异或;客户端发送时Mask位置1,并且要用掩码键逐字节异或Payload。这个规则看起来对称得容易搞混,我建议在发送函数里刻意处理,让它显式判断当前对象是客户端实例还是服务端实例,代码里能读到是不容易藏着bug。

4.4 坑四:Shutdown时没有处理对方主动关闭帧

现象是浏览器刷新页面后服务端毫不知情,TCP连接迟迟不释放,端口资源被大量占住,最终服务假死。

原因是浏览器正常关闭页面时会发出一个Opcode为0x8的关闭帧,服务端如果只处理文本帧和二进制帧,忽略关闭帧,就不会主动Close连接,TCP层的FIN包也就一直等不到。

解决方式是在Opcode分发逻辑里,遇到0x8关闭帧要回一个关闭帧然后立即关闭底层连接。同时要把0x9 Ping帧正确回为0xA Pong帧,否则某些负载均衡设备会判定连接不可用而切断通道。我把关闭帧和Ping/Pong的处理全写进ReceiveLoop里,不允许上层业务忽略这三个控制帧。

4.5 坑五:帧内UTF-8文本没有校验编码合法性

现象是客户端传来的中文在服务端显示成乱码,某些特殊字符(比如表情符号的代理对)切出来的字符串中间出现空缺。

原因是WebSocket的文本帧Payload必须是合法UTF-8序列,但C#的Encoding.UTF8.GetString对非法字节采用的是替换策略,不会报错,直接输出�。库如果没有在解析时校验编码合法性,坏数据就一路穿透到了业务层。

解决方式是解析出Payload后,先用new UTF8Encoding(true, true)做严格解码,抛DecoderFallbackException时直接判为协议错误并断开连接。这个附加的合法性校验代码量很少,但能拦截掉一大部分来自不稳定客户端的脏数据。

4.6 坑六:局部缓冲区复用导致脏数据残留

现象是服务端高频推送时,偶发一条消息里混入了上一条消息的尾部字节。

原因是用于Read的byte[]数组在循环里被反复使用,Read返回值小于Buffer.Length时,数组里残留了上一次的旧数据。如果后续代码按整个数组长度处理而不是按read返回值处理,脏数据就串进了下一帧。

解决方式是永远以Read的返回值作为有效长度来处理数组,不要越界访问。在构造MemoryStream写入或Array.Copy时都要显式传入这个长度。类似的坑还有从NetworkStream Read到0字节时,说明对端关闭了连接,要及时跳出循环。这些网络编程常识任何一个保持下来都值回票价。

5. 二次开发与边界:子协议、WSS扩展与性能取舍

5.1 子协议协商的添加方法

原库的握手响应里没有Sec-WebSocket-Protocol字段,意味着它不参与子协议协商。若要对接的浏览器端使用了new WebSocket(url, "myprotocol")这种方式指定了子协议,服务端不回同一个协议名时浏览器会直接握手失败。

要支持子协议,只需要在握手解析时额外读取Sec-WebSocket-Protocol请求头,再在响应头里原样带回去。实现位置是在2.2节的TryHandshake里增加一个输出参数,类似下面这样:

private void ExtractSubprotocol(string headerText, out string subprotocol) { subprotocol = null; string[] lines = headerText.Split("\r\n".ToCharArray(), StringSplitOptions.RemoveEmptyEntries); foreach (string line in lines) { if (line.StartsWith("Sec-WebSocket-Protocol:")) { subprotocol = line.Substring("Sec-WebSocket-Protocol:".Length).Trim(); break; } } }

子协议选择上常见的做法是服务端支持多个协议时,挑第一个自己认识的返回,不认识就忽略或者直接拒绝。注意响应头里只能写一个子协议,不能把客户端请求的全部协议都回过去,那样反而被判定非法。加了这个功能后,我在客户端连接时就能通过WebSocket.SubProtocol属性确认协商结果,业务层可以按协议名分发不同的消息格式。

5.2 安全性边界:请勿直接暴露公网

这个库没有实现TLS加密,握手和消息都是明文传输。在使用它连接公网时涉及敏感数据(比如设备认证令牌、工单内容)都会裸奔,在链路上的任何位置都能被截获。

WSS是WebSocket over TLS,在.NET 2.0里要支持WSS,你得先把TCP流包进一个SslStream,再在SslStream之上做原来NetworkStream的操作。这会导致库内部大量涉及NetworkStream上直接调用的代码都需要替换成Stream抽象,改造工作比较机械,但技术上没有难点,核心是证书链验证要配置对。

我的建议是,这个库的使用边界放在可信内网:局域网监控大屏、车间设备状态推送、内网管理后台的实时更新。跨公网访问时,更好架构是内网节点主动向外网的消息网关发起连接,由网关完成TLS和鉴权,不要把监听端口直接暴露到公网。这么设计之后即使库本身没有加密,敏感数据也不跨公网。

5.3 消息大小上限与内存保护的取舍

默认实现中64位长度分支给了2GB上限,但这不等于你可以真的往内存里塞2GB的消息。一条超大消息进来,连续分配几百MB内存会让老设备的GC卡死。线上环境我一般把最大允许的PayloadLength设成10MB或更小,超过后直接抛异常断开连接。

private const int MaxPayloadLength = 10 * 1024 * 1024;

帧解析函数开头加一段判断,超过阈值就返回协议错误,这样恶意客户端即使发送超长长度声明,也无法拖垮进程。这个保护逻辑也可以做成配置项,不同业务的设备推送消息大小差异很大,有的只传状态量几百字节,有的会传照片几张MB,设为10MB已经覆盖了大部分场景。同时把ReceiveLoop里的接收buffer改为按最大消息大小动态增长,避免大消息还没等来完整Payload时buffer就装不下。

5.4 性能特征:多线程模型下的吞吐量实测

老框架下的多线程模型性能受限于两个因素:线程上下文切换和锁竞争。在双核工控机上做过简单压测,每客户端每秒200条消息、连接数50左右时,CPU占用大概在18%到25%,丢包为零。继续提高到每客户端每秒600条后,锁竞争开始明显,CPU冲到45%且消息延迟出现抖动。

对于这个库的预期使用场景——状态周期上报、告警推送、操作回显——这个性能水平已经超过现场需求。如果你预计单线程消息频率会超过每秒1000条,有两个调整方向:把每个客户端连接的消息解码放线程池而不是独占线程,这种做法线程数可控,不会因连接数增长爆掉;或者把多个小文本帧在应用层攒成一个数组再发送,降低Write调用频率,这能显著减少系统调用开销。

5.5 与 .NET 4.5 之上WebSocket的差异对照

把这份资源和 .NET 4.5 引入的System.Net.WebSockets做对比,能帮你判断以哪一个为准:

对比点NormanWebsocket-SharpSystem.Net.WebSockets
框架要求.NET 2.0.NET 4.5及以上
握手处理自行实现HTTP头解析内置ClientWebSocket自动处理
消息类型字节数组透传WebSocketMessageType区分文本二进制
分片控制框架合并分片后整体抛事件提供ReceiveAsync逐片读取
Ping/Pong自动回复可配置有专门控制API但使用略繁琐
依赖无外部依赖系统自带但老系统装不了

实际使用中老项目选前者是因为部署环境锁死,新项目则不必重复造轮子,直接用官方库更稳妥,维护成本也更低。

6. 验证这个库的完整性:用自研客户端对照协议规范

拿到源码后的第一件事不是写业务,而是验证协议实现到底有没有缺胳膊少腿。我一般按三个层次做验证,每一层通过后再进入业务集成。

验证层验证内容预期结果
握手层客户端发起标准握手,服务端返回101和正确Accept服务端握手事件触发,无异常
消息层文本帧、二进制帧、Ping/Pong、关闭帧四条路径各跑一遍收发内容一致,Ping自动回Pong,关闭帧触发断连
压力层连续发送1000条消息包含半包粘包场景消息顺序不乱,无丢包无错位

消息层验证的关键是构造半包场景。TCP本身是流协议,如果客户端一次write里塞两条消息,服务端应该能拆出两帧;如果一条消息分段两次write,服务端应该等到完整后再触发事件。自测代码最直接的方式是让客户端每次发送前把消息切成两半,中间Sleep 10毫秒,观察服务端是否依然只触发一次消息事件。

public void SendChunked(string message) { byte[] data = Encoding.UTF8.GetBytes(message); byte[] header = BuildFrameHeader(data.Length, opcode: 0x1); NetworkStream stream = _tcpClient.GetStream(); // 分开写两次,人为制造TCP半包 stream.Write(header, 0, header.Length); stream.Flush(); Thread.Sleep(10); stream.Write(data, 0, data.Length); stream.Flush(); }

这个自测客户端函数的关键参数是opcode:0x1是文本帧,0x2是二进制帧,0x8是关闭帧。测试时把服务端的OnMessageReceived次数打出来,如果半包场景出现两次事件就意味着帧边界解析有问题,需要回查3.3节的缓冲循环逻辑。我在测这个库时还顺手验证了掩码方向:客户端发送时故意置Mask=0,服务端要主动报协议错误断开,不报错容忍则说明服务端对掩码规则太宽容,也会埋隐患。

还有一步容易被跳过的验证是用浏览器直接连。写一个静态页面里放new WebSocket("ws://localhost:8080/"),onmessage回调里打印数据,这样能确认握手响应和帧编码两方面都和浏览器兼容。浏览器是最严格的客户端,能用它跑通基本协议实现就没有大问题。我曾经的某次事故就是自测客户端兼容而浏览器不行,后来查出来是响应头里漏了Connection字段,浏览器不肯认。从那以后我每次改完握手代码都要强制打开浏览器页面做一轮实际连接测试,虽然多花两分钟,但再也没被这类低级的Header遗漏坑过。希望这套验证流程对你有用,也祝你接手的老项目早日跑上新协议。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询