news 2026/10/14 13:33:07

手写C# WebSocket服务端:从协议解析到生产级连接管理

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
手写C# WebSocket服务端:从协议解析到生产级连接管理

1. 项目概述:为什么一个C# WebSocket服务端值得花时间亲手写一遍

“C# WebSocket服务端实现”这八个字,看起来平平无奇,像是某本.NET入门书里的一节小练习。但如果你真在某次需求评审会上听到产品经理说“用户要实时看到订单状态变化,延迟不能超过800毫秒”,或者运维同事深夜发来消息:“长连接网关又崩了,排查发现是第三方SDK在高并发下内存泄漏”,你就会明白——这不是语法练习,而是生产环境里扛压的脊梁。

我做过三个不同量级的实时系统:某高校实验室的物联网数据中台(日均连接3000+设备)、某本地生活平台的即时配送调度模块(峰值每秒建连200+)、还有一个内部协作工具的消息同步服务(支持离线消息补推)。它们底层都绕不开一个干净、可控、可调试的WebSocket服务端。而C#生态里,很多人第一反应是直接上SignalR——它确实省事,但就像给一辆赛车装上自动挡和ABS,你享受便利的同时,也失去了对油门响应曲线、轮胎抓地临界点、制动热衰减的感知力。当线上出现“连接突然断开但OnClose没触发”“心跳包收不到却没报错”“GC暂停导致Ping超时被客户端踢出”这类问题时,SignalR的抽象层反而成了黑盒里的迷雾。

所以这次,我们不碰任何高层封装,就用.NET原生的System.Net.WebSockets,从零手写一个具备生产可用雏形的WebSocket服务端。它不追求功能大而全,但每个环节都经得起追问:握手阶段如何校验Origin防止CSRF?帧解析时怎么处理掩码(Mask)位与未掩码帧的兼容?连接池管理为何不用ConcurrentDictionary而选ConcurrentQueue+弱引用缓存?心跳保活是靠TCP KeepAlive还是应用层Ping/Pong?这些问题的答案,不会藏在API文档的角落,而是在你亲手写出第7个await socket.ReceiveAsync()调用、第3次重写WebSocketFrameReader逻辑、第5次压测后调整ReceiveBufferSize参数时,自然浮现出来的肌肉记忆。

这个实现适合三类人:一是刚学完C#基础、想把“网络编程”从概念落地为代码的开发者;二是正在用SignalR但被诡异断连问题困扰、需要底层视角辅助排查的工程师;三是架构设计阶段需要评估自研长连接服务可行性的技术负责人。它不教你“怎么快速上线”,而是带你理解“为什么这样设计才稳”。接下来的内容,就是我把过去三年踩过的坑、调过的参、画过的时序图,全部摊开来讲清楚。

2. 整体架构设计与核心思路拆解

2.1 为什么放弃Kestrel内置WebSocket中间件,选择自定义HttpListener方案

.NET 6+ 的Kestrel服务器确实提供了MapWebSocketManager扩展方法,表面看只需几行代码就能启动WebSocket服务:

app.Map("/ws", async context => { if (context.WebSockets.IsWebSocketRequest) { using var webSocket = await context.WebSockets.AcceptWebSocketAsync(); await EchoWebSocket(context, webSocket); } });

但实际压测时你会发现两个硬伤:连接生命周期不可控和错误传播链断裂。Kestrel的WebSocket中间件将WebSocket对象绑定到HttpContext生命周期,一旦HTTP上下文因超时或异常被释放,底层WebSocket连接会静默关闭,且OnClose事件根本不会触发——你甚至收不到1001 (Going Away)状态码。更麻烦的是,当客户端发送一个非法帧(比如长度字段溢出),Kestrel会直接抛出WebSocketException并终止整个HTTP请求管道,错误日志里只显示“An error occurred while sending the response”,根本看不到原始帧数据,排查如同盲人摸象。

所以我最终选择了HttpListener方案,它更底层、更“脏”,但也更透明。HttpListener监听HTTP请求后,手动完成WebSocket握手(Upgrade头校验、Sec-WebSocket-Accept计算),再通过context.AcceptWebSocketAsync()获取WebSocket实例。关键在于:这个WebSocket对象完全脱离HTTP上下文生命周期。你可以把它存入自己的连接池,设置独立的心跳定时器,甚至在连接关闭后继续处理未完成的异步接收任务。下面这段握手校验代码,就是整个服务稳定性的第一道闸门:

private static bool TryValidateWebSocketHandshake(HttpListenerContext context, out string acceptKey) { var request = context.Request; // 必须是GET方法 if (request.HttpMethod != "GET") throw new InvalidOperationException("WebSocket handshake requires GET method"); // 必须有Upgrade头且值为websocket if (!request.Headers.AllKeys.Contains("Upgrade") || !request.Headers["Upgrade"].Equals("websocket", StringComparison.OrdinalIgnoreCase)) throw new InvalidOperationException("Missing or invalid Upgrade header"); // 必须有Connection头且包含upgrade if (!request.Headers.AllKeys.Contains("Connection") || !request.Headers["Connection"].ToLower().Contains("upgrade")) throw new InvalidOperationException("Missing or invalid Connection header"); // 必须有Sec-WebSocket-Key头 if (!request.Headers.AllKeys.Contains("Sec-WebSocket-Key")) throw new InvalidOperationException("Missing Sec-WebSocket-Key header"); var key = request.Headers["Sec-WebSocket-Key"].Trim(); if (key.Length != 24 || !IsValidBase64(key)) throw new InvalidOperationException("Invalid Sec-WebSocket-Key format"); // 计算Sec-WebSocket-Accept:key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11" -> SHA1 -> Base64 var acceptBytes = SHA1.HashData(Encoding.UTF8.GetBytes(key + "258EAFA5-E914-47DA-95CA-C5AB0DC85B11")); acceptKey = Convert.ToBase64String(acceptBytes); return true; }

这段代码里藏着三个关键设计点:第一,IsValidBase64校验不是简单调用Convert.TryFromBase64String,而是检查字符串长度是否为4的倍数、字符是否在A-Z/a-z/0-9+/范围内——因为某些恶意客户端会发送看似合法实则无法解码的key,导致后续SHA1计算崩溃;第二,Sec-WebSocket-Accept的拼接字符串是硬编码的RFC标准值,绝不能用变量拼接,否则握手必然失败;第三,所有校验失败都抛出InvalidOperationException而非返回false,强制中断流程,避免状态不一致。这些细节,正是Kestrel中间件帮你屏蔽掉、却在生产环境里反复咬你的地方。

2.2 连接管理模型:为什么用“连接ID+弱引用缓存”替代ConcurrentDictionary

初学者常犯的错误,是把所有WebSocket实例塞进ConcurrentDictionary<string, WebSocket>,用随机GUID做Key。这看似线程安全,实则埋下两颗雷:内存泄漏和连接假死。

先说内存泄漏。WebSocket对象内部持有大量非托管资源(socket句柄、缓冲区内存),.NET的GC无法自动回收它。当你从字典里Remove一个连接时,如果该WebSocket还在进行ReceiveAsync或SendAsync,它的内部状态机可能处于“等待IO完成”的挂起状态,此时对象仍被IO完成端口(IOCP)线程持有,ConcurrentDictionary的键值对虽已删除,但WebSocket实例本身因强引用未被回收,内存持续增长。我们曾在线上环境观察到:每小时连接数波动在500~800之间,但WebSocket对象的GC代数始终卡在Gen2,内存占用每小时上涨12MB,三天后OOM。

解决方案是引入弱引用缓存(WeakReference Cache)。核心思想是:不直接持有WebSocket强引用,而是用WeakReference<WebSocket>包装,让GC能在内存压力下自动回收。但弱引用有个致命缺陷——你无法保证Target不为空。所以我们设计了一个双层结构:内存中维护一个ConcurrentDictionary<string, WeakReference<WebSocket>>存储连接元数据(ID、创建时间、最后活跃时间),同时用ConcurrentQueue<string>维护一个“活跃连接ID队列”。当需要广播消息时,遍历队列中的ID,从字典里取出WeakReference,调用TryGetTarget(out ws),仅当ws不为null且ws.State == WebSocketState.Open时才发送。这样既避免了强引用导致的泄漏,又通过队列保证了高频访问的局部性。

更关键的是连接假死检测。很多教程教你在OnClose事件里清理连接,但现实是:客户端拔网线、手机切后台、NAT超时断连,都不会触发OnClose。我们必须主动探测。我们的方案是:每个连接建立时,启动一个Timer,间隔设为KeepAliveInterval = 30秒,回调函数执行ws.PingAsync()。注意!这里必须用PingAsync而非SendAsync发自定义心跳包,因为Ping帧是WebSocket协议规定的控制帧,服务端发送Ping后,客户端必须回复Pong,且这个过程由底层协议栈自动处理,不经过你的应用层代码。如果连续3次PingAsync超时(我们设为5秒),则判定连接假死,主动调用ws.CloseAsync(WebSocketCloseStatus.EndpointUnavailable, "Ping timeout", CancellationToken.None)。这个逻辑写在WebSocketManager的StartKeepAliveTimer方法里,而不是放在ReceiveLoop中——因为ReceiveLoop可能因网络抖动长时间阻塞,导致心跳检测失灵。

2.3 消息分发策略:为什么选择“发布-订阅”而非“广播-过滤”

当有1000个客户端连接时,向其中特定50个推送消息,是该遍历所有连接逐个判断ID,还是让这50个客户端先“订阅”某个主题?答案显然是后者。但具体实现上,很多人会设计一个Dictionary<string, List<string>>,Key是主题名,Value是订阅该主题的连接ID列表。这在连接数少时没问题,但当主题数过万、单主题订阅者达千级时,List<string>的Contains查找是O(n)复杂度,每次发消息都要遍历整个列表,CPU瞬间拉满。

我们改用分层哈希索引。第一层是ConcurrentDictionary<string, TopicNode>,Key为主题名(如"order:12345"),Value是一个TopicNode对象;第二层TopicNode内部维护两个结构:ConcurrentHashSet<string>存储当前在线的订阅者ID(利用ConcurrentHashSet的O(1)查找),以及一个ConcurrentQueue<string>记录最近10分钟内的订阅/退订操作日志(用于故障恢复)。当客户端发送{"type":"subscribe","topic":"order:12345"}时,服务端解析JSON,调用TopicManager.Subscribe(clientId, topic),该方法先检查TopicNode是否存在,不存在则新建并加入顶层字典,然后将clientId加入ConcurrentHashSet。发送消息时,TopicManager.Publish(topic, message)直接从ConcurrentHashSet获取所有ID,批量发送。

这个设计解决了三个痛点:第一,ConcurrentHashSet的插入/删除/查找都是无锁的,比List快一个数量级;第二,TopicNode本身是轻量对象,不持有WebSocket引用,避免内存膨胀;第三,操作日志队列为分布式部署预留了接口——未来可以将日志推送到Redis Stream,由其他节点消费实现跨进程订阅。我们实测:在单机32核服务器上,支持5万个主题、每个主题平均200个订阅者时,Publish平均耗时稳定在1.2ms以内,P99不超过3.5ms。

3. 核心细节解析与实操要点

3.1 WebSocket帧解析:掩码(Mask)位处理的魔鬼细节

WebSocket协议规定,客户端发送的所有帧都必须启用掩码(Mask=1),服务端发送的帧必须禁用掩码(Mask=0)。这是强制的安全机制,防止代理服务器缓存或篡改数据。但很多C#教程直接调用WebSocket.ReceiveAsync,以为框架已帮你处理掩码,其实不然——ReceiveAsync返回的是原始字节流,掩码解密必须由你手动完成。

一个WebSocket帧的结构如下(简化版):

0 1 2 3 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 2 3 4 5 6 7 8 9 0 1 +-+-+-+-+-------+-+-------------+-------------------------------+ |F|R|R|R| opcode|M| Payload len | Extended payload length | |I|S|S|S| (4) |A| (7) | (16/64) | |N|V|V|V| |S| | (if payload len==126/127) | | |1|2|3| |K| | | +-+-+-+-+-------+-+-------------+ - - - - - - - - - - - - - - - + | Extended payload length continued, if payload len == 127 | + - - - - - - - - - - - - - - - +-------------------------------+ | |Masking-key (32 bits) | +-------------------------------+-------------------------------+ | Masking-key (continued) | Payload Data | +-------------------------------- - - - - - - - - - - - - - - - + | Payload Data continued ... | + - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - +

关键点在于:当Mask位为1时,紧随其后的4字节是Masking-key,之后的Payload Data每个字节都要与Masking-key对应字节异或(XOR)才能得到真实数据。例如Masking-key = [0x12, 0x34, 0x56, 0x78],Payload[0]要与0x12异或,Payload[1]与0x34异或,Payload[2]与0x56异或,Payload[3]与0x78异或,Payload[4]再与0x12异或……循环使用。

我们封装了一个WebSocketFrameDecoder类,核心解码方法如下:

public static void DecodePayload(byte[] payload, byte[] maskingKey, int offset = 0) { if (maskingKey == null) return; // 服务端发来的帧,无需解码 int keyIndex = 0; for (int i = offset; i < payload.Length; i++) { payload[i] ^= maskingKey[keyIndex]; keyIndex = (keyIndex + 1) % 4; // 循环使用4字节key } }

但这里有个极易忽略的陷阱:offset参数。ReceiveAsync返回的缓冲区ArraySegment<byte>通常包含帧头和载荷,而帧头长度不固定(取决于Payload Length字段的值)。如果你直接对整个缓冲区调用DecodePayload,就会把帧头字节也参与异或,导致后续解析失败。正确做法是:先解析帧头,确定Payload起始位置payloadStartOffset,再从此处开始解码。我们用一个Span<byte>辅助类来安全提取:

private static int GetPayloadStartOffset(ReadOnlySpan<byte> frameHeader) { // 解析FIN、RSV、Opcode(前2字节) var firstByte = frameHeader[0]; var secondByte = frameHeader[1]; // 获取Payload Length(第2字节后7位) var payloadLen = secondByte & 0x7F; int offset = 2; // 跳过前2字节 if (payloadLen == 126) // 16位长度 offset += 2; else if (payloadLen == 127) // 64位长度 offset += 8; // 如果Mask=1,再加4字节Masking-key if ((secondByte & 0x80) == 0x80) offset += 4; return offset; }

这个GetPayloadStartOffset方法必须在DecodePayload之前调用,否则解码必错。我们曾因此调试了整整两天,日志里看到的全是乱码JSON,直到用Wireshark抓包对比才发现:客户端发来的{"msg":"hello"},经错误解码后变成{"msh":"hdkko"}——l和o被异或成了d和k,而h和e因为位置偏移没被解码,侥幸保留。这种低级错误,在没有深入理解帧结构时,几乎无法避免。

3.2 内存管理:为什么用ArrayPool 而非new byte[8192]

WebSocket通信中,频繁分配和释放缓冲区是性能杀手。一个典型场景:每秒处理1000条消息,每条平均2KB,如果每次都new byte[2048],GC Gen0每秒触发上百次,CPU大量时间花在内存清扫上。.NET提供了ArrayPool<byte>,它是一个线程安全的对象池,能复用数组减少GC压力。

但直接用ArrayPool<byte>.Shared.Rent(8192)也有坑。ArrayPool默认最大租借大小是1024*1024(1MB),如果你租借2MB数组,它会退化为new byte[2097152],完全失去池化意义。更隐蔽的问题是:租借的数组长度不等于你实际需要的长度。Rent方法返回的数组长度可能大于请求值(比如请求8192,返回8200),你必须用Memory<T>或Span<T>精确控制使用范围,否则可能越界读写。

我们的做法是:定义一个WebSocketBufferManager单例,内部维护一个ConcurrentDictionary<int, ArrayPool<byte>>,Key为常用缓冲区大小(如1024、4096、8192、16384),Value为对应大小的专用池。这样既能避免大数组退化,又能按需精准分配。关键代码如下:

public class WebSocketBufferManager { private static readonly ConcurrentDictionary<int, ArrayPool<byte>> _pools = new ConcurrentDictionary<int, ArrayPool<byte>>(); public static ArrayPool<byte> GetPool(int bufferSize) { return _pools.GetOrAdd(bufferSize, size => ArrayPool<byte>.Create(size, maxArrays: 100)); // 最多缓存100个该大小数组 } public static Memory<byte> Rent(int bufferSize) { var pool = GetPool(bufferSize); var array = pool.Rent(bufferSize); return array.AsMemory(0, bufferSize); // 精确限定长度,避免越界 } public static void Return(Memory<byte> buffer, int bufferSize) { var pool = GetPool(bufferSize); pool.Return(buffer.ToArray()); // 注意:ToArray()会复制,实际应存原始数组引用 } }

提示:Return方法中的ToArray()是示意写法,实际应保存Rent返回的原始byte[]引用,否则Return时传入的数组与Rent时不一致,池化失效。我们在线上版本中,用ValueTuple<byte[], int>封装租借的数组和实际使用长度,确保归还时精准匹配。

另一个重要技巧是缓冲区预热。服务启动时,主动Rent并Return各档位缓冲区各10次,让池子提前填充,避免首请求时因池空而触发new。我们实测:开启预热后,GC Gen0频率下降70%,P99延迟从45ms降至12ms。

3.3 错误处理与连接恢复:OnClose事件的三大误区

WebSocket.OnClose事件看似简单,但90%的开发者都用错了。常见误区有三:

误区一:在OnClose里直接调用ws.CloseAsync()
这是最危险的操作。OnClose事件本身就是在连接关闭过程中触发的,此时ws.State可能已是Closing或Closed,再次调用CloseAsync会抛出InvalidOperationException: The WebSocket is in an invalid state.。正确做法是:在OnClose里只做清理工作(如从连接池移除ID、记录日志),绝不触碰ws对象。

误区二:认为OnClose能捕获所有断连原因
OnClose只在WebSocket协议层正常关闭时触发(如收到Close帧、调用CloseAsync)。但网络闪断、客户端崩溃、防火墙拦截等情况,OnClose根本不会执行。必须配合前面提到的PingAsync心跳检测,两者互补。我们在线上日志里统计过:OnClose触发占比约65%,其余35%靠心跳超时发现。

误区三:忽略CloseStatus和CloseDescription
OnClose事件参数包含WebSocketCloseStatus status和string description,这是诊断问题的黄金信息。比如status == WebSocketCloseStatus.PolicyViolation说明客户端违反了服务端策略(如发送超长消息),description里可能包含具体限制值;status == WebSocketCloseStatus.InternalServerError则指向服务端代码异常。我们把这些字段写入ELK日志,并配置告警规则:当PolicyViolation出现频率超过每分钟5次,自动触发钉钉告警,提示“疑似恶意扫描”。

为此,我们封装了一个WebSocketErrorHandler类,统一处理各类关闭场景:

public class WebSocketErrorHandler { public static void HandleClose(WebSocket webSocket, WebSocketCloseStatus status, string description, Exception? exception = null) { var logMessage = $"WebSocket closed. Status: {status}, Description: '{description}'"; if (exception != null) logMessage += $", Exception: {exception.GetType().Name}: {exception.Message}"; // 根据status分类处理 switch (status) { case WebSocketCloseStatus.Empty: // 客户端未发送关闭帧,可能是异常断连 Logger.Warn(logMessage + " (Possible network disconnect)"); break; case WebSocketCloseStatus.PolicyViolation: // 策略违规,记录详细信息并告警 Logger.Error(logMessage + " (Policy violation detected)"); AlertService.Trigger("WebSocket_Policy_Violation", description); break; case WebSocketCloseStatus.InternalServerError: // 服务端内部错误,需检查exception Logger.Fatal(logMessage + " (Server internal error)", exception); break; default: Logger.Info(logMessage); break; } } }

这个类被注入到每个连接的ReceiveLoop末尾,确保无论何种原因关闭,都有迹可循。记住:在分布式系统里,可观测性不是锦上添花,而是生存底线。

4. 实操过程与核心环节实现

4.1 从零搭建:完整代码结构与关键配置

现在,让我们把前面所有设计落地为可运行的代码。整个服务采用分层架构,目录结构如下:

WebSocketServer/ ├── Core/ # 核心协议与工具类 │ ├── WebSocketFrameDecoder.cs │ ├── WebSocketBufferManager.cs │ └── WebSocketErrorHandler.cs ├── Managers/ # 业务管理器 │ ├── ConnectionManager.cs │ ├── TopicManager.cs │ └── HeartbeatManager.cs ├── Models/ # 数据模型 │ ├── WebSocketConnection.cs │ └── TopicNode.cs ├── Protocols/ # 协议解析 │ └── JsonMessageParser.cs └── Program.cs # 入口点

Program.cs是启动入口,关键代码如下:

class Program { private static readonly HttpListener _listener = new HttpListener(); private static readonly ConnectionManager _connectionManager = new ConnectionManager(); private static readonly TopicManager _topicManager = new TopicManager(); static async Task Main(string[] args) { // 预热缓冲池 WebSocketBufferManager.WarmUp(); // 启动HTTP监听 _listener.Prefixes.Add("http://localhost:8080/ws/"); _listener.Start(); Console.WriteLine("WebSocket server started on http://localhost:8080/ws/"); // 启动后台任务:心跳检测、日志轮转等 _ = Task.Run(() => HeartbeatManager.Start()); try { while (_listener.IsListening) { var context = await _listener.GetContextAsync(); _ = HandleClientAsync(context); // 火焰式处理,不await } } catch (HttpListenerException ex) when (ex.ErrorCode == 995) // I/O操作被中止 { Console.WriteLine("Listener stopped."); } } private static async Task HandleClientAsync(HttpListenerContext context) { try { // 1. 验证WebSocket握手 if (!TryValidateWebSocketHandshake(context, out var acceptKey)) { context.Response.StatusCode = 400; context.Response.Close(); return; } // 2. 发送握手响应 context.Response.StatusCode = 101; context.Response.StatusDescription = "Switching Protocols"; context.Response.AddHeader("Upgrade", "websocket"); context.Response.AddHeader("Connection", "Upgrade"); context.Response.AddHeader("Sec-WebSocket-Accept", acceptKey); context.Response.Close(); // 3. 接受WebSocket连接 var webSocket = await context.AcceptWebSocketAsync(null); var connection = new WebSocketConnection(webSocket, context.Request.RemoteEndPoint.ToString()); // 4. 注册连接并启动接收循环 _connectionManager.Register(connection); _ = ReceiveLoopAsync(connection); // 不await,避免阻塞主线程 } catch (Exception ex) { Console.WriteLine($"Handle client error: {ex}"); context.Response.StatusCode = 500; context.Response.Close(); } } }

这里有几个关键配置点需要强调:

第一,HttpListener的线程模型。GetContextAsync()是异步的,但它的背后是同步I/O模型,每个请求都会占用一个线程。所以HandleClientAsync必须用_ = Task.Run(...)或_ = ReceiveLoopAsync(...)进行“火焰式”调用,即启动后立即返回,不等待完成。否则主线程会被阻塞,新请求无法接入。我们测试过:不加_ =前缀,QPS卡在120;加上后,QPS飙升至3200+。

第二,AcceptWebSocketAsync(null)的参数。传入null表示使用默认配置,但如果你需要自定义缓冲区大小,可以传入WebSocketOptions:

var options = new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30), ReceiveBufferSize = 8192, // 接收缓冲区,影响吞吐 SendBufferSize = 4096 // 发送缓冲区,影响延迟 }; await context.AcceptWebSocketAsync(options);

注意:ReceiveBufferSize不是越大越好。我们实测:设为64KB时,单连接吞吐提升15%,但内存占用翻倍;设为4KB时,内存节省40%,吞吐仅降3%。最终选择8KB作为平衡点,兼顾性能与资源。

第三,WebSocketConnection构造函数。它不只是包装WebSocket对象,还初始化了连接元数据:

public class WebSocketConnection { public WebSocket WebSocket { get; } public string ConnectionId { get; } public DateTime CreatedAt { get; } public DateTime LastActiveAt { get; private set; } public string RemoteAddress { get; } public WebSocketConnection(WebSocket webSocket, string remoteAddress) { WebSocket = webSocket; ConnectionId = Guid.NewGuid().ToString("N"); CreatedAt = DateTime.UtcNow; LastActiveAt = DateTime.UtcNow; RemoteAddress = remoteAddress; // 启动心跳定时器 HeartbeatManager.StartHeartbeat(this); } public void UpdateLastActive() => LastActiveAt = DateTime.UtcNow; }

这个类是连接管理的基石,所有业务逻辑都基于它展开。

4.2 消息接收循环:ReceiveLoopAsync的健壮实现

ReceiveLoopAsync是整个服务的心脏,它必须处理所有异常情况,确保连接不断。以下是精简后的核心逻辑:

private static async Task ReceiveLoopAsync(WebSocketConnection connection) { var webSocket = connection.WebSocket; var buffer = WebSocketBufferManager.Rent(8192); try { while (webSocket.State == WebSocketState.Open) { // 1. 接收帧数据 WebSocketReceiveResult result; try { result = await webSocket.ReceiveAsync(buffer, CancellationToken.None); } catch (OperationCanceledException) { // CancellationToken取消,正常退出 break; } catch (WebSocketException wse) when (wse.WebSocketErrorCode == WebSocketError.ConnectionClosedPrematurely) { // 连接被对方提前关闭 WebSocketErrorHandler.HandleClose(webSocket, WebSocketCloseStatus.Empty, "Connection closed prematurely"); break; } catch (Exception ex) { // 其他异常,记录日志并退出 Console.WriteLine($"Receive error: {ex}"); break; } // 2. 处理接收结果 if (result.MessageType == WebSocketMessageType.Close) { // 收到Close帧,准备关闭 await webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Client requested close", CancellationToken.None); WebSocketErrorHandler.HandleClose(webSocket, WebSocketCloseStatus.NormalClosure, "Client requested close"); break; } if (result.MessageType == WebSocketMessageType.Binary) { // 二进制消息,按需处理(如文件传输) await HandleBinaryMessageAsync(connection, buffer.Slice(0, result.Count)); continue; } if (result.MessageType == WebSocketMessageType.Text) { // 文本消息,解析JSON var text = Encoding.UTF8.GetString(buffer.Slice(0, result.Count).ToArray()); await HandleTextMessageAsync(connection, text); continue; } } } finally { // 3. 清理资源 WebSocketBufferManager.Return(buffer, 8192); _connectionManager.Unregister(connection.ConnectionId); // 关闭WebSocket(如果还没关) if (webSocket.State == WebSocketState.Open || webSocket.State == WebSocketState.CloseReceived) { try { await webSocket.CloseAsync(WebSocketCloseStatus.GoingAway, "Connection cleanup", CancellationToken.None); } catch { // CloseAsync可能失败,忽略 } } } }

这个循环的关键在于三层防御:第一层是try-catch捕获WebSocketException,区分ConnectionClosedPrematurely(对方断连)和InvalidState(状态异常);第二层是result.MessageType判断,严格按协议类型分流;第三层是finally块,确保无论何种路径退出,缓冲区和连接注册都得到清理。我们曾在线上遇到一种罕见情况:客户端发送一个超长文本帧(>10MB),ReceiveAsync返回result.Count=0且result.EndOfMessage=true,但buffer里全是0字节。如果不加result.Count > 0判断,后续GetString会返回空字符串,导致JSON解析失败。所以在HandleTextMessageAsync前,我们增加了校验:

if (result.Count == 0) { Console.WriteLine($"Empty message received from {connection.ConnectionId}"); continue; }

这种边角case,只有在真实流量中才会暴露。

4.3 主题订阅与消息推送:完整的Pub/Sub实现

现在,让我们实现最常用的业务功能:客户端订阅主题并接收推送。协议约定:客户端发送JSON消息,格式为{"type":"subscribe","topic":"user:123"}或{"type":"publish","topic":"user:123","data":"hello"}。

JsonMessageParser负责解析:

public static class JsonMessageParser { public static (string type, string topic, string data) Parse(string json) { try { using var doc = JsonDocument.Parse(json); var root = doc.RootElement; var type = root.GetProperty("type").GetString() ?? ""; var topic = root.GetProperty("topic").GetString() ?? ""; var data = root.GetProperty("data").GetString() ?? ""; return (type, topic, data); } catch (JsonException ex) { Console.WriteLine($"JSON parse error: {ex.Message}"); return ("error", "", ""); } } }

HandleTextMessageAsync根据type分发:

private static async Task HandleTextMessageAsync(WebSocketConnection connection, string text) { var (type, topic, data) = JsonMessageParser.Parse(text); switch (type) { case "subscribe": if (!string.IsNullOrWhiteSpace(topic)) { _topicManager.Subscribe(connection.ConnectionId, topic); await SendResponseAsync(connection, new { success = true, message = $"Subscribed to {topic}" }); } else { await SendResponseAsync(connection, new { success = false, error = "Missing topic" }); } break; case "unsubscribe": if (!string.IsNullOrWhiteSpace(topic)) { _topicManager.Unsubscribe(connection.ConnectionId, topic); await SendResponseAsync(connection, new { success = true, message = $"Unsubscribed from {topic}" }); } break; case "publish": if (!string.IsNullOrWhiteSpace(topic) && !string.IsNullOrWhiteSpace(data)) { // 服务端向topic推送消息 await _topicManager.Publish(topic, data); await SendResponseAsync(connection, new { success = true, message = "Published" }); } break; default: await SendResponseAsync(connection, new { success = false, error = $"Unknown type: {type}" }); break; } }

TopicManager.Publish是核心推送逻辑:

public async Task Publish(string topic, string message) { if (!_topics
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/14 13:32:49

C++移动语义:原理、优化与实践指南

1. 移动语义基础概念解析移动语义是现代C中最重要的特性之一&#xff0c;它彻底改变了我们处理对象所有权转移的方式。在C11之前&#xff0c;对象的传递主要依靠拷贝构造函数&#xff0c;这会导致不必要的内存分配和复制操作。想象一下搬家时的场景&#xff1a;传统拷贝就像把旧…

作者头像 李华
网站建设 2026/10/14 13:32:43

Java OA系统架构设计与高并发优化实践

1. 项目概述与背景解析办公自动化系统&#xff08;Office Automation System&#xff09;作为现代企业数字化转型的核心基础设施&#xff0c;正在经历从传统文档管理向智能协同平台的演进。基于Java技术栈构建的OA系统&#xff0c;凭借其跨平台特性、成熟的生态体系和稳健的性能…

作者头像 李华
网站建设 2026/10/14 13:31:48

DFS算法实战:字符串排列序数问题解析

1. 问题背景与需求分析今天想和大家分享一个关于深度优先搜索(DFS)的经典练习题——排列序数问题。这个问题在算法竞赛和面试中经常出现&#xff0c;也是理解DFS递归思想的绝佳案例。排列序数问题的核心是&#xff1a;给定一个由不同字符组成的字符串&#xff0c;要求输出该字符…

作者头像 李华
网站建设 2026/10/14 13:31:29

心理学在员工激励和团队管理中的应用(总监ppt)

将员工动机与团队协作研究转化为具体管理动作&#xff0c;覆盖目标设定、奖励认可、公平规则、心理安全、绩效反馈与冲突处理&#xff0c;并配套场景分析、效果评估和90天实施路线&#xff0c;适用于管理者培训、团队建设及人力资源管理交流。 表格解读一&#xff1a;员工激励机…

作者头像 李华
网站建设 2026/10/14 13:30:36

AOSP16替换原生hal的broadcastradio-default服务

需求AOSP16自带broadcastradio的hal服务进程&#xff0c;厂商需求是替换为自己开发的broadcastradio服务&#xff0c;并且不能修改原生的编译文件gen5_gvm.mk。 gen5_gvm.mk# BroadcastRadio PRODUCT_PACKAGES \android.hardware.broadcastradio-service.default解决方案在项目…

作者头像 李华
网站建设 2026/10/14 13:30:28

Unity 2020+ Prefab光照烘焙失效原因与修复方案

1. 项目概述&#xff1a;为什么Prefab烘焙突然“不香了”&#xff1f;2020年前后&#xff0c;Unity3d开发者圈里频繁出现一个扎心现象&#xff1a;明明在Scene视图里光影效果完美&#xff0c;一打包成Build或切换到Game视图&#xff0c;模型突然变灰、阴影消失、Lightmap全黑—…

作者头像 李华