☰
.NET WebSocket 实战指南:服务端、客户端、部署与避坑
2026/10/12 4:07:25 网站建设 项目流程

简介:基于.NET Framework 4.5 以上版本的 WinForms WebSocket 通信示例资源包,面向需要在桌面应用里集成实时双向通信的 .NET 开发者,帮助理解客户端、服务器端以及网页测试端三方的完整交互链路。压缩包共 174 个文件,大小约 984KB,包含 47 个 C# 源码文件、12 个已编译的 EXE、8 个 DLL 动态库,以及 config、resx、resources、HTML 等配置与资源文件,覆盖 WinForm 服务端、WinForm 客户端和网页测试三个可运行的工程。已有 453 人学习下载。资源内含可直接运行的 VS 解决方案,演示了 ClientWebSocket 建立连接、HttpListener 接受请求、AcceptWebSocketAsync 升级协议,以及网页端通过 JavaScript WebSocket API 收发消息的完整流程;同时展示了异步任务处理、UI 线程安全、异常与连接关闭等实战细节,并附带编译缓存与项目配置,适合对照代码逐步学习并快速改造到自己的实时聊天、数据推送等场景中。

1. 一个工单系统的实时通知需求,如何改变我对 .NET WebSocket 的看法

接到一个内部工单系统的实时通知需求时,我最开始用的是 HTTP 轮询。每三秒拉一次未读数量,客户端简单,服务端也没压力。等用户量上来后才意识到:三秒一次的无效请求堆积起来,数据库和网关都在为不存在的新消息买单。换 .NET WebSocket 之后,连接从轮询变成推送,服务端只在真正有新工单时才发数据,整体负载降了一个量级。这个标题要解决的问题就是:在 .NET 里用 WebSocket 做双向实时通信,怎么把服务端、客户端、部署、断线重连整套链路跑通。适合正在做实时推送、在线状态、协同编辑这类功能的后端开发者,以及对 SignalR 有犹豫、想先看看原生方案够不够用的工程师。我会从协议原理、服务端实现、客户端接入讲到几个最容易翻车的细节,最后给一套可以直接落地的连接管理思路。

2. WebSocket 协议要点与 .NET 选型:为什么原生实现足够大多数场景

2.1 握手与帧:WebSocket 真正做的事

WebSocket 并不是凭空出现的新的传输层协议,它借用 HTTP 的 80/443 端口完成一次握手,随后将连接升级为双向帧通信。握手的核心是一个 Upgrade 请求头,服务端确认后返回 101 状态码,后续数据不再走 HTTP 语义,而是走 WebSocket 帧。理解这一点很重要:它意味着 WebSocket 服务天然可以复用已有的 HTTP 服务、端口、证书体系和反向代理基础设施,不需要另开监听端口。

一次握手请求长这样:

GET /ws HTTP/1.1 Host: localhost:5210 Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ== Sec-WebSocket-Version: 13 Origin: http://localhost:3000

服务端收到后,会用Sec-WebSocket-Key拼接固定 GUID 做 SHA-1 摘要,把结果作为Sec-WebSocket-Accept返回。这个计算是协议规定的,保证服务端确实支持 WebSocket。如果响应头不对,浏览器或客户端会直接报握手失败。在 .NET 中AcceptWebSocketAsync已经封装了这一步,开发者不需要自己算摘要,但看日志时要能认出握手失败的根源在服务端还是客户端。

连接建立后,数据以帧为单位传输。帧结构里值得关注的几个字段:FIN 表示这是不是消息的最后一帧,Opcode 表示文本、二进制、关闭、Ping、Pong 等类型,Mask 表示客户端到服务端的帧是否加了掩码。协议强制要求:客户端发给服务端的帧必须加掩码,服务端发给客户端的帧不允许加掩码。这个不对称设计是为了防止早期浏览器被恶意脚本劫持后通过伪造 WebSocket 帧污染缓存,细节不用深究,但在抓包排查时看到客户端帧带 Mask 而服务端帧不带,是正常现象,别当成 bug。

2.2 原生 vs SignalR:什么时候该用 SignalR

.NET 生态里做 WebSocket,绕不开的问题是:用原生System.Net.WebSockets还是用 SignalR。SignalR 本质上是基于 WebSocket 的封装层,同时还提供了长轮询和 Server-Sent Events 作为回退方案,并内置了自动重连、分组、RPC 调用模型。看起来 SignalR 是更省事的选择,但它也有代价:引入了一整套消息协议和传输抽象的复杂度,排查问题时需要先搞懂它的回调机制和连接状态机。

我的选型标准很简单,用一张表说清楚:

场景原生 WebSocketSignalR
纯消息推送,双向 JSON 文本对话足够,代码更少偏重
需要自动重连自己实现内置
服务端主动调用客户端方法自己约定消息类型内置 RPC
需要兼容老旧浏览器不支持支持
二进制流、大文件分片原生更方便封装后反而受限

如果项目是内部系统,用户都用现代浏览器,消息交互主要是 JSON 文本帧,原生 WebSocket 完全够用。需要自动重连和复杂 RPC 再换 SignalR。SignalR 确实能省事,但遇到协议问题、灰度发布中的版本兼容问题,排查成本会更高。另一个常见做法是先接原生 WebSocket,后续需要 SignalR 的高级能力时把连接层替换掉,业务层只依赖自己的消息接口,这个替换成本并不大。

2.3 .NET 里的 WebSocket 抽象:它们分别解决什么问题

在 .NET 中区分三个抽象就能把这门技术用顺:服务端的System.Net.WebSockets.WebSocket,客户端的ClientWebSocket,以及接收结果WebSocketReceiveResult。WebSocket是服务端从握手成功后拿到的连接对象,负责收发帧和关闭连接;ClientWebSocket是客户端主动发起连接的工具,它内部处理了握手、掩码、心跳等琐碎细节,对外暴露的接口与服务端 WebSocket 保持对称。

WebSocketReceiveResult是接收循环里每次读取的结果,它告诉你当前帧的消息类型是文本还是二进制,以及EndOfMessage是否为 true。这个字段特别容易忽略:它表示的是一整条应用消息是否已经读完,而不是当前这次ReceiveAsync是否收到数据。如果消息体超过接收缓冲区大小,一次ReceiveAsync只能返回一部分,需要循环读取直到EndOfMessage为 true,才能拼出一整条消息。很多排查半天最后发现消息被截断的问题,根源就在这里。

3. 服务端实现:最小可运行 WebSocket 服务器与三个关键配置项

3.1 用命令创建最小 Web API 并启用 WebSocket

新建一个空的 ASP.NET Core Web 项目,不需要额外安装 NuGet 包,WebSocket 中间件已经包含在框架里。这个前提值得强调:原生 WebSocket 不是独立库,app.UseWebSockets()来自Microsoft.AspNetCore.WebSockets,它随 ASP.NET Core 共享框架分发,dotnet add package都不需要执行。

dotnet new web -n WebSocketDemo cd WebSocketDemo dotnet run

项目创建后,Program.cs里需要显式调用UseWebSockets()中间件,并在路由里判断IsWebSocketRequest。不调用UseWebSockets()时,请求来到AcceptWebSocketAsync会直接抛异常。这个顺序也是常见的坑:UseWebSockets()要放在UseRouting()之后、终端路由之前,确保路由匹配到Map处理函数时,WebSocket 中间件已经完成了对 Upgrade 请求的校验。

var builder = WebApplication.CreateBuilder(args); var app = builder.Build(); app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30), ReceiveBufferSize = 4 * 1024 }); app.Map("/ws", async context => { if (!context.WebSockets.IsWebSocketRequest) { context.Response.StatusCode = 400; return; } using var socket = await context.WebSockets.AcceptWebSocketAsync(); var buffer = new byte[4 * 1024]; 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, "bye", CancellationToken.None); break; } var text = Encoding.UTF8.GetString(buffer, 0, result.Count); Console.WriteLine($"recv: {text}"); var echoBytes = Encoding.UTF8.GetBytes($"echo: {text}"); await socket.SendAsync( new ArraySegment<byte>(echoBytes), WebSocketMessageType.Text, true, CancellationToken.None); } }); app.Run();

这段代码逻辑很简单:收到文本帧后原样加上前缀回显。ReceiveAsync的第一个参数是缓冲区,result.Count是本次实际收到的字节数;SendAsync的第三个参数true表示这是消息的最后一帧,也就是 FIN 置位。如果业务上有大消息需要分帧发送,这里就要传false,并在下一次发送时补传剩余数据。

3.2 接收循环的完整姿势:处理分片消息与半关闭状态

上面的示例代码只适合消息不超过 4KB 的场景。实际业务中一个 JSON 消息可能超过缓冲区大小,这时候ReceiveAsync会分段返回,需要拼装完整消息后再走业务逻辑。另一个更隐蔽的问题是:ReceiveAsync是一个长期占用的异步操作,它既承担检测关闭帧的职责,又是唯一能触发连接释放的入口。如果收到的是 Ping 帧,底层框架会自动回复 Pong,业务代码无需干预。

处理分段消息的循环骨架:

using var ms = new MemoryStream(); WebSocketReceiveResult result; do { result = await socket.ReceiveAsync( new ArraySegment<byte>(buffer), CancellationToken.None); if (result.MessageType == WebSocketMessageType.Close) { await socket.CloseAsync( WebSocketCloseStatus.NormalClosure, "bye", CancellationToken.None); return; } ms.Write(buffer, 0, result.Count); } while (!result.EndOfMessage); var message = Encoding.UTF8.GetString(ms.ToArray());

这里把while (socket.State == WebSocketState.Open)改成了do...while (!result.EndOfMessage)的处理方式,因为单次ReceiveAsync返回的EndOfMessage才决定一条应用消息的边界。收到 Close 帧时要先回一个 Close 帧完成关闭握手,再退出循环。此时连接才算真正优雅关闭,底层 TCP 连接由框架负责释放。

3.3 WebSocketOptions 的三个关键参数

KeepAliveInterval、ReceiveBufferSize、AllowedOrigins是服务端最值得关注的三个配置项。KeepAliveInterval默认 60 秒,作用是服务端主动发送 Ping 帧来探测连接是否仍然存活。如果对端已经异常消失但 TCP 层没有触发 RST,心跳帧是唯一能发现死连接的机制。内网高实时场景可以缩短到 30 秒甚至 15 秒,但注意 Ping 帧本身会占用带宽和 CPU,缩短心跳间隔不要让服务端在大规模连接时变成广播风暴。

ReceiveBufferSize默认是 4KB。它决定单次ReceiveAsync的最大读取量,并不是消息上限,但它会影响大消息的分片次数和内存分配。如果需要频繁接收大消息,可以把缓冲区扩大到 16KB 或 32KB,减少分段循环次数。这个值要在WebSocketOptions和服务端循环里的byte[]保持一致。

AllowedOrigins是浏览器跨域安全校验的关键。浏览器发起 WebSocket 时会在握手头里带Origin字段,服务端可以在这里配置允许的来源列表。注意:AllowedOrigins只在浏览器客户端场景生效,控制台、移动端原生客户端不受这个限制。如果配置了白名单但ClientWebSocket接入时被拒绝,就别往这个方向排查了。

app.UseWebSockets(new WebSocketOptions { KeepAliveInterval = TimeSpan.FromSeconds(30), ReceiveBufferSize = 16 * 1024, AllowedOrigins = { "http://localhost:3000", "https://internal.example.com" } });

允许的来源列表按具体部署环境收紧,*通配符虽然能写但不要用。内部系统通常只有固定的前端入口,白名单写死比依赖 CORS 策略更省心。

4. 客户端接入:控制台 ClientWebSocket 与浏览器端的双端对照

4.1 ClientWebSocket 连接代码:连接、发送、接收三件套

服务端写好后,客户端是整个链路最容易出问题的一环。很多开发者只写服务端,联调时用浏览器页面顶一下,等真正要接控制台程序、后台任务或移动端时才发现ClientWebSocket的行为和服务端不太一样。用ClientWebSocket做客户端有三件事要记住:先ConnectAsync完成握手,再SendAsync发数据,最后CloseAsync收尾。任何一步顺序错了,都会抛出运行时异常。

using System.Net.WebSockets; using System.Text; using var client = new ClientWebSocket(); var cts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); try { await client.ConnectAsync(new Uri("ws://127.0.0.1:5210/ws"), cts.Token); Console.WriteLine($"state: {client.State}"); var sendText = "{\"type\":\"ping\"}"; var sendBytes = Encoding.UTF8.GetBytes(sendText); await client.SendAsync( new ArraySegment<byte>(sendBytes), WebSocketMessageType.Text, true, CancellationToken.None); var buffer = new byte[4 * 1024]; var receiveResult = await client.ReceiveAsync( new ArraySegment<byte>(buffer), CancellationToken.None); var message = Encoding.UTF8.GetString(buffer, 0, receiveResult.Count); Console.WriteLine($"recv: {message}"); await client.CloseAsync( WebSocketCloseStatus.NormalClosure, "done", CancellationToken.None); } catch (Exception ex) { Console.WriteLine(ex); }

ConnectAsync的第二个参数是超时控制,超过 10 秒会抛出OperationCanceledException,避免 DNS 解析或网络黑洞把进程挂死。SendAsync和ReceiveAsync的最后一个参数同样是CancellationToken,建议各传一个独立的超时令牌,不要复用连接令牌,否则连接超时断开会同时取消正在进行的收发。CloseAsync会先发送 Close 帧并等待对端回应,如果对端不回应,它会卡住直到超时,所以关闭流程也要配令牌。

4.2 浏览器端调用:CORS、Origin 与自动掩码

浏览器端是 WebSocket 客户端最典型的形态,但它有两个客户端特有的机制:自动携带Origin头做跨域校验,以及所有发往服务端的帧自动加掩码。服务端无需关心掩码处理,握手时校验Origin即可。浏览器 WebSocket 对象没有独立的连接状态确认回调,所有交互都通过事件触发。

const ws = new WebSocket('ws://127.0.0.1:5210/ws'); ws.addEventListener('open', () => { console.log('connection open'); ws.send(JSON.stringify({ type: 'ping' })); }); ws.addEventListener('message', (event) => { console.log('recv:', event.data); }); ws.addEventListener('close', (event) => { console.log('closed:', event.code, event.reason); }); ws.addEventListener('error', (event) => { console.error('error:', event); });

浏览器端send()只能传字符串、ArrayBuffer、Blob或ArrayBufferView,不要传普通对象,需要先JSON.stringify。event.data的格式取决于服务端发的消息类型:文本帧对应字符串,二进制帧对应Blob或ArrayBuffer。联调时最容易出现的现象是:服务端收到了消息,但前端收不到回包;或者前端能收到但不解析。建议第一版客户端先全部使用 JSON 字符串,不要混用二进制,等协议稳定后再按需切换。

4.3 双端消息格式约定:JSON 文本帧的编码与结构

WebSocket 协议本身不关心消息内容,文本帧的编码统一为 UTF-8。.NET 端字符串转字节数组用Encoding.UTF8.GetBytes,反之用GetString,不要用Encoding.Default,否则中文在跨平台部署时会出现乱码。这是最容易查半天代码最后发现是编码问题的坑。

消息结构上,建议第一版就统一成带类型字段的 JSON:

{ "type": "message/read", "payload": { "id": 1001, "readAt": "2025-01-01T10:00:00Z" } }

type字段决定业务分发逻辑,payload放具体数据。服务端收到消息后,先解析出type,再决定丢给哪个处理函数。这比直接发裸 JSON 数组或者只发字符串需要传参拼接的方式好维护得多,后续升级协议时只加type枚举值,不用改传输层。

5. .NET WebSocket 避坑指南:四个最容易踩的坑与处理顺序

5.1 连接刚建立就发数据,抛“The WebSocket is not connected”

现象:客户端ConnectAsync返回后立刻调用SendAsync,抛WebSocketException,提示The WebSocket is not connected。服务端日志显示握手已经成功。

原因:ConnectAsync返回只能说明客户端已完成握手请求的发送,不保证服务端已经处理完 Upgrade 请求并进入可收发状态。在快速重连或高并发建立连接时,这个空窗期经常出现。很多开发者以为await返回就万事大吉,实际这个 async 方法返回时连接状态才刚进入Connecting,立刻 Send 就翻车。

解决:发送前检查client.State == WebSocketState.Open,不是 Open 就等待一个极短的延迟重试,或者直接利用重连机制重新建立连接。更可靠的写法是确保 ConnectAsync 之后没有立即顺延到业务发送,而是先做一个心跳确认,确认收到服务端 Pong 再发业务消息。

5.2 关闭时 CloseAsync 不返回,进程退出卡住

现象:程序执行到最后一行CloseAsync,控制台一直不退,挂在那里十几秒后由超时令牌强制打断。

原因:CloseAsync需要和服务端完成一次 Close 帧交换。如果服务端还挂着一个正在等待数据的ReceiveAsync调用,它会抢先收到连接关闭消息,但服务端代码里没有处理 Close 帧的逻辑,就不会回复 Close 响应,客户端这边就永远在等。

解决:关闭客户端之前,确保服务端接收循环进入了处理 Close 帧的分支。服务端的while循环里收到 Close 消息后必须调用CloseAsync响应。然后再考虑客户端这一侧:用CancellationToken给CloseAsync加超时兜底,不能裸调不传参数。这是血泪经验:所有涉及网络收发的关闭操作,都要配超时。

5.3 部署到反向代理后握手失败:升级请求头被吞

现象:本机直接跑服务端,客户端正常连接。部署到服务器后,浏览器连不上,网络面板显示握手失败或 502。

原因:反向代理默认不转发 WebSocket 的Upgrade头,客户端发送的握手请求被代理当成普通 HTTP 请求发给服务端,服务端判断IsWebSocketRequest为 false,返回 400。另一个相关原因是代理启用了响应缓冲,把 101 升级响应暂存在缓冲区里,客户端迟迟收不到升级确认,超时断开。

解决:在反向代理配置里显式开启 WebSocket 支持。以常见网关配置为例:

location /ws { proxy_pass http://backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; }

Connection: upgrade是握手的关键,没有它代理就不会把后续请求切换到隧道模式。注意proxy_read_timeout也需要调大,否则长时间空闲的连接会被代理主动断开,表现为前端过一段时间就收到 close 事件而没有任何错误提示。部署后第一件事就是用抓包工具或者服务端日志确认升级请求头是否穿过整条链路到了应用层。

5.4 静默断线:空闲超时与心跳缺失

现象:连接刚建立时一切正常,过了几分钟后不再收到任何消息,但前端close事件一直不触发。点一下页面又能恢复,或者过很久才报错。

原因:网络链路中的某个中间设备设置了空闲连接超时,通常是 60 秒或 120 秒没有任何数据就静默掐断 TCP 连接。而 TCP 断开本身不会主动通知应用层,只有当真的写数据到已断开的 socket 时才会报错。由于服务端和客户端都没有数据流动,双方都不知道连接已经死了。

解决:必须在应用层加心跳。心跳有两条路:服务端发 Ping 帧,客户端回复 Pong 帧,这是协议内置机制;或者业务层约定一个心跳消息类型,定时互相发送。两条路选一条就行。要注意:单独开一个Timer发送心跳,要确保它不会和正在执行的ReceiveAsync并发操作同一个 socket 句柄,否则会抛“并发操作不支持”的异常。常见做法是心跳定时器只负责发送,接收循环统一处理 Pong 消息并更新最后活跃时间。

6. 进阶:用连接管理做好广播与缩扩容,最后悔没早做的一件事

6.1 一个 ConcurrentDictionary 撑起的连接中心

单连接回显只是起步,真实系统需要管理大量连接,并支持向指定用户或全量广播。用一个静态的连接中心字典,把WebSocket对象保存下来,配合锁定和遍历广播,就能覆盖大部分内部系统需求。

public sealed class WebSocketConnectionManager { private readonly ConcurrentDictionary<string, WebSocket> _connections = new(); public string Add(WebSocket socket) { var id = Guid.NewGuid().ToString("N"); _connections.TryAdd(id, socket); return id; } public void Remove(string id) { if (_connections.TryRemove(id, out var socket)) { try { socket.Dispose(); } catch { } } } public async Task BroadcastAsync(string message, CancellationToken ct) { var bytes = Encoding.UTF8.GetBytes(message); var deadIds = new List<string>(); foreach (var pair in _connections) { if (pair.Value.State != WebSocketState.Open) { deadIds.Add(pair.Key); continue; } try { await pair.Value.SendAsync( new ArraySegment<byte>(bytes), WebSocketMessageType.Text, true, ct); } catch { deadIds.Add(pair.Key); } } foreach (var id in deadIds) { Remove(id); } } }

Remove里直接Dispose会跳过 Close 帧交换,但对已经断开的连接来说,少一次结束握手关系不大。广播时先跳过非 Open 状态的连接,再在发送异常时记录到deadIds,避免在遍历字典的同时修改集合导致并发异常。这个管理器还应该在每个连接接收循环退出时调用Remove,方法是把连接 Id 作为上下文传入接收循环。

6.2 缩扩容时先通知客户端,再关实例

线上部署多个实例时,发布新版本或缩容节点,如果直接杀进程,所有连接到该实例的 WebSocket 会被 TCP 重置,客户端只能等下一次心跳超时才感知到,少则几十秒多则数分钟。这个平滑性问题在内部系统里常常被忽略,直到有一次发布后同事反馈页面消息断断续续,我才意识到这是服务端主动断开带来的。

正确做法是:收到终止信号后,先向所有连接发送一条业务消息,例如{"type":"server/redirect","payload":{"message":"服务升级"}},然后等待 1 到 2 秒让客户端处理完,再调用CloseAsync完成正常关闭。客户端收到这条消息后主动发起重连,连接到还在运行的实例,整个切换过程对用户无感。这是我最后悔没早做的一件事:总以为 WebSocket 服务的发布和普通 HTTP 服务一样,重启就完事,结果一次发布把在线状态全部打没,影响了正在等实时反馈的同事。后来所有节点发布都走这个流程,再也没出过问题。

这套路线的整体成本并不高:原生 WebSocket 没有引入额外依赖,连接管理用一个字典就能撑起内部系统的规模,心跳和优雅关闭的代码加起来也不到一百行。用好了,它就是 .NET 后端最直接的实时通道;用不好,多半是栽在前面那四个坑上。希望这篇笔记能帮你把该避的坑提前避开,把时间花在真正的业务逻辑上。

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

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

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

立即咨询