ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

C# WebApi实战:原生WebSocket构建即时聊天与连接管理

C# WebApi实战:原生WebSocket构建即时聊天与连接管理

1. 从轮询到长连接:为什么WebApi项目最终都会走到WebSocket这一步

做过WebApi的人大概都有过这样的经历:项目初期用HTTP轮询做消息通知,前端每隔几秒发一次请求问“有没有新消息”。用户量小的时候一切正常,等到同时在线人数上去之后,服务器日志里全是空请求,数据库连接池被无意义的查询占满,响应时间肉眼可见地变长。这时候你才意识到,轮询本质上是一种“低效的妥协”——客户端不知道服务端什么时候有数据,只能靠不停地问来碰运气。

即时聊天场景对实时性的要求比普通通知高得多。消息发出后,对方应该在几百毫秒内看到,而不是等下一次轮询周期。WebSocket协议解决的正是这个问题:它在TCP之上建立一条全双工通道,连接建立后双方都可以主动推送数据,不需要反复握手。对于C# WebApi项目来说,把WebSocket集成进来,意味着你可以在同一个应用里同时提供RESTful接口和实时通信能力,不需要额外维护一套独立的服务。

这篇文章面向的是已经熟悉C#和ASP.NET Core基础、但还没在WebApi项目里落地过WebSocket的开发者。我会从协议握手的细节讲起,把连接管理、消息路由、心跳保活、异常重连这些实际开发中绕不开的问题逐个拆开,最后给出一个可以直接跑通的聊天Demo结构。中间会穿插我在实际项目中踩过的坑和验证过的参数配置,尽量让你少走弯路。

2. WebSocket在ASP.NET Core中的接入方式与选型对比

2.1 三种接入方案的适用边界

在ASP.NET Core里启用WebSocket支持,常见的有三条路:原生System.Net.WebSockets中间件、SignalR、以及第三方库如Fleck或WebSocketSharp。很多人一上来就选SignalR,觉得微软官方推荐的一定没错,但实际选型要看具体需求。

原生WebSocket中间件是最轻量的方案。你调用app.UseWebSockets()注册中间件,然后在Controller或中间件管道里检查HttpContext.WebSockets.IsWebSocketRequest,通过AcceptWebSocketAsync()完成握手。整个过程没有额外的抽象层,消息格式、连接管理、重连逻辑全部由你自己控制。适合需要精细控制协议细节、或者不想引入额外依赖的场景。

SignalR是在WebSocket之上封装了一层更高级的抽象。它自动处理连接协商、传输降级(WebSocket不可用时回退到Server-Sent Events或长轮询)、分组广播、强类型Hub等。如果你的聊天功能需要“房间”概念、需要向特定用户组推送、或者客户端环境复杂(比如有些企业内网屏蔽WebSocket),SignalR能省掉大量样板代码。代价是灵活性降低,调试时多了一层黑盒。

第三方库的优势在于跨平台和API简洁,但在ASP.NET Core项目里引入它们往往是为了兼容旧版.NET Framework,新项目没必要绕这个弯。

方案代码量可控性自动降级适用场景
原生中间件中高无协议定制、学习原理
SignalR低中有快速交付、复杂客户端
第三方库低中视库而定旧项目迁移

2.2 为什么我最终选了原生中间件

我参与过的一个内部协作工具,最初用SignalR做消息推送,后来因为需要自定义二进制消息格式(传输文件缩略图)和精细的连接生命周期管理,切回了原生WebSocket。切换之后代码量确实增加了,但排查问题时链路清晰了很多——消息从哪来、到哪去、在哪个环节丢的,打日志一目了然。

原生方案的核心代码集中在握手阶段。下面这段是典型的接入逻辑:

app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30) }); app.Use(async (context, next) => { if (context.Request.Path == "/ws/chat" && context.WebSockets.IsWebSocketRequest) { var socket = await context.WebSockets.AcceptWebSocketAsync(); await ChatHandler.HandleAsync(socket, context); } else { await next(); } });

KeepAliveInterval这个参数值得单独说。它控制服务器主动发送Ping帧的间隔,默认是2分钟。在聊天场景里,2分钟太长了——如果客户端网络异常断开,服务器要等两分钟才发现,这期间消息会发往一个死连接。我一般设成30秒,配合客户端的心跳响应,能在1分钟内识别掉线。

2.3 握手阶段的隐藏细节

WebSocket握手本质上是一次HTTP Upgrade请求。客户端发送的请求头里包含Sec-WebSocket-Key,服务器需要用它计算Sec-WebSocket-Accept返回。ASP.NET Core的AcceptWebSocketAsync()已经封装了这个过程,但有几个点容易忽略。

第一,握手请求也会经过中间件管道。如果你在UseWebSockets之前放了认证中间件,HttpContext.User在握手时就已经填充好了。这意味着你可以在接受连接之前校验用户身份,把未认证的请求直接挡掉。我见过有项目在握手后才做认证,结果匿名连接占满了连接池。

第二,子协议协商。客户端可以在Sec-WebSocket-Protocol头里声明支持的子协议,服务器通过AcceptWebSocketAsync(subProtocol)选择。聊天场景一般用不上,但如果你的消息格式有版本区分,可以用子协议来标识。

第三,跨域问题。WebSocket不受同源策略限制,但浏览器在握手时会带上Origin头。如果你的WebApi和前端不在同一个域,需要在握手逻辑里校验Origin,否则可能被恶意站点利用。

3. 连接池设计与消息路由:让每条消息找到该去的地方

3.1 用ConcurrentDictionary管理活跃连接

WebSocket连接建立后,服务器需要维护一个“谁在线”的映射表。最直接的做法是用ConcurrentDictionary<string, WebSocket>,key用用户ID,value是socket实例。选ConcurrentDictionary而不是普通Dictionary加锁,是因为聊天场景的读写频率很高——用户上下线、消息转发都要访问这个表,锁竞争会成为瓶颈。

public static class ConnectionManager { private static readonly ConcurrentDictionary<string, WebSocket> _sockets = new(); public static void Add(string userId, WebSocket socket) { _sockets.AddOrUpdate(userId, socket, (key, old) => socket); } public static bool Remove(string userId) { return _sockets.TryRemove(userId, out _); } public static WebSocket? Get(string userId) { return _sockets.TryGetValue(userId, out var socket) ? socket : null; } public static IEnumerable<string> OnlineUsers => _sockets.Keys; }

这里有个细节:同一个用户可能从多个设备登录。上面的实现里,后登录的设备会覆盖先前的socket,导致先登录的设备收不到消息。如果你的产品支持多端同时在线,value应该改成ConcurrentDictionary<string, ConcurrentBag<WebSocket>>或者用连接ID作为key、用户ID作为分组标签。

3.2 消息格式的约定与序列化

WebSocket传输的是帧,帧里可以是文本也可以是二进制。聊天消息通常用JSON文本,方便调试和跨语言。定义一个统一的消息信封:

public class ChatMessage { public string Type { get; set; } // "text", "image", "system" public string From { get; set; } public string To { get; set; } public string Content { get; set; } public long Timestamp { get; set; } }

Type字段用于区分消息类型,后续扩展图片、文件、撤回通知都靠它。Timestamp用Unix毫秒时间戳,避免时区问题。序列化用System.Text.Json,性能比Newtonsoft.Json好,而且ASP.NET Core默认集成。

发送时要注意:WebSocket.SendAsync不是线程安全的。如果多个线程同时向同一个socket写数据,帧会交错导致客户端解析失败。解决办法是在socket实例上挂一个SemaphoreSlim,发送前WaitAsync,发完Release。

private static readonly SemaphoreSlim _sendLock = new(1, 1); public static async Task SendAsync(WebSocket socket, ChatMessage message) { var bytes = JsonSerializer.SerializeToUtf8Bytes(message); await _sendLock.WaitAsync(); try { await socket.SendAsync( new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, CancellationToken.None); } finally { _sendLock.Release(); } }

3.3 接收循环与消息分发

每个连接需要一个独立的接收循环,持续调用ReceiveAsync读取客户端发来的数据。这个循环跑在后台任务里,不能阻塞请求线程。

public static async Task HandleAsync(WebSocket socket, HttpContext context) { var userId = context.User.Identity?.Name ?? Guid.NewGuid().ToString(); ConnectionManager.Add(userId, socket); var buffer = new byte[1024 * 4]; try { while (socket.State == WebSocketState.Open) { var result = await socket.ReceiveAsync( new ArraySegment<byte>(buffer), CancellationToken.None); if (result.MessageType == WebSocketMessageType.Close) { await socket.CloseAsync( WebSocketCloseStatus.NormalClosure, "closed", CancellationToken.None); break; } var json = Encoding.UTF8.GetString(buffer, 0, result.Count); var message = JsonSerializer.Deserialize<ChatMessage>(json); await DispatchAsync(message); } } catch (WebSocketException ex) { // 连接异常断开,记录日志 } finally { ConnectionManager.Remove(userId); } }

缓冲区大小设成4KB是个经验值。太小的会导致大消息分片读取,增加循环次数;太大浪费内存。如果消息可能超过4KB,需要处理result.EndOfMessage为false的情况,把多次读取的结果拼接起来。

DispatchAsync根据消息的To字段查找目标socket并转发。如果目标不在线,可以把消息存入离线队列(数据库或内存队列),等用户上线后推送。

4. 心跳、断线重连与异常处理:让连接真正“稳”下来

4.1 心跳机制的双向设计

WebSocket协议本身有Ping/Pong帧,但浏览器端的WebSocket API不暴露发送Ping帧的接口。所以实际项目中,心跳通常由应用层实现:客户端每隔N秒发送一个{"type":"ping"}文本消息,服务器收到后回复{"type":"pong"}。如果服务器连续M次没收到ping,就判定客户端掉线,主动关闭连接并清理资源。

服务器端的心跳检测可以放在接收循环里,用一个CancellationTokenSource配合定时器:

var lastPing = DateTime.UtcNow; var heartbeatTimer = new Timer(_ => { if ((DateTime.UtcNow - lastPing).TotalSeconds > 90) { socket.Abort(); } }, null, TimeSpan.FromSeconds(30), TimeSpan.FromSeconds(30));

90秒的阈值是这么算的:客户端每30秒发一次ping,允许丢两次,第三次还没收到就判定掉线。这个参数要根据实际网络环境调整,移动端网络切换频繁的话可以放宽到120秒。

4.2 客户端重连的退避策略

网络抖动导致连接断开是常态,客户端必须自动重连。但重连不能太激进——如果服务器刚重启,大量客户端同时重连会造成雪崩。指数退避是标准做法:第一次断开后等1秒重连,失败等2秒,再失败等4秒,上限30秒。

let retryDelay = 1000; const maxDelay = 30000; function connect() { const ws = new WebSocket('wss://your-host/ws/chat'); ws.onclose = () => { setTimeout(() => { retryDelay = Math.min(retryDelay * 2, maxDelay); connect(); }, retryDelay); }; ws.onopen = () => { retryDelay = 1000; // 重置 }; }

重连成功后,客户端需要重新拉取断线期间的消息。这要求服务器端有消息持久化,并且提供“按时间戳拉取”的接口。WebSocket负责实时推送,HTTP接口负责补漏,两者配合才能保证消息不丢。

4.3 常见异常与处理策略

实际运行中遇到的异常五花八门,下面这张表是我整理的高频问题:

异常现象根本原因处理方式
连接建立后立即断开握手响应头不完整检查AcceptWebSocketAsync是否被异常中断
消息发送后客户端收不到多线程并发写socket加发送锁
大量连接后服务器内存飙升缓冲区未释放用ArrayPool复用缓冲区
移动端切后台后连接失效系统回收资源客户端监听visibilitychange主动重连
反向代理后连接超时代理层空闲超时调大代理超时或缩短心跳间隔

反向代理这一项特别容易踩坑。Nginx默认的proxy_read_timeout是60秒,如果心跳间隔大于这个值,代理会主动断开连接。解决办法是在Nginx配置里把proxy_read_timeout和proxy_send_timeout调到300秒以上,同时确保心跳间隔小于代理超时。

5. 从Demo到生产:性能优化与安全加固

5.1 缓冲区复用与背压处理

每个连接分配4KB缓冲区,1000个连接就是4MB,看起来不多。但如果消息量大、GC频繁,内存碎片会成为问题。用ArrayPool<byte>.Shared.Rent(4096)从池里借缓冲区,用完归还,能显著降低GC压力。

背压是另一个容易被忽视的问题。如果服务器发送速度超过客户端接收速度,数据会在服务器端堆积。WebSocket.SendAsync返回的Task完成时只表示数据写入了操作系统缓冲区,不代表客户端收到了。对于聊天场景,可以在应用层加一个待发送队列,队列长度超过阈值时丢弃低优先级消息或断开连接。

5.2 认证与授权在WebSocket中的落地

WebSocket握手是HTTP请求,所以可以复用现有的认证机制。JWT放在Authorization头里,或者放在查询字符串里(浏览器WebSocket API不支持自定义头)。查询字符串方式要注意日志脱敏,避免token泄露。

握手成功后,把用户身份绑定到连接上。后续消息转发时校验发送者是否有权限向目标发送消息。群聊场景还要校验用户是否在群里。这些逻辑和HTTP接口的授权逻辑应该复用同一套服务,不要在两处各写一遍。

5.3 消息持久化与离线推送

聊天记录需要落库。每条消息在转发的同时写入数据库,字段包括发送者、接收者、内容、时间戳、消息ID。消息ID用雪花算法生成,保证全局唯一且有序。

离线推送的策略取决于产品形态。如果是内部工具,用户上线后拉取离线消息即可。如果需要系统级推送,得对接移动端的推送服务,这部分超出WebSocket的范畴,但架构上要预留接口——消息落库后触发一个事件,由独立的推送服务消费。

5.4 压力测试与容量规划

上线前用工具模拟并发连接,观察CPU、内存、网络IO的变化。我一般用websocat或自己写一个C#控制台程序,逐步增加连接数,找到性能拐点。

单台4核8G的服务器,用原生WebSocket中间件,维持5000个活跃连接、每秒处理200条消息,CPU占用在40%左右。这个数据供参考,实际取决于消息大小和处理逻辑复杂度。如果连接数需要上万,考虑用Redis做连接注册中心,把WebSocket服务做成无状态集群,网关层按用户ID做一致性哈希路由。

6. 一个可运行的聊天Demo结构拆解

6.1 项目分层与关键类

Demo按职责分成四层:WebSocketMiddleware负责握手和连接生命周期,ConnectionManager管理在线连接,MessageDispatcher处理消息路由,ChatRepository负责持久化。每层只依赖下一层的接口,方便替换实现。

启动配置里注册WebSocket中间件和依赖注入:

builder.Services.AddSingleton<IConnectionManager, ConnectionManager>(); builder.Services.AddScoped<IMessageDispatcher, MessageDispatcher>(); builder.Services.AddDbContext<ChatDbContext>(opt => opt.UseSqlite("Data Source=chat.db")); var app = builder.Build(); app.UseWebSockets(); app.UseMiddleware<WebSocketMiddleware>(); app.MapControllers(); app.Run();

6.2 前端连接与消息渲染

前端用一个ChatClient类封装WebSocket连接、心跳、重连和消息回调。收到消息后根据Type字段决定渲染成文本气泡、图片还是系统提示。消息列表用虚拟滚动,避免大量DOM节点导致页面卡顿。

class ChatClient { constructor(url) { this.url = url; this.handlers = {}; this.connect(); } connect() { this.ws = new WebSocket(this.url); this.ws.onmessage = (e) => { const msg = JSON.parse(e.data); if (msg.type === 'pong') return; this.handlers[msg.type]?.(msg); }; this.startHeartbeat(); } startHeartbeat() { this.heartbeat = setInterval(() => { this.send({ type: 'ping' }); }, 30000); } }

6.3 部署时的注意事项

如果部署在IIS后面,需要启用WebSocket协议支持(Windows功能里勾选)。IIS默认不支持WebSocket,不开启的话握手会返回400。Linux上用Kestrel直接监听,前面挂Nginx做反向代理,记得配置Upgrade和Connection头透传。

日志要记录连接的建立、关闭、异常断开,以及消息的收发数量。这些指标对排查问题和容量规划至关重要。用Serilog或NLog输出结构化日志,方便后续用ELK分析。

7. 一些实际项目中的经验碎片

连接管理里最容易出问题的是“僵尸连接”——socket状态是Open,但实际已经不可达。光靠State属性判断不够,必须结合心跳超时。我现在的做法是每个连接维护一个LastActiveTime,后台定时扫描,超过阈值就Abort。

消息顺序在单连接内是有保证的,但跨连接不保证。如果业务对顺序敏感(比如聊天记录必须按时间排列),要在客户端按时间戳排序,或者服务器端用单线程队列处理某个会话的消息。

调试WebSocket用浏览器开发者工具的Network面板,筛选WS类型,能看到每一帧的收发内容。Chrome还支持手动发送帧,测试很方便。服务端调试用websocat命令行工具,比写测试代码快得多。

最后说一个关于错误处理的体会:WebSocket的异常往往在断开时才暴露,比如SendAsync抛异常说明连接已经不可用。这时候不要重试发送,直接清理连接、标记用户离线,让客户端走重连流程。重试只会浪费资源,而且大概率还是失败。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进