简介这是一套面向C#开发者的WebSocket通信完整示例适合需要实现实时双向通信、研究服务端与客户端交互逻辑的中高级程序员也可作为ASP.NET项目集成WebSocket功能的参考。资源包内含客户端与服务端两个完整工程共76个文件主要由C#源文件、ASPX页面、配置文件、JavaScript与CSS前端资源构成压缩包仅333KB轻量易用。目前已有3509人学习下载属于较受关注的经典Demo。示例完整展示了服务端基于HttpListener与WebSocket类完成HTTP升级请求处理、Sec-WebSocket-Key验证、AcceptWebSocketAsync建立连接以及ReceiveAsync/SendAsync异步收发和关闭连接的流程客户端则通过ClientWebSocket实现ConnectAsync、消息发送、持续监听与关闭。源码还涉及多连接管理、异步编程、数据编码解码等关键点便于开发者深入理解WebSocket通信机制快速移植到自己的实时通信、消息推送或在线协作项目中。1. WebSocket Demo 源代码其实是通讯骨架一个能跑起来的长连接样本手头这份 C# WebSocket 客户端及服务端 Demo 源代码把长连接里最核心的三件事——HTTP 握手升级、双向收发、断开清理——用原生类库写完下载后改改端口就能跑。它解决的问题很具体HTTP 请求问答模式做不了服务端主动推送轮询又浪费资源WebSocket 把 TCP 连接升级成一条全双工通道弹幕、通知推送、远程控制这类场景都靠它撑。适合刚补完 C# 基础、想搞懂长连接到底怎么工作的新手也适合需要在现有系统里塞一个通讯模块、又不想引第三方依赖的开发者。服务端和客户端两部分各自独立拆开看、合起来跑都可以。2. 先看协议再动手握手、帧格式与 C# 实现选型2.1 握手不是玄学HTTP 如何升级成 WebSocket很多人第一次抓包看到 WebSocket 流量也是一堆 HTTP 头就以为 WebSocket 是 HTTP 的某个模式。严格说建立过程借用 HTTP 通道建立完成之后协议就切走了。客户端先发一个 GET 请求带着 Upgrade、Connection、Sec-WebSocket-Key 这几个头服务端校验通过后回 101连接从这一秒起不再按 HTTP 报文格式解析。一个典型的握手请求长这样GET /ws HTTP/1.1 Host: 192.168.1.10:9999 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ Sec-WebSocket-Version: 13Sec-WebSocket-Key 是 16 字节随机数做 Base64每次连接都不同。服务端拿到这个 Key拼上协议固定 GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11做一次 SHA-1 再 Base64得到 Sec-WebSocket-Accept 返回。注意这个 GUID 是规范里写死的不是自造的私货。响应长这样HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbKxOo如果服务端回的不是 101而是一个 400 或 403那握手就没成连接进不了 WebSocket 状态。调试时把这些头对照着看基本能判断问题出在哪一端。握手完成之后的数据交换是二进制帧。一帧里 FIN 位表示是不是最后一个分片OpCode 区分 Text0x1、Binary0x2、Close0x8、Ping0x9、Pong0xAMask 位决定载荷是否掩码。协议强制要求客户端发给服务端的帧必须有掩码服务端发给客户端的帧不能有掩码这条是校验项两边不遵守就会直接断链。好消息是 C# 里这些位运算和掩码处理都被封装好了不用自己碰但理解这些规则能让你看到 1002、1003 这类关闭码时心里有数。还有一个容易忽视的细节当对端收到不合法帧掩码错误、OpCode 未定义或格式错误的消息时会返回一个携带 1002 协议错误的 Close 帧。如果你在异常日志里看到 1002说明问题出在帧构造上而不是网络不通1003 则说明消息类型不支持。这两类错误码在排查时比看 TCP 层更有指向性。2.2 C# 方案的选型思路为什么这份 Demo 用原生类库C# 做 WebSocket 服务端有几条路。第一条用 HttpListener 加 AcceptWebSocketAsync这是 .NET 自带能力一个控制台程序就能跑不需要拉起一整套 Web 项目缺点是没有连接池、心跳、并发控制都要自己补。第二条用 ASP.NET Core 的 WebSocket 中间件路由、鉴权、依赖注入、日志都是现成的适合集成进正式 Web API但为一个 Demo 起一个 Web 项目样板代码有点多。第三条引用轻量第三方库API 封装得简单但多一层依赖升级节奏和项目维护情况不可控。第四条直接用 SignalR 这类高层框架重连、分组、扩展都有但抽象度高想理解协议细节反而隔着帘子。客户端这边没什么悬念ClientWebSocket 是内置类连接、发送、接收全部异步化跟 async/await 配合很顺手不需要绕第三方。这份 Demo 用的是 HttpListener 加 ClientWebSocket 的组合。选择原因很直接零第三方依赖编译环境干净代码里能清楚看到「监听 → 接收 → 回复 → 关闭」的边界。当你需要在两台内网机器之间做实时通讯时这套组合是最小的可运行单位如果项目将来要接入复杂鉴权、多租户、横向扩容再考虑搬进 ASP.NET Core 中间件也不迟。选型没有绝对最优只有跟场景是否匹配。方案依赖情况典型场景需要自己补的东西HttpListener AcceptWebSocketAsync.NET 自带轻量服务、Demo、内部工具连接管理、心跳、并发控制ASP.NET Core WebSocket 中间件框架自带正式 API、需要鉴权相对少但项目结构较重第三方轻量库NuGet 引用快速集成、偏脚本化依赖更新、协议兼容性SignalR 高层框架框架自带自动重连、分组推送定制底层行为较麻烦如果你只是先验证一个协议想法强烈建议第一条路代码量最少踩坑时也最容易定位。3. 服务端 Demo 落地HttpListener 从握手到广播3.1 服务端骨架监听前缀、握手认证与连接注册服务端第一个关键点是监听前缀。HttpListener 的前缀格式决定了程序绑在哪些网卡、哪些端口、哪些路径上写错一个字符表现可能完全不一样。Demo 里的典型写法如下public class WsServer { private readonly HttpListener _listener new HttpListener(); private readonly ConcurrentDictionarystring, WebSocket _clients new(); public void Start() { // 加号代表监听本机所有 IP路径 /ws 是握手地址 _listener.Prefixes.Add(http://:9999/ws); _listener.Start(); Console.WriteLine(服务已启动等待 WebSocket 连接...); while (true) { // GetContext 阻塞等待 HTTP 请求进入 HttpListenerContext context _listener.GetContext(); // 不是 WebSocket 升级请求直接拒绝 if (!context.Request.IsWebSocketRequest) { context.Response.StatusCode 400; context.Response.Close(); continue; } // 握手和后续收发交给异步方法主循环继续接请求 _ ProcessWebSocketAsync(context); } } }前缀里的加号等价于监听所有本机 IP包括回环地址和局域网网卡如果你写http://localhost:9999/ws另一台设备用局域网 IP 访问时TCP 能通到机器但 HttpListener 会认为没有匹配的前缀而拒绝。路径/ws尽量保留它相当于一个路由标识未来同一端口上可以再挂/api、/admin。IsWebSocketRequest 的判断必须做因为不是所有访问者都懂 WebSocket 协议普通 HTTP 客户端访问时应该拿到一个明确的 400 而不是挂死。Windows 上还有一个暗坑非管理员进程监听http://:9999/ws可能因为 URL ACL 权限不足而直接抛地址访问拒绝。常见做法是管理员执行一次netsh http add urlacl urlhttp://:9999/ws userEveryone或者干脆用管理员身份跑程序。要注意 .NET Framework 和 .NET Core 在下层的实现不同权限表现也有差异换环境跑通不了时优先查这个。3.2 接收循环与广播连接字典、分片拼接和发送锁连接建立之后的注册和接收逻辑我一般放在异步方法里private async Task ProcessWebSocketAsync(HttpListenerContext context) { WebSocket ws null; string clientId Guid.NewGuid().ToString(N); try { // 第二个参数 null 表示不强制子协议 WebSocketContext wsContext await context.AcceptWebSocketAsync(null); ws wsContext.WebSocket; _clients.TryAdd(clientId, ws); Console.WriteLine($客户端 {clientId} 连接在线数 {_clients.Count}); // 接收缓冲 4KB短消息够用长消息需要分片拼接 byte[] buffer new byte[4 * 1024]; while (ws.State WebSocketState.Open) { WebSocketReceiveResult result await ws.ReceiveAsync( new ArraySegmentbyte(buffer), CancellationToken.None); if (result.MessageType WebSocketMessageType.Close) { await ws.CloseAsync(WebSocketCloseStatus.NormalClosure, 客户端主动关闭, CancellationToken.None); break; } // result.Count 表示本次实际接收的字节数 // 4KB 缓冲接不下长消息业务中要改成 MemoryStream 拼接 string text Encoding.UTF8.GetString(buffer, 0, result.Count); Console.WriteLine($收到 {clientId}: {text}); await BroadcastAsync($客户端 {clientId} 说: {text}); } } catch (Exception ex) { Console.WriteLine($连接异常 {clientId}: {ex.Message}); } finally { _clients.TryRemove(clientId, out _); ws?.Dispose(); Console.WriteLine($客户端 {clientId} 离线在线数 {_clients.Count}); } }AcceptWebSocketAsync 的第二个参数是子协议协商列表Demo 传 null表示不参与协商如果两端要协商自定义协议客户端 Options.AddSubProtocol 和服务端这里要能对上对不上握手直接失败。ReceiveAsync 的 buffer 用 4KB 是为了内存占得少代价是长消息会被拆成多次返回result.EndOfMessage 为 false 时还需要继续读Demo 里为了主线清晰没有拼接真正用到业务上需要补循环后面客户端那一章给了完整写法。finally 里把连接从字典移除并 Dispose这一步不能省否则断开的连接一直躺在字典里在线数越积越多最后广播性能直接下降。广播部分我一般这样写private async Task BroadcastAsync(string message) { byte[] bytes Encoding.UTF8.GetBytes(message); ArraySegmentbyte segment new ArraySegmentbyte(bytes); // 先做快照避免遍历时字典被并发修改 foreach (var pair in _clients.ToArray()) { if (pair.Value.State ! WebSocketState.Open) continue; try { // 同一个 WebSocket 并发 SendAsync 会抛异常 // 工程中要为每个连接配 SemaphoreSlim 串行化发送 await pair.Value.SendAsync( segment, WebSocketMessageType.Text, true, CancellationToken.None); } catch (Exception ex) { Console.WriteLine($向 {pair.Key} 广播失败: {ex.Message}); } } }广播的细节这里把消息回给了发送者自己实际业务里通常要在广播前判断 clientId 不等于当前连接 ID。某个连接发送失败时先打日志不要立刻移除保留连接让心跳机制去判断更稳因为网络抖动只是一瞬间移除后客户端会自动重连但服务端这边会多一次不必要的断连日志。4. 客户端 Demo 落地ClientWebSocket 连接、发送与接收4.1 连接与发送Proxy、KeepAliveInterval 和 endOfMessage 三件事客户端最烦的问题往往不在 WebSocket 本身而在系统环境。ClientWebSocket 默认会走系统代理哪怕目标地址是内网 IP代理设置也可能插手有线网卡和无线网卡切换时代理配置还经常变。所以局域网直连时第一件事是把 Proxy 关掉public class WsClient { private ClientWebSocket _ws new ClientWebSocket(); private readonly Uri _uri new Uri(ws://192.168.1.10:9999/ws); public async Task ConnectAsync() { // 绕过系统代理内网直连必须关否则可能超时 _ws.Options.Proxy null; // 协议层心跳15 秒发一次 Ping 帧防止 NAT 空闲回收 _ws.Options.KeepAliveInterval TimeSpan.FromSeconds(15); using var cts new CancellationTokenSource(TimeSpan.FromSeconds(10)); await _ws.ConnectAsync(_uri, cts.Token); Console.WriteLine($连接成功当前状态: {_ws.State}); } public async Task SendTextAsync(string text) { if (_ws.State ! WebSocketState.Open) { Console.WriteLine(连接未就绪消息未发送); return; } byte[] bytes Encoding.UTF8.GetBytes(text); ArraySegmentbyte segment new ArraySegmentbyte(bytes); // 第三个参数 true 表示这是一条完整消息的结束帧 await _ws.SendAsync(segment, WebSocketMessageType.Text, true, CancellationToken.None); } }KeepAliveInterval 是 ClientWebSocket 自己控制的协议层 Ping它保证底层 TCP 连接在空闲时也有数据帧走动避免路由器和防火墙把连接当垃圾回收。但它不保证业务逻辑正常——业务死锁时连接照样是开的所以工程里还要有业务心跳。SendAsync 的第三个参数叫 endOfMessage传 false 时接收方会认为消息还没完直到某个分片传 true接收方才会把整段数据拼起来当作一条消息。新手最容易翻车的位置就在这里发送时漏掉 true服务端就会一直在等后续分片。另外每次发送的字节数组最好新分配如果为了性能复用同一个 byte[]要等上一次 SendAsync 真正完成后再覆盖否则数据会被写坏。4.2 接收循环分片拼接、Close 帧和优雅退出接收端和 TCP 那种「自己找分隔符拆包」的思路不同WebSocket 帧自带消息边界不需要你操心粘包问题。真正需要处理的是分片ReceiveAsync 把缓冲填满就返回一条消息超过缓冲长度就分多次返回所以接收循环要拼到 EndOfMessage 为 true 才结束。一份能直接用的接收循环长这样public async Task ReceiveLoopAsync() { // 缓冲给 64KB比服务端 4KB 大大消息分片次数少 byte[] buffer new byte[64 * 1024]; while (_ws.State WebSocketState.Open) { using var ms new MemoryStream(); while (true) { WebSocketReceiveResult result await _ws.ReceiveAsync( new ArraySegmentbyte(buffer), CancellationToken.None); // 对端发来 Close 帧回一个 Close 再退出才算优雅关闭 if (result.MessageType WebSocketMessageType.Close) { await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, 收到关闭请求, CancellationToken.None); return; } ms.Write(buffer, 0, result.Count); // EndOfMessage 为 true本条消息收完 if (result.EndOfMessage) break; } string text Encoding.UTF8.GetString(ms.ToArray()); Console.WriteLine($收到消息: {text}); // 这里把消息交给业务逻辑处理不要阻塞循环 } }MemoryStream 在这里承担拼接器的作用每次收到数据就往里写收到完整帧才取出来转字符串。对 Text 消息这样做是安全的因为 UTF-8 编码在分片边界上可能有字节不完整的情况如果直接把每一片转字符串再接起来多字节字符会被切坏先拼字节再转字符串就不会出现半个汉字的问题。这是很多人忽略的细节分片拼接的是字节不是字符串。接收循环通常放在独立后台 Task 里跑不要和 UI 线程混在一起消息到达后在任务里触发事件或写入队列界面层再订阅更新。这样连接管理和界面响应互不阻塞断线重连时只需要取消旧的接收 Task重建连接后再启动新的循环。5. 避坑指南Demo 跑通后仍然会遇到的五个典型问题5.1 握手 400前缀、URL ACL 与路径三连击现象客户端调用 ConnectAsync 时抛 WebSocketException异常信息明确写着服务器返回 400 Bad Request有些环境还没走到握手直接提示连接被重置。原因多半出在三处。第一服务端前缀写成了 localhost 而你用局域网 IP 访问第二Windows 下普通权限进程监听加号前缀URL ACL 没授权访问被系统拒绝第三客户端地址少写了路径服务端前缀是http://:9999/ws客户端却连ws://192.168.1.10:9999/路径匹配不上。解决统一服务端前缀为http://:9999/wsWindows 上用管理员执行一次netsh http add urlacl urlhttp://:9999/ws userEveryone客户端地址严格对齐/ws。用抓包工具看服务端返回的响应体里面通常写着具体拒绝原因比盲猜快得多。5.2 中文乱码与消息被截断编码和缓冲一起背锅现象服务端收到的中文变成问号或者一条长消息只收到一半日志打印出来的长度和发送端对不上。原因乱码几乎都是编码不一致一端用了 UTF-8另一端用了 Default 或 GB2312。截断则是接收缓冲只有 4KB而接收循环没有做分片拼接第二次 ReceiveAsync 返回的数据被丢掉。解决两端锁定 Encoding.UTF8不要依赖系统默认编码接收循环改成 4.2 那种 MemoryStream 拼接写法。排查时在两端分别打印字节长度长度一致说明传输没丢不一致就去查缓冲和分片处理。5.3 断网后连接依然 OpenTCP 死了应用层不知道现象拔掉网线、重启路由器后客户端 State 长时间显示 OpenSendAsync 不报错但对方已经收不到消息。原因TCP 在没有活跃数据时物理断链不会立刻反馈到应用层。WebSocket 的状态是根据本地收发情况维护的底层链路断了应用层还标记 Open。KeepAliveInterval 只是对抗空闲回收不负责探测死亡。解决应用层做业务心跳。客户端每 30 秒发一个{type:ping}服务端收到回{type:pong}客户端记录最近一次 pong 的时间超过 60 秒没更新就调用 Abort 强制断开触发重连流程。具体代码在第 6 章。5.4 局域网设备连不上三个检查点按顺序排现象手机或者另一台电脑连ws://192.168.x.x:9999/ws要么超时要么 ConnectionRefused。原因监听地址绑错、防火墙拦入站、AP 隔离挡终端互访三个原因任意一个都能造成这个表面现象。解决按顺序排查。先telnet 192.168.x.x 9999TCP 都不通就先查监听前缀和防火墙TCP 通但 WebSocket 握手失败再去查路径和升级头手机和电脑不在同一网段或开了 AP 隔离则需要调整网络环境。这一步用排除法不要在代码里瞎试。5.5 多线程同时 SendAsync一帧已经在进行中现象多个线程同时向同一连接发消息某个 SendAsync 调用抛出 InvalidOperationException提示已经有发送操作在进行中。原因WebSocket 连接不是线程安全的同一时刻只能有一个发送操作并发调用会把帧边界撕碎。广播线程、业务线程、心跳线程如果都直接调 SendAsync迟早撞车。解决为每个连接配一个信号量发送全部走同一个锁private readonly SemaphoreSlim _sendLock new SemaphoreSlim(1, 1); public async Task SendSafeAsync(string text) { await _sendLock.WaitAsync(); try { byte[] bytes Encoding.UTF8.GetBytes(text); ArraySegmentbyte segment new ArraySegmentbyte(bytes); await _ws.SendAsync(segment, WebSocketMessageType.Text, true, CancellationToken.None); } finally { _sendLock.Release(); } }锁粒度控制在单连接内不同连接的信号量互不干扰广播给多个连接时每个连接各自走自己的锁整体吞吐不会因为一把大锁被拖垮。6. 把 Demo 接进工程业务心跳、指数退避重连和补消息6.1 业务心跳Ping/Pong 与超时判活Demo 跑通只是起点长连接真正难在连接断掉之后怎么恢复。我一般在收发循环外面套一个生命周期管理循环把「连接、接收、异常处理、重连」串成一条线。心跳做两层。底层 KeepAliveInterval 管 NAT 保活上层业务心跳管业务链路的健康度。业务心跳的典型写法是客户端定时发{type:ping}服务端收到后回{type:pong}客户端每次收到 pong 都刷新本地时间戳。心跳 Task 检查时间戳超过 60 秒没有更新就主动 Abort 当前连接。Abort 比 CloseAsync 直接因为这时候连接已经不可信走正常关闭流程可能卡住。6.2 自动重连与消息补偿指数退避背后的参数重连用指数退避防止服务端重启时客户端以 1 秒一次的频率轰炸。常见策略是第一次 1 秒、第二次 2 秒、第三次 4 秒到 30 秒封顶private async Task RunWithReconnectAsync(CancellationToken stopToken) { int attempt 0; while (!stopToken.IsCancellationRequested) { try { await ConnectAsync(); attempt 0; // 连接成功退避计数归零 await ReceiveLoopAsync(); // 阻塞连接断开后自然退出 } catch (Exception ex) { Console.WriteLine($连接异常: {ex.Message}); } finally { _ws.Dispose(); // 必须换新对象旧对象状态已损坏 _ws new ClientWebSocket(); } int delaySeconds Math.Min(30, 1 attempt); attempt; Console.WriteLine(${delaySeconds} 秒后重连...); await Task.Delay(TimeSpan.FromSeconds(delaySeconds), stopToken); } }这段代码的细节连接成功后 attempt 清零否则服务端只是闪断一下客户端也要等满 30 秒才重连恢复速度太慢重连前必须丢掉旧 ClientWebSocket 并 new 一个新的已进入 Aborted 状态的对象无法复用。断线期间产生的业务消息重连后要处理补偿逻辑——要么把所有离线消息重发要么主动拉取服务端离线缓存具体取决于业务是推送型还是拉取型。我习惯加一个待发送队列重连成功先清队列再开始正常收发。最后留一个习惯凡是长连接客户端我都会把心跳、重连、收发日志三件事绑定在一起日志里带时间戳和当前连接状态排障时一眼能看出连接是断了重连的还是静默卡死的。这个习惯救过我很多次有一回线上问题定位全靠重连日志里的时间线。希望帮到你。本文还有配套的精品资源点击获取