资讯动态

WebSocket服务器端和客户端示例:从握手到心跳避坑实战

发布时间:2026/10/9 6:45:03 来源:尧图企业网站定制
简介这是一份面向C#/.NET与Web前端开发者的WebSocket通信示例包用基于.NET Framework 4.5的WinForm服务端和HTMLJavaScript客户端构成完整通信链路演示持久连接下的双向实时数据传输覆盖实时推送、在线聊天、消息通知等典型场景。压缩包共35个文件体积仅171KB包含9个C#源码文件、HTML客户端页面、jQuery库、Fleck相关dll依赖以及Visual Studio解决方案、工程配置与资源文件目录划分明确可快速定位服务端逻辑与客户端交互代码。服务端以WinForm图形界面展示连接状态和消息日志客户端页面通过JavaScript封装收发逻辑两者配合可直观理解协议交互。已有491人学习浏览。通过学习该示例可以掌握WebSocket握手建立、消息收发、连接关闭等核心流程在Visual Studio中打开sln即可编译运行WinForm服务端用浏览器直接打开HTML页面即可验证双端通信效果。示例使用了通用WebSocket实现客户端能够兼容主流现代浏览器适合希望快速入手实时通信开发的初中级开发者作为参考脚手架也便于在此基础上扩展业务逻辑。1. WebSocket服务器端和客户端示例轮询替身还是推送主力先看这一课如果你搜“WebSocket服务器端和客户端示例”大概率是想验证一件事长连接方案到底能不能替我解决服务器推送。这里先给个反直觉结论——这套示例最容易翻车的地方不在握手、不在收发消息而在“连接看起来活着实际上早就死了”。前几年我把轮询接口换成 WebSocketdemo 跑得飞起上线半小时服务器连接数却涨到峰值最后发现是客户端少做了心跳把 TCP 半开连接当成了可用连接。这篇笔记把帧格式、服务端最小实现、浏览器端参数、心跳机制实现按真实落地会踩到的顺序讲清楚适合要把长连接放进生产的前后端开发。你不需要精通网络协议但建议至少会写 Python 或 JavaScript。2. WebSocket 协议拆开看HTTP Upgrade、帧掩码与心跳机制实现很多教程把 WebSocket 当黑匣子抄库就用出问题就抓瞎。“服务端和客户端区别”这句话听起来简单实际隐藏着掩码规则、控制帧优先级这些硬约束。先把协议链路拆明白后面调参数才有依据。2.1 HTTP Upgrade 握手客户端和服务端的第一次对话WebSocket 的建立不是一次普通 TCP 连接。客户端先发一个普通 HTTP 请求携带 Upgrade 头服务端返回 101 状态码之后同一端口就从 HTTP 切换成 WebSocket。这个设计让 WebSocket 能穿过现有 80/443 端口体系也让它能复用 HTTP 的鉴权、Cookie 和 TLS 基础。一次典型握手长这样客户端发GET /ws HTTP/1.1 Host: example.com Upgrade: websocket Connection: Upgrade Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ Sec-WebSocket-Version: 13服务端校验 Sec-WebSocket-Key 后计算 Sec-WebSocket-Accept然后回HTTP/1.1 101 Switching Protocols Upgrade: websocket Connection: Upgrade Sec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbKxOoAccept 的值是固定套路把客户端传来的 Key 拼上一个固定 GUID258EAFA5-E914-47DA-95CA-C5AB0DC85B11做 SHA1再 base64。你可以用一条命令验证echo -n dGhlIHNhbXBsZSBub25jZQ258EAFA5-E914-47DA-95CA-C5AB0DC85B11 | openssl sha1 -binary | base64输出应该是 s3pPLMBiTxaQ9kYGzzhZRbKxOo。如果你在网关层排查这是最值得先手动验一遍的计算它能快速区分问题出在“客户端发错了 Key”还是“服务端没按协议回 101”。参数说明Sec-WebSocket-Key 没有任何加密意义它只是让服务端确认“这条升级请求是实时收到并能即时响应”顺便防止中间缓存节点把过期响应重放给客户端。网上有些说法把它当安全令牌那是误解真正的鉴权要放到后续请求头、Cookie 或子协议里。2.2 数据帧与掩码客户端到服务器必须加掩码握手完成后双方传输的是二进制帧。WebSocket 所有消息都由帧承载每一帧有固定头部格式。以客户端发往服务端的帧为例字段长度说明FIN1 bit是否最后一帧分片消息里靠它判断边界RSV1-33 bit扩展协商位没开启扩展时必须为 0Opcode4 bit0x1 文本、0x2 二进制、0x8 关闭、0x9 Ping、0xA PongMASK1 bit掩码位客户端发服务端必须为 1Payload len7/16/64 bit消息长度126 表示后接 16 位长度127 表示后接 64 位Masking key32 bit仅 MASK1 时有用于解掩码掩码是这条协议最容易被忽略的一条规则客户端发往服务端的帧必须掩码服务端发往客户端的帧必须不掩码。原因至今还有争议主流解释是防早期网络设备攻击。实践影响是任何人手写协议栈时如果忘了把收到的 payload 做掩码反转或者忘了在发送端加掩码就会看到乱码或服务端直接断开。解掩码算法很简单payload 的第 i 个字节与 masking key 的第 i mod 4 个字节异或即可。但我不建议生产环境手写这个逻辑现成库已经把边界和分片都兜住了手写代码只适合做协议学习。分片是另一个边界问题。一个大的文本消息可能被拆成多个帧发送首帧 FIN0直到最后一帧 FIN1接收方必须把分片缓存拼接。浏览器 API 帮你拼好了但服务端如果用底层库要确认库是否默认自动拼接。websockets 库会自动处理但如果你在 Nginx 层做透传要理解这只是一条 TCP 流WebSocket 的消息边界本身在帧头里不存在裸 TCP 的粘包。如果你看到客户端先后发两条消息被显示成一条那往往不是粘包而是接收端把两个帧的 payload 直接 concat 了却没有按帧拆开。2.3 心跳机制实现Ping/Pong 控制帧的参数选择长连接挂在网络里中间可能经过运营商 NAT、公司防火墙、云负载均衡。它们的空闲超时从 30 秒到 15 分钟不等。TCP 层明明没人发 RST但连接实际上已经断掉。应用层如果没有心跳就会出现“连接还在收不到推送”的假死。这就是为什么 WebSocket 心跳机制实现不能省。WebSocket 本身提供了控制帧Pingopcode 0x9和 Pongopcode 0xA。收到 Ping 必须回 PongPong 的 payload 一般原样带回。常见做法是客户端负责发心跳每 25 到 30 秒发一个 Ping服务端收到后自动回 Pong服务端另外统计每个连接最近一次收到消息的时间超过阈值比如 80 秒就主动断开。间隔怎么定我一般按接入层超时来倒推。如果你知道接入层空闲超时是 60 秒心跳就发 20 到 30 秒一次留足重发和网络抖动余量。太频繁比如 3 秒一个 Ping会白白吃掉带宽和 CPU太稀疏比如 60 秒会撞上中间设备的超时。另一个做法是服务端主动 Ping 客户端判断客户端是否还活着但别做太激进。服务端代码里一个常见参数是 ping_timeout它指的是“服务端发出 Ping 后等待 Pong 的超时”。websockets 库默认 20 秒这个值一般够用。客户端如果自己发 Ping那么服务端可能不再主动 Ping你需要把服务端的 ping_interval 设成 None避免两边都发造成帧流量翻倍。这是最容易被忽略的一个点库的默认行为是会发 Ping 的如果你客户端也发就得显式关掉一侧。3. 用 Python websockets 库跑通服务端和客户端最小命令与参数调整如果你赶时间可以先看这里的代码跑通再回头补协议。这个标题既然是“WebSocket服务器端和客户端示例”那我们就真把两个端都写出来跑一遍。选型我用的是 Python 生态里最专注的 websockets 第三方库不用标准库手写。3.1 选型为什么我用 websockets 库而不是 aiohttp 或 fastapi常见做法是如果你只做 WebSocket 服务和纯客户端脚本websockets 库最轻文档干净异步实现直接基于 asyncio。如果你的服务还带一批 HTTP 接口可以考虑 FastAPI 或 aiohttp它们把 WebSocket 和 HTTP 路由放在同一个进程里省了一套部署。但示例阶段我倾向让职责更纯粹先吃透长连接本身。选 websockets 库还有一个原因它的服务端把 Ping/Pong、帧解析、分片都处理好了且对外暴露了 heartbeat 相关参数。换 aiohttp 时你要处理的细节多一些不是不好而是示例阶段不必要。想理解“服务端和客户端区别”用同一个库写两端能最快看到差异服务端是监听客户端是发起连接两者角色决定了可用 API 完全不同。3.2 服务端最小示例回显、广播与连接生命周期下面这段是服务端它做三件事接受连接、把收到的文本原样回给客户端、同时广播给所有在线连接。为了演示心跳机制实现我设置了服务端主动 Ping 的间隔。import asyncio import websockets # 保存当前在线连接方便广播 connected set() async def echo_handler(websocket, pathNone): # 将新连接加入集合并打印地址供排查 connected.add(websocket) print(f[connect] {websocket.remote_address}) try: async for message in websocket: print(f[recv] {websocket.remote_address}: {message}) # 原样回给发送方 await websocket.send(message) # 广播给其他客户端 for peer in list(connected): if peer is not websocket: await peer.send(f[broadcast] {message}) except websockets.ConnectionClosed: print(f[closed] {websocket.remote_address}) finally: connected.remove(websocket) async def main(): # ping_interval20 表示服务端每 20 秒主动 Ping # ping_timeout60 表示 60 秒内没收到 Pong 就判定连接死亡 async with websockets.serve( echo_handler, 127.0.0.1, 8765, ping_interval20, ping_timeout60, max_size2**20 ) as server: await asyncio.Future() # 让服务一直运行 if __name__ __main__: asyncio.run(main())逻辑说明async for message 在库内部会持续接收并重组帧收到文本内容就给变量 message连接关闭时会抛 ConnectionClosed。这里我写成 handler 协程每个连接都会创建独立任务符合异步高并发模型。参数说明ping_interval20 表示服务端每 20 秒主动发一次 Ping这适合客户端不主动发心跳的场景如果客户端自己有心跳可以把 ping_interval 设为 None避免双重 Ping。ping_timeout60 表示发出去 Ping 后等 Pong 最多 60 秒超过就抛异常并关闭连接。max_size 限制单条消息的大小我这里限 1MB防止恶意客户端用超大 frame 撑爆内存。生产环境按业务调整传大文件可能要放宽到 8MB但别无脑放宽最好配合鉴权和频控。3.3 客户端最小示例连接、收发与指数退避重连客户端要模拟真实使用连接、收发、断线重连。常见的轮询替代场景里客户端可能是一个 Python 后台服务或测试脚本。import asyncio import websockets async def client(): uri ws://127.0.0.1:8765 retry 0 while True: try: async with websockets.connect( uri, ping_intervalNone, # 服务端已主动 Ping客户端就不重复发 ping_timeout20, open_timeout10, max_size2**20 ) as ws: retry 0 # 连接成功就重置重试计数 await ws.send(hello from client) reply await ws.recv() print(f[recv] {reply}) break # 先跑一轮就退出方便演示 except (websockets.ConnectionClosed, OSError, asyncio.TimeoutError) as e: retry 1 wait min(2 ** retry, 30) # 指数退避最多等 30 秒 print(f[retry] {e} - sleep {wait}s) await asyncio.sleep(wait) if __name__ __main__: asyncio.run(client())逻辑说明async with 保证退出时自动关闭连接。这里发送后立刻 recv对服务端的回显正好。如果服务端推送频率不定recv 会一直挂起等待这符合长连接模型。参数说明客户端把 ping_interval 设成 None是为了避免和示例服务端的主动 Ping 撞车如果服务端关掉了主动 Ping你就要让客户端来发。open_timeout 控制握手阶段的超时单位秒生产环境建议设 10 到 15太短会偶发误判太长会让用户觉得卡。指数退避重连是血泪经验不做退避的客户端服务端一重启就会演变成连接风暴一瞬间几千个请求打过来把刚启动的服务再次打挂。这样一套两端示例已经覆盖“连接、收发、心跳、断线重连”四件事。跑的时候先起服务端再起客户端观察服务端控制台输出 [connect] 和 [recv]就能确认链路通了。很多教程到这就结束了但生产环境你不会只面对本机进程下一章把客户端换成浏览器再讨论鉴权和接入层参数。4. 浏览器客户端与鉴权参数设计WebSocket API、凭证通道与 Nginx 接入层服务端准备好了现在到大多数真实产品形态浏览器连 WebSocket。这章解决两个问题浏览器里的 API 怎么写以及连接凭证和接入层参数怎么配。4.1 浏览器原生 WebSocket API事件驱动与二进制消息浏览器端没有 Python 那种 async for全程事件驱动。一个最小客户端长这样const ws new WebSocket(ws://${location.host}/ws); ws.addEventListener(open, () { console.log(连接已建立); ws.send(hello from browser); }); ws.addEventListener(message, (event) { console.log(收到服务端消息, event.data); }); ws.addEventListener(close, (event) { console.log(连接关闭, event.code, event.reason); }); ws.addEventListener(error, () { console.log(出现错误随后会触发 close); });这段代码看起来简单但有几个要点。message 事件的 event.data 类型由服务端帧的 Opcode 决定文本帧对应 string二进制帧对应 Blob除非你主动设置 ws.binaryType arraybuffer。如果你做的是实时视频帧或自定义二进制协议一定记得设 binaryType否则收到 Blob 后再转 ArrayBuffer 会多绕一步。close 事件的 code 字段值得重视。正常关闭是 1000服务端主动关闭可能是 1001服务即将重启、1008策略违规常见于鉴权失败。如果你看到 1006说明连接异常断开浏览器不会给出 reason这对前端是个黑匣子得配合服务端日志去查。很多前端同学一看到 1006 就怀疑自己代码实际更多是接入层或网络问题。4.2 鉴权参数怎么传子协议、Token、Cookie 三个通道的取舍WebSocket 握手是 HTTP所以鉴权可以复用 HTTP 的能力但三个通道各有取舍。第一种在 URL query 上带 tokenws://example.com/ws?tokenxxx。实现最简单路径参数能直接被服务端拿到。缺点也明显token 会出现在接入层访问日志、浏览器历史、网关日志里。生产环境用这个通道要确保日志脱敏并给 token 做短期有效期。第二种放在子协议Sec-WebSocket-Protocol里。浏览器创建连接时传 protocols 数组服务端可以从握手请求里读到子协议并选择要不要接受。这个通道不会被浏览器历史记录但同样可能被网关日志记录而且子协议更常用于应用层协议协商不建议把长 token 塞进去。用来传版本号或协议名比较合适比如 chat.v2。第三种依赖 Cookie。浏览器对 WebSocket 握手会自动带上该域名的 Cookie服务端解析时直接用会话 ID这是一般产品最省事的做法。注意两点一是子域和路径作用域Cookie 必须在请求路径下可见二是如果鉴权失败服务端应该在握手阶段就返回 403而不是等连接建立后再关闭这样前端能拿到明确错误而不是 1006。我一般优先用 Cookie 或短 token 的 query取决于现有登录体系。服务端唯一要记住的是不要在连接建立后再“补做”鉴权应该在 Upgrade 握手时同步校验否则未授权用户也能占着一个连接。4.3 Nginx 接入层配置Upgrade 头与连接超时参数真实部署里 WebSocket 前面往往会有一层 Nginx。它转发的是 TCP 流但默认行为是按 HTTP 请求处理必须显式开启 Upgrade。下面是一份能用的配置片段map $http_upgrade $connection_upgrade { default upgrade; close; } upstream ws_backend { server 127.0.0.1:8765; } server { listen 80; location /ws { proxy_pass http://ws_backend; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection $connection_upgrade; proxy_set_header Host $host; proxy_read_timeout 75s; proxy_send_timeout 75s; } }逻辑说明map 那段代码把原始请求里的 Upgrade 头映射到 $connection_upgrade没有 Upgrade 的普通请求就变成 close有 Upgrade 的就变成 upgrade。proxy_set_header 这两行是 WebSocket 穿透的关键少了第二行Nginx 会用默认 keep-alive 语义后端收不到正确的升级请求。参数说明proxy_read_timeout 和 proxy_send_timeout 控制 Nginx 等待后端和客户端数据的时间单位秒。这里 75 秒是 Nginx 默认空闲超时思路如果 WebSocket 两端超过这个时间没有任何数据Nginx 会主动断开。这就是为什么客户端心跳不能太稀疏。如果你的心跳间隔是 30 秒75 秒足够覆盖两次心跳如果把心跳设成 90 秒这里就必须调大否则连接会周期性断开。Nginx 的 access log 会记录到 101 状态码。查看日志里是否有 101 响应是判断升级是否成功的第一步。如果看到 502通常是后端没起来看到 400通常是握手请求少了关键头。建议先把日志格式里加上 $http_upgrade 和 $connection_upgrade这层排查比协议栈手算更常见。5. 避坑一秒断连、假死连接与并发打爆服务器的五个现场这五个现场来自真实线上故障整理按“现象→原因→解决”写你可以直接抄排查路径。5.1 现象客户端连上后 1~3 秒被断开浏览器报 1006现象浏览器建连成功也收到过消息但过几秒突然报 1006服务端日志里能看到 ConnectionClosed。原因最常见的是接入层没配 Upgrade 头。Nginx 默认把连接当普通 HTTP 短连接后端收到请求后看到缺少 Upgrade 或 Connection 头直接返回 400也有情况是配置里只写了 proxy_set_header Upgrade没写 Connection导致升级请求不完整。另一个容易被忽略的原因是客户端访问的端口是 Nginx 80而不是后端 8765但没有走 Nginx跨域被拦也会报成类似现象。解决先看 Nginx access log 里返回的是 101 还是 400。如果是 400检查 Upgrade 和 Connection 两个头是否都配置。如果是 101 但客户端还是断再看网络中间设备有没有空闲超时。用 websocat 或 wscat 在本机直连后端能快速区分“后端问题”还是“接入层问题”。5.2 现象客户端连发两条消息服务端收到内容拼在一起现象客户端先发“hello”再发“world”服务端在一条消息里收到“helloworld”第一反应是“TCP 粘包了”。原因WebSocket 本身定义了消息边界库也会按帧解析不会把两条消息拼成一条。出现这种问题一般是服务端没用 WebSocket 库的按消息读取接口而是直接读了底层 TCP 流或者手写帧解析时没处理分片。另一种可能是客户端把两条消息放在同一个 send 调用里服务端按文本读出来自然是一条。不能拿裸 TCP 的思维看 WebSocket。解决服务端不要用 read() 从底层 socket 读用库提供的按消息接口比如 websockets 库的 recv() 或 async for。如果必须手写协议栈逐帧解析 Opcode 和 FIN分片消息要拼到 FIN1 才输出。排查时在服务端把每条消息的长度和内容打印出来对比客户端实际发送的条数能一眼看出是发送端还是接收端的问题。5.3 现象连接数持续上涨内存和文件描述符飙升现象系统显示几千个 ESTABLISHED 连接但活跃用户只有几百内存持续增长最后连接被系统拒绝。原因客户端异常退出、网络闪断后TCP 连接在服务端没有被发现。服务端没有心跳或心跳超时设置太长也可能是客户端断线后不断重连没有退避形成重连风暴。旧连接残留和新连接到来叠加把连接数打满。解决服务端打开 ping_interval 和 ping_timeout同时给每个连接记录最近活动时间超过阈值直接调用 close()。客户端重连必须带指数退避并设置最大重试次数。服务端进程里加一个定时任务遍历在线连接清理死亡连接比依赖操作系统 TCP keepalive 更可控。Linux 的 tcp_keepalive_time 默认 7200 秒对 WebSocket 应用来说太慢。5.4 现象心跳间隔“看起来”正常连接仍按周期被断现象客户端按 30 秒发 Ping但每 60~70 秒连接还是会断一次断开时间像有周期。原因中间设备或接入层的空闲超时恰好小于你的心跳周期。比如 Nginx 的 proxy_read_timeout 默认 60s你 30s 发一次心跳理论没问题但客户端在后台页面被节流定时器被浏览器降频实际发送间隔超过 60 秒就被接入层断开。另一个原因是服务端也主动 Ping两边心跳周期叠加你以为每 30 秒有一次实际心跳但对端看到的间隔并不均匀。解决客户端不要依赖裸 setTimeout 写心跳要基于每次真实发送和接收成功的时间来推动下一次心跳如果页面切到后台考虑用 Web Worker 或适当放宽服务端超时。服务端 ping_interval 和客户端 Ping 只保留一个避免两套心跳定时器互相错位。把 Nginx 的 proxy_read_timeout 调大到 100~120 秒同时客户端心跳 30 秒一次留出安全边际。5.5 现象前端日志 1006后端日志却是正常关闭现象浏览器 close code 是 1006异常但服务端日志里打印的是 ConnectionClosedcode 是 1000两边对不上。原因1006 表示浏览器端根本没收到关闭帧连接是被网络层断开的。服务端日志显示的 1000 是服务端主动 close 时自己看到的或者服务端主动 close 时直接断开 TCP没先发关闭帧客户端自然拿到 1006。解决服务端要优雅关机调用库的 close() 而不是直接关 socket让库先发 Close 帧并等对端回包设一个短超时。排查时用 tcpdump 抓包看有没有 Close 帧。这种两边日志对不上的情况会让人白忙半天抓包是最直接的证据。6. 压测与验收把 WebSocket 示例从能跑到能上线的最后一步一个能本地跑的示例离上线还差几步验证。我的习惯是先做三件事冒烟、小压测、断网演练。冒烟用 wscat 或一个小脚本同时开 100 个连接确认服务端 100 个连接都升级成功再轮流收发消息。小压测用 Python asyncio 起 500 个连接每个连接每秒发一条消息观察响应延迟和错误率看有没有连接被服务端主动断开。压测里注意一个细节不要只看吞吐要看“长连接存活率”——压测结束后连接还剩多少。很多服务端小流量下没问题一上量就误杀连接多半是心跳定时任务导致事件循环拥堵或异步代码里出现了阻塞调用。断网演练的做法压测中直接拔掉网线观察客户端在心跳超时后会不会触发重连重连之间有没有退避服务端会不会清理死连接。如果能录下“拔线时刻到第一条重连成功”的时间这个数据就是运维和前端沟通时最硬的证据。我当年吃过一次亏客户端重连逻辑写对了但服务端没清理旧连接拔线演练结束后连接数翻倍旧连接全部假死。后来我把演练做成固定上线前检查项每次发版前先跑一遍连接数曲线和内存曲线都留档。最后一个习惯上生产前把日志结构化记录连接建立、关闭、Ping/Pong 异常、重连次数至少保留一个 key 标识客户端会话。这样线上再出 1006你能从日志里找到“这个连接最后一次收到数据是什么时候”而不是对着浏览器干猜。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑