
简介这是一份用C#语言实现的WebSocket服务器端源代码示例面向正在学习网络编程、希望掌握浏览器与服务器之间双向实时通信技术的开发者。项目基于System.Net.WebSockets命名空间完整演示了从HTTP升级握手建立连接到接收、发送文本与二进制消息再到多客户端并发管理等核心环节同时附带了聊天业务逻辑和简易客户端可用于验证消息发布、订阅与广播。压缩包共18个文件主体为10个C#源程序文件另有3个工程配置文件、1个解决方案文件以及网页脚本等资源包体仅44KB结构紧凑便于通读与改造。目前已有577人学习下载阅读时还能学到连接安全的注意事项、异常断开的重连处理、心跳保活机制等实用经验能为自主开发实时通信、在线客服等应用打下基础。1. 当上位机需要主动“推”数据为什么C#工程师绕不开WebSocketServer工业上位机、设备数据采集、产线看板这类项目最头疼的不是采集而是“怎么把数据及时送到该看的人手里”。早期做法是客户端定时轮询几秒钟查一次数据库或HTTP接口数据量大了之后数据库连接烧得厉害实时性还卡在轮询间隔上。换用WebSocket之后服务器可以主动把扭矩值、温度、状态位变化直接推给所有订阅端延迟降到百毫秒级连接复用也让端口压力小了一个量级。这套C# WebSocketServer服务器源代码解决的就是这个“主动推送”的问题。它不是市面上那些包一层壳的第三方库而是从TCP Socket层开始自己实现了HTTP Upgrade握手、帧解析、掩码处理、心跳保活和断线重连的完整服务端适合两类人一类是刚接触Socket编程、想搞明白WebSocket协议到底怎么回事的C#开发者另一类是已经在做上位机或物联网网关、需要一个不依赖IIS和ASP.NET Core、能直接嵌进WinForm或Windows服务的轻量通信组件的工程师。核心价值在于你能在这份代码里看到一条完整的落地方案而不是一个调完就忘的黑匣子。2. 跑通最小可用服务先让服务器在本地转起来2.1 拿到代码包之后先看这4个文件源代码包解压之后不要急着按F5先花两分钟把目录结构认清楚。一个规范实现的WebSocketServer项目通常不会把所有逻辑塞进一个文件里而是会区分出几个职责明确的模块。典型的结构是这样的一个入口文件负责启动监听、管理连接生命周期一个协议处理文件专门负责HTTP Upgrade握手和RFC 6455帧解析一个消息路由或事件回调文件把收到的文本帧、二进制帧分发给上层业务逻辑还有一个配置或常量文件存放监听端口、心跳间隔、缓冲区大小这些可调参数。如果你的包里文件比这个多多出来的往往是示例客户端、日志记录器或者压力测试脚本这些不是核心但建议先留着后面调试会用到。第一步先用Visual Studio或dotnet CLI把解决方案编译一下。如果是老式.NET Framework项目装好对应版本的Developer Pack如果是.NET Core/.NET 5项目确认SDK版本不低于项目目标框架。编译报错最多的地方是using缺失和NuGet引用先读一下README或者csproj里的PackageReference缺什么补什么。提示如果项目里引用了外部库但没给packages文件夹编译前先执行dotnet restore不要手贱去删bin和obj目录除非你知道自己要干什么。2.2 用命令行启动并验证TCP监听状态编译通过之后启动服务正常情况下控制台会打印一行日志告诉你WebSocket Server已经启动并监听了某个端口。这时候先别连客户端直接在命令行验证端口是否真的在监听。以管理员身份打开PowerShell或CMD执行netstat -ano | findstr 8080如果看到LISTENING状态说明TCP层已经通了。这一步很重要它能帮你把“程序没起来”和“协议没通”两个问题分开。端口没监听就去查启动日志、防火墙、端口占用端口监听了但客户端连不上才轮到抓包看协议。接下来用浏览器做一个最快的手工验证。在地址栏输入ws://127.0.0.1:8080浏览器会自动发起WebSocket握手注意看服务器控制台有没有输出“客户端已连接”之类的日志。如果没有日志或者握手失败大概率是握手响应没有按RFC 6455的格式返回。// 控制台直接粘这个脚本10秒内能看到服务器推送测试消息 const ws new WebSocket(ws://127.0.0.1:8080); ws.onopen () console.log(连接打开); ws.onmessage (e) console.log(收到:, e.data); ws.onclose () console.log(连接关闭); setTimeout(() ws.close(), 10000);这段代码的逻辑很短创建WebSocket连接挂上open、message、close三个事件处理器10秒后主动关闭。如果服务器实现了心跳连接打开后的10秒内你就应该能收到服务器主动发来的ping帧或业务心跳消息。浏览器控制台把收到的数据打印出来你就完成了第一个完整的“客户端发起握手到服务器推送数据”的回路。2.3 端口参数与监听地址的3个调整要点启动服务之前有几个参数必然要动不改的话后面部署必出问题。第一是监听地址。源代码里默认可能是IPAddress.Any或IPAddress.Loopback。Loopback只允许本机连接调试没问题但部署到产线服务器上、需要让别的机器连的时候就必须改成Any或者显式指定内网IP地址。否则你会在别的机器上反复踩“连不上”的坑而本机却一切正常。第二是监听端口。8080是开发常用端口但生产环境要尤其注意被安全软件或系统保留端口占用的可能性。Windows下执行netsh interface ipv4 show excludedportrange protocoltcp能看到系统动态保留的端口区间如果你要用的端口落在保留区间里绑定会静默失败或报“地址已被占用”。换端口之前先查这个。第三是缓冲区大小。源码里通常会定义一个接收缓冲区的字节数组长度常见的是4KB或者8KB。接收缓存太小大帧消息会被拆成多次读取增加解析复杂度太大则每个连接都占着内存客户端数量多了之后开销可观。// 配置类里的典型参数注意看注释含义 public class WsConfig { public int Port { get; set; } 8080; // 监听端口 public string BindAddress { get; set; } 0.0.0.0; // 监听地址 public int BufferSize { get; set; } 8192; // 收包缓冲区 public int KeepAliveInterval { get; set; } 60; // 心跳间隔秒数 }参数说明BindAddress填0.0.0.0表示监听本机所有网卡客户端可以用任何能路由到这台机器的IP访问BufferSize决定单次Socket.Receive能读多少数据8192字节在多数工控场景下够用但如果你要传大图片或长文本日志建议调到16KB以上再跑压测。KeepAliveInterval关系到服务端多久发一次心跳来探测死连接设得太短会频繁占用带宽设得太长则断线感知滞后典型值在30到90秒之间。3. 拆解源码的三个核心模块握手、封包、心跳3.1 从HTTP Upgrade到101状态码服务端连接建立的完整链路WebSocket连接不是凭空建立的它借用HTTP协议完成“升级”。客户端先发一个带着Upgrade: websocket头部的HTTP GET请求服务器验证通过后返回 101 Switching Protocols之后这条TCP连接才切换成WebSocket数据帧模式。源码里跟这个阶段对应的是握手处理函数。它要做的检查包括请求方法必须是GETUpgrade头的值必须是websocket不区分大小写Sec-WebSocket-Key必须存在且非空版本号如果不是13应该返回400并附带Sec-WebSocket-Version: 13响应头要求客户端降级。关键的加密计算在这两行——服务器要把客户端传来的Key拼上一个固定的GUIDRFC 6455规定的字符串然后做SHA1哈希再做Base64编码把结果放在响应头Sec-WebSocket-Accept里返回。这个值是整个握手的核心验证凭据算错了客户端会直接报握手失败。// 握手响应头的Accept值计算RFC 6455规定的固定逻辑 public static string ComputeAcceptKey(string secWebSocketKey) { const string magicGuid 258EAFA5-E914-47DA-95CA-C5AB0DC85B11; string combined secWebSocketKey magicGuid; using var sha1 System.Security.Cryptography.SHA1.Create(); byte[] hashBytes sha1.ComputeHash(System.Text.Encoding.UTF8.GetBytes(combined)); return Convert.ToBase64String(hashBytes); }这段代码的逻辑核心就三步拼接字符串、SHA1哈希、Base64编码。参数说明magicGuid是协议写死的常量不能改也不能少secWebSocketKey来自客户端请求头长度是24字节的Base64字符串。这里最常见的坑是编码问题——有些实现用ASCII编码去算哈希遇到非ASCII字符的Key就会算出不同的结果导致握手失败。调试这个函数时网上有现成的Key-Accept对照表拿一组已知值验算一分钟就能定位是拼接问题还是哈希算法问题。3.2 帧结构解析FIN位、Opcode和掩码的逐字节处理握手完成之后收发数据全部走帧格式。一帧数据的第一个字节拆成三部分最高位是FIN标志表示这一帧是不是消息的最后一帧低4位是Opcode标识帧类型1表示文本帧、2表示二进制帧、8表示关闭连接、9表示Ping、10表示Pong。第二个字节分成两部分最高位是MASK标志表示负载数据有没有被掩码处理。按协议要求客户端发给服务器的帧必须带掩码服务器发给客户端的帧必须不带掩码。很多自己写协议的人在这里翻车——服务端收帧的时候没有对MASK位置1的帧做掩码反转解析出来的数据就是一团乱码。掩码反转的算法不复杂用客户端带过来的4字节掩码Key按byte[i % 4]逐字节异或负载数据即可。长度字段的处理是另一个容易出错的地方。7位长度值小于126时这个值就是真实负载长度等于126时后续2字节是长度等于127时后续8字节是长度。源码里通常会用一个long来承接这个值但如果你用的是int来接收长度而没有做溢出检查一个恶意客户端构造超大长度值就能让你的服务器崩溃。// 帧解析中处理负载长度的分支注意类型转换的边界 if ((payloadLen 0x7F) 126) { byte[] lenBytes new byte[2]; // 这里要手动拼出16位长度注意大端序 } else if ((payloadLen 0x7F) 127) { byte[] lenBytes new byte[8]; // 64位长度业务场景几乎不会触发但要防止恶意包把长度字段撑爆 }参数说明不管是2字节还是8字节的长度字段网络字节序都是大端序。用BinaryReader读的时候要注意字节序方向BitConverter默认按本机字节序而本机几乎都是小端直接转换会得到完全错误的长度值。正确做法是先反转数组或者用BinaryPrimitives里的方法读取大端序整数。3.3 文本帧还是二进制帧服务端如何区分并分发分帧逻辑过关后消息的分发路由就简单了。根据帧头里的Opcode判断类型1表示文本对应的Payload直接按UTF-8解码成字符串2表示二进制保持byte[]原样8表示关闭帧响应一个关闭帧后释放连接。分发层有两种常见设计。一种是事件委托服务器解析完帧之后触发一个MessageReceived事件把ClientId、消息类型、数据内容作为事件参数传给注册的处理器。另一种是回调队列把消息投递到一个ConcurrentQueue里由业务线程异步消费。两种方式没有绝对好环事件驱动写起来直观适合消息量不大、业务逻辑简单回调队列便于控制消费速度适合高频数据采集场景防止UI线程或者数据库写入被堵死。工业上位机场景里强烈建议把文本帧和二进制帧区分对待。状态指令、设备上下线通知这些用文本帧JSON序列化方便调试图像帧、原始采样数据流用二进制帧省掉Base64编码的膨胀开销。源码里如果统一按文本处理接二进制帧时会因为解码失败而断开连接你要么改解析逻辑要么在客户端发送之前统一编码成Base64字符串。3.4 心跳保活与断线感知KeepAlive参数的工程权衡TCP本身没有天然的“对方是否活着”的即时反馈断开的一方可能已经断电或者宕机而另一方还在傻等。WebSocket协议里专门设计了Ping和Pong帧来解决这个问题服务器定时发Ping帧客户端收到后必须回Pong帧如果服务器超过设定时间没收到任何回复就可以判定这条连接死了。源码里和心跳相关的地方通常是两个参数间隔和超时。间隔是多久发一次Ping超时是连续多少次没有Pong就断开。实际经验是心跳间隔设成30秒超时判定设成2次即60秒无响应就断开这种组合在绝大多数局域网工控场景里够灵敏又不会制造太多无效流量。值得特别注意的是C#的Socket.Poll和TCP KeepAlive属于TCP层的心跳机制和WebSocket应用层心跳不是一回事。TCP KeepAlive默认2小时才开始探测对WebSocket这种需要秒级感知的协议来说根本不够用。你不需要把TCP层的KeepAlive关掉但要清楚它只是一个兜底应用层的Ping/Pong才是真正的判定依据。注意心跳定时器用System.Timers.Timer时Elapsed事件在ThreadPool线程上执行回调函数里不要做阻塞操作尤其不要在这个线程里直接操作UI控件。跨线程更新UI前先检查InvokeRequired否则会抛出偶发的跨线程异常在线程池压力大的时候尤其明显。4. 从“能跑”到“敢用”并发连接、粘包半包与资源释放的避坑记录4.1 高频推送下的粘包与半包按帧解析和按流解析的差别工控场景下服务器经常需要每100毫秒推一次数据这时候TCP的流式特性就会暴露问题多个小的WebSocket帧可能被操作系统合并成一次网络包送达表现为“粘包”反过来一个大的帧被拆成多个TCP分段表现为“半包”。这两种问题的根源都在于——TCP层只保证字节流的顺序不保证消息边界。解决方案只有一个严格按帧格式解析每次读完数据先把帧头解码出来根据长度字段计算整帧总长度不够就继续读够了才取帧取完之后把剩余字节交给下一个帧的解析。源码里如果用的是NetworkStream.Read单次读取拼进MemoryStream然后再整体解析那么粘包时多读进来的属于下一帧的字节会被当成当前帧的负载内容解析出错误数据。常见改法是引入缓冲队列每次读完先检查当前缓冲区里是否有一个完整帧有就切出去解析剩下的留到下一轮继续处理。// 半包处理的核心思路循环读直到凑够一个完整帧 private bool TryReadOneFrame(byte[] buffer, ref int offset, ref int remaining) { // 先读2字节帧头够1个字节才能拿到fin/opcode // 够2个字节才能拿到7位基础长度 // 长度是126还要再多读2字节127则多读8字节 // 最后跳过掩码Key若有再等负载长度 }这段伪逻辑描述了逐字节推进的过程。参数说明每次读网络流前先检查缓冲区内剩余字节数是几个关键的台阶——2字节、22字节或28字节、加上4字节掩码Key、再加上负载长度。每一步不足就退出等待下一次Read事件触发绝不能在数据没凑齐时强行解析这是协议解析和普通“读文件”最大的不同。4.2 100个客户端的压力场景线程模型与异步读写的影响WebSocket服务器要面对的客户端数量和连接频率都远高于普通TCP服务。每个客户端可能几秒钟就重连一次如果服务端按“每连接一线程”的方式实现几百个客户端同时在线时线程上下文切换开销会直接把CPU打满。高效做法是异步IO加少量工作线程。C#里的SocketAsyncEventArgs或async/await模式都能实现单线程处理大量连接的效果——注意这里说的“单线程”是IO线程少不是业务逻辑单线程。消息回调、数据广播、心跳发送这些操作的并发安全仍然要靠锁或并发集合来保证。如果用async void处理Socket事件千万不要在异步方法里做耗时运算而不加任何控制。帧数据要广播给30个客户端每个客户端的发送缓冲区都独立网络慢的客户端会拖慢整体发送循环。建议发送循环里逐个客户端单独try-catch一个客户端出异常不要影响其余29个这是血泪经验——否则一个闪断的客户端就能让整个推送服务停摆。4.3 异常断开与半开连接为什么心跳正常还会堆积死连接心跳定时器正常发Ping接收消息的事件也在触发但服务器内存持续攀升连接数不减。这种情况多数不是心跳的问题而是业务层把连接引用存进了某个静态Dictionary却没有在另一端反向清理对应条目。也就是说Socket层已经把这个连接当作死连接关掉了但业务层的字典里还留着它导致每次广播遍历都会撞见一个已释放的对象。排查方法很简单在连接断开的地方打一条日志把断开原因正常关闭、心跳超时、IO异常也打出来然后在业务字典的清理方法里也打一条日志对比两边日志是否成对出现。如果打了很多“连接断开”但业务字典完全没有清理动作说明断开的回调没有被注册到正确的层级。还有一种是客户端拔掉网线但不主动发FIN包TCP连接就会进入半开状态。这种死连接对服务器是不可见的因为双方都不主动发数据心跳发Ping也收不到Pong超时之后才能发现。在Wi-Fi或移动网络环境下这个“发现时间”可能是几秒到几分钟不等这是物理网络决定的不是代码能彻底消除的只能通过调小心跳间隔来缩感知窗口。4.4 一个隐蔽的坑TLS/SSL证书与wss://的兼容性问题本地测试用ws://127.0.0.1:8080什么都正常换成线上生产环境用wss://yourdomain:8080就连不上。原因百分之百是TLS证书问题——WebSocket的TLS握手走的就是HTTPS那套证书链校验如果证书是自签的客户端默认不信任连接直接失败。C#的TcpListener默认监听的就是裸TCP不做任何加密。要做wss需要套一层SslStream在握手阶段加载X509Certificate2证书并执行AuthenticateAsServer。这里有个常见的二次翻车点用IIS或ASP.NET Core时证书绑定是框架自动完成的自己写Socket层WebSocket时没人替你处理信任链证书私钥权限不对或者证书没有包含完整链都会导致握手失败。注意自签名证书用于测试时客户端那边可以临时跳过证书校验但这只是开发捷径生产环境务必要换正式证书。另外不要在同一端口上同时开ws和wss——监听器的TLS设置在Accept之前就固定了同一个端口不能用两种协议混跑这种代码跑起来表现是时通时断极其难排查。4.5 释放连接时最容易漏掉的两个资源关闭WebSocket连接不是socket.Close()一下就完事。自己在Socket层写服务端时最容易漏掉的是发送缓冲区的并发访问锁没有释放以及业务层的连接订阅关系没有解绑。第一条看起来像小事但如果在广播逻辑里用了lock(socket)然后Close时又有人持着同一个socket对象执行发送就会产生锁竞争甚至死锁。第二条更隐蔽——上位机界面订阅了某个客户端的状态推送如果连接关闭时没有退订界面事件处理器会持续收到已释放连接的空引用在WinForm里表现为偶发的ObjectDisposedException让排查的人一头雾水。清理顺序建议是先解绑业务事件再清字典引用最后关闭Socket顺序反了就会在解绑过程中触发新的异常。5. 价值最大化把WebSocketServer嵌入上位机工程的两个加分技巧5.1 用独立线程跑服务端避免阻塞WinForm主线程上位机项目里最常见的是把WebSocketServer塞进WinForm的UI线程服务端收到消息后直接更新表格控件。这种做法在客户端数量少时没有明显问题但当数据推送频率升高、或者某个客户端阻塞了发送队列时UI界面会开始卡顿、按钮响应延迟。推荐的做法是让WebSocketServer跑在独立的CancellationTokenSource控制的线程里封装成一个可以被Start/Stop控制的Windows服务或后台类。UI线程只订阅它的数据事件事件回调里用BeginInvoke更新控件内容。总线速度上消息从Socket接收到UI控件显示延迟一般控制在几毫秒到几十毫秒之间完全满足产线实时监视的需求。// 在WinForm里启动WebSocket服务的示例骨架 private void btnStart_Click(object sender, EventArgs e) { _cts new CancellationTokenSource(); _server new WebSocketServer(8080); _server.OnMessage (clientId, message) { if (InvokeRequired) BeginInvoke(new Action(() UpdateGrid(clientId, message))); else UpdateGrid(clientId, message); }; Task.Run(() _server.Start(_cts.Token), _cts.Token); }逻辑说明点击按钮创建取消令牌和服务器实例注册消息事件然后用Task.Run把Start方法放到线程池。事件回调里通过InvokeRequired判断线程必要时用BeginInvoke切回UI线程更新控件。参数说明CancellationToken的作用是在窗体关闭时给Start方法里的监听循环发停止信号避免服务线程常驻导致进程无法退出不用Token而用abort线程的方式容易在关闭时遇到SocketException且无法清理资源。5.2 客户端代码的四种组合哪一种最适合你的业务自己写服务端代码之后客户端既可以是浏览器JS也可以是C#的ClientWebSocket还可以是第三方库。代码里自带的服务端如果兼容性好这几种客户端应该都能连上而且各有用处。浏览器客户端适合做产线看板刷新为零延迟C#的ClientWebSocket适合做中控程序互连收发二进制数据方便第三方库适合快速验证服务端的协议正确性。调试期最推荐的组合是服务端启动后用一个小工具同时打开多个客户端连接确认服务端的广播逻辑是否把一条消息分发给所有在线客户端。// C#侧最小化客户端用于验证服务端连通性 using var ws new ClientWebSocket(); await ws.ConnectAsync(new Uri(ws://127.0.0.1:8080), CancellationToken.None); var bytes Encoding.UTF8.GetBytes({\type\:\ping\}); await ws.SendAsync(new ArraySegmentbyte(bytes), WebSocketMessageType.Text, true, CancellationToken.None); while (ws.State WebSocketState.Open) { var recvBuffer new byte[1024]; var result await ws.ReceiveAsync(new ArraySegmentbyte(recvBuffer), CancellationToken.None); Console.WriteLine(Encoding.UTF8.GetString(recvBuffer, 0, result.Count)); if (result.MessageType WebSocketMessageType.Close) break; }逻辑很简单连接后发一条JSON的测试消息然后循环接收服务器推送。参数说明SendAsync第三个参数true表示帧的FIN位置1代表这条消息是完整一帧ReceiveAsync的缓冲区设为1024字节如果服务器推送的数据超过这个长度会先收到截断数据外层循环再收下一次——这时候就需要用result.EndOfMessage判断是否收完。这个小客户端既是调试工具也可以作为正式上位机的通信基座代码量不大但已经能处理文本帧、二进制帧和关闭帧三类情况。5.3 用PowerShell脚本模拟客户端批量连接做验证沉下心来操作的时候用浏览器开几个标签页测并发太笨了。一个几十行的PowerShell脚本就能模拟几十个并发连接压一下服务端的线程模型和广播性能。# 批量建立50个连接每个连接定时发心跳消息 $connections 1..50 | ForEach-Object { $id $_ [System.Net.WebSockets.ClientWebSocket]::new() } foreach ($ws in $connections) { $ws.ConnectAsync([Uri]::new(ws://127.0.0.1:8080), [Threading.CancellationToken]::None).Wait() } while ($true) { Start-Sleep -Seconds 2 $bytes [Text.Encoding]::UTF8.GetBytes(heartbeat) foreach ($ws in $connections) { $ws.SendAsync([ArraySegment[byte]]::new($bytes), [Net.WebSockets.WebSocketMessageType]::Text, $true, [Threading.CancellationToken]::None).Wait() # 注意真实场景不要同步等待SendAsync这里简化写法仅用于压测 } }这段脚本的价值不在代码质量而在压测思路——连接建立之后服务端进程稳定、内存不涨、心跳照发那就是基本可用的。脚本里用Wait是同步等待异步结果在模拟客户端里没问题真正的客户端代码不要模仿这种写法要使用async/await正确处理大量异步IO否则ThreadPool会被WAIT浪费用尽客户端自己先垮了。消息广播的验证要做到三个维度时间维度上消息到达各客户端的延迟是否均匀内容维度上每个客户端收到的消息是否一致且顺序正确异常维度上随机杀掉几个连接服务端能否在心跳超时后清理对应资源。这三个维度都过了服务端才算真正敢交出去上线。做了这么多年上位机通信我的习惯是每改一次协议解析逻辑就跑一遍回归脚本不要总觉得“就改了一个判断条件不会有问题”——帧结构的解析只要错一个bit客户端和服务端就能互相“礼貌”地挂死谁也不会报错。希望这份拆解能帮你在WebSocketServer的调试路上少走几个弯希望帮到你。本文还有配套的精品资源点击获取