Spring Boot 整合 WebSocket 實現服務端推送訊息到客戶端

武培軒發表於2020-07-28

假設有這樣一個場景:服務端的資源經常在更新,客戶端需要儘量及時地瞭解到這些更新發生後展示給使用者,如果是 HTTP 1.1,通常會開啟 ajax 請求詢問服務端是否有更新,通過定時器反覆輪詢服務端響應的資源是否有更新。

ajax 輪詢

在長時間不更新的情況下,反覆地去詢問會對伺服器造成很大的壓力,對網路也有很大的消耗,如果定時的時間比較大,服務端有更新的話,客戶端可能需要等待定時器達到以後才能獲知,這個資訊也不能很及時地獲取到。

而有了 WebSocket 協議,就能很好地解決這些問題,WebSocket 可以反向通知的,通常向服務端訂閱一類訊息,服務端發現這類訊息有更新就會不停地通知客戶端。

WebSocket

WebSocket 簡介

WebSocket 協議是基於 TCP 的一種新的網路協議,它實現了瀏覽器與伺服器全雙工(full-duplex)通訊—允許伺服器主動傳送資訊給客戶端,這樣就可以實現從客戶端傳送訊息到伺服器,而伺服器又可以轉發訊息到客戶端,這樣就能夠實現客戶端之間的互動。對於 WebSocket 的開發,Spring 也提供了良好的支援,目前很多瀏覽器已經實現了 WebSocket 協議,但是依舊存在著很多瀏覽器沒有實現該協議,為了相容那些沒有實現該協議的瀏覽器,往往還需要通過 STOMP 協議來完成這些相容。

下面我們在 Spring Boot 中整合 WebSocket 來實現服務端推送訊息到客戶端。

Spring Boot 整合 WebSocket

首先建立一個 Spring Boot 專案,然後在 pom.xml 加入如下依賴整合 WebSocket:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

開啟配置

接下來在 config 包下建立一個 WebSocket 配置類 WebSocketConfiguration,在配置類上加入註解 @EnableWebSocket,表明開啟 WebSocket,內部例項化 ServerEndpointExporter 的 Bean,該 Bean 會自動註冊 @ServerEndpoint 註解宣告的端點,程式碼如下:

@Configuration
@EnableWebSocket
public class WebSocketConfiguration {

    @Bean
    public ServerEndpointExporter serverEndpointExporter() {
        return new ServerEndpointExporter();
    }
}

編寫端點服務類

接下來使用 @ServerEndpoint 定義一個端點服務類,在端點服務類中,可以定義 WebSocket 的開啟、關閉、錯誤和傳送訊息的方法,具體程式碼如下所示:

@ServerEndpoint("/websocket/{userId}")
@Component
public class WebSocketServer {

    private static final Logger log = LoggerFactory.getLogger(WebSocketServer.class);

    /**
     * 當前線上連線數
     */
    private static AtomicInteger onlineCount = new AtomicInteger(0);

    /**
     * 用來存放每個客戶端對應的 WebSocketServer 物件
     */
    private static ConcurrentHashMap<String, WebSocketServer> webSocketMap = new ConcurrentHashMap<>();

    /**
     * 與某個客戶端的連線會話,需要通過它來給客戶端傳送資料
     */
    private Session session;

    /**
     * 接收 userId
     */
    private String userId = "";

    /**
     * 連線建立成功呼叫的方法
     */
    @OnOpen
    public void onOpen(Session session, @PathParam("userId") String userId) {
        this.session = session;
        this.userId = userId;
        if (webSocketMap.containsKey(userId)) {
            webSocketMap.remove(userId);
            webSocketMap.put(userId, this);
        } else {
            webSocketMap.put(userId, this);
            addOnlineCount();
        }
        log.info("使用者連線:" + userId + ",當前線上人數為:" + getOnlineCount());
        try {
            sendMessage("連線成功!");
        } catch (IOException e) {
            log.error("使用者:" + userId + ",網路異常!!!!!!");
        }
    }

    /**
     * 連線關閉呼叫的方法
     */
    @OnClose
    public void onClose() {
        if (webSocketMap.containsKey(userId)) {
            webSocketMap.remove(userId);
            subOnlineCount();
        }
        log.info("使用者退出:" + userId + ",當前線上人數為:" + getOnlineCount());
    }

    /**
     * 收到客戶端訊息後呼叫的方法
     *
     * @param message 客戶端傳送過來的訊息
     */
    @OnMessage
    public void onMessage(String message, Session session) {
        log.info("使用者訊息:" + userId + ",報文:" + message);
        if (!StringUtils.isEmpty(message)) {
            try {
                JSONObject jsonObject = JSON.parseObject(message);
                jsonObject.put("fromUserId", this.userId);
                String toUserId = jsonObject.getString("toUserId");
                if (!StringUtils.isEmpty(toUserId) && webSocketMap.containsKey(toUserId)) {
                    webSocketMap.get(toUserId).sendMessage(jsonObject.toJSONString());
                } else {
                    log.error("請求的 userId:" + toUserId + "不在該伺服器上");
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    /**
     * 發生錯誤時呼叫
     *
     * @param session
     * @param error
     */
    @OnError
    public void onError(Session session, Throwable error) {
        log.error("使用者錯誤:" + this.userId + ",原因:" + error.getMessage());
        error.printStackTrace();
    }

    /**
     * 實現伺服器主動推送
     */
    public void sendMessage(String message) throws IOException {
        this.session.getBasicRemote().sendText(message);
    }

    public static synchronized AtomicInteger getOnlineCount() {
        return onlineCount;
    }

    public static synchronized void addOnlineCount() {
        WebSocketServer.onlineCount.getAndIncrement();
    }

    public static synchronized void subOnlineCount() {
        WebSocketServer.onlineCount.getAndDecrement();
    }
}

其中,@ServerEndpoint("/websocket/{userId}")表示讓 Spring 建立 WebSocket 的服務端點,其中請求地址是 /websocket/{userId}

另外 WebSocket 一共有四個事件,分別對應 JSR-356 定義的 @OnOpen、@OnMessage、@OnClose、@OnError 註解。

  • @OnOpen:標註客戶端開啟 WebSocket 服務端點呼叫方法
  • @OnClose:標註客戶端關閉 WebSocket 服務端點呼叫方法
  • @OnMessage:標註客戶端傳送訊息,WebSocket 服務端點呼叫方法
  • @OnError:標註客戶端請求 WebSocket 服務端點發生異常呼叫方法

接下來啟動專案,使用 WebSocket 線上測試工具(http://www.easyswoole.com/wstool.html)進行測試,有能力的也可以自己寫個 html 測試。

開啟網頁後,在服務地址中輸入ws://127.0.0.1:8080/websocket/wupx,點選開啟連線按鈕,訊息記錄中會多一條由伺服器端傳送的連線成功!記錄。

接下來再開啟一個網頁,服務地址中輸入ws://127.0.0.1:8080/websocket/huxy,點選開啟連線按鈕,然後回到第一次開啟的網頁在訊息框中輸入{"toUserId":"huxy","message":"i love you"},點選傳送到服務端,第二個網頁中會收到服務端推送的訊息{"fromUserId":"wupx","message":"i love you","toUserId":"huxy"}

同樣,專案的日誌中也會有相應的日誌:

2020-06-30 12:40:48.894  INFO 78908 --- [nio-8080-exec-1] com.wupx.server.WebSocketServer          : 使用者連線:wupx,當前線上人數為:1
2020-06-30 12:40:58.073  INFO 78908 --- [nio-8080-exec-2] com.wupx.server.WebSocketServer          : 使用者連線:huxy,當前線上人數為:2
2020-06-30 12:41:05.870  INFO 78908 --- [nio-8080-exec-3] com.wupx.server.WebSocketServer          : 使用者訊息:wupx,報文:{"toUserId":"huxy","message":"i love you"}

總結

本文簡單地介紹了 Spring Boot 整合 WebSocket 實現服務端主動推送訊息到客戶端,是不是十分簡單呢?大家可以自己也寫個 demo 試試!

本文的完整程式碼在 https://github.com/wupeixuan/SpringBoot-Learnwebsocket 目錄下。

最好的關係就是互相成就,大家的點贊、在看、轉發、留言就是我創作的最大動力。

參考

https://github.com/wupeixuan/SpringBoot-Learn

《深入淺出Spring Boot 2.x》

相關文章