Spring Boot WebSocket 兩種集成方式詳解
一次說(shuō)清楚:原生 @ServerEndpoint 與 Spring 整合 WebSocketHandler,配置差異、踩坑全記錄
前言
WebSocket 是實(shí)現(xiàn)服務(wù)器主動(dòng)推送、實(shí)時(shí)通信的利器,常見(jiàn)于聊天室、消息通知、實(shí)時(shí)監(jiān)控大屏等場(chǎng)景。Spring Boot 集成 WebSocket 有兩條路,很多人在這里摔跟頭,原因只有一個(gè):把兩套配置混用了。
本文會(huì)講清楚:
- 兩種方式各自的工作原理
- 各自的完整配置步驟
- 最容易踩的坑(以及為什么會(huì)踩)
- 選型建議
一、兩種方式的本質(zhì)區(qū)別
| 維度 | 原生 JSR-356(@ServerEndpoint) | Spring 整合(WebSocketHandler) |
|---|---|---|
| 規(guī)范來(lái)源 | Java EE 標(biāo)準(zhǔn),javax.websocket | Spring 框架封裝,org.springframework.web.socket |
| 底層容器 | 由 Servlet 容器(Tomcat/Jetty)直接管理 | 由 Spring DispatcherServlet 統(tǒng)一管理 |
| 實(shí)例生命周期 | 每個(gè)連接 new 一個(gè)新實(shí)例 | 單例 Handler 處理所有連接 |
| 與 Spring 集成 | 需要額外橋接(ServerEndpointExporter) | 原生支持,Bean 注入無(wú)障礙 |
| 適用場(chǎng)景 | 輕量、快速上手 | 需要 Spring 生態(tài)深度整合 |
二、方式一:原生 JSR-356(@ServerEndpoint)
2.1 原理
JSR-356 是 Java EE 標(biāo)準(zhǔn)的 WebSocket API。Spring Boot 內(nèi)嵌的 Tomcat 本身就支持這套規(guī)范,但 Spring 容器默認(rèn)不會(huì)掃描 @ServerEndpoint 注解的類。
ServerEndpointExporter 的作用就是充當(dāng)"橋梁"——它在 Spring 啟動(dòng)時(shí),把所有被 @ServerEndpoint 標(biāo)注的類手動(dòng)注冊(cè)到底層 Servlet 容器的 WebSocket 運(yùn)行時(shí)中。
Spring容器啟動(dòng)
└── ServerEndpointExporter.afterPropertiesSet()
└── 掃描 @ServerEndpoint 類
└── 注冊(cè)到 ServerContainer(Tomcat WebSocket 運(yùn)行時(shí))2.2 依賴
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-websocket</artifactId>
</dependency>2.3 第一步:WebSocket 配置類
@Configuration
public class WebSocketConfig {
/**
* 向 Spring 容器注冊(cè) ServerEndpointExporter
* 它會(huì)在應(yīng)用啟動(dòng)后,將所有 @ServerEndpoint 注解的類注冊(cè)到底層 Servlet 容器
* 注意:使用外部容器(如獨(dú)立部署的 Tomcat)時(shí),不需要注冊(cè)此 Bean,
* 外部容器會(huì)自行完成注冊(cè)
*/
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}
}禁忌:這個(gè)配置類不能加
@EnableWebSocket,也不能實(shí)現(xiàn)WebSocketConfigurer。否則兩套機(jī)制沖突,啟動(dòng)時(shí)會(huì)拋出類轉(zhuǎn)換異常(ClassCastException)。
2.4 第二步:WebSocket 服務(wù)端點(diǎn)
@Component // ① 必須交給 Spring 容器,才能在內(nèi)部注入 Service 等 Bean
@ServerEndpoint("/ws/chat/{roomId}") // ② 定義 WebSocket 連接路徑
public class ChatWebSocketServer {
// ③ 核心踩坑點(diǎn):@ServerEndpoint 每個(gè)連接都會(huì) new 一個(gè)新實(shí)例
// 因此不能用普通的 @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;
/**
* 連接建立成功時(shí)觸發(fā)
*/
@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āng)前在線人數(shù):%d%n",
userId, roomId, SESSION_MAP.size());
}
/**
* 收到客戶端消息時(shí)觸發(fā)
*/
@OnMessage
public void onMessage(String message, Session session) {
System.out.printf("收到用戶 [%s] 的消息:%s%n", userId, message);
// 調(diào)用業(yè)務(wù) Service 處理消息(static 注入,可正常使用)
messageService.saveMessage(userId, message);
// 廣播給所有在線用戶
broadcastMessage(userId + ": " + message);
}
/**
* 連接關(guān)閉時(shí)觸發(fā)
*/
@OnClose
public void onClose() {
SESSION_MAP.remove(userId);
System.out.printf("用戶 [%s] 斷開(kāi)連接,當(dāng)前在線人數(shù):%d%n",
userId, SESSION_MAP.size());
}
/**
* 發(fā)生錯(cuò)誤時(shí)觸發(fā)
*/
@OnError
public void onError(Session session, Throwable error) {
System.err.printf("用戶 [%s] 發(fā)生錯(cuò)誤:%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();
}
});
}
/**
* 向指定用戶發(fā)送消息(可供外部調(diào)用)
*/
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 抽象,通過(guò) WebSocketConfigurer 將處理器注冊(cè)進(jìn) Spring 的 WebSocket 路由體系,請(qǐng)求由 DispatcherServlet 統(tǒng)一入口分發(fā)。
HTTP 請(qǐng)求升級(jí)為 WebSocket
└── DispatcherServlet
└── WebSocketHandlerMapping(路徑路由)
└── 你的 WebSocketHandler(處理具體邏輯)因?yàn)槿淘?Spring 生態(tài)內(nèi),Bean 注入、攔截器、權(quán)限校驗(yàn)都可以無(wú)縫對(duì)接。
3.2 第一步:WebSocket 配置類
@Configuration
@EnableWebSocket // ① 開(kāi)啟 Spring WebSocket 支持
public class WebSocketConfig implements WebSocketConfigurer { // ② 實(shí)現(xiàn)此接口
@Autowired
private ChatWebSocketHandler chatWebSocketHandler;
@Autowired
private WebSocketAuthInterceptor authInterceptor;
/**
* ③ 重寫此方法,將處理器注冊(cè)到指定路徑
*/
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry
.addHandler(chatWebSocketHandler, "/ws/chat") // 注冊(cè)處理器和路徑
.addInterceptors(authInterceptor) // 可添加握手?jǐn)r截器
.setAllowedOrigins("*"); // 跨域配置
}
}3.3 第二步:握手?jǐn)r截器(可選但推薦)
握手?jǐn)r截器在 WebSocket 連接建立之前執(zhí)行,常用于身份驗(yàn)證、權(quán)限校驗(yàn)、將用戶信息存入 Session attributes。
@Component
public class WebSocketAuthInterceptor implements HandshakeInterceptor {
/**
* WebSocket 握手前執(zhí)行:返回 false 則拒絕連接
*/
@Override
public boolean beforeHandshake(ServerHttpRequest request,
ServerHttpResponse response,
WebSocketHandler wsHandler,
Map<String, Object> attributes) throws Exception {
// 從請(qǐng)求參數(shù)或 Header 中獲取 Token,驗(yàn)證用戶身份
String token = ((ServletServerHttpRequest) request)
.getServletRequest().getParameter("token");
if (token == null || !isValidToken(token)) {
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return false; // 拒絕握手
}
// 將用戶信息存入 attributes,后續(xù) Handler 中可以取到
attributes.put("userId", parseUserId(token));
return true;
}
@Override
public void afterHandshake(ServerHttpRequest request, ServerHttpResponse response,
WebSocketHandler wsHandler, Exception exception) {
// 握手后執(zhí)行,一般留空
}
private boolean isValidToken(String token) {
// 實(shí)際項(xiàng)目中調(diào)用 JWT 解析或 Redis 校驗(yàn)
return token.startsWith("valid_");
}
private String parseUserId(String token) {
return token.replace("valid_", "");
}
}3.4 第三步:WebSocket 處理器
@Component // ① 交給 Spring 容器管理,正常 @Autowired 注入無(wú)任何問(wèn)題
public class ChatWebSocketHandler extends TextWebSocketHandler { // ② 繼承此類處理文本消息
@Autowired
private MessageService messageService; // ③ 單例 Handler,直接 @Autowired 完全沒(méi)問(wèn)題
// 維護(hù)在線 Session 的線程安全 Map
private static final ConcurrentHashMap<String, WebSocketSession> SESSION_MAP
= new ConcurrentHashMap<>();
/**
* 連接建立成功時(shí)觸發(fā)
*/
@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āng)前在線:%d%n", userId, SESSION_MAP.size());
}
/**
* 收到文本消息時(shí)觸發(fā)
*/
@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);
// 調(diào)用業(yè)務(wù) Service
messageService.saveMessage(userId, payload);
// 廣播消息
broadcastMessage(userId + ": " + payload);
}
/**
* 連接關(guān)閉時(shí)觸發(fā)
*/
@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] 已斷開(kāi),當(dāng)前在線:%d%n", userId, SESSION_MAP.size());
}
/**
* 傳輸異常時(shí)觸發(fā)
*/
@Override
public void handleTransportError(WebSocketSession session, Throwable exception)
throws Exception {
System.err.println("傳輸錯(cuò)誤:" + 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
錯(cuò)誤場(chǎng)景:配置類同時(shí)寫了 ServerEndpointExporter Bean 又實(shí)現(xiàn)了 WebSocketConfigurer。
報(bào)錯(cuò)特征:
java.lang.ClassCastException: class X cannot be cast to class Y
根因:原生方式繞過(guò) Spring MVC 直接對(duì)接 Servlet 容器;Spring 整合方式走 DispatcherServlet 體系。兩套路由機(jī)制同時(shí)工作,處理同一個(gè) WebSocket 請(qǐng)求時(shí)類型不匹配,直接炸。
解法:二選一,堅(jiān)決不混用。
坑二:原生方式 @Autowired 注入為 null
錯(cuò)誤場(chǎng)景:
@ServerEndpoint("/ws/chat")
@Component
public class ChatServer {
@Autowired
private UserService userService; // 運(yùn)行時(shí)是 null!
@OnMessage
public void onMessage(String msg) {
userService.doSomething(msg); // NullPointerException
}
}根因:@ServerEndpoint 類由 Servlet 容器管理實(shí)例化,每來(lái)一個(gè)連接就 new 一個(gè)新對(duì)象。Spring 只管理它在自己容器里的那一個(gè)原型實(shí)例,Servlet 容器 new 出來(lái)的新實(shí)例 Spring 不認(rèn)識(shí),自然也不會(huì)注入。
解法:static 字段 + setter 注入(Spring 注入的那一個(gè)實(shí)例執(zhí)行 setter,寫入 static 字段,所有實(shí)例共享):
@ServerEndpoint("/ws/chat")
@Component
public class ChatServer {
private static UserService userService;
@Autowired // Spring 對(duì)它管理的那個(gè)實(shí)例執(zhí)行此方法,寫入 static 字段
public void setUserService(UserService userService) {
ChatServer.userService = userService;
}
}坑三:跨域配置不生效
- 原生方式跨域:在
@ServerEndpoint注解本身無(wú)跨域配置項(xiàng),需要在 Nginx 層或 Filter 層處理 - Spring 整合方式:直接在
addHandler(...).setAllowedOrigins("*")配置,簡(jiǎn)潔明了
坑四:外部 Tomcat 部署時(shí)不需要ServerEndpointExporter
用嵌入式 Tomcat(spring-boot:run 或打 jar 包)時(shí)需要注冊(cè) ServerEndpointExporter;打 war 包部署到外部 Tomcat 時(shí),外部容器會(huì)自己掃描 @ServerEndpoint,再注冊(cè) ServerEndpointExporter 反而會(huì)報(bào)錯(cuò)。
// 嵌入式容器:需要
// 外部容器:刪掉這個(gè) Bean
@Bean
public ServerEndpointExporter serverEndpointExporter() {
return new ServerEndpointExporter();
}五、包路徑對(duì)照表
兩種方式的核心類來(lái)自完全不同的包,混用時(shí) IDE 的自動(dòng)補(bǔ)全會(huì)"幫你犯錯(cuò)",務(wù)必留意:
| 功能 | 原生 JSR-356 | Spring 整合 |
|---|---|---|
| 端點(diǎn)/處理器注解 | javax.websocket.@ServerEndpoint | 實(shí)現(xiàn) org.springframework.web.socket.WebSocketHandler |
| 連接建立 | @OnOpen | afterConnectionEstablished() |
| 接收消息 | @OnMessage | handleTextMessage() |
| 連接關(guān)閉 | @OnClose | afterConnectionClosed() |
| 錯(cuò)誤處理 | @OnError | handleTransportError() |
| Session 類型 | javax.websocket.Session | org.springframework.web.socket.WebSocketSession |
| 消息類型 | String / ByteBuffer | TextMessage / BinaryMessage |
六、前端連接示例
無(wú)論哪種后端方式,前端連接寫法完全一樣:
// 原生方式路徑示例
const ws1 = new WebSocket('ws://localhost:8080/ws/chat/room123');
// Spring 整合方式路徑示例(附帶 token 參數(shù)用于握手?jǐn)r截器驗(yàn)證)
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('連接已關(guān)閉,code:', event.code);
};
ws2.onerror = (error) => {
console.error('連接錯(cuò)誤:', error);
};七、選型建議
需要權(quán)限校驗(yàn)、Session 管理、與 Spring Security 集成?
└── 選 Spring 整合方式(WebSocketHandler)
快速實(shí)現(xiàn)、團(tuán)隊(duì)熟悉 Java EE 規(guī)范、無(wú)復(fù)雜 Spring 生態(tài)依賴?
└── 選原生方式(@ServerEndpoint)
需要打 war 包部署到外部 Tomcat?
└── 兩種都行,但原生方式記得去掉 ServerEndpointExporter Bean
追求更強(qiáng)的消息抽象(發(fā)布訂閱、廣播頻道)?
└── 考慮 Spring WebSocket + STOMP 協(xié)議(本文未涉及,可作進(jìn)階方向)總結(jié)
| 原生 @ServerEndpoint | Spring WebSocketHandler | |
|---|---|---|
| 配置類 | @Configuration + ServerEndpointExporter Bean | @Configuration + @EnableWebSocket + 實(shí)現(xiàn) WebSocketConfigurer |
| 處理器 | @ServerEndpoint + @Component | 實(shí)現(xiàn) WebSocketHandler + @Component |
| Bean 注入 | 必須用 static 字段 + setter 注入 | 直接 @Autowired,無(wú)限制 |
| 實(shí)例模型 | 每連接一個(gè)新實(shí)例 | 全局單例 |
| 互相混用 | 嚴(yán)禁,直接報(bào)錯(cuò) | 嚴(yán)禁,直接報(bào)錯(cuò) |
記住一句話:選定一套,配全套,絕不混搭。
到此這篇關(guān)于Spring Boot WebSocket 兩種集成方式詳解的文章就介紹到這了,更多相關(guān)Spring Boot WebSocket集成內(nèi)容請(qǐng)搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
- 空指針HttpSession異常之SpringBoot集成WebSocket的方法
- Springboot如何集成websocket
- SpringBoot3集成WebSocket的全過(guò)程
- SpringBoot集成netty實(shí)現(xiàn)websocket通信功能
- SpringBoot集成WebSocket實(shí)現(xiàn)后臺(tái)向前端推送信息
- SpringBoot集成WebSocket遇到的問(wèn)題及解決
- SpringBoot集成WebSocket的兩種方式(JDK內(nèi)置版和Spring封裝版)
- springboot集成websocket的四種方式小結(jié)
- 詳解springboot集成websocket的兩種實(shí)現(xiàn)方式
相關(guān)文章
JavaWeb中struts2實(shí)現(xiàn)文件上傳下載功能實(shí)例解析
這篇文章主要介紹了JavaWeb中struts2文件上傳下載功能的實(shí)現(xiàn),在Web應(yīng)用系統(tǒng)開(kāi)發(fā)中,文件上傳和下載功能是非常常用的功能,需要的朋友可以參考下2016-05-05
springboot實(shí)現(xiàn)異步調(diào)用@Async的示例
這篇文章主要介紹了springboot實(shí)現(xiàn)異步調(diào)用@Async的示例,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友們下面隨著小編來(lái)一起學(xué)習(xí)學(xué)習(xí)吧2020-12-12
Java實(shí)現(xiàn)自動(dòng)獲取法定節(jié)假日詳細(xì)代碼
這篇文章主要給大家介紹了關(guān)于Java實(shí)現(xiàn)自動(dòng)獲取法定節(jié)假日的相關(guān)資料,獲取并處理節(jié)假日數(shù)據(jù)是一個(gè)常見(jiàn)需求,特別是在需要安排任務(wù)調(diào)度、假期通知等功能的場(chǎng)景中,需要的朋友可以參考下2024-05-05
Java 基礎(chǔ)之內(nèi)部類詳解及實(shí)例
這篇文章主要介紹了 Java 基礎(chǔ)之內(nèi)部類詳解及實(shí)例的相關(guān)資料,需要的朋友可以參考下2017-03-03
Spring Boot Swagger2使用方法過(guò)程解析
這篇文章主要介紹了Spring Boot Swagger2使用方法過(guò)程解析,文中通過(guò)示例代碼介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2020-08-08
spring基礎(chǔ)系列之JavaConfig配置詳解
本篇文章主要介紹了spring基礎(chǔ)系列之JavaConfig配置詳解,小編覺(jué)得挺不錯(cuò)的,現(xiàn)在分享給大家,也給大家做個(gè)參考。一起跟隨小編過(guò)來(lái)看看吧2017-07-07
SpringBoot 動(dòng)態(tài)配置Profile環(huán)境的方式
這篇文章主要介紹了SpringBoot 動(dòng)態(tài)配置Profile環(huán)境的方式,本文通過(guò)圖文實(shí)例相結(jié)合給大家介紹的非常詳細(xì),對(duì)大家的學(xué)習(xí)或工作具有一定的參考借鑒價(jià)值,需要的朋友可以參考下2021-10-10
Java如何判斷一個(gè)空對(duì)象的常見(jiàn)方法
在Java中判斷對(duì)象是否為空是一項(xiàng)重要的編程技巧,可以有效防止空指針異常的發(fā)生,下面這篇文章主要給大家介紹了關(guān)于利用Java如何判斷一個(gè)空對(duì)象的相關(guān)資料,需要的朋友可以參考下2024-01-01
Spring?Boot與Redis的緩存一致性問(wèn)題解決
在使用緩存時(shí),緩存一致性問(wèn)題是一個(gè)常見(jiàn)的挑戰(zhàn),本文主要介紹了Spring?Boot與Redis的緩存一致性問(wèn)題,具有一定的參考價(jià)值,感興趣的可以了解一下2024-07-07

