简介:本资源是一套面向C#开发者的学习实践包,聚焦WebSocketSharp框架在实时通信场景中的完整应用,适用于在线聊天、股票行情推送、多人游戏等双向交互类项目开发。资源包含客户端与服务器双端可运行示例工程(WebSocketSharpClient与WebSocketSharpServer),涵盖连接管理、消息收发、事件处理、SSL配置及自定义行为扩展等核心用法,配套代码结构清晰、注释完备,便于初学者理解协议原理并快速上手实战。压缩包共94个文件,以17个C#源码文件(.cs)为核心,辅以8个动态库(.dll)、6个配置文件(.config)、4个资源文件(.resx)及编译产物(.exe/.pdb/.xml等),整体大小为1.91MB,目录组织规范,支持直接加载VS解决方案运行调试。已有909人学习下载,是掌握C# WebSocket全栈开发的实用入门参考。
1. 不选内置ClientWebSocket的核心理由:一次被API逼疯后的选型复盘
先说个实际场景:前两年我给一套设备数据采集上位机做实时看板,客户端要连服务端推送的WebSocket服务,数据量不大但要求稳定、能断线重连、还要带心跳。第一反应是用.NET自带的ClientWebSocket,结果写起来才发现那个API有多反人类。收数据要维护ArraySegment<byte>、循环读ReceiveAsync、还要自己处理消息边界,服务端一推送二进制帧,ReceiveAsync返回的EndOfMessage判断稍微写错一帧,整个消息就粘包了。最难受的是连接状态没有一个干净的事件通知,OnOpen、OnClose全靠自己用WebSocketState去轮询或绑定回调,写了个半成品就觉得不对劲。
后来换了WebSocketSharp,一个第三方的轻量WebSocket库,整个体验立刻不一样。一套事件驱动的API——OnOpen、OnMessage、OnError、OnClose,收发消息就是Send(string)、Send(byte[]),服务端还能通过WebSocketServer挂载自定义服务。没有繁琐的状态机维护,也没有消息边界的手工计算,对中小型项目、工具型应用、Unity客户端、上位机、IoT网关这类场景来说,属于“装上就能跑”的省心方案。
这篇文章我就围绕着WebSocketSharp的实际用法展开,覆盖客户端、服务端、SSL/TLS、断线重连、心跳保活、二进制消息处理这些在真实项目里躲不开的点。穿插一些我在实际项目中踩过的坑,比如消息分片、线程上下文切换、服务端广播稳定性这类文档里很少写透的细节。如果你也在用C#做实时通信、设备控制、监控面板或者游戏客户端,这篇应该能帮你少走不少弯路。
2. 会话建立前必须做对的几件小事:从NuGet引入到第一个连接跑通
2.1 包引入和运行时环境
WebSocketSharp在NuGet上的包名就叫WebSocketSharp,但这里有个细节很容易坑到人。你搜出来的可能是两个包,一个叫WebSocketSharp,一个叫WebSocketSharp-netstandard。传统.NET Framework项目用前者没问题,但如果你是.NET Core或.NET 5+,直接用前者很可能会在运行时遇到依赖缺失,尤其是System.Text.Encoding.CodePages这类底层程序集加载失败,报的错就像热搜词里那个著名的“无法加载一个或多个请求的类型,检索LoaderExceptions属性”一样让人头大。
我的建议很简单:新项目一律用WebSocketSharp-netstandard,它内部做了标准库兼容处理,.NET Core 3.1到.NET 8都能跑。Unity项目如果包管理器不方便,也可以直接把源码拷进Assets里用,它不依赖IL2CPP的额外特殊处理,这点我实测过。
安装命令:
dotnet add package WebSocketSharp-netstandard或者Visual Studio的NuGet包管理器里搜索WebSocketSharp-netstandard,安装最新稳定版即可。
2.2 最小可用客户端:三分钟跑通一条消息
安装完包,一个能和服务端对话的最小客户端长这样:
using WebSocketSharp; using (var ws = new WebSocket("ws://localhost:8080/socket")) { ws.OnMessage += (sender, e) => { Console.WriteLine($"收到消息: {e.Data}"); }; ws.Connect(); ws.Send("你好,服务端"); Console.ReadLine(); }这段代码做了三件事:创建连接对象、注册消息回调、主动发一条文本消息。很多人第一次用这个库,会习惯性地找ConnectAsync,但实际上这个库的Connect()内部是阻塞式握手,在UI线程里调用会卡界面,最好放到后台线程或配合异步包装。如果你的业务需要异步风格,可以自己包一层Task.Run,或者用ConnectAsync()的扩展,但官方核心API是同步的,这是它的设计取向。
握手成功后,ws.IsAlive会变为true,ReadyState变为WebSocketState.Open。如果连接失败,OnError会先触发,然后OnClose也会触发,所以错误处理最好同时挂这两个事件,避免漏掉状态。
2.3 带Headers、Cookie和自定义协议的握手配置
实际项目里WebSocket服务往往不止一个裸连接,经常要带token鉴权、子协议协商、自定义Header。构造WebSocket对象时,这些都可以通过设置属性完成:
var ws = new WebSocket("wss://api.example.com/socket") { Origin = "https://client.example.com", // 自定义请求头,有些服务端用它来传token }; ws.SetCookie(new Cookie("session_id", "abc123")); ws.SetHeader("X-Auth-Token", "token-value"); // 如果服务端要求子协议,比如 graphql-ws、mqtt over ws ws.Protocol = "graphql-ws"; ws.OnMessage += (sender, e) => Console.WriteLine(e.Data); ws.Connect();这里补充一个容易忽略的点:在WebSocket握手阶段,浏览器端是不允许手动设置Origin之外的Header的,但WebSocketSharp是原生客户端,没有这个限制。你可以在握手时传任何服务端认可的Header、Cookie,这对接那些需要鉴权的私有协议非常有用。换句话说,这其实比浏览器灵活得多,服务端在检测到非法来源时也可以通过Origin做拦截,所以开发时如果要模拟浏览器环境,千万别漏掉这一项。
2.4 URL格式与wss的默认端口坑
构造WebSocket时,URL如果写错,不会在构造阶段报错,而是会在Connect()时触发OnError。最常见的错误是:
- 漏掉协议前缀,直接写
localhost:8080/socket,以为库会帮你补全。实际它会抛ArgumentException,提示URL格式错误。 - 把
ws和wss写反,或者wss使用了非443端口时没有追加端口号。 - 路径写错。服务端如果路由是
/socket,你连/也会握手失败,因为服务端找不到对应的处理服务。
这一类错误的手感是“看起来代码没问题,但服务端一直收不到”,排查时优先看URL字符串实际拼接出来了什么,不要在脑子里脑补。
3. 客户端高频操作的完整拆解:Send、广播接收和连接控制
3.1 发送文本和二进制:不止是传字符串
Send有若干重载,最常用的是Send(string)和Send(byte[])。文本消息走UTF-8编码,二进制消息会以Opcode.Binary发出。服务端用JavaScript的WebSocket接收时,如果要区分文本和二进制,可以通过event.data的typeof判断,如果是Blob或ArrayBuffer就是二进制帧。
在实际的上位机和硬件通信场景里,设备端往往直接下发二进制协议,比如帧头、命令字、长度、数据体、校验位。这时候直接构造byte[]发出去即可:
byte[] frame = new byte[] { 0xAA, 0x55, // 帧头 0x01, // 命令字 0x00, 0x1E, // 数据长度 30 // ... 数据体 0x00, // 校验位占位 }; ws.Send(frame);这里有一个WebSocket协议层面的机制要提一下:WebSocket允许消息分片发送。如果你调用Send时传入的是一个很大的byte[],比如几十MB,WebSocketSharp内部会根据MaxPayloadSize等配置决定是否分片,接收端会自动重组,应用层感知不到分片过程。这个机制保证了任何合法的消息都能完整送达,但也意味着接收端在消息到达前会占用一定内存缓冲,所以稍后讲服务端时我会专门提一嘴如何控制最大消息体。
3.2 用SendAsync和队列避免并发发送的隐性问题
WebSocketSharp的同步Send在单线程场景下很省心,但在多线程场景下要小心——它的底层WebSocket发送通道并不是无限制并发安全的。如果两个线程同时调用Send,虽然底层有锁,但高频发送下会出现“发送顺序不确定”的风险。比如你用生产者-消费者模式,两个工作线程分别发送不同类型的数据,服务端接收顺序可能和入队顺序不一致,这在协议对时序有要求的场景下非常致命。
我的做法是:发送统一走队列。一个独立的后台线程从ConcurrentQueue<byte[]>中取出消息依次调用Send,或者用SendAsync加回调来保证串行化。
private readonly ConcurrentQueue<byte[]> _sendQueue = new(); private bool _sending; private void EnqueueSend(byte[] data) { _sendQueue.Enqueue(data); if (_sending) return; _sending = true; SendNextFromQueue(); } private void SendNextFromQueue() { if (_sendQueue.TryDequeue(out var data)) { ws.SendAsync(data, success => { if (!success) { // 记录发送失败日志,考虑重试或标记连接异常 } SendNextFromQueue(); }); } else { _sending = false; } }这段代码的关键点是:同一时刻只允许一个SendAsync在飞,回调里再拉取下一条,既保证了顺序,又不会让发送线程池炸掉。核心业务里千万不要看到有SendAsync就直接无脑调用,异步并不等于安全,回填队列的顺序问题才是隐藏在表面之下的雷。
3.3 OnMessage里拿到的究竟是文本还是字节:别让类型判断坑了你
OnMessage事件的MessageEventArgs里有两个常用成员:e.Data(字符串)和e.RawData(byte[])。很多人以为文本和二进制都能从e.Data拿,其实不是。当服务端发来二进制帧时,e.Data内部会尝试从RawData按UTF-8解码成字符串,如果二进制内容不是合法的UTF-8序列,e.Data可能得到乱码或者触发解码异常。
正确做法是用一个开关判断消息类型:
ws.OnMessage += (sender, e) => { if (e.IsText) { // 文本消息 string text = e.Data; HandleText(text); } else if (e.IsBinary) { // 二进制消息 byte[] raw = e.RawData; HandleBinary(raw); } };这个判断逻辑在混合协议(一条连接既传JSON指令又传文件流)中非常关键。不要偷懒统一走e.Data,不然等你调试到某个特殊字节时,乱码问题会让你怀疑人生。
3.4 Ping/Pong心跳机制:服务器为什么会掐掉你的连接
WebSocket协议本身有Ping和Pong控制帧,WebSocketSharp的WebSocket对象内部实现了自动响应Ping再回Pong的逻辑。也就是说,如果服务端主动发Ping,库会自动回Pong,不需要你手动处理。但很多场景下服务端并不会主动发Ping,它只是默默等待客户端的数据,一旦超过空闲超时(比如若干秒没有收到任何帧),服务端会直接断开。这种时候就需要客户端侧主动发心跳保活。
WebSocketSharp没有内置的自动心跳定时器,但实现起来非常简单:
var pingTimer = new System.Timers.Timer(30 * 1000); pingTimer.Elapsed += (sender, e) => { if (ws.IsAlive) { ws.Ping(); } }; pingTimer.Start();ws.Ping()会发一个Ping控制帧,服务端必须回Pong,这个交换能有效刷新服务端的“最后活跃时间”。需要注意,如果网络已经断开但TCP层还没感知,Ping()可能不会立刻报错,因为底层的写缓冲还能“假装成功”。所以判断连接是否真正可用,还是要依赖收到OnClose或OnError事件,不能只靠Ping返回的布尔值断定已断开。
为了更稳妥,可以在Ping返回false时主动断开重连:
if (!ws.Ping()) { Console.WriteLine("心跳失败,准备重连"); ws.Close(); Reconnect(); }4. 服务端:把一个WebSocket服务嵌入现有程序,而不是单独搭网关
4.1 用WebSocketServer挂载自定义服务
WebSocketSharp不只是客户端,它还提供了一个轻量的WebSocketServer,可以在你的进程内直接开一个WebSocket服务端。这非常适合上位机场景——你的采集程序本身就运行在工厂局域网内,没必要为了一个WebSocket服务额外部署Node.js或者独立的网关程序。
最基本的服务端代码:
var server = new WebSocketServer("ws://0.0.0.0:8080"); server.AddWebSocketService<ChatBehavior>("/chat"); server.Start(); Console.WriteLine("服务已启动,等待连接...");AddWebSocketService<T>里的T必须继承WebSocketBehavior,这个类相当于一个会话处理器,每个客户端连接都会实例化一个行为对象:
public class ChatBehavior : WebSocketBehavior { protected override void OnMessage(MessageEventArgs e) { // 收到客户端消息,e.Data或e.RawData是本次消息的数据 Console.WriteLine($"收到: {e.Data}"); // 单发:回复当前客户端 Send($"服务端回复: {e.Data}"); // 群发:给所有连接到本路径的客户端广播 Sessions.Broadcast($"广播消息: {e.Data}"); } protected override void OnOpen() { Console.WriteLine($"新连接: {ID}"); } protected override void OnClose(CloseEventArgs e) { Console.WriteLine($"连接关闭: {ID}, 原因: {e.Reason}"); } }这里有个细节需要注意:OnMessage里的Sessions.Broadcast是同步向所有连接发消息,当连接数很多或者消息体很大时,Broadcast本身会占用一定时间。如果担心这里阻塞后续消息处理,可以先把广播消息放进队列,由独立线程负责分发。
4.2 服务端向指定客户端发送消息:会话管理
WebSocketBehavior的Sessions属性是一个WebSocketSessionManager,它提供了Broadcast、Close、ActiveIDs等方法。如果需要向特定客户端推送消息,可以在连接建立时记录它的ID,然后在外部通过这个ID发送:
public class DeviceBehavior : WebSocketBehavior { protected override void OnOpen() { DeviceRegistry.Register(ID, this); } protected override void OnClose(CloseEventArgs e) { DeviceRegistry.Unregister(ID); } public void PushData(string data) { Send(data); } }外部调用:
server.WebSocketServices["/device"].Sessions.SendTo("data", deviceId);注意SendTo的第二个参数是会话ID,这个ID是WebSocketSharp内部生成的字符串,每次连接都会不同。如果你在自己的业务协议里有设备ID,需要自己维护“设备ID到会话ID”的映射。别指望库帮你做设备鉴权和映射,这一层业务语义必须由应用层实现。
4.3 服务端安全边界:限制消息大小和端口监听范围
我见过不少直接把WebSocketServer跑在公网服务器上的案例,这算是把这个库用到了它的能力边界之上。它毕竟不是专业的网关服务器,缺少连接数限制、速率限制、请求鉴权框架这些成熟能力。如果你只是内网用,问题不大;如果要暴露到公网,务必在前面加一层Nginx或云负载均衡做WebSocket代理,并开启鉴权。
单就库自身的防护来说,至少要把MaxPayloadSize设置的合理。默认值在某些版本里比较大,可能到几十MB,但如果你只传输设备状态数据,几KB就足够了。
server.MaxPayloadSize = 1024 * 100; // 最大100KB这个限制同时作用于服务端和客户端。当接收到的单条消息超限时,库会直接按协议错误关闭连接。对正常业务来说这是合理的防御性设计,防止恶意连接蹭蹭往你内存里灌大帧。
绑定地址也要注意:如果你只想局域网内访问,绑成ws://192.168.1.50:8080即可;如果绑0.0.0.0,表示监听所有网卡接口,包括可能暴露到公网的那块。
5. 二进制消息处理和粘包:上位机场景最容易翻车的地方
5.1 为什么WebSocket里没有“粘包”问题,但你还是会碰到“半包”
TCP下有粘包/半包问题,但WebSocket协议自身是消息边界清晰的——每一帧都带有长度信息,接收端把完整消息重组后,才会触发OnMessage。这本来是WebSocket的一大优势,但在实际项目中我遇到过一个非常隐蔽的坑:当你用e.RawData处理二进制数据时,如果对方的一个业务数据包非常大,而你在发送端是分段写入的,比如把一个大文件按1KB一段写进同一个Send调用,由于WebSocketSharp默认不会自动合并多次Send调用,服务端会收到多条独立消息。这其实不是WebSocket层面的粘包,而是应用层协议的“分片语义”问题。
处理方式很直接:业务协议要在消息体里自带长度字段,接收端维护一个累积缓冲区,拿到一条消息先读长度,然后判断是否够一条完整指令,不够就继续等下一条。这是应用层设计的范畴,和WebSocket协议无关,但你不做的话,后续排查会特别困难。
5.2 大帧传输的缓存设置:别用默认值直接怼大文件
如果你确实需要通过WebSocket传大文件,或者大尺寸图片,MaxPayloadSize要调大,同时要注意内存占用。假设你允许50MB的单条消息,当收到一条50MB的消息时,RawData会完整加载到内存,50MB×并发连接数,内存会迅速攀升。对于上位机这种7x24小时跑的程序,建议对大文件走“分片+确认”的应用层协议,而不是一次性怼一个大帧。这也是我经历的教训:最初为了省事直接传大图,几个客户端同时传图,内存涨了几百MB,最后把进程给拖垮了。
5.3 二进制协议解析的一个小模板
下面是一个简化版的协议解析器,适用于“帧头+长度+数据体”这种最常见的设备协议:
public class BinaryFrameParser { private readonly byte[] _buffer = new byte[1024 * 1024]; private int _bufferLength; private int _expectedLength = -1; public void Append(byte[] data) { Array.Copy(data, 0, _buffer, _bufferLength, data.Length); _bufferLength += data.Length; while (true) { if (_expectedLength == -1) { if (_bufferLength < 4) return; // 至少需要2字节帧头+2字节长度 int frameHeader = (_buffer[0] << 8) | _buffer[1]; if (frameHeader != 0xAA55) throw new InvalidDataException("帧头错误"); _expectedLength = (_buffer[2] << 8) | _buffer[3]; } if (_bufferLength < 4 + _expectedLength) return; // 数据不完整,继续等 byte[] payload = new byte[_expectedLength]; Array.Copy(_buffer, 4, payload, 0, _expectedLength); // 处理完整帧 ProcessFrame(payload); int consumed = 4 + _expectedLength; Array.Copy(_buffer, consumed, _buffer, 0, _bufferLength - consumed); _bufferLength -= consumed; _expectedLength = -1; } } }这个代码的重点是while (true)循环——因为一次OnMessage可能携带多条完整业务帧,也可能只携带半条。通过循环消费掉所有完整帧,剩下不完整的留在缓冲区内等下一段数据,这个模式在任何流式协议处理里都通用。
6. WSS、代理和网络环境异常:真实部署比Demo多出来的那些事
6.1 服务端配置WSS:自签名证书和客户端跳过校验
WebSocketSharp做WSS服务端时,需要配置SSL证书。最简单的思路是用HttpListener或ServicePointManager去加载证书。WebSocketSharp自带的方式是通过SslConfiguration:
var server = new WebSocketServer("wss://0.0.0.0:4433") { SslConfiguration = { ServerCertificate = new X509Certificate2("cert.pfx", "password"), EnabledSslProtocols = System.Security.Authentication.SslProtocols.Tls12 } }; server.Start();注意证书是pfx格式或者带私钥的证书。如果是自签名证书,客户端那边默认会校验失败,这时候你需要在客户端设置证书校验回调。在WebSocketSharp中:
ws.SslConfiguration.ServerCertificateValidationCallback = (sender, certificate, chain, sslPolicyErrors) => true;这段代码在开发环境很方便,但生产环境千万不要直接return true,这会让你暴露在中间人攻击之下。上线前要换成真正的证书校验逻辑,或者直接使用受信任的CA证书。
6.2 客户端连代理:WebSocketSharp能不能走HTTP代理
如果你的客户端运行在需要通过HTTP代理访问外网的环境中,WebSocketSharp提供了一个设置代理的方法:
ws.SetProxy("http://proxy.example.com:8080", "username", "password");设置后,握手请求会通过代理转发到目标服务器。但有一个地方容易忽略:SetProxy并不会自动对目标URL的host做校验,如果你走的是wss且代理服务器要求额外证书,那么SslConfiguration的校验回调同样生效。实测下来,在部分企业网络下,代理对WebSocket的长期连接支持不好,会静默断掉空闲连接,所以这类场景一定要配合心跳重连来兜底。
6.3 网络环境不好:断线重连的完整状态机
热搜词里出现了“遇见网络环境不好怎么办”,这个在WebSocket场景下几乎是必答题。网络波动、服务器重启、路由器NAT超时,都会让长连接突然断开。设计重连机制时,最忌讳的是无脑Thread.Sleep(1000)循环重连,这样既可能造成服务端连接风暴,又会在网络恢复前疯狂刷错误日志。
推荐的状态机是:
- 连接断开(
OnClose触发)后,不立刻重连,先等待一个基础间隔,比如3秒。 - 每次重连失败,间隔翻倍,封顶比如60秒。指数退避能有效减轻服务端压力。
- 支持“手动重连”按钮或指令,触发后立即重置退避指数。
- 重连成功后重置退避指数,并做一次业务层的同步(比如重新订阅、拉取离线消息)。
简化实现:
private int _retryCount; private Timer _reconnectTimer; private void ScheduleReconnect() { int delay = Math.Min(60, 3 * (int)Math.Pow(2, _retryCount)); _retryCount++; _reconnectTimer = new Timer(_ => { try { ws.Connect(); _retryCount = 0; } catch { ScheduleReconnect(); } }, null, delay * 1000, Timeout.Infinite); }这个写法有一个关键点:_reconnectTimer是一次性timer,回调里如果连接失败会再次调用ScheduleReconnect重新计时。避免用Timer的Period参数做固定间隔重连,因为这样当连接建立后你还得手动停掉timer,逻辑上容易乱。
6.4 UI线程更新:OnMessage回调到底在哪个线程跑
OnMessage事件默认在后台线程触发。如果你在WinForms或WPF里直接在这个回调里操作控件,必然遇到著名的“线程间操作无效”异常。解法有几种:
- WinForms:用
Control.BeginInvoke或SynchronizationContext.Post切回UI线程。 - WPF:同样用
Dispatcher.BeginInvoke。 - 如果你用的是
async/await,可以在构造函数里捕获SynchronizationContext,然后在OnMessage回调里await通过它切换上下文。
实际我会建议:不要在OnMessage里直接处理UI。先把消息体放队列,由UI侧的定时器或Binding机制消费。这样消息回调只做最轻量的事情,避免高频推送时UI卡顿把消息处理线程堵死。这个设计在数据刷新频率超过每秒几十帧时尤其重要。
7. 多线程并发下的稳定性:锁、线程池和消息乱序的应对
7.1 WebSocketSharp的线程模型并不复杂,但需要尊重
WebSocketSharp内部消息接收是单线程的,也就是说OnMessage不会并发进入同一个连接实例。这是一个很好的设计,意味着你不必在回调里加锁保护所有共享状态。但也正因如此,处理耗时操作千万别放在回调里同步执行,否则后续所有消息都会被卡住。比如一条消息需要做数据库写入、图像识别、第三方API调用等耗时操作,应该直接丢到线程池:
ws.OnMessage += (sender, e) => { Task.Run(() => ProcessMessage(e)); };这样做的代价是处理顺序可能乱掉。对顺序敏感的消息,要么用串行任务队列,要么在应用层维护消息序号。
7.2 事件里的异常:OnError和OnClose为什么是最佳拍档
很多人在使用过程中只看OnMessage,一旦连接异常关闭,连个日志都没有。我的习惯是一上来就同时挂上OnError和OnClose,并且把两个事件的触发内容都记到日志里。
ws.OnError += (sender, e) => { Console.WriteLine($"WebSocket错误: {e.Message}"); if (e.Exception != null) { Console.WriteLine(e.Exception); } }; ws.OnClose += (sender, e) => { Console.WriteLine($"连接关闭: {e.Code} {e.Reason}"); };CloseEventArgs中有Code和Reason,Code是WebSocket标准定义的关闭状态码。比如1006表示异常关闭,1000表示正常关闭。如果在日志里看到1006,基本可以断定连接是被底层中断的,而不是主动Close()调用的结果。
7.3 高并发下服务端的垃圾回收压力
当服务端同时挂着几千个连接,而且每个连接都在高频收发消息时,被反复创建和丢弃的byte[]会对GC造成很大压力。可以做的优化是:OnMessage里尽量复用解析用临时对象,不要在回调里频繁new byte[]。另外,如果消息内容需要广播给其他人,最好先做一个对象池,避免每次广播都让每个连接复制一份完整的消息体。Sessions.Broadcast(string)的底层会对每个连接编码一次,数据量很大时要做好心理准备。更高效的做法是直接向Sessions里每个行为实例发预编码的字节数组,虽然修改起来要多写几行代码,但性能差异非常明显。
8. 把连接会话封装成应用层组件:脱离Demo的工程化习惯
8.1 一个可用的心跳+重连封装
前面讲了很多零散的能力,现在组合一下。下面这个ReconnectingWebSocket封装类可以被直接拿去做上位机或后台服务的WebSocket客户端基座:
public class ReconnectingWebSocket : IDisposable { private WebSocket _ws; private readonly string _url; private readonly TimeSpan _heartbeatInterval = TimeSpan.FromSeconds(30); private Timer _heartbeatTimer; private Timer _reconnectTimer; private bool _disposed; private bool _manualClose; public event Action<string> OnTextReceived; public event Action<byte[]> OnBinaryReceived; public event Action OnConnected; public event Action<string> OnClosed; public ReconnectingWebSocket(string url) { _url = url; } public void Start() { _manualClose = false; ConnectInternal(); } private void ConnectInternal() { _ws = new WebSocket(_url); _ws.OnOpen += (s, e) => { OnConnected?.Invoke(); StartHeartbeat(); }; _ws.OnMessage += (s, e) => { if (e.IsText) OnTextReceived?.Invoke(e.Data); else if (e.IsBinary) OnBinaryReceived?.Invoke(e.RawData); }; _ws.OnClose += (s, e) => { StopHeartbeat(); OnClosed?.Invoke($"{(int)e.Code}: {e.Reason}"); if (!_manualClose) ScheduleReconnect(); }; _ws.OnError += (s, e) => { Console.WriteLine($"[WS错误] {e.Message}"); }; _ws.Connect(); } private void StartHeartbeat() { _heartbeatTimer?.Dispose(); _heartbeatTimer = new Timer(_ => { if (_ws != null && _ws.IsAlive) { if (!_ws.Ping()) { Console.WriteLine("[WS] Ping失败,准备重连"); _ws.Close(); } } }, null, _heartbeatInterval, _heartbeatInterval); } private void ScheduleReconnect() { _reconnectTimer?.Dispose(); _reconnectTimer = new Timer(_ => { if (_disposed) return; Console.WriteLine("[WS] 正在重连..."); ConnectInternal(); }, null, 3000, Timeout.Infinite); } public void SendText(string text) { _ws?.Send(text); } public void SendBinary(byte[] data) { _ws?.Send(data); } public void Stop() { _manualClose = true; StopHeartbeat(); if (_ws != null) { _ws.Close(); _ws = null; } } public void Dispose() { _disposed = true; Stop(); _heartbeatTimer?.Dispose(); _reconnectTimer?.Dispose(); } }使用方式很简单:创建实例、订阅事件、调用Start()、发送时用SendText或SendBinary。这个封装把心跳、断线重连、事件分发都收敛到了一个类里,业务层不需要关心底层WebSocket状态变化,你随时可以调整心跳间隔和重连策略而不影响上层逻辑。
8.2 消息序列化层的选择:JSON、MessagePack还是自定义二进制
文本消息用System.Text.Json序列化是绝大多数项目的选择,但要注意WebSocketSharp默认使用UTF-8编码文本,JsonSerializer.Serialize默认也是UTF-8,所以一般不会遇到编码问题。如果对性能有更极致的追求,MessagePack是更好的选择,它不仅是二进制格式,而且序列化/反序列化速度远超JSON,体积也更小。
上位机或者IoT场景,我更倾向于直接在协议层用自定义二进制帧。核心原因是设备协议往往有固定的字节布局,用JSON表达反而别扭。但如果你在开发类似内部聊天、通知面板这类应用,JSON的调试效率远高于二进制。总结一句话:协议选型没有银弹,看团队的调试习惯和业务场景。
8.3 测试时的利器:用Node.js或Python做联调客户端
C#客户端写完了,不代表服务端也一定正确。我在联调阶段经常先用Node.js或Python快速验证服务端行为。比如用Python的websocket-client库:
import websocket ws = websocket.create_connection("ws://localhost:8080/device") ws.send("hello") print(ws.recv()) ws.close()这样能快速区分是服务端问题还是C#客户端问题。如果你不想引入额外语言,也可以用WebSocketSharp自己写一个临时控制台客户端,省得每次都用同一个有业务逻辑的客户端来调试。
9. 日志排查:把看不见的协议层显性化
9.1 开启内置日志和调试输出
WebSocketSharp内部有Log对象,默认只记录Error级别。调试时你可以把它调整到LogLevel.Trace,它会输出完整的帧收发信息,包括帧类型、长度等,对排查握手失败、消息被截断等问题非常有帮助。
ws.Log.Level = LogLevel.Trace; ws.Log.Output = (data, path) => { Console.WriteLine($"[WS] {data}"); };服务端同理:
server.Log.Level = LogLevel.Trace; server.Log.Output = (data, path) => Console.WriteLine($"[SERVER] {data}");这在生产环境不建议长期开启,Trace级别会输出大量内容,影响性能。
9.2 抓包:看不了的连接问题,用工具看
如果日志查不出问题,下一步就是抓包。Windows下可以用Wireshark,过滤器写tcp.port == 8080就能看到WebSocket握手和帧交互。Linux下可以用tcpdump抓取后导入Wireshark分析。
抓包能直观地看到:握手是否成功、服务端返回的101 Switching Protocols是否出现、TLS握手是否失败、Ping/Pong帧是否按时发出。很多疑难杂症,比如代理层偷偷断开空闲连接、NAT超时、GC卡顿导致的假死,只有抓包才能定位到根因。
9.3 一个典型问题:WSS握手显示"the SSL connection could not be established"
这个报错字面意思是SSL连接建立失败。原因可能是证书不受信任、证书过期、TLS版本不匹配、服务端要求客户端证书但你没提供。排查顺序是:
- 先确认服务器证书本身有效。
- 检查
EnabledSslProtocols是否匹配,比如客户端只开TLS1.2,服务端只支持TLS1.3,也会握手失败。 - 检查
ServerCertificateValidationCallback是否被正确设置。 - 用浏览器直接访问
https://你的域名:端口,看看能否正常建立HTTPS连接,排除证书本身的问题。
这个错误在我经历过的事情里,有一半是证书校验回调没设置,另一半是目标服务器不支持TLS1.2。所以在代码里既要设置ServerCertificateValidationCallback,也要指定SslProtocols为Tls12或更高版本。
10. 一个更稳的框架?坦诚聊聊WebSocketSharp的边界
WebSocketSharp胜在简单、轻量、事件驱动。但如果你需要更完善的生产级框架,可以考虑SignalR(微软官方,支持自动重连、消息压缩、多传输协议)、SuperSocket(偏TCP/UDP层面的网络通信框架)、或者Fleck(同样是轻量WebSocket服务端,API风格类似)。实际项目中:
- 如果是.NET 6+的新项目,且服务端对WebSocket要求很高(比如需要大规模广播、多协议、认证授权),优先考虑
SignalR。它内置了组管理、用户管理、断线重连、背压处理等能力,远超WebSocketSharp自己封装的水平。 - 如果是Unity、上位机、嵌入式网关这类运行环境受限、只做轻量通信的场景,
WebSocketSharp完全够用,而且你已经在用它了,没必要为了升级而升级。 - 如果客户端和服务端都是C#,但你想省掉WebSocket这一层,直接用TCP或
SignalR会更方便。WebSocket的价值在于跨平台、跨语言,比如前端浏览器直接连接。
我的态度是:框架是手段,业务是目的。搞清楚你的连接规模、消息频率、断线容忍度、团队技能栈,再选型,比单方面追求“更重的框架”要靠谱得多。WebSocketSharp在中小规模项目中,提供了远超内置API的开发效率和足够稳定的表现,这是它仍然值得推荐的原因。
11. 我的实际项目收尾体会
上面这些内容,基本都是从真实项目里一个个坑爬出来的。如果你要走一条比较顺的路,我的建议是:
第一,先把心跳重连做成标配功能,不要等到线上断了再补。所有长连接系统,网络环境很少有完全可靠的,哪怕是局域网,防火墙NAT超时也可能悄悄断掉你。
第二,协议层尽早确定是文本还是二进制,并写清楚消息边界和长度规则。WebSocket虽然帮你解决了TCP层粘包,但应用层的“半条业务消息”问题依然要靠自己处理。
第三,任何一个OnMessage回调里,都不要直接执行耗时的长任务。先用日志记录消息到达时间,再异步处理业务逻辑,这样排查问题的时候才能区分是消息没到,还是到了但处理慢。
第四,多线程发送一律走队列。不要为了省事直接裸调Send,顺序错乱引发的线上排障成本远超你写队列的那点时间。
第五,把OnError和OnClose日志级别调成可见,才不至于在连接莫名其妙断开时两眼一抹黑。
我在项目里用这套WebSocketSharp封装跑了接近一年,压力不算特别大,峰值同时在线也就几百个连接,每秒消息量几十条,一直很稳定。对它性能和功能的预期,只要定位准确,它不会让你失望。
本文还有配套的精品资源,点击获取