【WebSocket 02】 握手拦截实现 Token 鉴权、Ping/Pong 心跳保活、前端断线自动重连

作者:逆境不可逃

技术永无止境

希望我的内容可以帮助到你!!!!


大家吼 ! 我是 逆境不可逃 今天给大家带来文章

《【WebSocket 02】 握手拦截实现 Token 鉴权、Ping/Pong 心跳保活、前端断线自动重连 》

本文章属于栏目 WebSocket

紧接上一篇文章

【WebSocket 01】 入门原理剖析,手写群发消息、私聊会话功能-CSDN博客

A . 第一部分 握手拦截器 + Token 鉴权(未登录禁止创建 WS 连接)

核心知识点

原生@ServerEndpoint无法在握手阶段拦截,JSR356 提供HandshakeInterceptor 握手拦截器,在 HTTP 升级 101 之前拦截请求:

  1. 握手阶段校验 URL 携带的 token,无效直接拒绝握手(不会创建 WS 连接)
  2. 把登录后的用户信息存入 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 连接

重点

  1. 拦截在HTTP 握手阶段,非法用户根本建立不了长连接,节省服务端资源
  2. attributes是拦截器和 WS 处理器的数据桥梁
  3. 两套实现区分:
    • JSR356:@ServerEndpoint,无法使用 Spring 拦截器
    • Spring:TextWebSocketHandler + HandshakeInterceptor,项目鉴权首选

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>

三、关键规则说明

  1. 被动断开(异常掉线、服务关停、断网,code≠1000):自动指数退避重连 1s → 2s →4s →8s →10s(之后永久 10s 一轮重试)
  2. 手动关闭 close (1000):直接停止重连,符合产品逻辑
  3. 重连成功后:retryCount重置为 0,下次断开重新从 1s 起步

四、测试方式

  1. 正常 token:admin123,输入 uid 打开页面,连接成功
  2. 测试 1:后端重启 SpringBoot 服务关闭→前端触发 onclose→按指数倒计时自动重连;重启服务后,等待倒计时结束自动连上
  3. 测试 2:点击【手动断开】 连接关闭,不会自动重连
  4. 测试 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>

四、心跳规则梳理

  1. 客户端每 20s 发一次 ping
  2. 服务端收到 ping 立刻返回 pong
  3. 客户端发 ping 后 8s 没收到 pong → 心跳丢失 + 1
  4. 连续丢 3 次 → 客户端主动close(3001)关闭连接,自动触发断线重连

五、两种测试方案

  1. 正常测试:打开页面,控制台每隔 20s 自动心跳收发 ping/pong,日志正常打印

Ping

Pong

  1. 模拟断网 / 僵死连接:禁用网卡 / 关闭后端,前端连续丢 3 次心跳→主动断连→进入指数重连

六、Nginx 补充知识点

Nginx 默认超时配置:

复制代码
proxy_read_timeout 60s;

小于心跳周期就会被掐连接,生产心跳间隔必须小于 nginx proxy_read_timeout(本案例 20s < 60s,安全)。

总结

本文介绍了WebSocket实战中的三个关键技术点:

  1. 握手拦截器+Token鉴权 - 通过Spring的HandshakeInterceptor在HTTP握手阶段拦截请求,校验Token有效性,未登录用户直接拒绝连接;

  2. 前端断线自动重连 - 采用指数退避算法实现自动重连(1s→2s→4s→...最大10s),区分被动断开和主动关闭场景;

  3. Ping/Pong心跳保活 - 客户端定时发送ping,服务端返回pong,连续3次未收到响应则判定掉线并触发重连机制,解决Nginx/防火墙空闲超时问题。文章包含完整的代码实现和测试方案。

相关推荐
安当加密1 小时前
汽车OTA升级怎么保证安全?从固件签名到密钥全生命周期管理
网络·安全·汽车
网络研究院1 小时前
关键基础设施与认知领域:网络攻击作为跨海事、能源和数字网络的胁迫工具
网络·能源·网络攻击·海洋·关键基础设施
小二·1 小时前
HTTPS 证书问题排查(SSL/TLS)实战
网络协议·https·ssl
byte轻骑兵2 小时前
【AVRCP】规范精讲[23]: 字符集切换全流程与两种典型场景解析
网络·人机交互·媒体·avrcp·媒体控制·车机蓝牙
InHand云飞小白2 小时前
连锁门店IT运维实战:如何用“云+端“架构解决分布式网络管理难题
运维·网络·5g·安全·智能路由器·5g路由器
Anthony_2312 小时前
Linux 从基础操作到故障排查
linux·运维·服务器·网络·nginx·ubuntu·centos
折翅鵬10 小时前
Android史诗级网络优化实践总结
android·网络
网安小白的进阶之路13 小时前
B模块 安全通信网络 第二门课IPv6与WLAN 01
网络·安全
学习3人组14 小时前
Cisco ASA防火墙 NAT实验:源NAT+目的NAT(Trust/Untrust双区域,无DMZ)
网络·网络安全