從今天開始又要進入一個我比較不熟悉的技術實作了,但我還是蠻期待的,每次剛開始接觸一個不太擅長的領域,從迷迷糊糊到建立高層次理解的過程,往往讓人感到非常過癮。從第 16 天開始我要將 WebSocket 的推送功能整合進當前的站內信模組,預計會花個 5~7 天來慢慢從最小可行性的功能逐漸優化,然後慢慢升級系統的穩固性。
我希望在剛開始建立高層次理解時,搭配實作的程式碼或配置都能夠是最小可行的內容,少一行系統就會完全掛掉,多一行都是冗餘的程度,所以接下來我只會實作三個 WebSocket 的配置類,然後再生成一個可供測試的客戶端 .html 檔,用於建立連接,三個配置類分別是:
WebSocketConfig
(實作 WebSocketConfigurer
介面)NotificationHandler
(繼承 TextWebSocketHandler
)SessionManager
這三個類別彼此都有依賴關係,所以創建的順序不一,但我先從 WebSocketConfig
開始好了。
這邊的用途是用來定義一個類似 API 的端點,如果用 RestfulAPI 的規範來類比的話,它的作用就像我們 Controller 上那個 @RequestMapping
註解,用來把請求路由到 Spring 的 Controller,而 registry.addHandler(notificationHandler, "/ws/notifications")
這邊就是指定說這個端點會對應到哪一個 Controller 的概念,但是在 WebSocket 的配置下是用 Handler 來稱呼,不過基本上可以理解它們是相同角色:
@EnableWebSocket
@Configuration
@RequiredArgsConstructor
public class WebSocketConfig implements WebSocketConfigurer {
private final NotificationHandler notificationHandler;
@Override
public void registerWebSocketHandlers(WebSocketHandlerRegistry registry) {
registry.addHandler(notificationHandler, "/ws/notifications")
.setAllowedOrigins("*"); // 允許跨域
}
}
這邊就是實際上 WebSocket 鏈路下 Controller (Handler) 的地方,經過端點的路由到達這邊,這裡會根據客戶端發送的請求類型來決定會路由到 Handler 的哪個方法,再去進行 WebSocket 的連接、消息發送、取消連接等等操作。
/**
* WebSocket 處理器
* 負責處理 WebSocket 的連接、消息、斷開
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class NotificationHandler extends TextWebSocketHandler {
private final SessionManager sessionManager;
/**
* 建立連接:提取 userId 並註冊 session
*/
@Override
public void afterConnectionEstablished(WebSocketSession session) throws Exception {
var userId = getUserId(session);
if (userId != null) {
sessionManager.addSession(userId, session);
} else {
log.warn("UserId doesn't exist, closing connection");
session.close();
}
}
@Override
public void afterConnectionClosed(WebSocketSession session, CloseStatus status) throws Exception {
var userId = getUserId(session);
if (userId != null) {
sessionManager.removeSession(userId);
}
}
@Override
protected void handleTextMessage(WebSocketSession session, TextMessage message) throws Exception {
var userId = getUserId(session);
log.debug("Received from user {}: {}", userId, message.getPayload());
session.sendMessage(new TextMessage("Test: " + message.getPayload()));
}
@Override
public void handleTransportError(WebSocketSession session, Throwable exception) throws Exception {
var userId = getUserId(session);
log.error("Transport error for user {}", userId, exception);
if (userId != null) {
sessionManager.removeSession(userId);
}
}
private Integer getUserId(WebSocketSession session) {
try {
var uri = session.getUri();
if (uri != null && uri.getQuery() != null) {
var params = uri.getQuery().split("&");
for (var param : params) {
var kv = param.split("=");
if (kv.length == 2 && "userId".equals(kv[0])) {
return Integer.parseInt(kv[1]);
}
}
}
} catch (Exception e) {
log.warn("Error extracting userId", e);
}
return null;
}
}
這個注入在 Handler 裡面的 SessionManager 的角色就相當於 Controller 之於 Service 一般,是用來實際執行業務邏輯的層面,這邊保存完連接的 session 後直接回傳結果給 Handler,連接以後這個連線就會一直存在著,直到可能違反了某個我們後端定義的連線規則等等。
@Slf4j
@Component
public class SessionManager {
private final ConcurrentHashMap<Integer, WebSocketSession> sessions = new ConcurrentHashMap<>();
public void addSession(Integer userId, WebSocketSession session) {
sessions.put(userId, session);
log.info("User {} connected", userId);
}
public void removeSession(Integer userId) {
sessions.remove(userId);
log.info("User {} disconnected", userId);
}
public boolean sendMessage(Integer userId, String message) {
var session = sessions.get(userId);
if (session != null && session.isOpen()) {
try {
session.sendMessage(new TextMessage(message));
return true;
} catch (Exception e) {
log.warn("Failed to send message to user {}", userId, e);
sessions.remove(userId);
return false;
}
}
return false;
}
public boolean isOnline(Integer userId) {
var session = sessions.get(userId);
return session != null && session.isOpen();
}
}
我生成了一個很簡單的測試畫面,用按鈕的方式向後端發送連接請求,連接成功後會收到後端的響應,發送消息也會收到後端的回覆,這個 html 檔我先放在現有模組的專案目錄下,實際上是會前後端分離的,現在單純來測試看看連接是否成功:
看樣子連接有成功,我們的客戶端跟後端已經通過 WebSocket 實現了雙向連接,之後就可以透過這個連接通道推送站內信的通知給用戶了。
這邊有個概念需要釐清,當客戶端第一次發送 WebSocket 連接請求時,用的協議是 ws:// 而不是 http:// , 但即便如此,尚未連接前客戶端發來的請求實際上還是 http 協議的請求,只不過它是一個「協議升級」的請求,意思是「我想跟你握手,因為我想跟你建立關係,當你也回握以後,我們之間才算真正的朋友」,所以當 Server 確定跟客戶端建立連接後,就會把協議切換成 ws:// ,之後在此連接週期中的互通都會使用 ws 協議來通信。
今天完成了在 SpringBoot 裡的 WebSocket 基礎配置,也對 SpringBoot 系統如何處理 WebSocket 的請求鏈路有了高層次的理解,因為發現了它可以用 RestfulAPI 來解釋的地方,所以讓我更好理解基於 WebSocket 的前後端是如何溝通,明天開始就要逐步增加基於今天成果的功能,加油