拓冰建站拓冰建站
首页 / 资讯中心 / 正文

WebSocket实战:从握手到心跳,详解连接管理与断线重连

简介这是一份WebSocket服务器端与客户端完整示例面向需要快速上手实时通信开发的.NET与Web前端开发者。服务器端采用C# WinForm应用.NET Framework 4.5客户端为HTMLJavaScript两者配合演示了从建立持久连接、双向收发消息到断线处理的核心流程适合作为课设、毕设或内部培训的参考模板。压缩包共35个文件大小仅171KB包含9个C#源代码文件含窗体逻辑与程序入口、3个可执行文件、3个DLL如Fleck.dll提供WebSocket服务端实现、HTML/JS客户端页面、Visual Studio解决方案及配置文件等结构精简便于直接打开工程运行调试。已有490人学习下载。通过这套示例读者可以一次性掌握C#服务端与浏览器客户端对接的完整链路理解WebSocket与HTTP的差异并可直接复用代码或按需扩展对学习实时通信、消息推送等场景很有帮助。1. WebSocket不是升级版HTTP——服务器端和客户端示例在解决什么一个常见的理解偏差是觉得 WebSocket 就是 HTTP 加了个长连接开关。动手写服务器端和客户端示例时第一件要扔掉的就是请求-响应模型连接建立后两端对等服务器端能随时推数据客户端面对的不是一次返回结果而是一串事件——open、message、close、error。示例代码本身很简单难的是这四个事件之间的状态迁移像 onclose code 1006、连上了却收不到消息根子都在这。下面的路径用 Node.js ws 库写服务器端浏览器和 Node 写客户端从最小可运行示例推到广播、鉴权和断线重连。适合做即时通讯、服务端推送、协同编辑或者准备 websocket 面试题的前后端工程师。2. WebSocket服务器端握手、心跳与最小可运行的连接2.1 选型先回答一个问题谁来维护连接集合服务器端选型别上来就看框架功能列表先想清楚谁来维护连接集合、谁来发心跳这两个问题。从零裸写要自己实现帧解析和掩码处理成本高Socket.IO 这种库则自带私有协议前后端都得用它的客户端。ws 库恰好是中间态协议是标准 RFC 6455没有私有封装换任何客户端都能连而且 ws 本身就是很多上层框架的底层依赖出了诡异问题可以直接读它的源码定位。技术栈定死的场景另说Netty 适合对吞吐和连接数有硬指标的网关型服务Spring Boot 的 WebSocket 需要自己在 WebSocketHandler 里管 session 集合Gin 本身不内置 WebSocket要挂 gorilla/websocket 或 coder/websocket。单进程示例都好写但一旦多实例部署wss.clients 这个本地集合就管不到其他实例上的连接了。所以选型时先确认这个服务未来会不会扩容。会的话要么选自带集群方案的框架要么把连接注册表外置ws 库这条路就是后者。2.2 服务器端最小实现一个能直接跑的 echo 服务// server.js const { WebSocketServer, WebSocket } require(ws); const wss new WebSocketServer({ port: 8080, path: /ws }); wss.on(connection, (ws, req) { console.log(client connected:, req.socket.remoteAddress); ws.on(message, (data, isBinary) { if (isBinary) { ws.send(data, { binary: true }); return; } const msg JSON.parse(data.toString()); if (msg.type echo) { ws.send(JSON.stringify({ type: echo, data: msg.data, ts: Date.now() })); } }); ws.on(close, (code, reason) { console.log(closed:, code, reason.toString()); }); ws.send(JSON.stringify({ type: welcome, ts: Date.now() })); }); setInterval(() { wss.clients.forEach((ws) { if (ws.readyState WebSocket.OPEN) { ws.send(JSON.stringify({ type: tick, ts: Date.now() })); } }); }, 10000).unref();三个要点。第一path: /ws让握手只接受指定路径其余路径直接被拒绝能挡掉一部分扫描流量。第二message回调的第二个参数isBinary决定按文本还是二进制回包很多客户端收消息乱码就是服务器端把文本帧当成二进制原样转发。第三setInterval演示服务器端主动推送.unref()保证这个定时器不会阻止进程退出。运行node server.js之后用wscat -c ws://localhost:8080/ws连接发一条{type:echo,data:hi}能收到回包就说明双向链路通了。2.3 握手不是魔法Origin 检查与跨站劫持WebSocket 连接的前奏是一次 HTTP Upgrade 请求。客户端发送Sec-WebSocket-Key服务器端把它和固定 GUID 拼起来做 SHA-1再通过Sec-WebSocket-Accept返回。这个计算 ws 库自动完成但 Origin 校验必须自己做浏览器发起的握手会带 Origin 头服务器端不检查任意网页都能连上来这就是跨站 WebSocket 劫持CSWSH。ws 8.x 的verifyClient已经不建议使用更保险的做法是在 connection 回调里直接检查wss.on(connection, (ws, req) { const origin req.headers.origin; if (origin !origin.startsWith(https://trusted.example)) { ws.close(4403, origin forbidden); return; } // 正常业务逻辑 });这里有个容易误杀的点服务器端到服务器端的客户端不会带 Origin 头所以判断条件应该是有 Origin 且不在白名单才拒绝而不是没有 Origin 就拒绝。关闭码 4403 是业务自定义的客户端能在 close 事件里读到并对这类错误做专门提示而不是无脑重连。2.4 心跳参数ping 间隔怎么定、超时怎么判TCP 连接断掉后除非某端写数据否则双方都感知不到断连。手机切网络、笔记本休眠唤醒都会留下大量半开连接服务器端内存里还挂着这些 socket。标准做法是 ping/pong服务器端定时 ping客户端按协议自动回 pongws 库把 pong 转成pong事件。用标记法筛选失活连接wss.on(connection, (ws) { ws.isAlive true; ws.on(pong, () { ws.isAlive true; }); }); setInterval(() { wss.clients.forEach((ws) { if (ws.isAlive false) { ws.close(4001, heartbeat timeout); ws.terminate(); return; } ws.isAlive false; ws.ping(); }); }, 30000);两个参数要配套着调。ping 间隔 30 秒必须小于前面网关层的 idle timeoutNginx 默认的 proxy_read_timeout 是 60 秒心跳间隔接近这个值就可能被网关先掐断。超时判定给两个周期第一轮没收到 pong 先标记第二轮还没收到才断开给慢速网络一次缓冲机会。断开时先ws.close(4001)再terminate()前者给客户端明确关闭码后者兜底强制断开。语音长连接这类高实时场景ping 间隔要压到 10 秒以内对应的 Nginx 超时参数也要调大。Python 端用 websockets 库时handler 写成async def voice_socket(websocket)心跳逻辑类似区别是 asyncio 要另起一个 task 定时 ping否则事件循环被业务阻塞时心跳也会停摆。参数建议值说明ping 间隔30 秒实时场景 5-10 秒必须小于网关 idle timeout超时判定连续 2 次未回 pong给网络波动留缓冲关闭码4001 业务心跳超时与 1006 异常断连区分开terminate 兜底紧跟 close 之后防止 close 帧发不出去3. WebSocket客户端浏览器、Vue 和 Node 三种连接写法3.1 浏览器客户端先注册事件再想 send浏览器端的 WebSocket 全局对象 API 面极小。连接、收发、关闭加一个 readyState就这五样。最容易踩的坑是new 完立刻 send构造函数发起的握手是异步的连接没建立就 send 会直接抛异常。正确写法是先注册事件在 open 之后做首次发送const ws new WebSocket(ws://localhost:8080/ws); ws.addEventListener(open, () { ws.send(JSON.stringify({ type: echo, data: hello })); }); ws.addEventListener(message, (ev) { console.log(recv:, ev.data); }); ws.addEventListener(close, (ev) { console.log(closed: ${ev.code} ${ev.reason}); }); ws.addEventListener(error, () { console.log(transport error); });message事件里的ev.data类型取决于帧类型文本帧是 string二进制帧默认是 Blob。做二进制协议时先设ws.binaryType arraybuffer再包一层 Uint8Array 解析。还有一个顺序问题网络异常时浏览器会先触发 error 再触发 close两个事件都会到所以重连逻辑只放在 close 里error 里只打日志避免一次故障触发两次重连。3.2 Vue 项目里封装 useWebSocket组件卸载必须断连Vue 项目里接入 WebSocket常见问题集中在组件生命周期上路由切换后组件销毁连接还开着服务器端连接数一路涨。Vue 3 的标准做法是封装成组合式函数用 onBeforeUnmount 保证断连import { ref, onMounted, onBeforeUnmount } from vue; export function useWebSocket(url, { onMessage } {}) { const status ref(closed); let ws null; function connect() { ws new WebSocket(url); status.value connecting; ws.addEventListener(open, () { status.value open; }); ws.addEventListener(message, (ev) { if (onMessage) onMessage(ev.data); }); ws.addEventListener(close, () { status.value closed; }); } function send(data) { if (ws ws.readyState WebSocket.OPEN) ws.send(data); } onMounted(connect); onBeforeUnmount(() { if (ws) ws.close(); }); return { status, send }; }两个进阶点。第一多个组件同时调用这个函数会各自建连接最好把连接实例提升到 Pinia用单一连接加订阅者列表组件只向订阅器注册回调。第二遇到浏览器 H5 能连上、打包成 App 连不上先别怀疑代码大概率是证书链问题App 内嵌 WebView 对 wss 证书的校验比桌面浏览器严格用 https 域名加 443 端口在真机复测一次能排除掉大部分情况。3.3 Node 原生客户端补上浏览器没有的请求头能力浏览器 WebSocket 客户端有两个硬限制握手不能带自定义请求头也无法设置 Authorization。做服务器端对服务器端的连接、自动化测试脚本时用 ws 库的客户端模式补上这两个能力const WebSocket require(ws); const ws new WebSocket(ws://localhost:8080/ws, { headers: { Authorization: Bearer token }, handshakeTimeout: 5000, }); ws.on(open, () { ws.send(from node); }); ws.on(message, (data) { console.log(recv:, data.toString()); }); ws.on(close, (code, reason) { console.log(code, reason.toString()); });handshakeTimeout控制握手等待时间超过直接报错关闭避免客户端挂住。headers只在非浏览器环境可用浏览器端要么把 token 放 URL query要么放子协议里两种方式在鉴权部分会用到。Node 端最容易忽略的是ws.on(error, ...)EventEmitter 的 error 事件没有监听器时会直接抛异常把进程带崩生产环境的 Node WebSocket 客户端必须注册 error 监听。3.4 readyState 是联调时的第一张地图readyState值含义能否 sendCONNECTING0握手进行中否OPEN1双向可收发能CLOSING2已发 close 等待对端否CLOSED3连接已关闭否联调时先查状态再谈消息。连不上看握手连上就断看关闭码和心跳两个方向完全不同。面试里被问 WebSocket 和 HTTP 的区别时把客户端这四态迁移讲清楚再补一句服务器端可以主动推、客户端只能被动收基本就能覆盖考点。4. WebSocket联调排错1006 关闭、断线重连与半开连接4.1 onclose code 1006 到底是谁断开的1006 在 RFC 6455 里的定义是异常关闭意思是连接结束时没有收到对端的 close 帧。日志里常见的[websocket] onclose, code: 1006, reason:, reconnect: truereason 为空是正常的因为对端本来就没给你 reason。触发 1006 的有三类服务器端进程崩溃或直接 terminate()、客户端网络切换导致 TCP 被中间设备重置、网关层超时静默断开。怎么区分看服务器端日志。如果服务器端记录了 close(4001) 这类业务关闭码说明是应用层主动关的如果服务器端完全没有关闭记录断连发生在网络链路或者更靠外的网关上。Go 客户端常见的stream disconnected before completion: failed to send websocket request: io是同一类问题在不同语言里的表现发送请求时底层连接已死io 写操作直接失败。排查顺序固定为先看服务器端连接日志再看网关 error log 里有没有连接被提前关闭的记录最后才怀疑客户端代码。多数情况下问题在中间层不在两端。现象通常原因排查位置1006 且服务器端无任何关闭日志网络层断连或网关掐线网关日志、客户端网卡切换记录1006 且服务器端有 close 日志应用层主动断开服务器端关闭码与心跳判定客户端能发、收不到服务器端推送半开连接或网关超时心跳间隔 vs 网关超时配置4.2 断线重连指数退避和抖动缺一不可服务器端发布是 WebSocket 事故高发时刻。一批连接被断开所有客户端同时重连服务器端瞬间收到大量握手请求重启后再次被打垮。客户端重连必须带指数退避和抖动let attempt 0; let ws null; const maxDelay 15000; function connect() { ws new WebSocket(ws://localhost:8080/ws); ws.addEventListener(open, () { attempt 0; }); ws.addEventListener(close, (ev) { if (ev.code 4401 || ev.code 4403) return; const delay Math.min(1000 * 2 ** attempt, maxDelay) Math.random() * 500; attempt 1; setTimeout(connect, delay); }); ws.addEventListener(error, () ws.close()); } connect();attempt在 open 时清零保证网络恢复后重连间隔重新从 1 秒开始。随机抖动加在退避值后面让一批客户端错开重连窗口。4401 和 4403 这类服务器端踢人的关闭码要区别对待token 失效时继续重连只会无限打握手正确做法是停下来引导用户重新登录。退避封顶 15 秒是经验值对消息推送类够用实时性要求更高的场景可以降到 5 秒但要确认服务器端能扛住对应的握手频率。提示close 事件里的 code 要分流处理4401、4403 这类业务关闭码说明重连没有意义只有 1006 和网络类错误才进入退避重连。4.3 wscat 和 DevTools三层故障点怎么分连不上至少有三个层面DNS 和证书进不了门、Upgrade 握手被拒、握手过了但连接被立即断开。先用 wscat 做最小复现绕开浏览器的一切干扰# 测试握手和双向通信 wscat -c ws://localhost:8080/ws # 带 Origin 测试服务器端的白名单逻辑 wscat -c ws://localhost:8080/ws -H Origin: https://trusted.example # 自签名证书环境跳过证书校验 wscat -k -c wss://api.internal.example:8443/wswscat 无输出直接进交互模式说明握手成功返回 400 是路径或校验问题返回 404 通常是 Nginx 没把 Upgrade 头转发到后端连接被拒绝通常是端口没监听或防火墙拦截。浏览器端用 DevTools 的 Network 面板WebSocket 连接在 WS 分类下单独列出Frames 子页签能逐帧看到发送和收到的数据。Frames 里只有上行没有下行问题就锁定在服务器端业务逻辑跟协议层无关了。4.4 半开连接为什么查不出问题半开连接是最难复现的一类问题TCP 层还连着但应用层已经死掉。典型场景是电脑休眠唤醒或者手机从 WiFi 切到移动网络期间两端都不发数据TCP 协议栈不会主动发现对方失联。这时服务器端推消息客户端收不到但客户端发消息服务器端还能收到因为 TCP 重传机制在自己这一侧仍然工作。要根治只能靠心跳而且服务器端 ping 只能证明连接通业务是否正常还需要客户端主动上报业务心跳{ type: heartbeat, ts: 1700000000000, seq: 42 }服务器端记录每个连接最近一次业务心跳时间超过阈值先告警再断开。有一个经常被忽略的点心跳间隔要和网关超时联动。Nginx 默认proxy_read_timeout是 60 秒如果业务心跳间隔设成 55 秒任何一次轻微抖动都会让网关先掐断连接。建议业务心跳间隔 20-30 秒服务器端 ping 间隔 30 秒两层心跳错开避免在同一点竞争判断。5. WebSocket上线前最后一步广播、鉴权与连接数验证5.1 广播和群组连接集合的管理方式ws 库的wss.clients是当前进程内的连接集合广播就是遍历集合向所有 OPEN 状态的连接发送消息。群组要自己维护房间映射一个 Map 把 roomId 对应到一组 ws 实例连接 close 时从所有房间里摘除。这跟 Netty 里 ChannelGroup、Spring Boot 里 WebSocketSession 的管理是同一套逻辑区别只在 API 命名。const rooms new Map(); function joinRoom(roomId, ws) { const list rooms.get(roomId) || []; list.push(ws); rooms.set(roomId, list); ws.on(close, () { rooms.set(roomId, list.filter((c) c ! ws)); }); } function sendToRoom(roomId, message) { const list rooms.get(roomId) || []; const data JSON.stringify(message); for (const client of list) { if (client.readyState WebSocket.OPEN) client.send(data); } }发送前必须判断 readyStateclose 后残留的连接执行 send 会抛异常。多实例部署时rooms 这张表要放到 Redis Pub/Sub 或消息队列里本进程只负责自己实例上的连接其他实例在订阅通道上拿到广播内容后本地派发。这是单机示例变分布式服务的第一道坎。5.2 鉴权放握手阶段token 放哪由客户端类型决定浏览器端不能自定义请求头鉴权信息只能走 URL query 或子协议。查 netty websocket 怎么做鉴权、看 Spring Boot 的拦截器实现结论都一样在 upgrade 之前完成校验别等第一条消息。标准的握手前校验长这样const http require(http); const { URL } require(url); const { WebSocketServer } require(ws); const server http.createServer(); const wss new WebSocketServer({ server, path: /ws }); wss.on(connection, (ws, req) { const token new URL(req.url, http://localhost).searchParams.get(token); if (!verify(token)) { ws.close(4401, unauthorized); return; } }); server.listen(8080);先建 HTTP 服务再挂 WebSocketServer好处是健康检查、限流这些 HTTP 中间件能和 WebSocket 共用同一个端口。token 在 URL 里会进访问日志介意就换子协议传服务器端到服务器端则直接用自定义头。业务关闭码要设计成客户端可识别的枚举4401 未授权、4403 来源不允许、4001 心跳超时客户端的重连逻辑才能按码分流。5.3 验证一次连 500 个连接确认广播全量到达上线前别只开一个客户端手点。至少用脚本开几百条连接验证广播是否每个连接都收到const WebSocket require(ws); const COUNT 500; let received 0; const start Date.now(); for (let i 0; i COUNT; i) { const ws new WebSocket(ws://localhost:8080/ws); ws.on(message, (data) { if (data.toString().includes(broadcast)) { received; if (received COUNT) { console.log(broadcast delivered to ${COUNT} clients in ${Date.now() - start}ms); process.exit(0); } } }); }跑完看两个指标全量到达的耗时、是否有连接掉线。单机 500 连接内遍历发送耗时应远低于 100ms超过就要检查是不是日志写入或 JSON 序列化阻塞了事件循环。WebSocket 服务的监控指标跟普通 HTTP 不同重点看活跃连接数、握手失败率、重连频率。采集时重连频率务必按连接维度去重否则一次批量断连会让告警系统刷出上千条重复记录。本文还有配套的精品资源点击获取
分享:

看完干货,该让你的企业上线了

免费需求沟通 · 48 小时内出具建站方案 · 河南本地可上门