ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Node.js WebSocket长连接实战:心跳保活与断线重连源码解析

Node.js WebSocket长连接实战:心跳保活与断线重连源码解析

简介:一份面向WebSocket初学者的客户端与服务端实例源码包,由Java实现服务端,客户端基于HTML5 WebSocket。WebSocket可建立客户端与服务端之间的全双工长连接,这份实例展示了这一机制。资源支持客户端测试任意语言服务端,也可用服务端测试任意语言客户端,便于理解握手、消息收发及跨语言联动。运行环境要求服务端JDK7以上,客户端Tomcat7以上。压缩包共16个文件,主要包含Java源码、JSP页面、class文件和Eclipse工程配置(.project、.classpath、.settings等),源码与配置分离,可直接导入IDE运行。包内同时提供客户端与服务端两个工程模块,目录划分清晰,便于区分对照。整体仅16KB,结构精简,适合快速搭建测试环境或作为WebSocket入门模板。目前已有1264人学习下载,适合学习Java WebSocket、HTML5实时通信或需验证多语言服务端兼容性的开发者。通过该实例可掌握从客户端到服务端的完整调用链路,并以此为基础扩展聊天室、消息推送等实时应用;同时也可借助跨语言测试特性,排查不同语言实现下的连接异常问题。

1. webSocket实例源码:从零搭一套长连接通信,不只是跑通 demo

如果你写过多端应用,大概率经历过这样的场景:服务端算完一批数据,客户端这边却只能靠轮询一遍遍去问“好了吗”,问一次两次还行,一秒问十次,服务器和带宽都得跟着遭殃。这个 webSocket 客户端与服务端实例源码,就是用来解决“服务端主动找客户端说话”这个问题的。它里面有一套完整的 Node.js 服务端实现、一个可直接使用的客户端封装,以及基于原生 WebSocket 协议的消息收发示例,不是只演示“连接成功”的那种教学 demo,而是把连接管理、心跳保活、断线重连、消息广播这些生产环境里真正绕不开的细节都做了出来。适合正在做即时通讯、消息推送、实时看板、在线协作类项目的开发者,照着跑通之后,换成自己的业务协议就能直接接进项目里。

2. 整体架构与关键技术选型:为什么用 Node.js + 原生 ws 而不是 Socket.io

2.1 技术栈与协议基础:从 HTTP 到 WebSocket 的升级逻辑

入手这份源码之前,先理解它为什么这样选型。WebSocket 和 HTTP 的关系不是替代,而是互补——HTTP 是“请求-响应”模型,客户端不请求,服务端就没法主动发数据;WebSocket 则是在一次 HTTP 握手之后,把连接升级成一条全双工的 TCP 通道,双方随时可以往这条通道里写数据。

这份实例源码服务端选用的是 Node.js 环境下的 ws 库,没有使用 Socket.io。原因很务实:Socket.io 虽然自带降级方案和自动重连,但它封装层偏厚,遇到二进制流、自定义协议头这些场景时会觉得“隔了一层”。原生 ws 更贴近协议底层,连接状态、消息帧、心跳包都是自己可控的,二次开发时也更容易替换成其他语言的服务端实现。

// 服务端核心入口:创建 WebSocket 服务并处理连接 const { WebSocketServer } = require('ws'); const wss = new WebSocketServer({ port: 8080, path: '/ws' }); wss.on('connection', (ws, req) => { // req.url 里可以携带 token 等鉴权参数 console.log(`[连接接入] 客户端: ${req.socket.remoteAddress}`); ws.isAlive = true; ws.on('pong', () => { ws.isAlive = true; }); });

这段代码做了三件事:实例化 WebSocketServer 并监听 8080 端口的/ws路径;在 connection 回调里拿到握手请求对象 req,方便后续读取 URL 参数做鉴权;给每个连接对象挂上 isAlive 标记并监听 pong 事件,为后面的心跳检测机制做准备。需要留意的参数是path: '/ws',它限制了只有访问/ws路径的请求才会升级为 WebSocket 连接,这样做的好处是同一个端口上还可以继续提供 HTTP 接口服务,互不干扰。

2.2 目录结构与模块职责:每个文件在该管什么

拿到源码后第一件事不是看代码,而是看目录结构。这份实例采用的是典型的“服务端 + 客户端 + 共享协议”三段式布局:

websocket-demo/ ├── server/ │ ├── index.js # 服务端入口:创建服务、注册路由 │ ├── heartbeat.js # 心跳模块:定时检测连接健康状态 │ ├── broadcast.js # 广播模块:向所有连接推送消息 │ └── utils.js # 工具函数:消息封装、日志输出 ├── client/ │ ├── index.html # 浏览器端测试页面 │ ├── client.js # 客户端封装:连接、重连、心跳 │ └── style.css # 调试面板样式 ├── shared/ │ └── protocol.js # 前后端共用的消息协议常量 └── package.json

服务端拆成四个文件,目的是让连接管理、心跳逻辑、广播推送各自独立,改造时不用在几百行的 index.js 里到处翻。client.js 是纯前端实现,不依赖任何框架,直接 script 标签引入就能用。shared/protocol.js 这个文件很容易被忽略,但我建议你不要删——它把消息类型常量集中定义,前后端共用同一份,就不会出现“服务端发的是type: 'chat',客户端判断的是msgType: 'chat'”这种低级但对线上影响极大的不匹配问题。

3. 核心模块拆解:一个消息从客户端发出到服务端返回的完整链路

3.1 消息协议设计:JSON 格式与 type 字段的分发机制

先看 shared/protocol.js,这是整个系统能跑起来的“共同语言”。它定义了几种基础消息类型:

// shared/protocol.js const Protocol = { // 客户端 -> 服务端 C2S: { CHAT: 'chat', // 单聊消息 JOIN_ROOM: 'joinRoom', // 加入房间 LEAVE_ROOM: 'leaveRoom', // 离开房间 HEARTBEAT: 'ping' // 心跳包 }, // 服务端 -> 客户端 S2C: { CHAT: 'chat', // 聊天消息 SYSTEM: 'system', // 系统通知 HEARTBEAT: 'pong', // 心跳响应 ERROR: 'error' // 错误信息 } }; function packMessage(type, payload = {}) { return JSON.stringify({ type, payload, timestamp: Date.now() }); }

packMessage 函数把每次要发送的数据统一封装成{ type, payload, timestamp }三个字段的结构。type 决定这个消息“是什么”,payload 承载具体内容,timestamp 记录发送时间,既方便调试排查延迟问题,也为后续做消息去重或排序提供了时间依据。这套设计在真实项目中几乎是标准做法,你甚至可以把它直接搬进生产项目里。

3.2 服务端消息处理:从收到消息到广播给全部客户端的路由逻辑

服务端收到消息后,先解析 JSON,再按 type 字段分发处理,类似一个极简版的消息路由。看 index.js 里的核心处理逻辑:

ws.on('message', (data) => { let msg; try { msg = JSON.parse(data.toString()); } catch (e) { ws.send(packMessage(Protocol.S2C.ERROR, { message: '消息格式错误,需要 JSON 字符串' })); return; } switch (msg.type) { case Protocol.C2S.CHAT: // 业务处理:把收到的消息广播给所有在线客户端 wss.clients.forEach((client) => { if (client.readyState === WebSocket.OPEN) { client.send(packMessage(Protocol.S2C.CHAT, { from: msg.payload.from, content: msg.payload.content })); } }); break; case Protocol.C2S.HEARTBEAT: ws.send(packMessage(Protocol.S2C.HEARTBEAT, { time: Date.now() })); break; default: ws.send(packMessage(Protocol.S2C.ERROR, { message: `未知消息类型: ${msg.type}` })); } });

这里有个容易踩坑的细节:遍历wss.clients时必须检查readyState是否为WebSocket.OPEN。原因在于,wss.clients集合里可能包含正在关闭或已经关闭的连接,直接调用 send 方法会抛异常。这也是我在前文提到“用原生 ws 更贴近协议底层”的体现——这些细节 Socket.io 帮你做了,但你是看不见的,出了问题只能把黑匣子当黑匣子处理,毫无头绪。

消息分发逻辑本身很直白:解析失败则返回错误消息,不直接断开连接,给客户端一个容错机会;CHAT 类型把消息广播给所有在线客户端;HEARTBEAT 则立即回一个 pong,保障心跳机制正常运作。

3.3 心跳保活机制:定时巡检 + 超时断连,避免死连接拖死服务端

心跳机制是这套源码里最值得反复读的部分。WebSocket 连接在 TCP 层面不会主动通知你“对端已经消失”,比如手机 APP 被系统杀掉、Wi-Fi 切换到 4G,这些场景下 TCP 连接可能还“活着”,但实际已经联系不上对端了。如果不做清理,这些“死连接”会一直占用服务端资源。

心跳机制采取双向策略:客户端定时发送 ping,服务端回 pong,同时服务端也定时检查所有连接的活跃状态:

// server/heartbeat.js function startHeartbeatCheck(wss, interval = 30000) { const timer = setInterval(() => { wss.clients.forEach((ws) => { if (!ws.isAlive) { // 连续两个周期没有收到 pong,判定为死连接 ws.terminate(); return; } ws.isAlive = false; // 先标记为 false,等待下一次 pong 把它改回来 ws.ping(); // 主动发送 ping 帧 }); }, interval); wss.on('close', () => clearInterval(timer)); return timer; }

这段代码的巧妙之处在于:先把所有连接标记为“可疑”,只有主动 ping 且收到 pong 的连接才会被“平反”。这样即使某个客户端忘了发心跳,服务端也能主动探测到连接状态。ws.ping()是 ws 库提供的方法,发送的是 WebSocket 协议层面的控制帧,不会干扰业务消息的收发。间隔时间interval是参数字段,建议你根据实际场景调整——如果是手机端弱网环境,30 秒太长,改成 15 秒更稳妥;如果是服务端之间的内网通信,60 秒也完全可行。

3.4 客户端封装:自动重连、指数退避、心跳上报

客户端的实现同样不是简单 new 一个 WebSocket 就完事,而是封装了完整的状态管理:

// client/client.js class WSClient { constructor(url, options = {}) { this.url = url; this.reconnectAttempts = 0; this.maxReconnectAttempts = options.maxReconnectAttempts || 10; this.heartbeatInterval = options.heartbeatInterval || 20000; this.ws = null; this.listeners = {}; } connect() { this.ws = new WebSocket(this.url); this.ws.onopen = () => { console.log('连接已建立'); this.reconnectAttempts = 0; // 重置重连计数 this.startHeartbeat(); // 开启心跳定时器 }; this.ws.onclose = () => { console.log('连接关闭'); this.stopHeartbeat(); this.reconnect(); // 触发重连逻辑 }; this.ws.onerror = (err) => { console.error('连接错误:', err); }; } reconnect() { if (this.reconnectAttempts >= this.maxReconnectAttempts) { console.error('重连次数已达上限,请检查网络或服务端状态'); return; } const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempts), 30000); const attempt = ++this.reconnectAttempts; console.log(`第 ${attempt} 次重连,延迟 ${delay}ms`); setTimeout(() => this.connect(), delay); } startHeartbeat() { this.heartbeatTimer = setInterval(() => { this.send(Protocol.C2S.HEARTBEAT, {}); }, this.heartbeatInterval); } }

重连策略使用指数退避算法:第一次断开后 1 秒重连,第二次 2 秒,第三次 4 秒,以此类推直到 30 秒封顶。这样做的目的是避免服务端故障恢复时,几十个客户端同时疯狂重连,形成“惊群效应”把服务端再次打垮。maxReconnectAttempts参数默认设了上限,防止客户端在无网状态下无限重连耗尽手机电量——开发调试时可以调大,生产环境建议维持这个限制。

4. 避坑指南:跑这份源码最容易遇到的 4 个问题

4.1 现象:客户端连接时报 404,服务端完全没有收到请求

原因:path路径不匹配。如果服务端设置了path: '/ws',客户端连接地址就必须写成ws://localhost:8080/ws,写成ws://localhost:8080会直接 404。

解决:统一使用目录中的shared/protocol.js里导出的配置常量来拼接地址,而不是在 index.html 里手写 URL。如果必须要改路径,服务端和客户端要同步修改,建议做一个全局搜索确认两端一致。

4.2 现象:服务端向客户端发消息时抛错WebSocket is not open

原因:调用 send 方法时连接已经进入 CLOSING 或 CLOSED 状态。这个报错在遍历wss.clients广播消息时最常见,因为你无法确定每个连接的真实状态。

解决:发送前必须判断client.readyState === WebSocket.OPEN。如果你改写出更复杂的广播逻辑,建议单独封装一个 safeSend 函数:

function safeSend(ws, data) { if (ws.readyState === WebSocket.OPEN) { ws.send(data); } }

4.3 现象:浏览器端连接在 1 分钟左右被自动断开

原因:你的服务端部署在 Nginx 后面,而 Nginx 默认的 proxy_read_timeout 是 60 秒。WebSocket 连接如果在这个时间内没有任何数据传输,会被 Nginx 判定为超时并切断。这是 WebSocket 项目上生产环境时最高频的翻车点,本地连没事,一上服务器就断。

解决:在 Nginx 配置里加上长连接参数:

location /ws { proxy_pass http://127.0.0.1:8080; proxy_http_version 1.1; proxy_set_header Upgrade $http_upgrade; proxy_set_header Connection "upgrade"; proxy_read_timeout 3600s; }

同时确认客户端心跳间隔小于 60 秒,双重保障。

4.4 现象:服务端日志里频繁出现close事件,但查不到业务报错

原因:心跳检测机制把某些“活着但没响应”的连接判定为死连接并执行了 terminate。这类情况多半是客户端页面切到了后台,浏览器的定时器被降频,心跳 ping 发送不及时。

解决:在前端监听 visibilitychange 事件,页面切到后台时手动暂停心跳,切回来时立即补发一次 ping 并重置心跳定时器。这个小改动能让后台页面的连接寿命明显延长。

5. 本地运行与联调测试:从 clone 到模拟 100 个并发连接

5.1 环境准备与启动步骤:Node.js 版本要求和依赖安装

运行这份源码需要本机安装 Node.js。建议使用 14 及以上版本,因为代码中用到了一些较新的 JavaScript 语法。依赖只有一个 ws 库,安装成本极低:

# 在项目根目录执行 npm install # 启动服务端 node server/index.js

看到终端输出[服务启动] WebSocket 服务已监听 8080 端口就说明服务端就绪了。然后直接用浏览器打开client/index.html,页面会显示一个调试面板,输入昵称后点击“连接”,就能看到连接状态从 CONNECTING 变为 OPEN。

5.2 双端联调:用浏览器控制台模拟多客户端消息广播

浏览器端的调试面板只能模拟一个客户端,想验证消息广播机制,最直接的方式是打开多个浏览器标签页。每个标签页独立建立一条 WebSocket 连接,在其中任意一个页面发送消息,其他页面都应该实时收到。

如果想模拟更多并发连接,不建议开几十个标签页——浏览器对同一域名的并发连接数是有限制的。更专业的做法是写一个简单的 Node 脚本压测:

// 使用 Node.js 内置的 WebSocket 客户端批量创建连接 const WebSocket = require('ws'); const total = 100; const clients = []; for (let i = 0; i < total; i++) { const ws = new WebSocket('ws://localhost:8080/ws'); ws.on('open', () => { ws.send(JSON.stringify({ type: 'chat', payload: { from: `user-${i}`, content: 'hello' } })); }); clients.push(ws); } setTimeout(() => { clients.forEach(c => c.close()); process.exit(0); }, 10000);

这个脚本会创建 100 条连接并向服务端发送消息。运行后观察服务端日志,确认所有连接都正常建立和收发消息,没有出现内存溢出或事件循环阻塞。压测时重点关注服务端内存和 CPU 占用,如果内存持续增长不回落,大概率是消息监听器没释放,需要检查代码里是否有 addEventListener 后忘记 remove 的情况。

6. 二次开发:把这份源码改造成带鉴权和消息类型的 IM 系统

6.1 第一步:连接时校验身份——把 token 放进握手请求

当前源码的连接是匿名的,任何客户端都可以直接连上来。改造的第一步是加上鉴权。推荐的做法是把 token 放在 URL 查询参数里,服务端在 connection 回调中校验:

// server/index.js 改造 const url = require('url'); wss.on('connection', (ws, req) => { const params = url.parse(req.url, true).query; const token = params.token; if (!token || !verifyToken(token)) { ws.close(4001, '未经授权的连接'); return; } // 通过鉴权,继续后续逻辑 ws.userId = getUserIdFromToken(token); });

verifyToken 函数里你可以接 Redis 会话、JWT 解析或者数据库查询,只需要返回布尔值和用户 ID。注意 ws.close 的第二个参数要求是字符串,它会作为关闭原因发送给客户端,方便客户端判断重连策略——比如收到 4001 就不应该自动重连,而收到 1006 这种异常断开则应该立即重试。

6.2 第二步:从广播改成点对点——用 userId 精确定位接收方

当前所有消息都是广播给所有客户端,这在真实场景中不现实。改造的核心是通过 userId 维护一个“在线用户表”:

const onlineUsers = new Map(); wss.on('connection', (ws, req) => { // 假设上一步鉴权已经拿到了 ws.userId onlineUsers.set(ws.userId, ws); ws.on('close', () => { onlineUsers.delete(ws.userId); }); }); // 点对点发送 function sendToUser(userId, message) { const targetWs = onlineUsers.get(userId); if (targetWs && targetWs.readyState === WebSocket.OPEN) { targetWs.send(message); } }

Map 结构做用户映射天然合适,查找和删除都是 O(1) 复杂度。注意在 close 时清理 Map,否则长时间跑会产生内存泄漏。如果用户量很大,单机 Map 不够用,就需要引入 Redis 的 Pub/Sub 机制做跨节点消息路由,这就是后话了。

6.3 第三步:加入房间分组——支持多人会话和群聊

在点对点基础上再扩展一层房间分组,就接近一个可用的 IM 核心了:

const rooms = new Map(); // roomId -> Set<ws> function joinRoom(roomId, ws) { if (!rooms.has(roomId)) { rooms.set(roomId, new Set()); } rooms.get(roomId).add(ws); } function broadcastToRoom(roomId, message) { const room = rooms.get(roomId); if (!room) return; room.forEach(ws => { if (ws.readyState === WebSocket.OPEN) { ws.send(message); } }); }

房间数据结构和在线用户表类似,只是 value 从单个 WebSocket 换成了 Set 集合。配合前文的鉴权,你来设计群聊消息协议时只需要在 payload 里加上 roomId 字段,服务端收到消息后调用 broadcastToRoom 即可。

这套源码最让我满意的地方是它没有过度封装,每个机制都看得见摸得着。我第一次拿它改生产项目时,就是照着心跳模块重写了一套适合弱网环境的长连接方案,直接把移动端消息到达率提高了不少。从那以后我每次搭 WebSocket 服务,都强制自己先画一张连接生命周期图——从握手、鉴权、心跳、重连到关闭,每一步的状态和触发条件全标出来,然后再写代码,至少能少踩一半的坑。希望这套源码和这篇解析能帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进