
作者:逆境不可逃
技术永无止境
希望我的内容可以帮助到你!!!!
大家吼 ! 我是 逆境不可逃 今天给大家带来文章
《【WebSocket 02】 握手拦截实现 Token 鉴权、Ping/Pong 心跳保活、前端断线自动重连 》
本文章属于栏目 WebSocket
紧接上一篇文章
【WebSocket 01】 入门原理剖析,手写群发消息、私聊会话功能-CSDN博客
A . 第一部分 握手拦截器 + Token 鉴权(未登录禁止创建 WS 连接)
核心知识点
原生@ServerEndpoint无法在握手阶段拦截,JSR356 提供HandshakeInterceptor 握手拦截器,在 HTTP 升级 101 之前拦截请求:
- 握手阶段校验 URL 携带的 token,无效直接拒绝握手(不会创建 WS 连接)
- 把登录后的用户信息存入 WS 会话,后续
@OnOpen直接取用,不用重复解析参数
流程:前端带 token 发起 HTTP 握手请求 → 拦截器校验 token → 校验失败返回非 101,连接直接断掉;校验成功 → 升级 WS。
1、新建握手拦截器
import org.springframework.http.server.ServerHttpRequest;
import org.springframework.http.server.ServerHttpResponse;
import org.springframework.http.server.ServletServerHttpRequest;
import org.springframework.web.socket.WebSocketHandler;
import org.springframework.web.socket.server.HandshakeInterceptor;
import java.util.Map;
public class TokenHandshakeInterceptor implements HandshakeInterceptor {
/**
* 握手前拦截,return false=拒绝连接
* attributes:可以存数据,后续onOpen从这里取值
*/
@Override
public boolean beforeHandshake(ServerHttpRequest request,
ServerHttpResponse response,
WebSocketHandler wsHandler,
Map<String, Object> attributes) throws Exception {
ServletServerHttpRequest servletRequest = (ServletServerHttpRequest) request;
// 获取url参数 token
String token = servletRequest.getServletRequest().getParameter("token");
// 模拟token校验:正式环境查Redis/数据库
if(token == null || !token.equals("admin123")){
System.out.println("token非法,拦截握手");
return false;
}
// 解析用户ID,存入attribute,传递到ws会话
String uid = servletRequest.getServletRequest().getParameter("uid");
attributes.put("uid", uid);
return true;
}
// 握手成功后回调,一般不用
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response, WebSocketHandler wsHandler, Exception exception) {
}
}
2、注意:拦截器不能搭配@ServerEndpoint注解模式!
@ServerEndpoint是 JSR356 原生,和 Spring 的HandshakeInterceptor两套体系。 切换为Spring 标准 WebSocketHandler 写法(企业主流),修改配置类注册端点 + 拦截器:
import org.springframework.context.annotation.Configuration;
import org.springframework.web.socket.config.annotation.EnableWebSocket;
import org.springframework.web.socket.config.annotation.WebSocketConfigurer;
import org.springframework.web.socket.config.annotation.WebSocketHandlerRegistry;
@Configuration
@EnableWebSocket //开启spring ws
public class WebSocketConfig implements WebSocketConfigurer {
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(new CustomWsHandler(), "/ws/demo")
.addInterceptors(new TokenHandshakeInterceptor()) //绑定自定义拦截器
.setAllowedOrigins("*"); //跨域,测试用,生产配置指定域名
}
}
3、自定义消息处理器 CustomWsHandler
import org.springframework.web.socket.CloseStatus;
import org.springframework.web.socket.TextMessage;
import org.springframework.web.socket.WebSocketSession;
import org.springframework.web.socket.handler.TextWebSocketHandler;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;
public class CustomWsHandler extends TextWebSocketHandler {
// uid -> session
private final ConcurrentHashMap<String, WebSocketSession> userMap = new ConcurrentHashMap<>();
/** 连接成功(握手放行后才进入) */
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
// 从拦截器存入的属性取出uid
String uid = (String) session.getAttributes().get("uid");
userMap.put(uid, session);
System.out.println("用户"+uid+"上线,在线:"+userMap.size());
session.sendMessage(new TextMessage("你的ID:" + uid));
}
/** 收到客户端消息 */
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String uid = (String) session.getAttributes().get("uid");
String content = message.getPayload();
System.out.println(uid + ":" + content);
// 沿用之前规则:@uid:xxx私聊,否则群发
if(content.startsWith("@")){
String[] arr = content.split(":",2);
String targetUid = arr[0].replace("@","");
String msg = arr[1];
WebSocketSession target = userMap.get(targetUid);
if(target != null && target.isOpen()){
target.sendMessage(new TextMessage("【私聊"+uid+"】"+msg));
}else{
session.sendMessage(new TextMessage("目标用户不在线"));
}
}else{
//群发
for(WebSocketSession s : userMap.values()){
s.sendMessage(new TextMessage("【"+uid+"群发】"+content));
}
}
}
/** 连接关闭 */
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
String uid = (String) session.getAttributes().get("uid");
userMap.remove(uid);
System.out.println("用户"+uid+"下线");
}
}
4、前端页面改造(连接拼接 token+uid)
// 正确参数:token=admin123&uid=1001
let uid = prompt("输入用户ID");
let token = prompt("输入token,正确值:admin123");
let ws = new WebSocket(`ws://localhost:8080/ws/demo?token=${token}&uid=${uid}`);
5、测试规则
token 填admin123:握手成功,正常收发消息、私聊群发


token 随便填 / 空:握手失败,前端直接触发onclose,后端不会创建任何 WS 连接


重点
- 拦截在HTTP 握手阶段,非法用户根本建立不了长连接,节省服务端资源
attributes是拦截器和 WS 处理器的数据桥梁- 两套实现区分:
- JSR356:
@ServerEndpoint,无法使用 Spring 拦截器 - Spring:
TextWebSocketHandler + HandshakeInterceptor,项目鉴权首选
- JSR356:
B . 第二部分 前端断线自动重连(指数退避算法)
一、业务痛点
网络波动、切后台、Nginx 空闲超时、服务重启都会导致 WS 被动onclose,原生不会自动重连,用户需要手动刷新页面,所以必须前端自主实现重连。
核心方案:指数退避重试
重连间隔:1s → 2s → 4s → 8s...上限10s,避免短时间疯狂请求打垮服务,到达最大间隔后固定时长重试。
二、完整改造前端代码
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8">
<title>WS自动重连</title>
</head>
<body>
<div>
<h3>消息接收区</h3>
<div id="msgBox" style="border:1px solid #ccc;height:300px;overflow-y:auto;padding:10px;"></div>
<br>
<input type="text" id="inputMsg" placeholder="群发直接输内容,私聊@uid:消息">
<button onclick="sendMsg()">发送</button>
<button onclick="closeConn()">手动断开</button>
</div>
<script>
let msgBox = document.getElementById("msgBox");
let inputMsg = document.getElementById("inputMsg");
// 重连配置
const config = {
baseDelay: 1000, // 初始1s
maxDelay: 10000, // 最大间隔10s
retryCount: 0, // 当前重试次数
timer: null, // 重连定时器
uid: prompt("输入用户ID"),
token: prompt("输入token:admin123")
}
let ws = null;
// 初始化连接
function initConnect(){
// 销毁旧定时器
if(config.timer) clearTimeout(config.timer);
let url = `ws://localhost:8080/ws/demo?token=${config.token}&uid=${config.uid}`;
ws = new WebSocket(url);
// 连接成功
ws.onopen = function(){
appendMsg("✅ 连接成功,重连次数清零");
config.retryCount = 0; // 连上后重试计数归零
}
// 收到消息
ws.onmessage = function(e){
appendMsg("📩 服务端:" + e.data);
}
// 连接关闭触发重连
ws.onclose = function(e){
appendMsg(`❌ 连接断开,准备自动重连,code:${e.code}`);
reconnect();
}
ws.onerror = function(){
appendMsg("⚠️ 连接异常");
}
}
// 指数退避重连逻辑
function reconnect(){
// 计算间隔:base * 2^次数,不超过最大值
let delay = config.baseDelay * Math.pow(2, config.retryCount);
delay = Math.min(delay, config.maxDelay);
appendMsg(`⏳ ${delay/1000}秒后进行第${config.retryCount+1}次重连`);
config.timer = setTimeout(()=>{
config.retryCount++;
initConnect();
}, delay)
}
// 发送消息(增加连接状态判断)
function sendMsg(){
let val = inputMsg.value.trim();
if(!val) return;
if(ws.readyState !== WebSocket.OPEN){
appendMsg("⚠️ 当前未连上,无法发送");
return;
}
ws.send(val);
appendMsg("👤 我:" + val);
inputMsg.value = "";
}
// 手动关闭(主动关闭不触发重连)
function closeConn(){
if(ws && ws.readyState === WebSocket.OPEN){
// 1000是正常关闭码,约定:code=1000用户手动退出,不重连
ws.close(1000,"用户手动关闭");
if(config.timer) clearTimeout(config.timer);
appendMsg("已手动关闭,不再自动重连");
}
}
// 打印消息
function appendMsg(text){
msgBox.innerHTML += text + "<br>";
msgBox.scrollTop = msgBox.scrollHeight;
}
// 页面初始化建立连接
initConnect();
// 页面卸载清除定时器,防止后台继续重连
window.onbeforeunload = ()=>{
if(config.timer) clearTimeout(config.timer);
}
</script>
</body>
</html>
三、关键规则说明
- 被动断开(异常掉线、服务关停、断网,code≠1000):自动指数退避重连 1s → 2s →4s →8s →10s(之后永久 10s 一轮重试)
- 手动关闭 close (1000):直接停止重连,符合产品逻辑
- 重连成功后:
retryCount重置为 0,下次断开重新从 1s 起步
四、测试方式
- 正常 token:
admin123,输入 uid 打开页面,连接成功 - 测试 1:后端重启 SpringBoot 服务关闭→前端触发 onclose→按指数倒计时自动重连;重启服务后,等待倒计时结束自动连上
- 测试 2:点击【手动断开】 连接关闭,不会自动重连
- 测试 3:输错 token 握手直接失败,不断重试(模拟登录过期场景,后续结合 token 过期改造)

关闭服务端

重启服务端

C . 第二部分 Ping/Pong 心跳保活机制(解决 Nginx / 防火墙空闲超时断连)
一、问题背景
Nginx、路由器、防火墙默认空闲一段时间自动掐断 TCP 连接 (默认 60s~300s 无数据就断开),两端无任何 onclose 提示,前端以为在线、后端以为在线,消息发不出去→僵死连接内存泄漏 。 解决方案:心跳保活,前端定时发 ping,后端回复 pong;超时没收到 pong 判定掉线,主动关闭触发重连。
约定通信规范:
- 前端发送字符串:
ping - 后端收到
ping原样返回:pong
二、后端改造(沿用上一节 Spring TextWebSocketHandler 代码)
只修改CustomWsHandler#handleTextMessage,新增心跳判断:
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
String uid = (String) session.getAttributes().get("uid");
String content = message.getPayload();
// ==========心跳处理 start==========
if("ping".equals(content)){
session.sendMessage(new TextMessage("pong"));
return;
}
// ==========心跳处理 end==========
System.out.println(uid + ":" + content);
// 原有私聊、群发逻辑不变
if(content.startsWith("@")){
String[] arr = content.split(":",2);
String targetUid = arr[0].replace("@","");
String msg = arr[1];
WebSocketSession target = userMap.get(targetUid);
if(target != null && target.isOpen()){
target.sendMessage(new TextMessage("【私聊"+uid+"】"+msg));
}else{
session.sendMessage(new TextMessage("目标用户不在线"));
}
}else{
for(WebSocketSession s : userMap.values()){
s.sendMessage(new TextMessage("【"+uid+"群发】"+content));
}
}
}
三、前端改造:新增心跳定时器 + 超时掉线逻辑
在原有 JS 代码基础上,新增心跳配置,完整替换 script:
<script>
let msgBox = document.getElementById("msgBox");
let inputMsg = document.getElementById("inputMsg");
// 重连+心跳全局配置
const config = {
baseDelay: 1000,
maxDelay: 10000,
retryCount: 0,
timer: null, // 重连定时器
heartTimer: null, // 心跳发送定时器
heartTimeout: null, // 心跳超时定时器
heartInterval: 20000,// 20s发一次ping
heartFailCount: 0, // 连续未收到pong次数
maxHeartFail:3, // 丢失3次心跳判定掉线
uid: prompt("输入用户ID"),
token: prompt("输入token:admin123")
}
let ws = null;
// 初始化连接
function initConnect(){
clearAllTimer(); // 清空所有旧定时器
let url = `ws://localhost:8080/ws/demo?token=${config.token}&uid=${config.uid}`;
ws = new WebSocket(url);
ws.onopen = function(){
appendMsg("✅ 连接成功,重连次数清零,开启心跳");
config.retryCount = 0;
config.heartFailCount = 0;
startHeartBeat(); // 开启心跳
}
ws.onmessage = function(e){
// 收到pong,重置失败计数,清除超时
if(e.data === "pong"){
config.heartFailCount = 0;
clearTimeout(config.heartTimeout);
return;
}
appendMsg("📩 服务端:" + e.data);
}
ws.onclose = function(e){
appendMsg(`❌ 连接断开code:${e.code}`);
clearAllTimer();
reconnect();
}
ws.onerror = function(){
appendMsg("⚠️ 连接异常");
}
}
// 开启心跳:定时发ping
function startHeartBeat(){
config.heartTimer = setInterval(()=>{
if(ws.readyState !== WebSocket.OPEN) return;
ws.send("ping");
// 单次ping超时计时
config.heartTimeout = setTimeout(()=>{
config.heartFailCount++;
appendMsg(`⚠️ 心跳丢失${config.heartFailCount}次`);
// 连续丢包达到阈值,主动断连触发重连
if(config.heartFailCount >= config.maxHeartFail){
appendMsg("💔 心跳失联,主动关闭连接");
ws.close(3001,"心跳超时断开");
}
},8000); // 8s没收到pong算单次丢失
},config.heartInterval);
}
// 清理全部定时器:防止内存残留
function clearAllTimer(){
if(config.timer) clearTimeout(config.timer);
if(config.heartTimer) clearInterval(config.heartTimer);
if(config.heartTimeout) clearTimeout(config.heartTimeout);
}
// 指数退避重连(原有逻辑不变)
function reconnect(){
let delay = config.baseDelay * Math.pow(2, config.retryCount);
delay = Math.min(delay, config.maxDelay);
appendMsg(`⏳ ${delay/1000}s后第${config.retryCount+1}次重连`);
config.timer = setTimeout(()=>{
config.retryCount++;
initConnect();
}, delay)
}
// 发送消息
function sendMsg(){
let val = inputMsg.value.trim();
if(!val) return;
if(ws.readyState !== WebSocket.OPEN){
appendMsg("⚠️ 当前未连上,无法发送");
return;
}
ws.send(val);
appendMsg("👤 我:" + val);
inputMsg.value = "";
}
// 手动关闭
function closeConn(){
if(ws && ws.readyState === WebSocket.OPEN){
ws.close(1000,"用户手动关闭");
clearAllTimer();
appendMsg("已手动关闭,停止心跳与重连");
}
}
function appendMsg(text){
msgBox.innerHTML += text + "<br>";
msgBox.scrollTop = msgBox.scrollHeight;
}
initConnect();
window.onbeforeunload = ()=> clearAllTimer();
</script>
四、心跳规则梳理
- 客户端每 20s 发一次 ping
- 服务端收到 ping 立刻返回 pong
- 客户端发 ping 后 8s 没收到 pong → 心跳丢失 + 1
- 连续丢 3 次 → 客户端主动
close(3001)关闭连接,自动触发断线重连
五、两种测试方案
- 正常测试:打开页面,控制台每隔 20s 自动心跳收发 ping/pong,日志正常打印
Ping

Pong

- 模拟断网 / 僵死连接:禁用网卡 / 关闭后端,前端连续丢 3 次心跳→主动断连→进入指数重连
六、Nginx 补充知识点
Nginx 默认超时配置:
proxy_read_timeout 60s;
小于心跳周期就会被掐连接,生产心跳间隔必须小于 nginx proxy_read_timeout(本案例 20s < 60s,安全)。
总结
本文介绍了WebSocket实战中的三个关键技术点:
-
握手拦截器+Token鉴权 - 通过Spring的HandshakeInterceptor在HTTP握手阶段拦截请求,校验Token有效性,未登录用户直接拒绝连接;
-
前端断线自动重连 - 采用指数退避算法实现自动重连(1s→2s→4s→...最大10s),区分被动断开和主动关闭场景;
-
Ping/Pong心跳保活 - 客户端定时发送ping,服务端返回pong,连续3次未收到响应则判定掉线并触发重连机制,解决Nginx/防火墙空闲超时问题。文章包含完整的代码实现和测试方案。
