Spring Boot WebSocket 两种集成方式深度解析
一次说清楚:原生@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
错误场景:配置类同时写了ServerEndpointExporterBean 又实现了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 | SpringWebSocketHandler | |
|---|---|---|
| 配置类 | @Configuration+ServerEndpointExporterBean | @Configuration+@EnableWebSocket+ 实现WebSocketConfigurer |
| 处理器 | @ServerEndpoint+@Component | 实现WebSocketHandler+@Component |
| Bean 注入 | 必须用static字段 + setter 注入 | 直接@Autowired,无限制 |
| 实例模型 | 每连接一个新实例 | 全局单例 |
| 互相混用 | 严禁,直接报错 | 严禁,直接报错 |
记住一句话:选定一套,配全套,绝不混搭。
