☰
C# WebSocket长连接封装实战:心跳保活、断线重连与消息分发
2026/10/14 15:07:23 网站建设 项目流程

简介:这份资源是作者自行改写的 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. 连接管理层的设计取舍:为什么不能直接用原生 ClientWebSocket

2.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 Action<string> OnMessage; public event Action<bool> 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。在业务代码里,消息可能来自定时器、来自用户操作、来自其他服务的回调,并发发送几乎不可避免。

解决办法是加一个发送队列,用Channel<string>或者SemaphoreSlim串行化:

private readonly Channel<string> _sendQueue = Channel.CreateUnbounded<string>(); private async Task SendLoopAsync() { await foreach (var msg in _sendQueue.Reader.ReadAllAsync(_cts.Token)) { var bytes = Encoding.UTF8.GetBytes(msg); // 单线程串行发送,杜绝并发冲突 await _ws.SendAsync(new ArraySegment<byte>(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为true:

private 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 ArraySegment<byte>(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 Dictionary<string, Action<string>> _handlers = new(); public void Register(string type, Action<string> 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 小时保持连接,把连接管理、心跳、重连、可观测性这四件事做扎实,后面省下的维护精力远超前期投入。希望帮到你。

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

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

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

立即咨询