☰
C# WebSocketServer源码实现:握手、帧解析与广播详解
2026/10/7 22:59:56 网站建设 项目流程

简介:一份面向C#开发者的WebSocket服务器源码包,演示如何借助System.Net.WebSockets实现浏览器与服务器间的持久双向通信,适合需要掌握实时交互应用或聊天服务开发的中级程序员。压缩包共18个文件,以cs源码、csproj工程文件及sln解决方案为主,外加aspx、config、js等辅助文件,整体仅44KB,结构精简便于快速梳理。已有577人学习下载。代码中可见WebSocketServer核心连接处理、WebSocketChatServer聊天广播逻辑及ChatClient客户端测试项目,覆盖了HTTP Upgrade握手、消息收发、多客户端并发、心跳保活等关键机制。通过阅读这套迷你示例,可以直观理解C# WebSocket服务的搭建、调试与扩展路径,对自建实时通信模块有直接参考价值。

1. 为什么一个 C# WebSocketServer 服务器源代码值得自己维护:从拉取实时数据到内网推送

做上位机或者写局域网工具的人,迟早会遇到一个需求:浏览器页面要盯着 C# 后端的数据看。轮询太浪费,一秒一刷不够实时,两秒一刷用户嫌慢。C# WebSocketServer 服务器源代码 这个标题要解决的,就是用 C# 自己实现一个跑在 TCP 之上的 WebSocket 服务端,把握手、帧解析、心跳、广播全部握在自己手里。适合的场景很明确:内网监控面板、上位机实时曲线、物联网网关往网页推状态、远程触发指令。适合的人群是 C# 开发者,尤其是写过 TCP 监听端口程序、但对 WebSocket 协议细节还不够熟的人。自己维护这套代码,换来的是无框架依赖、单文件可部署、每一帧都能控制,排查问题不用黑匣子猜。

2. 握手实现:用 TcpListener 在 C# 里把 HTTP 升级成 WebSocket 的 101 响应

2.1 握手的核心:Sec-WebSocket-Accept 不是随便填的

WebSocket 和普通 TCP 长连接最大的区别是它先借 HTTP 协议完成一次升级握手。客户端发来的请求头里有三样东西必须拿到:Upgrade: websocket、Connection: Upgrade、Sec-WebSocket-Key。服务端要做的不是解析 HTTP 语义,而是校验后回一个101 Switching Protocols,再把Sec-WebSocket-Key拼上一个固定 GUID,做 SHA1 哈希后转 Base64,写进响应头Sec-WebSocket-Accept。

这个 GUID 是协议固定的,全世界的 WebSocket 握手都用它,值就是258EAFA5-E914-47DA-95CA-C5AB0DC85B11。千万别自己发明一个字符串,客户端那里的算法是固定的,你用别的 GUID 算出的 accept 值,浏览器一定会拒绝握手,现象就是 Console 里报Error during WebSocket handshake,但服务器端又看不到任何异常,只能靠抓包才能定位。我一般会在握手前打印收到的原始请求头,先人工确认Sec-WebSocket-Key有没有被代理截掉。

请求头字段作用服务端处理
Upgrade: websocket声明要升级协议不校验具体值,但响应里必须回写Upgrade: websocket
Connection: Upgrade声明连接要升级响应里必须回写Connection: Upgrade
Sec-WebSocket-Key16 字节随机值 Base64拼 GUID 做 SHA1 再 Base64,回写Sec-WebSocket-Accept
Sec-WebSocket-Version协议版本只支持 13,其他版本直接拒绝

2.2 最小握手代码:先按行切头部,再按长度等数据

写握手的时候有个很常见的坑:TCP 是流,不是消息,你第一次ReadAsync拿到的可能只是请求头的前半截。如果直接按ReadAsync返回的字节数去解析,一定会偶发解析失败。正确做法是先把数据追加进一个StringBuilder,一直读到底部出现\r\n\r\n才认为头部完整,再去按行提取字段。

// HandshakeHandler.cs using System.Net.Sockets; using System.Security.Cryptography; using System.Text; public class HandshakeHandler { private const string WsGuid = "258EAFA5-E914-47DA-95CA-C5AB0DC85B11"; public async Task<bool> ReceiveAndRespondAsync(TcpClient client, CancellationToken ct) { var stream = client.GetStream(); var buffer = new byte[4096]; var sb = new StringBuilder(); // 循环读取,直到 \r\n\r\n 出现,代表 HTTP 头部完整 while (sb.ToString().IndexOf("\r\n\r\n", StringComparison.Ordinal) < 0) { int n = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), ct); if (n <= 0) return false; sb.Append(Encoding.ASCII.GetString(buffer, 0, n)); if (sb.Length > 16384) return false; // 防御超长头部 } var headers = sb.ToString(); var key = ExtractHeader(headers, "Sec-WebSocket-Key"); if (string.IsNullOrEmpty(key)) return false; var accept = ComputeAccept(key); var response = "HTTP/1.1 101 Switching Protocols\r\n" + "Upgrade: websocket\r\n" + "Connection: Upgrade\r\n" + $"Sec-WebSocket-Accept: {accept}\r\n\r\n"; await stream.WriteAsync(Encoding.ASCII.GetBytes(response), ct); return true; } }

ExtractHeader要注意大小写不敏感,很多客户端发的是sec-websocket-key全小写。按行拆分后,取第一个冒号作为键值分隔点,别用Split(':')直接拆,因为值里可能出现冒号。ComputeAccept里先把key + WsGuid转 ASCII,再走 SHA1,最后 Base64,三步缺一不可。这段代码不区分客户端是 Chrome、微信内置浏览器还是自写客户端,协议层是统一的。

2.3 响应头必须一次写完,别用 Write 两次

我之前犯过一个小错误:把101状态行和响应头分两次WriteAsync,中间还插了一次日志打印。结果是浏览器间歇性握手失败,因为部分客户端对响应延迟敏感,而且网络包一拆,接收端可能先看到空响应。正确的做法是把整个响应拼成一个字符串,一次性WriteAsync,写完立刻进入帧读取循环,不要再往流里写日志或做耗时操作。

还有一个值得提的点:握手阶段读到的多余字节不要扔掉。我一般会在握手结束后把StringBuilder里\r\n\r\n之后剩余的内容保留下来,因为客户端可能在握手完成的同时就发出了第一帧数据。这个现象在局域网环境和自写客户端里很常见,如果直接丢弃,第一帧消息就会神秘失踪,表现为“连接成功但第一条消息永远收不到”。

3. 帧解析细节:按位拆出 Fin、Opcode 与 9/16/64 位载荷长度

3.1 帧头只有 2 个字节起步,但信息密度很高

握手完成后,双方向发送的都是二进制帧。帧头的前两个字节承载了这次传输的控制信息。第一个字节:最高位FIN表示这是不是最后一帧,低 4 位是Opcode,中间 3 位是扩展协议用的 RSV,正常情况下必须是 0。第二个字节:最高位MASK表示载荷是否被掩码,低 7 位是载荷长度。

服务端收到的客户端帧,MASK必须置 1。这个约束是协议硬性规定的,浏览器和其他合规客户端都会带上 4 字节的掩码密钥。如果收到一个未掩码的帧,按协议直接关闭连接。反过来,服务端发出去的帧MASK必须置 0,否则客户端会按协议错误处理,直接断开。很多自写服务器翻车就翻在“收发共用一套编码函数”,忘了参数上区分方向。

位段长度名称取值含义
byte0 bit71FIN1=消息结束,0=还有后续分片帧
byte0 bit6-43RSV必须为 0,为 1 时说明启用了扩展协议
byte0 bit3-04Opcode1=文本,2=二进制,8=关闭,9=Ping,10=Pong
byte1 bit71MASK客户端帧为 1,服务端帧为 0
byte1 bit6-07载荷长度0-125 为实际长度,126 走 16 位扩展,127 走 64 位扩展

3.2 读帧头不能只读一次:5 种长度情况都要覆盖

写一个ReadFrameHeaderAsync,核心原则是“读不够就往死里等”。TCP 的ReadAsync可能一次只返回 1 个字节,也可能一次返回 8KB 数据,所以必须把帧头里每个字段的长度算清楚,再按长度去读,绝不能用ReadAsync的返回值作为“消息长度”。

// FrameReader.cs public class FrameHeader { public bool Fin; public int Opcode; public bool Masked; public long PayloadLength; public byte[] MaskKey; } public static async Task<FrameHeader> ReadFrameHeaderAsync( NetworkStream stream, byte[] buffer, CancellationToken ct) { await ReadExactlyAsync(stream, buffer, 2, ct); byte b0 = buffer[0]; byte b1 = buffer[1]; var header = new FrameHeader { Fin = (b0 & 0x80) != 0, Opcode = b0 & 0x0F, Masked = (b1 & 0x80) != 0, PayloadLength = b1 & 0x7F }; if (!header.Masked) throw new InvalidDataException("客户端帧未掩码,违反协议"); if (header.PayloadLength == 126) { await ReadExactlyAsync(stream, buffer, 2, ct); header.PayloadLength = (buffer[0] << 8) | buffer[1]; } else if (header.PayloadLength == 127) { await ReadExactlyAsync(stream, buffer, 8, ct); header.PayloadLength = 0; for (int i = 0; i < 8; i++) header.PayloadLength = (header.PayloadLength << 8) | buffer[i]; if ((header.PayloadLength & 0x8000000000000000) != 0) throw new InvalidDataException("载荷长度高位被置位,非法帧"); } if (header.Masked) { header.MaskKey = new byte[4]; await ReadExactlyAsync(stream, header.MaskKey, 4, ct); } return header; } private static async Task ReadExactlyAsync( NetworkStream stream, byte[] buffer, int count, CancellationToken ct) { int read = 0; while (read < count) { int n = await stream.ReadAsync(buffer.AsMemory(read, count - read), ct); if (n <= 0) throw new EndOfStreamException("连接已关闭"); read += n; } }

注意载荷长度的三档设计:7 位最多表示 125,超过 125 时第一个长度字节置 126,跟随 2 字节 16 位长度;超过 65535 时置 127,跟随 8 字节 64 位长度。读取 64 位长度时我在循环里逐字节左移拼值,这样规避了BitConverter.IsLittleEndian的平台差异,服务器要在 x86 和 ARM 上表现一致,就得这么写。

3.3 掩码运算:读载荷时逐个字节异或

帧头读完,接下来读载荷区。因为客户端帧带着掩码,所以读出来的每个字节都要跟MaskKey[i % 4]做异或,才能还原真实数据。这个运算是字节级的:

byte[] payload = new byte[header.PayloadLength]; await ReadExactlyAsync(stream, payload, payload.Length, ct); if (header.Masked) { for (int i = 0; i < payload.Length; i++) payload[i] ^= header.MaskKey[i % 4]; }

掩码的意义不在于加密,是早期协议为了防止缓存污染攻击留下的设计,所以一定不要把它当成“解密”去对待。客户端发来的数据经过异或还原后,文本帧直接按UTF8.GetString解码即可。这里有一个非常容易被忽略的细节:解码必须等一整帧读完再做。如果你用ReadAsync返回的字节数组直接转字符串,遇到消息被 TCP 拆成两段到达时,就会出现半个中文导致的乱码,而且不是每次都复现,最容易在弱网环境翻车,抓包都看不出来。

3.4 消息重组:Fin=0 时要把 payload 暂存

单帧 Fin=1 表示消息结束了,但 WebSocket 允许发送方把一个逻辑消息拆成多个分片帧。这些分片帧的 Opcode 全是0x0,只有第一帧带真实类型(1 或 2),并且 Fin=0。如果只处理 Opcode=1/2,完全忽略 Opcode=0,客户端一旦发送大消息自动分片,服务端就会丢失数据。

处理分片的标准做法是在每个连接上维护一个MessageBuilder:

// MessageBuilder.cs public class MessageBuilder { private MemoryStream _buffer = new MemoryStream(); private int _messageType = 0; public bool Push(byte opcode, byte[] payload, bool fin) { if (_buffer.Length == 0) _messageType = opcode; // 第一帧记录真实类型 _buffer.Write(payload); if (!fin) return false; Received = _buffer.ToArray(); _buffer.SetLength(0); return true; } public byte[] Received { get; private set; } }

为什么要做的这么重?因为浏览器等客户端在发超过 64KB 的消息时,不保证不分片。你只能被动接收。分片帧之间允许穿插 Ping/Pong 控制帧,但不能穿插新的数据帧。如果_buffer里还攒着数据,又来了一个 Opcode=1 的新消息帧,说明客户端协议实现有问题,直接关连接是最安全的处理方式。

4. 连接管理与广播:在线表、心跳保活与并发发送锁

4.1 用 ConcurrentDictionary 维护在线表,别用 List

多客户端连接是 WebSocket 服务器和普通 TCP 回显程序最大的分水岭。最常见的错误是用List<TcpClient>存连接,然后在广播时遍历,边遍历边有人断开,List在遍历时被修改,直接抛集合已修改异常。我用ConcurrentDictionary<string, Session>做在线表,key 用Guid.NewGuid().ToString("N")生成的 32 位短 ID,给每个连接一个唯一标识,方便后续按客户端踢人、做单发和群组推送。

// Session.cs public class WebSocketSession { public string Id { get; } = Guid.NewGuid().ToString("N"); public TcpClient Client { get; set; } public NetworkStream Stream { get; set; } public CancellationTokenSource Cts { get; } = new CancellationTokenSource(); public DateTime LastPongAt { get; set; } = DateTime.UtcNow; public SemaphoreSlim SendLock { get; } = new SemaphoreSlim(1, 1); }

Cts这个字段不是为了华丽,它负责一件事:当服务端主动关闭某个连接时,通过Cts.Cancel()把阻塞在读循环里的ReadAsync打断,否则那个线程会一直卡在连接上不释放。LastPongAt后面心跳用。SendLock是信号量,用于保证同一时刻只有一个线程往NetworkStream上写数据,避免广播线程和其他发送线程撞车。

4.2 每连接一个异步循环:把接收、处理、异常分开

服务器主循环只做一件事:接受 TCP 连接,然后立刻把处理工作丢给后台任务,自己继续 Accept。这样不会因为某个客户端发来脏数据导致整个服务器 Accept 卡住。每个连接的循环里,顺序永远是:读帧头 -> 读载荷 -> 按 Opcode 分发逻辑,异常统一捕获,finally 里清掉会话。

// WebSocketServer.cs public async Task ProcessClientAsync(TcpClient client) { var session = new WebSocketSession { Client = client, Stream = client.GetStream() }; _sessions[session.Id] = session; try { var handshakeOk = await _handshake.ReceiveAndRespondAsync(client, session.Cts.Token); if (!handshakeOk) return; // 进入帧处理循环 while (!session.Cts.IsCancellationRequested) { var header = await FrameReader.ReadFrameHeaderAsync(session.Stream, _buffer, session.Cts.Token); // 先读整个 payload,再按类型处理 var payload = await FrameReader.ReadPayloadAsync(session.Stream, header, session.Cts.Token); // opcode 8=关闭 9=Ping 10=Pong 1=文本 2=二进制 0=分片续帧 await DispatchAsync(session, header, payload); } } catch (Exception ex) { // 记录异常日志,然后走清理 Console.WriteLine($"[{session.Id}] {ex.Message}"); } finally { RemoveSession(session); } }

每个连接一个循环意味着:一个连接的消息处理再慢,也不会拖累其他客户端。这里对“慢客户端”要有心理准备:如果客户端不读数据,TCP 窗口填满后,WriteAsync也会阻塞,所以要给每次发送套超时控制,常见做法是WriteAsync时把CancellationToken设成 5 秒后触发取消。

4.3 心跳保活:Ping 要发,回没回更要查

WebSocket 有专门的控制帧:Opcode=9 是 Ping,Opcode=10 是 Pong。客户端收到 Ping 后按协议自动回 Pong。心跳的意义不是证明“连接活着”,而是释放半开连接。网络断开时,TCP 不会立刻通知你,如果只靠读循环阻塞,那么一条物理断开的连接会永远占着线程和文件句柄。

// 每个连接启动一个心跳定时器 var heartbeat = new Timer(async _ => { try { var stale = DateTime.UtcNow - session.LastPongAt > TimeSpan.FromSeconds(60); if (stale) { Console.WriteLine($"[{session.Id}] 心跳超时,关闭连接"); session.Client.Close(); return; } // 发 Ping,载荷里带上时间戳,便于排查延迟 await session.Stream.WriteAsync(FrameEncoder.EncodeControlFrame(9, BitConverter.GetBytes(DateTime.UtcNow.Ticks))); } catch { /* 忽略,主循环会发现连接断开 */ } }, null, TimeSpan.FromSeconds(10), TimeSpan.FromSeconds(30));

参数怎么设?我在内网一般是 30 秒发一次 Ping,60 秒内没收到 Pong 就掐掉连接。公网环境网络抖动大,Ping 间隔可以放到 45 秒,超时放到 120 秒。注意这里有个矛盾:心跳超时判断依赖LastPongAt,而LastPongAt只在收到 Pong 帧时更新,所以必须在帧分发逻辑里对 Opcode=10 的帧单独赋值session.LastPongAt = DateTime.UtcNow,这一步漏了,心跳会误杀所有正常连接。

4.4 广播与单发:并发发流必须上锁

广播的时候最容易出灵魂拷问:为什么NetworkStream.WriteAsync会抛InvalidOperationException?因为NetworkStream不是线程安全的,框架不允许两个线程同时写入同一个流。广播会产生并发写:A 线程给张三发,B 线程也在给张三发。解决办法是每个 Session 里的SendLock:

public async Task SendAsync(WebSocketSession session, byte[] payload, int opcode = 1) { var frame = FrameEncoder.EncodeDataFrame(opcode, payload, masked: false); await session.SendLock.WaitAsync(); try { await session.Stream.WriteAsync(frame); await session.Stream.FlushAsync(); } catch { session.Client.Close(); } finally { session.SendLock.Release(); } }

注意FlushAsync在网络流上其实是无操作,但保留它无害,而且将来如果换成带缓冲的流,不至于漏刷新。广播遍历集合时,我用_sessions.ToArray()做快照,遍历过程中客户端断开也不用担心集合被修改。发送失败的直接从在线表移除,这里不用怕误删,因为RemoveSession里会判断TryRemove的返回值。

5. 避坑:C# WebSocketServer 送上线的 5 个常见翻车点

5.1 握手成功但连接立刻断开:响应头写错导致浏览器直接拒绝

现象:服务端日志显示握手完成,但浏览器 WebSocket 对象触发onclose,代码 1006。

原因:响应头里\r\n被写成了\n,或者响应头和正文之间少了空行。HTTP 解析要求每行以\r\n结尾,头部和正文之间要有一个空行,也就是连续两个\r\n。字符串拼接时一眼看不出问题,但实际发出去的就是坏报文。

解决:把响应拼好后,先Encoding.ASCII.GetBytes看一遍字节内容,确认空行存在。另外不要用Environment.NewLine,在 Linux 上它是\n,必须写死\r\n。

5.2 偶发消息不全:每帧必须按长度读,不能按 ReadAsync 返回值读

现象:收发消息 10 次里有一次是残的,或者两条消息拼在一起,高频率收发时尤其明显。

原因:TCP 是字节流,一条 WebSocket 帧可能被拆成 3 个包到达,ReadAsync 一次可能只返回其中一部分。如果直接把本次返回的字节当作一帧消息处理,粘包半包就来了。

解决:所有读取的地方都走ReadExactlyAsync,帧头 2 字节、扩展长度、掩码键、载荷区全部按精确数量读。任何“先读再看够不够”的逻辑都不要留。

5.3 广播时偶发 ObjectDisposedException

现象:客户端断开后,广播线程还在往它的流里写数据,抛ObjectDisposedException或IOException。

原因:关闭连接只是把客户端从在线表移除,但发送线程可能已经拿走了 Session 引用,正卡在WriteAsync上。先关闭流再发送,竞态条件就触发。

解决:发送逻辑统一走SendAsync,里面先SendLock.WaitAsync,再检查Client.Connected,写入时捕获异常,任何异常都视为连接已死,立即 Close 并从在线表移除。不要在广播循环里逐个判断Connected后不处理异常,因为判断到写入之间还有时间窗。

5.4 收不到关闭帧,服务端不知道客户端已经走了

现象:客户端直接断电或 App 被杀,服务端在线表里连接一直挂着,占用大量句柄。

原因:客户端异常退出时,TCP 没有发 FIN,服务端读循环永远阻塞等待,心跳又没有做超时检测。

解决:按 4.3 的配置做 Ping/Pong 超时。同时给ReadAsync所在的 CancellationToken 加超时,比如Cts.CancelAfter(TimeSpan.FromSeconds(120)),两层保障,任何一层触发都能清掉僵尸连接。

5.5 服务端发二进制数据把客户端搞崩

现象:服务端用Encoding.UTF8.GetString(payload)转完再发给客户端,图片、文件等二进制数据变成乱码或触发解析错误。

原因:很多代码只写了文本帧的收发,遇到二进制数据直接套文本解码。WebSocket 的文本帧 Opcode=1,二进制帧 Opcode=2,处理逻辑必须分支,二进制帧不能做 UTF8 解码,直接原样转发或落盘。

解决:分发逻辑里按 Opcode 分路,文本走 UTF8,二进制走byte[]。客户端发来的二进制可能不分片,也可能分片,组装逻辑和文本完全一样,只是解码这一步不同。

6. 验证手段与生产化改造:从 101 响应到敢接入真实业务

这部分我每次都会先做三件事,再决定要不要继续投入:第一,用命令行验证握手;第二,用 C# 自带ClientWebSocket验证收发;第三,做消息积压测试。命令行验证最快,一条 curl 就能看到是否返回 101:

curl --include --no-buffer \ -H "Connection: Upgrade" \ -H "Upgrade: websocket" \ -H "Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==" \ -H "Sec-WebSocket-Version: 13" \ http://127.0.0.1:8080/

看到HTTP/1.1 101 Switching Protocols就说明握手实现没问题。但 curl 不会完成帧通讯,所以接着用自写客户端做回环测试。ClientWebSocket是 .NET 自带的客户端,不需要任何第三方包:

using var ws = new ClientWebSocket(); await ws.ConnectAsync(new Uri("ws://127.0.0.1:8080/"), CancellationToken.None); var send = Encoding.UTF8.GetBytes("{\"action\":\"ping\"}"); await ws.SendAsync(send, WebSocketMessageType.Text, true, CancellationToken.None); var buffer = new byte[1024]; var result = await ws.ReceiveAsync(buffer, CancellationToken.None); Console.WriteLine(Encoding.UTF8.GetString(buffer, 0, result.Count));

把服务端广播里加一个计数器,在 WinForms 状态栏或者控制台日志里输出每秒收发的消息数和在线连接数,就能提前发现广播瓶颈。上线前我会把三个改造做掉:限制单帧最大载荷和最大连接数,防止 OOM;把控制台程序注册成 Windows 服务或丢进 NSSM 托管,开机自启;所有异常和连接事件落日志文件,给每条连接打上 ID,排查时才不用靠猜。

这个方向是值得投入的,尤其是内网实时通信场景。等连接数真到了几万甚至十万,再做 NIO 或换成熟的通信框架,但在这之前,自己维护的这套源码能让你对网线里走的每一个字节都有掌控感。我的教训是:最初做广播时贪图代码简单,没做半包读取,上线后偶发丢帧被业务方吐槽,后来把所有读取改成精确长度才彻底消停。希望帮到你。

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

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

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

立即咨询