Spring Boot WebSocket 两种集成方式深度解析
WebSocket 是实现服务器主动推送、实时通信的利器,常见于聊天室、消息通知、实时监控大屏等场景。Spring Boot 集成 WebSocket 有两条路把两套配置混用了。两种方式各自的工作原理各自的完整配置步骤最容易踩的坑(以及为什么会踩)选型建议原生Spring配置类Bean+ 实现处理器@Component实现@ComponentBean 注入必须用static字段 + setter
一次说清楚:原生 @ServerEndpoint 与 Spring 整合 WebSocketHandler,配置差异、踩坑全记录
前言
WebSocket 是实现服务器主动推送、实时通信的利器,常见于聊天室、消息通知、实时监控大屏等场景。Spring Boot 集成 WebSocket 有两条路,很多人在这里摔跟头,原因只有一个:把两套配置混用了。
本文会讲清楚:
- 两种方式各自的工作原理
- 各自的完整配置步骤
- 最容易踩的坑(以及为什么会踩)
- 选型建议
一、两种方式的本质区别
| 维度 | 原生 JSR-356(@ServerEndpoint) |
Spring 整合(WebSocketHandler) |
|---|---|---|
| 规范来源 | Java EE 标准,javax.websocket |
Spring 框架封装,org.springframework.web.socket |
| 底层容器 | 由 Servlet 容器(Tomcat/Jetty)直接管理 | 由 Spring DispatcherServlet 统一管理 |
| 实例生命周期 | 每个连接 new 一个新实例 | 单例 Handler 处理所有连接 |
| 与 Spring 集成 | 需要额外桥接(ServerEndpointExporter) |
原生支持,Bean 注入无障碍 |
| 适用场景 | 轻量、快速上手 | 需要 Spring 生态深度整合 |
二、方式一:原生 JSR-356(@ServerEndpoint)
2.1 原理
JSR-356 是 Java EE 标准的 WebSocket API。Spring Boot 内嵌的 Tomcat 本身就支持这套规范,但 Spring 容器默认不会扫描 @ServerEndpoint 注解的类。
ServerEndpointExporter 的作用就是充当"桥梁"——它在 Spring 启动时,把所有被 @ServerEndpoint 标注的类手动注册到底层 Servlet 容器的 WebSocket 运行时中。
Spring容器启动
└── ServerEndpointExporter.afterPropertiesSet()
└── 扫描 @ServerEndpoint 类
└── 注册到 ServerContainer(Tomcat WebSocket 运行时)
2.2 依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>
2.3 第一步:WebSocket 配置类
@Configuration
public class WebSocketConfig {
/**
* 向 Spring 容器注册 ServerEndpointExporter
* 它会在应用启动后,将所有 @ServerEndpoint 注解的类注册到底层 Servlet 容器
* 注意:使用外部容器(如独立部署的 Tomcat)时,不需要注册此 Bean,
* 外部容器会自行完成注册
*/
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}
禁忌:这个配置类不能加
@EnableWebSocket,也不能实现WebSocketConfigurer。否则两套机制冲突,启动时会抛出类转换异常(ClassCastException)。
2.4 第二步:WebSocket 服务端点
@Component // ① 必须交给 Spring 容器,才能在内部注入 Service 等 Bean
@ServerEndpoint("/ws/chat/{roomId}") // ② 定义 WebSocket 连接路径
public class ChatWebSocketServer {
// ③ 核心踩坑点:@ServerEndpoint 每个连接都会 new 一个新实例
// 因此不能用普通的 @Autowired 字段注入,必须用 static 字段 + setter 注入
private static MessageService messageService;
@Autowired
public void setMessageService(MessageService messageService) {
ChatWebSocketServer.messageService = messageService;
}
// ④ 线程安全:用 ConcurrentHashMap 管理所有在线 Session
private static final ConcurrentHashMap<String, Session> SESSION_MAP
= new ConcurrentHashMap<>();
private Session session;
private String userId;
/**
* 连接建立成功时触发
*/
@OnOpen
public void onOpen(Session session, @PathParam("roomId") String roomId) {
this.session = session;
this.userId = session.getId();
SESSION_MAP.put(userId, session);
System.out.printf("用户 [%s] 加入房间 [%s],当前在线人数:%d%n",
userId, roomId, SESSION_MAP.size());
}
/**
* 收到客户端消息时触发
*/
@OnMessage
public void onMessage(String message, Session session) {
System.out.printf("收到用户 [%s] 的消息:%s%n", userId, message);
// 调用业务 Service 处理消息(static 注入,可正常使用)
messageService.saveMessage(userId, message);
// 广播给所有在线用户
broadcastMessage(userId + ": " + message);
}
/**
* 连接关闭时触发
*/
@OnClose
public void onClose() {
SESSION_MAP.remove(userId);
System.out.printf("用户 [%s] 断开连接,当前在线人数:%d%n",
userId, SESSION_MAP.size());
}
/**
* 发生错误时触发
*/
@OnError
public void onError(Session session, Throwable error) {
System.err.printf("用户 [%s] 发生错误:%s%n", userId, error.getMessage());
error.printStackTrace();
}
/**
* 广播消息给所有在线用户
*/
private void broadcastMessage(String message) {
SESSION_MAP.values().forEach(s -> {
try {
if (s.isOpen()) {
s.getBasicRemote().sendText(message);
}
} catch (IOException e) {
e.printStackTrace();
}
});
}
/**
* 向指定用户发送消息(可供外部调用)
*/
public static void sendMessageToUser(String userId, String message) {
Session session = SESSION_MAP.get(userId);
if (session != null && session.isOpen()) {
try {
session.getBasicRemote().sendText(message);
} catch (IOException e) {
e.printStackTrace();
}
}
}
}
三、方式二:Spring 整合 WebSocket(WebSocketHandler)
3.1 原理
这套方案是 Spring 自己封装的 WebSocket 抽象,通过 WebSocketConfigurer 将处理器注册进 Spring 的 WebSocket 路由体系,请求由 DispatcherServlet 统一入口分发。
HTTP 请求升级为 WebSocket
└── DispatcherServlet
└── WebSocketHandlerMapping(路径路由)
└── 你的 WebSocketHandler(处理具体逻辑)
因为全程在 Spring 生态内,Bean 注入、拦截器、权限校验都可以无缝对接。
3.2 第一步:WebSocket 配置类
@Configuration
@EnableWebSocket // ① 开启 Spring WebSocket 支持
public class WebSocketConfig implements WebSocketConfigurer { // ② 实现此接口
@Autowired
private ChatWebSocketHandler chatWebSocketHandler;
@Autowired
private WebSocketAuthInterceptor authInterceptor;
/**
* ③ 重写此方法,将处理器注册到指定路径
*/
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry
.addHandler(chatWebSocketHandler, "/ws/chat") // 注册处理器和路径
.addInterceptors(authInterceptor) // 可添加握手拦截器
.setAllowedOrigins("*"); // 跨域配置
}
}
3.3 第二步:握手拦截器(可选但推荐)
握手拦截器在 WebSocket 连接建立之前执行,常用于身份验证、权限校验、将用户信息存入 Session attributes。
@Component
public class WebSocketAuthInterceptor implements HandshakeInterceptor {
/**
* WebSocket 握手前执行:返回 false 则拒绝连接
*/
@Override
public boolean beforeHandshake(ServerHttpRequest request,
ServerHttpResponse response,
WebSocketHandler wsHandler,
Map<String, Object> attributes) throws Exception {
// 从请求参数或 Header 中获取 Token,验证用户身份
String token = ((ServletServerHttpRequest) request)
.getServletRequest().getParameter("token");
if (token == null || !isValidToken(token)) {
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return false; // 拒绝握手
}
// 将用户信息存入 attributes,后续 Handler 中可以取到
attributes.put("userId", parseUserId(token));
return true;
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Exception exception) {
// 握手后执行,一般留空
}
private boolean isValidToken(String token) {
// 实际项目中调用 JWT 解析或 Redis 校验
return token.startsWith("valid_");
}
private String parseUserId(String token) {
return token.replace("valid_", "");
}
}
3.4 第三步:WebSocket 处理器
@Component // ① 交给 Spring 容器管理,正常 @Autowired 注入无任何问题
public class ChatWebSocketHandler extends TextWebSocketHandler { // ② 继承此类处理文本消息
@Autowired
private MessageService messageService; // ③ 单例 Handler,直接 @Autowired 完全没问题
// 维护在线 Session 的线程安全 Map
private static final ConcurrentHashMap<String, WebSocketSession> SESSION_MAP
= new ConcurrentHashMap<>();
/**
* 连接建立成功时触发
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
String userId = (String) session.getAttributes().get("userId");
SESSION_MAP.put(userId, session);
System.out.printf("用户 [%s] 已连接,当前在线:%d%n", userId, SESSION_MAP.size());
}
/**
* 收到文本消息时触发
*/
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message)
throws Exception {
String userId = (String) session.getAttributes().get("userId");
String payload = message.getPayload();
System.out.printf("收到 [%s] 的消息:%s%n", userId, payload);
// 调用业务 Service
messageService.saveMessage(userId, payload);
// 广播消息
broadcastMessage(userId + ": " + payload);
}
/**
* 连接关闭时触发
*/
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status)
throws Exception {
String userId = (String) session.getAttributes().get("userId");
SESSION_MAP.remove(userId);
System.out.printf("用户 [%s] 已断开,当前在线:%d%n", userId, SESSION_MAP.size());
}
/**
* 传输异常时触发
*/
@Override
public void handleTransportError(WebSocketSession session, Throwable exception)
throws Exception {
System.err.println("传输错误:" + exception.getMessage());
session.close(CloseStatus.SERVER_ERROR);
}
private void broadcastMessage(String message) {
SESSION_MAP.values().forEach(s -> {
try {
if (s.isOpen()) {
s.sendMessage(new TextMessage(message));
}
} catch (IOException e) {
e.printStackTrace();
}
});
}
}
四、最容易踩的坑,逐一拆解
坑一:两套配置混用 → ClassCastException
错误场景:配置类同时写了 ServerEndpointExporter Bean 又实现了 WebSocketConfigurer。
报错特征:
java.lang.ClassCastException: class X cannot be cast to class Y
根因:原生方式绕过 Spring MVC 直接对接 Servlet 容器;Spring 整合方式走 DispatcherServlet 体系。两套路由机制同时工作,处理同一个 WebSocket 请求时类型不匹配,直接炸。
解法:二选一,坚决不混用。
坑二:原生方式 @Autowired 注入为 null
错误场景:
@ServerEndpoint("/ws/chat")
@Component
public class ChatServer {
@Autowired
private UserService userService; // 运行时是 null!
@OnMessage
public void onMessage(String msg) {
userService.doSomething(msg); // NullPointerException
}
}
根因:@ServerEndpoint 类由 Servlet 容器管理实例化,每来一个连接就 new 一个新对象。Spring 只管理它在自己容器里的那一个原型实例,Servlet 容器 new 出来的新实例 Spring 不认识,自然也不会注入。
解法:static 字段 + setter 注入(Spring 注入的那一个实例执行 setter,写入 static 字段,所有实例共享):
@ServerEndpoint("/ws/chat")
@Component
public class ChatServer {
private static UserService userService;
@Autowired // Spring 对它管理的那个实例执行此方法,写入 static 字段
public void setUserService(UserService userService) {
ChatServer.userService = userService;
}
}
坑三:跨域配置不生效
- 原生方式跨域:在
@ServerEndpoint注解本身无跨域配置项,需要在 Nginx 层或 Filter 层处理 - Spring 整合方式:直接在
addHandler(...).setAllowedOrigins("*")配置,简洁明了
坑四:外部 Tomcat 部署时不需要 ServerEndpointExporter
用嵌入式 Tomcat(spring-boot:run 或打 jar 包)时需要注册 ServerEndpointExporter;打 war 包部署到外部 Tomcat 时,外部容器会自己扫描 @ServerEndpoint,再注册 ServerEndpointExporter 反而会报错。
// 嵌入式容器:需要
// 外部容器:删掉这个 Bean
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
五、包路径对照表
两种方式的核心类来自完全不同的包,混用时 IDE 的自动补全会"帮你犯错",务必留意:
| 功能 | 原生 JSR-356 | Spring 整合 |
|---|---|---|
| 端点/处理器注解 | javax.websocket.@ServerEndpoint |
实现 org.springframework.web.socket.WebSocketHandler |
| 连接建立 | @OnOpen |
afterConnectionEstablished() |
| 接收消息 | @OnMessage |
handleTextMessage() |
| 连接关闭 | @OnClose |
afterConnectionClosed() |
| 错误处理 | @OnError |
handleTransportError() |
| Session 类型 | javax.websocket.Session |
org.springframework.web.socket.WebSocketSession |
| 消息类型 | String / ByteBuffer |
TextMessage / BinaryMessage |
六、前端连接示例
无论哪种后端方式,前端连接写法完全一样:
// 原生方式路径示例
const ws1 = new WebSocket('ws://localhost:8080/ws/chat/room123');
// Spring 整合方式路径示例(附带 token 参数用于握手拦截器验证)
const ws2 = new WebSocket('ws://localhost:8080/ws/chat?token=valid_user001');
ws2.onopen = () => {
console.log('连接已建立');
ws2.send(JSON.stringify({ type: 'chat', content: 'Hello!' }));
};
ws2.onmessage = (event) => {
console.log('收到消息:', event.data);
};
ws2.onclose = (event) => {
console.log('连接已关闭,code:', event.code);
};
ws2.onerror = (error) => {
console.error('连接错误:', error);
};
七、选型建议
需要权限校验、Session 管理、与 Spring Security 集成?
└── 选 Spring 整合方式(WebSocketHandler)
快速实现、团队熟悉 Java EE 规范、无复杂 Spring 生态依赖?
└── 选原生方式(@ServerEndpoint)
需要打 war 包部署到外部 Tomcat?
└── 两种都行,但原生方式记得去掉 ServerEndpointExporter Bean
追求更强的消息抽象(发布订阅、广播频道)?
└── 考虑 Spring WebSocket + STOMP 协议(本文未涉及,可作进阶方向)
总结
原生 @ServerEndpoint |
Spring WebSocketHandler |
|
|---|---|---|
| 配置类 | @Configuration + ServerEndpointExporter Bean |
@Configuration + @EnableWebSocket + 实现 WebSocketConfigurer |
| 处理器 | @ServerEndpoint + @Component |
实现 WebSocketHandler + @Component |
| Bean 注入 | 必须用 static 字段 + setter 注入 |
直接 @Autowired,无限制 |
| 实例模型 | 每连接一个新实例 | 全局单例 |
| 互相混用 | 严禁,直接报错 | 严禁,直接报错 |
记住一句话:选定一套,配全套,绝不混搭。
openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构
更多推荐



所有评论(0)