☰
WebSocket服务器端和客户端示例:从握手到心跳避坑实战
2026/10/9 6:44:59 网站建设 项目流程

简介:这是一份面向C#/.NET与Web前端开发者的WebSocket通信示例包,用基于.NET Framework 4.5的WinForm服务端和HTML+JavaScript客户端构成完整通信链路,演示持久连接下的双向实时数据传输,覆盖实时推送、在线聊天、消息通知等典型场景。压缩包共35个文件,体积仅171KB,包含9个C#源码文件、HTML客户端页面、jQuery库、Fleck相关dll依赖,以及Visual Studio解决方案、工程配置与资源文件,目录划分明确,可快速定位服务端逻辑与客户端交互代码。服务端以WinForm图形界面展示连接状态和消息日志,客户端页面通过JavaScript封装收发逻辑,两者配合可直观理解协议交互。已有491人学习浏览。通过学习该示例,可以掌握WebSocket握手建立、消息收发、连接关闭等核心流程;在Visual Studio中打开sln即可编译运行WinForm服务端,用浏览器直接打开HTML页面即可验证双端通信效果。示例使用了通用WebSocket实现,客户端能够兼容主流现代浏览器,适合希望快速入手实时通信开发的初中级开发者作为参考脚手架,也便于在此基础上扩展业务逻辑。

1. WebSocket服务器端和客户端示例:轮询替身还是推送主力,先看这一课

如果你搜“WebSocket服务器端和客户端示例”,大概率是想验证一件事:长连接方案到底能不能替我解决服务器推送。这里先给个反直觉结论——这套示例最容易翻车的地方不在握手、不在收发消息,而在“连接看起来活着,实际上早就死了”。前几年我把轮询接口换成 WebSocket,demo 跑得飞起,上线半小时服务器连接数却涨到峰值,最后发现是客户端少做了心跳,把 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: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=

Accept 的值是固定套路:把客户端传来的 Key 拼上一个固定 GUID(258EAFA5-E914-47DA-95CA-C5AB0DC85B11),做 SHA1,再 base64。你可以用一条命令验证:

echo -n "dGhlIHNhbXBsZSBub25jZQ==258EAFA5-E914-47DA-95CA-C5AB0DC85B11" | openssl sha1 -binary | base64

输出应该是 s3pPLMBiTxaQ9kYGzzhZRbK+xOo=。如果你在网关层排查,这是最值得先手动验一遍的计算,它能快速区分问题出在“客户端发错了 Key”还是“服务端没按协议回 101”。

参数说明:Sec-WebSocket-Key 没有任何加密意义,它只是让服务端确认“这条升级请求是实时收到并能即时响应”,顺便防止中间缓存节点把过期响应重放给客户端。网上有些说法把它当安全令牌,那是误解,真正的鉴权要放到后续请求头、Cookie 或子协议里。

2.2 数据帧与掩码:客户端到服务器必须加掩码

握手完成后,双方传输的是二进制帧。WebSocket 所有消息都由帧承载,每一帧有固定头部格式。以客户端发往服务端的帧为例:

字段长度说明
FIN1 bit是否最后一帧,分片消息里靠它判断边界
RSV1-33 bit扩展协商位,没开启扩展时必须为 0
Opcode4 bit0x1 文本、0x2 二进制、0x8 关闭、0x9 Ping、0xA Pong
MASK1 bit掩码位,客户端发服务端必须为 1
Payload len7/16/64 bit消息长度,126 表示后接 16 位长度,127 表示后接 64 位
Masking key32 bit仅 MASK=1 时有,用于解掩码

掩码是这条协议最容易被忽略的一条规则:客户端发往服务端的帧必须掩码,服务端发往客户端的帧必须不掩码。原因至今还有争议,主流解释是防早期网络设备攻击。实践影响是,任何人手写协议栈时,如果忘了把收到的 payload 做掩码反转,或者忘了在发送端加掩码,就会看到乱码或服务端直接断开。

解掩码算法很简单,payload 的第 i 个字节与 masking key 的第 i mod 4 个字节异或即可。但我不建议生产环境手写这个逻辑,现成库已经把边界和分片都兜住了,手写代码只适合做协议学习。

分片是另一个边界问题。一个大的文本消息可能被拆成多个帧发送,首帧 FIN=0,直到最后一帧 FIN=1,接收方必须把分片缓存拼接。浏览器 API 帮你拼好了,但服务端如果用底层库,要确认库是否默认自动拼接。websockets 库会自动处理,但如果你在 Nginx 层做透传,要理解这只是一条 TCP 流,WebSocket 的消息边界本身在帧头里,不存在裸 TCP 的粘包。如果你看到客户端先后发两条消息被显示成一条,那往往不是粘包,而是接收端把两个帧的 payload 直接 concat 了,却没有按帧拆开。

2.3 心跳机制实现:Ping/Pong 控制帧的参数选择

长连接挂在网络里,中间可能经过运营商 NAT、公司防火墙、云负载均衡。它们的空闲超时从 30 秒到 15 分钟不等。TCP 层明明没人发 RST,但连接实际上已经断掉。应用层如果没有心跳,就会出现“连接还在,收不到推送”的假死。这就是为什么 WebSocket 心跳机制实现不能省。

WebSocket 本身提供了控制帧:Ping(opcode 0x9)和 Pong(opcode 0xA)。收到 Ping 必须回 Pong,Pong 的 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, path=None): # 将新连接加入集合,并打印地址供排查 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_interval=20 表示服务端每 20 秒主动 Ping # ping_timeout=60 表示 60 秒内没收到 Pong 就判定连接死亡 async with websockets.serve( echo_handler, "127.0.0.1", 8765, ping_interval=20, ping_timeout=60, max_size=2**20 ) as server: await asyncio.Future() # 让服务一直运行 if __name__ == "__main__": asyncio.run(main())

逻辑说明:async for message 在库内部会持续接收并重组帧,收到文本内容就给变量 message;连接关闭时会抛 ConnectionClosed。这里我写成 handler 协程,每个连接都会创建独立任务,符合异步高并发模型。

参数说明:ping_interval=20 表示服务端每 20 秒主动发一次 Ping,这适合客户端不主动发心跳的场景;如果客户端自己有心跳,可以把 ping_interval 设为 None,避免双重 Ping。ping_timeout=60 表示发出去 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_interval=None, # 服务端已主动 Ping,客户端就不重复发 ping_timeout=20, open_timeout=10, max_size=2**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 上带 token:ws://example.com/ws?token=xxx。实现最简单,路径参数能直接被服务端拿到。缺点也明显: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,分片消息要拼到 FIN=1 才输出。排查时在服务端把每条消息的长度和内容打印出来,对比客户端实际发送的条数,能一眼看出是发送端还是接收端的问题。

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(异常),但服务端日志里打印的是 ConnectionClosed,code 是 1000,两边对不上。

原因:1006 表示浏览器端根本没收到关闭帧,连接是被网络层断开的。服务端日志显示的 1000 是服务端主动 close 时自己看到的;或者服务端主动 close 时直接断开 TCP,没先发关闭帧,客户端自然拿到 1006。

解决:服务端要优雅关机,调用库的 close() 而不是直接关 socket,让库先发 Close 帧并等对端回包,设一个短超时。排查时用 tcpdump 抓包看有没有 Close 帧。这种两边日志对不上的情况,会让人白忙半天,抓包是最直接的证据。

6. 压测与验收:把 WebSocket 示例从能跑到能上线的最后一步

一个能本地跑的示例,离上线还差几步验证。我的习惯是先做三件事:冒烟、小压测、断网演练。

冒烟用 wscat 或一个小脚本同时开 100 个连接,确认服务端 100 个连接都升级成功,再轮流收发消息。小压测用 Python asyncio 起 500 个连接,每个连接每秒发一条消息,观察响应延迟和错误率,看有没有连接被服务端主动断开。压测里注意一个细节:不要只看吞吐,要看“长连接存活率”——压测结束后连接还剩多少。很多服务端小流量下没问题,一上量就误杀连接,多半是心跳定时任务导致事件循环拥堵,或异步代码里出现了阻塞调用。

断网演练的做法:压测中直接拔掉网线,观察客户端在心跳超时后会不会触发重连,重连之间有没有退避,服务端会不会清理死连接。如果能录下“拔线时刻到第一条重连成功”的时间,这个数据就是运维和前端沟通时最硬的证据。我当年吃过一次亏:客户端重连逻辑写对了,但服务端没清理旧连接,拔线演练结束后连接数翻倍,旧连接全部假死。后来我把演练做成固定上线前检查项,每次发版前先跑一遍,连接数曲线和内存曲线都留档。

最后一个习惯:上生产前把日志结构化,记录连接建立、关闭、Ping/Pong 异常、重连次数,至少保留一个 key 标识客户端会话。这样线上再出 1006,你能从日志里找到“这个连接最后一次收到数据是什么时候”,而不是对着浏览器干猜。希望帮到你。

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

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

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

立即咨询