加群联系作者vx:xiaoda0423
仓库地址:webvueblog.github.io/JavaPlusDoc...
能力 | 说明 |
---|---|
支持 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
也可以换成Redisson
的RMap
,支持分布式节点同步踢人!- 可以在服务端定时心跳检测,把死掉的连接清理掉
- 可以扩展消息总线,做到一条命令踢掉多个服务节点的连接
能力 | 说明 |
---|---|
✅ | 在线用户统一管理 |
✅ | 支持单点登录强制踢掉旧连接 |
✅ | 异常情况自动清理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 项目中自动生效,并且支持自定义鉴权和依赖注入。