iT邦幫忙

2025 iThome 鐵人賽

DAY 16
1

前言

從今天開始又要進入一個我比較不熟悉的技術實作了,但我還是蠻期待的,每次剛開始接觸一個不太擅長的領域,從迷迷糊糊到建立高層次理解的過程,往往讓人感到非常過癮。從第 16 天開始我要將 WebSocket 的推送功能整合進當前的站內信模組,預計會花個 5~7 天來慢慢從最小可行性的功能逐漸優化,然後慢慢升級系統的穩固性。

實作 Lab:WebSocket 基礎配置

我希望在剛開始建立高層次理解時,搭配實作的程式碼或配置都能夠是最小可行的內容,少一行系統就會完全掛掉,多一行都是冗餘的程度,所以接下來我只會實作三個 WebSocket 的配置類,然後再生成一個可供測試的客戶端 .html 檔,用於建立連接,三個配置類分別是:

  1. WebSocketConfig (實作 WebSocketConfigurer 介面)
  2. NotificationHandler (繼承 TextWebSocketHandler)
  3. SessionManager

這三個類別彼此都有依賴關係,所以創建的順序不一,但我先從 WebSocketConfig 開始好了。

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("*"); // 允許跨域
    }

}

NotificationHandler

這邊就是實際上 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;
    }
}

SessionManager

這個注入在 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 連接畫面:

我生成了一個很簡單的測試畫面,用按鈕的方式向後端發送連接請求,連接成功後會收到後端的響應,發送消息也會收到後端的回覆,這個 html 檔我先放在現有模組的專案目錄下,實際上是會前後端分離的,現在單純來測試看看連接是否成功:

https://ithelp.ithome.com.tw/upload/images/20250906/20161582NJ8TT3mTZq.png

看樣子連接有成功,我們的客戶端跟後端已經通過 WebSocket 實現了雙向連接,之後就可以透過這個連接通道推送站內信的通知給用戶了。

這邊有個概念需要釐清,當客戶端第一次發送 WebSocket 連接請求時,用的協議是 ws:// 而不是 http:// , 但即便如此,尚未連接前客戶端發來的請求實際上還是 http 協議的請求,只不過它是一個「協議升級」的請求,意思是「我想跟你握手,因為我想跟你建立關係,當你也回握以後,我們之間才算真正的朋友」,所以當 Server 確定跟客戶端建立連接後,就會把協議切換成 ws:// ,之後在此連接週期中的互通都會使用 ws 協議來通信。

總結

今天完成了在 SpringBoot 裡的 WebSocket 基礎配置,也對 SpringBoot 系統如何處理 WebSocket 的請求鏈路有了高層次的理解,因為發現了它可以用 RestfulAPI 來解釋的地方,所以讓我更好理解基於 WebSocket 的前後端是如何溝通,明天開始就要逐步增加基於今天成果的功能,加油


上一篇
Day 15 中場休息一下
系列文
系統設計一招一式:最基本的功練到爛熟就是殺手鐧,從單體架構到分布式系統的 Lab 實作筆記16
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言