资讯动态

C# WebSocket长连接封装实战:心跳保活、断线重连与消息分发

发布时间:2026/10/14 15:07:33 来源:尧图企业网站定制
简介这份资源是作者自行改写的 WebSocket SDK基于 websocket-sharp 构建专为 C# .NET Framework 2.0 运行环境设计适合需要在老旧框架下实现实时通信的开发者。它支持多种安全校验方式可搭建 wss 服务器并额外提供了用于 API 服务器的客户端实现覆盖服务端与客户端双向通信场景。压缩包共 103 个文件约 1.51MB以 81 个 cs 源码文件为核心辅以 5 个 dll 与 5 个 pdb 调试文件另有 csproj 工程文件、snk 签名文件及少量缓存与配置文本整体结构完整、便于直接编译调试。内容涵盖 WebSocket 会话管理、HTTP 服务器、请求头处理与 WebSocketBehavior 等模块读者可借此理解底层握手、连接维护与安全校验的实现思路。目前已有 731 人学习下载适合具备一定 C# 基础、希望深入掌握 WebSocket 通信机制的中级开发者参考。1. 从 NormanWebsocket-Sharp.zip 说起一个 C# 长连接方案到底解决什么问题如果你在 C# 项目里做过实时通信大概率经历过这样的场景客户端和服务端用原生ClientWebSocket连上了跑通 demo 没问题但一上生产就各种掉线、重连失败、心跳超时、消息粘包最后代码里堆满了try-catch和定时器维护起来像拆炸弹。NormanWebsocket-Sharp.zip 这个标题指向的就是围绕 C# 生态里 WebSocket 长连接封装的一类落地方案——它不是一个官方框架而是一套把连接管理、心跳保活、断线重连、消息分发这几件事打包好的工程实践集合。这类方案的核心价值在于把「连接生命周期」从业务代码里剥离出来。你不需要在每个业务模块里手动判断WebSocketState也不需要自己写重连退避逻辑。适合谁适合正在做 IM、实时推送、设备状态同步、协作编辑这类需要服务端主动推消息的 C# 后端或 Unity 客户端开发者。如果你只是偶尔发个请求HTTP 就够了不用往下看。但如果你发现自己的 WebSocket 代码已经超过 300 行还在打补丁那这套思路值得认真过一遍。2. 连接管理层的设计取舍为什么不能直接用原生 ClientWebSocket2.1 原生 API 的三个硬伤System.Net.WebSockets.ClientWebSocket是 .NET 标准库自带的能用但它是「裸」的。第一个硬伤是没有自动重连。网络抖动、服务端重启、NAT 超时任何一个发生State就变成Aborted之后所有SendAsync直接抛异常你得自己捕获、自己重建实例、自己恢复订阅关系。第二个硬伤是接收缓冲区需要手动管理。ReceiveAsync每次只给你一个WebSocketReceiveResult消息分片时你得自己拼MemoryStream稍不注意就丢包。第三个硬伤是没有心跳机制。TCP 层的 keep-alive 默认两小时才探测一次对于需要秒级感知断线的场景完全不够用。我见过不少项目在这三个点上翻车上线第一天看着日志里全是WebSocketException排查半天发现是服务端做了 60 秒空闲断开而客户端没有任何保活动作。2.2 封装层的职责边界一个可靠的封装层应该管什么、不该管什么这个边界如果划错了后面会非常难受。我的经验是管连接、管心跳、管重连、管消息队列但不管业务序列化。也就是说封装层只负责把byte[]或string可靠地送出去、收回来至于这个字符串是 JSON 还是 Protobuf是登录消息还是聊天消息交给上层。下面是一个最小可用的连接管理器骨架用 C# 写核心是状态机和重连退避public class WebSocketConnection : IDisposable { private ClientWebSocket _ws; private readonly Uri _uri; private readonly CancellationTokenSource _cts new(); private int _retryCount; private const int MaxRetryDelayMs 30000; public event Actionstring OnMessage; public event Actionbool OnStateChanged; public WebSocketConnection(string url) _uri new Uri(url); public async Task StartAsync() { _ws new ClientWebSocket(); // 关键设置心跳间隔部分服务端依赖此头部 _ws.Options.KeepAliveInterval TimeSpan.FromSeconds(20); try { await _ws.ConnectAsync(_uri, _cts.Token); _retryCount 0; OnStateChanged?.Invoke(true); _ ReceiveLoopAsync(); // 接收循环独立跑 _ HeartbeatLoopAsync(); // 心跳循环独立跑 } catch (Exception ex) { OnStateChanged?.Invoke(false); await ScheduleReconnectAsync(); } } private async Task ScheduleReconnectAsync() { // 指数退避1s, 2s, 4s ... 最大 30s var delay Math.Min(1000 * (int)Math.Pow(2, _retryCount), MaxRetryDelayMs); _retryCount; await Task.Delay(delay, _cts.Token); await StartAsync(); } }这段代码里有两个参数值得说清楚。KeepAliveInterval设为 20 秒是因为大多数反向代理和负载均衡的空闲超时在 60 秒左右20 秒发一次 Ping 能稳定续命。重连退避用2^n而不是固定间隔是为了避免服务端刚重启就被大量客户端同时冲击——这个细节在客户端数量上千时尤其明显固定 1 秒重连会让服务端启动瞬间被打满。2.3 心跳与超时检测的配合光发 Ping 不够还得检测 Pong 有没有回来。有些网络环境会「假连接」——TCP 层没断但数据已经不通了。我的做法是维护一个_lastPongTime心跳循环里检查距离上次收到任何数据是否超过2 * KeepAliveInterval超过就主动Abort()触发重连。private DateTime _lastRecvTime DateTime.UtcNow; private async Task HeartbeatLoopAsync() { while (!_cts.IsCancellationRequested) { await Task.Delay(TimeSpan.FromSeconds(20), _cts.Token); if ((DateTime.UtcNow - _lastRecvTime).TotalSeconds 45) { // 超过 45 秒没收到任何数据判定假死 _ws.Abort(); await ScheduleReconnectAsync(); return; } // 发送业务层心跳包比协议层 Ping 更可靠 await SendAsync({\type\:\ping\}); } }注意这里用了业务层心跳包而不是依赖协议层 Ping原因是某些代理会吞掉控制帧但不会吞数据帧。这个坑我在一个跨平台系统里踩过协议层 Ping 正常但消息就是不通换成业务心跳立刻暴露问题。3. 消息收发与线程安全把并发问题关进笼子3.1 发送队列的必要性ClientWebSocket有一个非常隐蔽的限制同一时刻只允许一个SendAsync在执行。如果你在两个线程里同时调SendAsync第二个会直接抛InvalidOperationException。在业务代码里消息可能来自定时器、来自用户操作、来自其他服务的回调并发发送几乎不可避免。解决办法是加一个发送队列用Channelstring或者SemaphoreSlim串行化private readonly Channelstring _sendQueue Channel.CreateUnboundedstring(); private async Task SendLoopAsync() { await foreach (var msg in _sendQueue.Reader.ReadAllAsync(_cts.Token)) { var bytes Encoding.UTF8.GetBytes(msg); // 单线程串行发送杜绝并发冲突 await _ws.SendAsync(new ArraySegmentbyte(bytes), WebSocketMessageType.Text, true, _cts.Token); } } public ValueTask SendAsync(string message) _sendQueue.Writer.WriteAsync(message);用Channel而不是ConcurrentQueue加锁是因为Channel天然支持异步等待队列空时ReadAllAsync会挂起而不是空转CPU 占用更低。队列设成无界是为了不丢消息但如果你的业务可能瞬间产生十万条消息建议改成有界队列并在满时做降级处理否则内存会涨得很难看。3.2 接收循环的分片拼接接收这边最容易出的问题是消息分片。WebSocket 协议允许一条消息拆成多个帧发送EndOfMessage为false时表示还有后续帧。很多人只调一次ReceiveAsync就当成完整消息处理结果长消息被截断短消息偶尔正常排查起来非常玄学。正确的做法是循环接收直到EndOfMessage为trueprivate async Task ReceiveLoopAsync() { var buffer new byte[8192]; var ms new MemoryStream(); while (_ws.State WebSocketState.Open !_cts.IsCancellationRequested) { var result await _ws.ReceiveAsync(new ArraySegmentbyte(buffer), _cts.Token); _lastRecvTime DateTime.UtcNow; // 任何数据都刷新活跃时间 if (result.MessageType WebSocketMessageType.Close) { await _ws.CloseAsync(WebSocketCloseStatus.NormalClosure, , _cts.Token); break; } ms.Write(buffer, 0, result.Count); if (result.EndOfMessage) { var text Encoding.UTF8.GetString(ms.ToArray()); ms.SetLength(0); // 复用 MemoryStream避免频繁分配 OnMessage?.Invoke(text); } } }缓冲区大小 8192 是个经验值太小会导致分片频繁、系统调用多太大则浪费内存。MemoryStream用SetLength(0)复用而不是每次new在消息量大时能明显降低 GC 压力——这个优化在 Unity 客户端上尤其重要因为 Unity 的 GC 停顿会直接掉帧。3.3 消息分发的解耦收到消息后怎么分发给业务有两种常见做法。一种是在OnMessage事件里用switch判断消息类型简单直接但耦合重另一种是注册处理器字典按消息类型路由private readonly Dictionarystring, Actionstring _handlers new(); public void Register(string type, Actionstring handler) _handlers[type] handler; private void Dispatch(string raw) { // 假设消息格式为 {type:xxx,payload:...} using var doc JsonDocument.Parse(raw); var type doc.RootElement.GetProperty(type).GetString(); if (type ! null _handlers.TryGetValue(type, out var handler)) handler(raw); else Console.WriteLine($未注册的消息类型: {type}); }字典路由的好处是新增消息类型时不用改分发代码坏处是如果注册时机不对比如连接建立前就收到消息处理器还没注册上消息就丢了。我的习惯是在StartAsync之前完成所有注册并且在Dispatch里对未注册类型打日志而不是静默丢弃方便排查。4. 避坑与排查那些让长连接「看起来正常」的陷阱4.1 重连后订阅关系丢失现象断线重连成功日志显示连接已恢复但业务收不到推送了。原因服务端的订阅关系绑定在旧连接上新连接建立后没有重新订阅。很多封装只做了 TCP 层重连没做业务层状态恢复。解决在OnStateChanged(true)的回调里触发「重订阅」逻辑把所有需要订阅的频道重新发一遍。更稳妥的做法是维护一个_subscriptions集合重连后遍历重发。4.2 心跳包被业务消息淹没现象心跳定时器明明在跑但服务端还是判定超时断开。原因心跳发送和业务消息发送共用一个队列业务高峰期队列积压心跳包排在几千条消息后面实际发出时已经超时。解决心跳走独立通道或者给心跳包设优先级。简单做法是心跳直接调SendAsync并加一个独立的SemaphoreSlim不和业务队列混在一起。4.3 关闭时的竞态异常现象程序退出时偶发ObjectDisposedException或OperationCanceledException。原因Dispose和接收循环、心跳循环并发执行_cts.Cancel()之后循环里的Task.Delay和ReceiveAsync同时抛异常没有统一捕获。解决在循环里对OperationCanceledException单独处理并正常退出Dispose时先Cancel再await循环任务结束最后才_ws.Dispose()。顺序错了就会翻车。4.4 大消息导致内存暴涨现象传输大文件或长文本时客户端内存飙升甚至 OOM。原因MemoryStream拼接分片时没有上限一条 100MB 的消息会在内存里完整拼出来。解决设置单条消息大小上限比如 4MB超过就拒绝并断开让业务改用分块传输或 HTTP 下载。这个限制要在协议设计阶段就定好事后加会很被动。4.5 SSL 证书校验在部分环境失败现象开发机正常部署到某些服务器后连接直接失败报证书错误。原因目标环境的根证书链不完整或者使用了自签名证书。解决生产环境不要图省事直接ServerCertificateCustomValidationCallback (a,b,c,d) true这会引入中间人风险。正确做法是把自签名证书导入信任链或者在校验回调里比对证书指纹。5. 进阶技巧把重连做成可观测、可配置的工程能力前面讲的都是「能跑」这一章讲「跑得明白」。长连接最怕的不是断而是断了你不知道、重连了你不知道、重连失败了你还是不知道。所以进阶的第一步是把连接状态暴露成指标。我一般会在连接管理器里加几个计数器TotalConnects、TotalDisconnects、CurrentRetryCount、LastConnectedAt、LastMessageAt。这些值定时打日志或者上报到监控系统线上出问题时一眼就能看出是「频繁重连」还是「连上后没消息」。public class ConnectionMetrics { public long TotalConnects; public long TotalDisconnects; public int CurrentRetryCount; public DateTime LastConnectedAt; public DateTime LastMessageAt; public void Dump() { Console.WriteLine( $连接次数{TotalConnects} 断开次数{TotalDisconnects} $当前重试{CurrentRetryCount} $最后连接{LastConnectedAt:HH:mm:ss} $最后消息{LastMessageAt:HH:mm:ss}); } }第二步是把重连参数做成可配置。不同网络环境对重连策略的容忍度完全不同内网可以激进一点1 秒重试公网移动网络得保守退避上限拉到 60 秒否则手机频繁切换基站时会把电量耗光。下面这张表是我在几个项目里总结出来的参考值场景初始退避最大退避心跳间隔假死判定内网服务500ms5s10s25s公网 PC 客户端1s30s20s45s移动端2s60s30s75s弱网设备3s120s45s120s这张表不是拍脑袋来的。移动端把最大退避拉到 60 秒是因为我在一个跨平台系统里观察到手机锁屏后系统会限制后台网络激进重连只会不断失败并触发系统省电策略反而更难恢复。弱网设备比如工业现场的采集终端心跳放到 45 秒是因为那些网络本身延迟就高心跳太频繁会挤占本就不宽裕的带宽。第三步是给重连加一个「恢复回调」链。连接恢复后要做的事往往不止重订阅可能还有拉取断线期间漏掉的消息、重新鉴权刷新 token、重置本地状态机。这些逻辑如果散落在各处很容易漏。我的做法是定义一个IRecoveryHandler接口所有需要在重连后执行的逻辑都实现它连接管理器按顺序调用public interface IRecoveryHandler { Task OnReconnectedAsync(); } // 注册示例 connection.OnStateChanged async (connected) { if (!connected) return; foreach (var handler in _recoveryHandlers) await handler.OnReconnectedAsync(); };顺序很重要先刷新 token再重订阅最后拉漏消息。顺序反了会出现「用旧 token 订阅被拒」或者「拉消息时订阅还没建立导致又漏一批」的连锁问题。最后说一个我自己的习惯永远在本地留一个「连接日志环形缓冲区」。不用大最近 200 条状态变更就够包含时间戳、事件类型、重试次数。用户反馈「用着用着就没消息了」的时候让 TA 导出这个缓冲区比猜半天管用得多。这个后悔药成本极低但省下的排查时间是以小时计的。这套方案值不值得投入如果你只是做个 demo原生 API 凑合能用。但只要你的 C# 项目需要 7x24 小时保持连接把连接管理、心跳、重连、可观测性这四件事做扎实后面省下的维护精力远超前期投入。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑