分布式微服务系统架构第120集:专业WebSocket鉴权

加群联系作者vx:xiaoda0423

仓库地址:webvueblog.github.io/JavaPlusDoc...

1024bat.cn/

能力 说明
支持 Header 传 token 不光从 URL,也能从 WebSocket 请求头拿 token
握手失败自定义返回码 拒绝连接时能带上明确的 HTTP 错误码,比如 401 未授权
支持主动踢人下线 比如token过期、恶意连接,可以主动断开
统一异常处理 保证连接失败时有标准返回,不是莫名其妙报错

🛠️ 进阶版完整代码


(1)改造 BaseEndpointConfigure --- 高级版

typescript 复制代码
import javax.websocket.HandshakeResponse;
import javax.websocket.server.HandshakeRequest;
import javax.websocket.server.ServerEndpointConfig;
import javax.servlet.http.HttpSession;
import java.util.List;
import java.util.Map;

public class BaseEndpointConfigure extends ServerEndpointConfig.Configurator {

    @Override
    public void modifyHandshake(ServerEndpointConfig sec, HandshakeRequest request, HandshakeResponse response) {
        String token = extractToken(request);

        if (!validateToken(token)) {
            // 拒绝握手,抛一个特殊异常
            throw new WebSocketAuthenticationException("Token无效,拒绝连接");
        }

        // 保存到用户属性,后续可以从Session里拿
        sec.getUserProperties().put("token", token);
    }

    private String extractToken(HandshakeRequest request) {
        // 1. 优先从 Header 中取
        Map<String, List<String>> headers = request.getHeaders();
        List<String> tokenList = headers.get("Sec-WebSocket-Protocol");
        if (tokenList != null && !tokenList.isEmpty()) {
            return tokenList.get(0);
        }

        // 2. 如果Header没有,从 URL query 参数取
        String query = request.getQueryString();
        if (query != null && query.startsWith("token=")) {
            return query.substring(6);
        }
        return null;
    }

    private boolean validateToken(String token) {
        // TODO: 这里可以接你的认证中心、JWT解析、Redis等逻辑
        return token != null && token.length() > 5;
    }
}

(2)定义一个「握手鉴权异常」WebSocketAuthenticationException

scala 复制代码
public class WebSocketAuthenticationException extends RuntimeException {
    public WebSocketAuthenticationException(String message) {
        super(message);
    }
}

(3)在 WebSocketConfig添加统一异常处理

serverEndpointExporter() 方法外包装一层 try-catch。

typescript 复制代码
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Bean;
import org.springframework.web.socket.server.standard.ServerEndpointExporter;

@Configuration
public class WebSocketConfig {

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }

    @Bean
    public BaseEndpointConfigure newConfigure() {
        return new BaseEndpointConfigure();
    }
}

(4)改造 @ServerEndpoint --- 带token、踢人

typescript 复制代码
import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;

@ServerEndpoint(value = "/ws/echo", configurator = BaseEndpointConfigure.class)
public class EchoWebSocket {

    @OnOpen
    public void onOpen(Session session, EndpointConfig config) {
        String token = (String) config.getUserProperties().get("token");
        System.out.println("WebSocket连接建立,token=" + token);

        // 你可以在这里根据业务逻辑判断
        // 比如token过期了,踢掉
        if ("expiredToken".equals(token)) {
            try {
                session.close(new CloseReason(CloseReason.CloseCodes.VIOLATED_POLICY, "Token已过期,断开连接"));
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    @OnMessage
    public void onMessage(String message, Session session) {
        try {
            session.getBasicRemote().sendText("服务端返回:" + message);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @OnClose
    public void onClose(Session session) {
        System.out.println("WebSocket连接关闭");
    }

    @OnError
    public void onError(Session session, Throwable error) {
        System.err.println("WebSocket发生错误:" + error.getMessage());
    }
}

🧠 核心变化总结

项目 变化点
token提取 支持 Header 优先,URL 兜底
校验逻辑 灵活扩展,可以接认证中心、数据库
握手失败 自定义异常,友好提示
连接后控制 支持业务主动踢人
统一处理 保证握手和运行阶段都能优雅处理异常

📈 提升体验Tips(可选项)

  • 如果想让客户端更友好收到拒绝原因,可以自定义 WebSocket握手拦截器 → 返回HTTP状态码,比如401
  • 可以加上心跳检测机制,比如客户端定时发送ping消息,服务端回复pong
  • 可以加SessionManager,统一管理所有在线Session(方便批量踢人/广播消息)
  • 连接时支持 token
  • 握手失败给明确的拒绝理由
  • 连接成功后可以踢人
  • 全程异常不留死角
  • 可无缝对接后端认证体系

🎯 总体目标

  • 每个登录用户,只能有一个有效WebSocket连接
  • 新连接建立时,踢掉老连接(比如账号在别的地方登录)
  • 有一个统一Session管理器 ,支持:
    • 根据 userId 查找Session
    • 踢人(主动断开连接)
    • 广播消息(选配)
  • 接入现有的 token鉴权流程,自然兼容!

🏗️ 正式搭建


(1)新增统一管理类:WebSocketSessionManager

typescript 复制代码
import javax.websocket.Session;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;

public class WebSocketSessionManager {

    // userId -> Session
    private static final ConcurrentHashMap<String, Session> userSessionMap = new ConcurrentHashMap<>();

    /**
     * 添加新的连接
     */
    public static void addSession(String userId, Session session) {
        // 如果有旧连接,先踢掉
        Session oldSession = userSessionMap.put(userId, session);
        if (oldSession != null && oldSession.isOpen()) {
            try {
                oldSession.close(); // 踢掉老连接
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 移除连接
     */
    public static void removeSession(String userId) {
        userSessionMap.remove(userId);
    }

    /**
     * 通过userId获取Session
     */
    public static Session getSession(String userId) {
        return userSessionMap.get(userId);
    }

    /**
     * 主动发送消息
     */
    public static void sendMessage(String userId, String message) {
        Session session = getSession(userId);
        if (session != null && session.isOpen()) {
            try {
                session.getBasicRemote().sendText(message);
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 踢掉某个用户
     */
    public static void kickOutUser(String userId, String reason) {
        Session session = getSession(userId);
        if (session != null && session.isOpen()) {
            try {
                session.close(new javax.websocket.CloseReason(
                        javax.websocket.CloseReason.CloseCodes.NORMAL_CLOSURE, reason
                ));
            } catch (IOException e) {
                e.printStackTrace();
            }
            removeSession(userId);
        }
    }
}

(2)在 @ServerEndpoint 里接入管理器

比如在 EchoWebSocket 里用:

typescript 复制代码
import javax.websocket.*;
import javax.websocket.server.ServerEndpoint;
import java.io.IOException;

@ServerEndpoint(value = "/ws/echo", configurator = BaseEndpointConfigure.class)
public class EchoWebSocket {

    private String userId;

    @OnOpen
    public void onOpen(Session session, EndpointConfig config) {
        String token = (String) config.getUserProperties().get("token");
        
        // 假设这里解析出来userId(一般token里面带的)
        this.userId = parseUserIdFromToken(token);

        System.out.println("WebSocket连接建立,userId=" + userId);

        // 加入Session管理器
        WebSocketSessionManager.addSession(userId, session);
    }

    @OnMessage
    public void onMessage(String message, Session session) {
        try {
            session.getBasicRemote().sendText("服务端返回:" + message);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @OnClose
    public void onClose(Session session) {
        System.out.println("WebSocket连接关闭,userId=" + userId);
        WebSocketSessionManager.removeSession(userId);
    }

    @OnError
    public void onError(Session session, Throwable error) {
        System.err.println("WebSocket发生错误:" + error.getMessage());
    }

    private String parseUserIdFromToken(String token) {
        // TODO: 实际可以解析JWT或者查Redis拿到userId
        return token; // 这里简单演示,假设token本身就是userId
    }
}

✅ 这样每次有新连接:

  • 自动把老的连接踢掉
  • 新的连接注册进管理器
  • 关闭时/出错时也自动清理Session

(3)其他地方想踢人怎么办?

比如后台管理系统发现用户异常,需要踢人:

arduino 复制代码
WebSocketSessionManager.kickOutUser("userId123", "你的账号在别处登录,已被强制下线");

一行代码,干掉!

📈 整体流程图

rust 复制代码
客户端连接 -> 解析token -> 解析userId -> WebSocketSessionManager管理session
                 ↓
        同userId已有session?
                ↓是
        踢掉老session,注册新session
  • userSessionMap 也可以换成 RedissonRMap,支持分布式节点同步踢人
  • 可以在服务端定时心跳检测,把死掉的连接清理掉
  • 可以扩展消息总线,做到一条命令踢掉多个服务节点的连接
能力 说明
在线用户统一管理
支持单点登录强制踢掉旧连接
异常情况自动清理Session
跨服务节点可以扩展Redisson同步

📦 WebSocket心跳机制 + 服务端检测离线清理 + 自动重连 + 分布式节点同步踢人

支持单机 & 多节点分布式同步

🛠️ 1. 整体设计目标

功能模块 说明
心跳机制 客户端定时发心跳(比如5秒),服务器更新最后活跃时间
离线清理 服务器定时扫描,超时未心跳的连接强制踢掉
自动重连 客户端断开后自动发起重连
分布式同步踢人 多节点部署,用 Redis 发布/订阅消息,所有节点同步踢人

🏗️ 2. 服务端改造步骤


(1)WebSocketSessionManager 加强版

不仅保存 Session,还记录最后活跃时间戳

java 复制代码
import javax.websocket.Session;
import java.io.IOException;
import java.util.concurrent.ConcurrentHashMap;

public class WebSocketSessionManager {

    static class SessionInfo {
        Session session;
        volatile long lastActiveTime;

        SessionInfo(Session session) {
            this.session = session;
            this.lastActiveTime = System.currentTimeMillis();
        }
    }

    // userId -> SessionInfo
    private static final ConcurrentHashMap<String, SessionInfo> userSessionMap = new ConcurrentHashMap<>();

    public static void addSession(String userId, Session session) {
        SessionInfo oldInfo = userSessionMap.put(userId, new SessionInfo(session));
        if (oldInfo != null && oldInfo.session.isOpen()) {
            try {
                oldInfo.session.close();
            } catch (IOException e) {
                e.printStackTrace();
            }
        }
    }

    public static void removeSession(String userId) {
        userSessionMap.remove(userId);
    }

    public static Session getSession(String userId) {
        SessionInfo info = userSessionMap.get(userId);
        return info != null ? info.session : null;
    }

    public static void updateActiveTime(String userId) {
        SessionInfo info = userSessionMap.get(userId);
        if (info != null) {
            info.lastActiveTime = System.currentTimeMillis();
        }
    }

    public static void kickOutUser(String userId, String reason) {
        Session session = getSession(userId);
        if (session != null && session.isOpen()) {
            try {
                session.close(new javax.websocket.CloseReason(
                        javax.websocket.CloseReason.CloseCodes.NORMAL_CLOSURE, reason
                ));
            } catch (IOException e) {
                e.printStackTrace();
            }
            removeSession(userId);
        }
    }

    // 定时扫描超时的Session
    public static void clearInactiveSessions(long timeoutMillis) {
        long now = System.currentTimeMillis();
        userSessionMap.forEach((userId, info) -> {
            if (now - info.lastActiveTime > timeoutMillis) {
                System.out.println("清理超时连接 userId=" + userId);
                kickOutUser(userId, "连接超时无心跳,已被踢出");
            }
        });
    }
}

(2)WebSocket接口调整(EchoWebSocket示例)

接收心跳消息,刷新活跃时间!

typescript 复制代码
@ServerEndpoint(value = "/ws/echo", configurator = BaseEndpointConfigure.class)
public class EchoWebSocket {

    private String userId;

    @OnOpen
    public void onOpen(Session session, EndpointConfig config) {
        String token = (String) config.getUserProperties().get("token");
        this.userId = parseUserIdFromToken(token);

        WebSocketSessionManager.addSession(userId, session);
        System.out.println("WebSocket连接建立:" + userId);
    }

    @OnMessage
    public void onMessage(String message, Session session) {
        if ("heartbeat".equals(message)) {
            WebSocketSessionManager.updateActiveTime(userId);
            return;
        }

        try {
            session.getBasicRemote().sendText("服务端回应:" + message);
        } catch (Exception e) {
            e.printStackTrace();
        }
    }

    @OnClose
    public void onClose(Session session) {
        WebSocketSessionManager.removeSession(userId);
        System.out.println("WebSocket连接关闭:" + userId);
    }

    @OnError
    public void onError(Session session, Throwable error) {
        System.err.println("WebSocket错误:" + error.getMessage());
    }

    private String parseUserIdFromToken(String token) {
        return token;
    }
}

(3)加一个定时任务清理死连接

可以用 Spring Task 简单搞定:

java 复制代码
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;

@Component
public class WebSocketHeartbeatChecker {

    // 每30秒检查一次
    @Scheduled(fixedDelay = 30000)
    public void clearInactiveSessions() {
        long timeout = 60_000; // 1分钟无心跳,认为超时
        WebSocketSessionManager.clearInactiveSessions(timeout);
    }
}

(4)分布式节点同步踢人(Redisson Pub/Sub)

新增监听Redis消息,同步踢人:

发布踢人:

typescript 复制代码
@Autowired
private RedissonClient redissonClient;

public void kickUserGlobally(String userId) {
    // 发送广播消息到 Redis
    redissonClient.getTopic("websocket:kickout").publish(userId);
    // 本地也踢一次(防止延迟)
    WebSocketSessionManager.kickOutUser(userId, "系统通知:账号被踢下线");
}

监听 Redis 消息:

kotlin 复制代码
import org.redisson.api.listener.MessageListener;
import org.springframework.stereotype.Component;

import javax.annotation.PostConstruct;
import javax.annotation.Resource;

@Component
public class KickoutSubscriber {

    @Resource
    private RedissonClient redissonClient;

    @PostConstruct
    public void subscribeKickout() {
        redissonClient.getTopic("websocket:kickout").addListener(String.class, (channel, userId) -> {
            System.out.println("收到踢人消息,踢掉 userId=" + userId);
            WebSocketSessionManager.kickOutUser(userId, "分布式踢人同步");
        });
    }
}

🏗️ 整体目标

功能模块 说明
单节点推送 给指定用户推送消息(单发)
广播推送 给所有在线用户推送消息(群发)
分布式同步 多节点部署时,广播推送同步到所有节点(用 Redis 发布订阅)
高可用容错 断网/重连/节点故障下自动修复连接

这是一个 Spring Boot 中专门用于支持 WebSocket 的配置类。

它主要做两件事:

主要内容 说明
注册 WebSocket 端点 通过 ServerEndpointExporter 自动扫描并注册使用了 @ServerEndpoint 注解的类
自定义配置注入 通过 BaseEndpointConfigure 提供 WebSocket 握手过程中的扩展(比如自定义鉴权、拦截器、参数绑定)

1. ServerEndpointExporter 作用

java 复制代码
@Bean
public ServerEndpointExporter serverEndpointExporter() 
  • 开启 WebSocket 支持
    ServerEndpointExporter 是 Spring Boot + WebSocket 集成中必需的。
    它会去扫描应用中所有用 @ServerEndpoint 注解标记的类,并把它们注册为 WebSocket 的服务端点(Endpoint)。
  • 没有它会怎样?
    ➔ WebSocket 服务启动不起来,@ServerEndpoint 不生效,客户端也连接不上。

✅ 加了它之后,像这种 @ServerEndpoint 标注的类才能工作:

kotlin 复制代码
@ServerEndpoint("/ws/notify")
public class NotificationWebSocket {
    ...
}

2. BaseEndpointConfigure 作用

java 复制代码
@Bean
public BaseEndpointConfigure newConfigure()
  • 这是你自定义的一个扩展点 (比如继承 ServerEndpointConfig.Configurator)。

  • 可以在这里 控制 WebSocket 建立连接前后的逻辑,比如:

    • 自定义 session 属性
    • 握手时进行用户鉴权(比如通过 token 获取 userId)
    • 注入 Spring Bean 进 WebSocket 类中(解决 @ServerEndpoint 里不能用 @Autowired 问题)

🔔 通常 BaseEndpointConfigure 里会 override 这些方法

java 复制代码
@Override
public <T> T getEndpointInstance(Class<T> endpointClass) throws InstantiationException {
    return ApplicationContextHolder.getBean(endpointClass); // 让 WebSocket 支持注入 Spring Bean
}

@Override
public void modifyHandshake(ServerEndpointConfig config, HandshakeRequest request, HandshakeResponse response) {
    // 在握手阶段,提取 Token 鉴权,传递用户信息到 WebSocket
}

🎯 什么时候需要写这个 WebSocketConfig?

场景 是否需要
Spring Boot 原生 WebSocket 项目 ✅ 必须要有
项目中有 @ServerEndpoint 注解的 WebSocket类 ✅ 必须要有
需要自定义握手逻辑,比如认证、授权 ✅ 必须要有
使用第三方容器,比如外置Tomcat ❌ 可以不用(但建议也加上 BaseEndpointConfigure)

这段 WebSocketConfig 就是:

让你的 WebSocket 服务端点能在 Spring Boot 项目中自动生效,并且支持自定义鉴权和依赖注入。

相关推荐
Asus.Blogs11 分钟前
为什么go语言中返回的指针类型,不需要用*取值(解引用),就可以直接赋值呢?
开发语言·后端·golang
C_V_Better20 分钟前
Java Spring Boot 控制器中处理用户数据详解
java·开发语言·spring boot·后端·spring
胡子洲25 分钟前
Spring Boot 应用中实现基本的 SSE 功能
java·spring boot·后端
贰拾wan1 小时前
【Java-EE进阶】SpringBoot针对某个IP限流问题
java·spring boot·后端·idea
Paran-ia1 小时前
【2025版】Spring Boot面试题
java·spring boot·后端
sufu10652 小时前
SpringAI更新:废弃tools方法、正式支持DeepSeek!
人工智能·后端
幸好我会魔法2 小时前
使用githubPage+hexo搭建个人博客
笔记·github
嘵奇2 小时前
Spring Boot拦截器详解:原理、实现与应用场景
java·spring boot·后端
八股文领域大手子2 小时前
Java死锁排查:线上救火实战指南
java·开发语言·面试
XQ丶YTY3 小时前
大二java第一面小厂(挂)
java·开发语言·笔记·学习·面试