1. 程式人生 > 其它 >Spring 系列 (11) - Springboot+WebSocket 實現傳送 JSON 訊息例項 (二)

Spring 系列 (11) - Springboot+WebSocket 實現傳送 JSON 訊息例項 (二)

STOMP 即 Simple (or Streaming) Text Orientated Messaging Protocol,簡單(流)文字定向訊息協議,它提供了一個可互操作的連線格式,允許 STOMP 客戶端與任意 STOMP 訊息代理(Broker)進行互動。

本文使用 STOMP 來實現傳送 JSON 訊息例項。


1. 開發環境

    Windows版本:Windows 10 Home (20H2)   
    IntelliJ IDEA (https://www.jetbrains.com/idea/download/):Community Edition for Windows 2020.1.4
    Apache Maven (https://maven.apache.org/):3.8.1

    注:Spring 開發環境的搭建,可以參考 “

Spring基礎知識(1)- Spring簡介、Spring體系結構和開發環境配置 ”。


2. 建立 Spring Boot 基礎專案

    專案例項名稱:SpringbootExample11
    Spring Boot 版本:2.6.6

    建立步驟:

        (1) 建立 Maven 專案例項 SpringbootExample11;
        (2) Spring Boot Web 配置;
        (3) 匯入 Thymeleaf 依賴包;
        (4) 配置 jQuery;
        
    具體操作請參考 “Spring 系列 (2) - 在 Spring Boot 專案裡使用 Thymeleaf、JQuery+Bootstrap 和國際化

” 裡的專案例項 SpringbootExample02,文末包含如何使用 spring-boot-maven-plugin 外掛執行打包的內容。

    SpringbootExample11 和 SpringbootExample02 相比,SpringbootExample11 不配置 Bootstrap、模版檔案(templates/*.html)和國際化。


3. 配置 Security

    1) 修改 pom.xml,匯入 Security 依賴包

 1         <project ... >
 2             ...
 3             <
dependencies> 4 ... 5 6 <!-- Spring security --> 7 <dependency> 8 <groupId>org.springframework.boot</groupId> 9 <artifactId>spring-boot-starter-security</artifactId> 10 </dependency> 11 12 ... 13 </dependencies> 14 15 ... 16 </project>


        在IDE中專案列表 -> SpringbootExample11 -> 點選滑鼠右鍵 -> Maven -> Reload Project

     2) 修改 src/main/resources/application.properties 檔案,新增如下配置

        # security
        spring.security.user.name=admin
        spring.security.user.password=123456
        spring.security.user.roles=admin

        執行並訪問 http://localhost:9090/test,自動跳轉到 http://localhost:9090/login (Spring security 的預設頁面),輸入上面的使用者名稱和密碼登入,登入後跳轉到 http://localhost:9090/test。


4. 配置 STOMP

    1) 修改 pom.xml,匯入 WebSocket 依賴包

 1        <project ... >
 2             ...
 3             <dependencies>
 4                 ...
 5 
 6                 <dependency>
 7                     <groupId>org.springframework.boot</groupId>
 8                     <artifactId>spring-boot-starter-websocket</artifactId>
 9                 </dependency>
10 
11                 ...
12             </dependencies>
13 
14             ...
15         </project>


        在IDE中專案列表 -> SpringbootExample11 -> 點選滑鼠右鍵 -> Maven -> Reload Project

    2) 建立 src/main/java/com/example/ws/WSStompConfig.java 檔案

 1         package com.example.ws;
 2 
 3         import org.springframework.beans.factory.annotation.Autowired;
 4         import org.springframework.context.annotation.Configuration;
 5         import org.springframework.messaging.simp.config.MessageBrokerRegistry;
 6         import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
 7         import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
 8         import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
 9 
10         @Configuration
11         @EnableWebSocketMessageBroker
12         public class WSStompConfig implements WebSocketMessageBrokerConfigurer {
13             @Autowired
14             private WSStompInterceptor wsStompInterceptor;
15 
16             @Override
17             public void registerStompEndpoints(StompEndpointRegistry registry) {
18                 // 配置客戶端嘗試連線地址
19                 registry.addEndpoint("/websocket")    // 設定連線節點,http://hostname:port/websocket
20                         //.setHandshakeHandler()               // 握手處理,主要是連線的時候認證獲取其他資料驗證等
21                         .addInterceptors(wsStompInterceptor)   // 設定握手攔截器
22                         .setAllowedOriginPatterns("*")         // 配置跨域, 不能用 setAllowedOrigins("*")
23                         .withSockJS();                         // 開啟 sockJS 支援,這裡可以對不支援 stomp 的瀏覽器進行相容
24             }
25 
26             @Override
27             public void configureMessageBroker(MessageBrokerRegistry registry) {
28                 // 這裡使用的是記憶體模式,生產環境可以使用 RabbitMQ 或者其他 MQ。
29                 // 點對點應配置一個 /queue 訊息代理,廣播式應配置一個 /topic 訊息代理
30                 registry.enableSimpleBroker("/topic", "/queue");
31 
32                 // 客戶端向服務端傳送訊息需有 /app 字首
33                 registry.setApplicationDestinationPrefixes("/app");
34 
35                 // 指定使用者傳送(一對一)的字首 /user/
36                 registry.setUserDestinationPrefix("/user/");
37             }
38         }


    3) 建立 src/main/java/com/example/ws/WSStompInterceptor.java 檔案

 1         package com.example.ws;
 2 
 3         import java.util.Map;
 4 
 5         import org.springframework.stereotype.Component;
 6         import org.springframework.http.server.ServerHttpRequest;
 7         import org.springframework.http.server.ServerHttpResponse;
 8         import org.springframework.web.socket.WebSocketHandler;
 9         import org.springframework.web.socket.server.support.HttpSessionHandshakeInterceptor;
10 
11         @Component
12         public class WSStompInterceptor extends HttpSessionHandshakeInterceptor {
13             @Override
14             public boolean beforeHandshake(ServerHttpRequest request,
15                                         ServerHttpResponse response,
16                                         WebSocketHandler wsHandler,
17                                         Map<String, Object> attributes) throws Exception {
18                 return super.beforeHandshake(request, response, wsHandler, attributes);
19             }
20 
21             @Override
22             public void afterHandshake(ServerHttpRequest request,
23                                     ServerHttpResponse response,
24                                     WebSocketHandler wsHandler,
25                                     Exception ex) {
26                 super.afterHandshake(request, response, wsHandler, ex);
27             }
28 
29         }


    4) 建立 src/main/java/com/example/ws/WSMessage.java 檔案

 1         package com.example.ws;
 2 
 3         public class WSMessage {
 4             private String content;
 5 
 6             public String getContent() {
 7                 return content;
 8             }
 9 
10             public void setContent(String content) {
11                 this.content = content;
12             }
13         }


    5) 建立 src/main/java/com/example/ws/WSStompHandler.java 檔案

 1         package com.example.ws;
 2 
 3         import java.security.Principal;
 4 
 5         import org.springframework.beans.factory.annotation.Autowired;
 6         import org.springframework.web.bind.annotation.RestController;
 7         import org.springframework.messaging.simp.SimpMessagingTemplate;
 8         import org.springframework.messaging.simp.annotation.SubscribeMapping;
 9         import org.springframework.messaging.handler.annotation.DestinationVariable;
10         import org.springframework.messaging.handler.annotation.MessageMapping;
11         import org.springframework.messaging.handler.annotation.SendTo;
12 
13         @RestController
14         public class WSStompHandler {
15             @Autowired
16             private SimpMessagingTemplate simpMessagingTemplate;
17 
18             // Broadcast
19             @MessageMapping("/broadcast")
20             @SendTo("/topic/broadcast")
21             public WSMessage broadcast(WSMessage requestMsg) {
22                 // 這裡是有 return,如果不寫 @SendTo 預設和 /topic/broadcast 一樣
23 
24                 WSMessage responseMsg = new WSMessage();
25                 responseMsg.setContent(requestMsg.getContent() + " - from server (Broadcast)");
26 
27                 return responseMsg;
28             }
29 
30             // User
31             @MessageMapping("/one")
32             //@SendToUser("/queue/one") 如果存在 return, 可以使用這種方式
33             public void one(WSMessage requestMsg, Principal principal) {
34                 // 注意為什麼使用 queue,主要目的是為了區分廣播和佇列的方式。實際採用 topic,也沒有關係。但是為了好理解
35 
36                 WSMessage responseMsg = new WSMessage();
37                 responseMsg.setContent(requestMsg.getContent() + " - from server (User)");
38 
39                 simpMessagingTemplate.convertAndSendToUser(principal.getName(), "/queue/one", responseMsg);
40             }
41 
42             // Subscribe
43             @SubscribeMapping("/subscribe/{id}")
44             public WSMessage subscribe(@DestinationVariable Long id) {
45 
46                 WSMessage responseMsg = new WSMessage();
47                 responseMsg.setContent("Subscribe success - from server (Subscribe)");
48 
49                 return responseMsg;
50             }
51 
52         }

  注:WSStompHandler 是 @RestController 註解修飾的類,或許定義成 WSStompController 更符合習慣,這裡暫時作為 Handler 來定義。


5. 配置 SockJS、StompJS

    SockJS: https://github.com/sockjs/sockjs-client/tree/main/dist
    StomJS:https://github.com/jmesnil/stomp-websocket/tree/master/lib
    
    本文使用 sockjs.min.js(1.6.0) 和 stomp.min.js(2.3.4),兩者放到 src/main/resources/static/lib/ 目錄下。
    
    目錄結構如下

      static
        |
        |- lib
            |- jquery
            |     |- jquery-3.6.0.min.js
            |
            |- sockjs.min.js
            |- sockjs.min.js.map
            |- stomp.min.js


6. 測試例項 (Web 模式)

    1) 建立 src/main/resources/templates/stomp_client.html 檔案

  1         <html lang="en" xmlns:th="http://www.thymeleaf.org">
  2         <head>
  3             <meta charset="UTF-8">
  4             <title>STOMP Client</title>
  5             <script language="javascript" th:src="@{/lib/jquery/jquery-3.6.0.min.js}"></script>
  6             <script language="javascript" th:src="@{/lib/sockjs.min.js}"></script>
  7             <script language="javascript" th:src="@{/lib/stomp.min.js}"></script>
  8         </head>
  9         <body>
 10             <h4>WebSocket STOMP - Client</h4>
 11             <hr>
 12 
 13             <p>
 14                 <label>WebSocket Url:</label><br>
 15                 <input id="url" type="text" th:value="@{/websocket}" value="http://localhost:9090/websocket" style="width: 240px;"><br><br>
 16                 <button id="connect">Connect</button>&nbsp;<button id="disconnect" disabled="disabled">Disconnect</button><br><br>
 17 
 18                 <label>Subscribed Message: </label><br>
 19                 <input id="subscribeMsg" type="text" disabled="disabled" style="width: 50%;"><br><br>
 20             </p>
 21             <hr>
 22             <p>
 23                 <label>Broadcast: </label><br>
 24                 <input id="broadcastText" type="text" style="width: 50%;" value="broadcast message"><button id="broadcastButton">Send</button><br><br>
 25 
 26                 <label>Return Message: </label><br>
 27                 <input id="broadcastMsg" type="text" disabled="disabled" style="width: 50%;"><br><br>
 28             </p>
 29             <hr>
 30             <p>
 31                 <label>User: </label><br>
 32                 <input id="userText" type="text" style="width: 50%;" value="user message"><button id="userButton">Send</button><br><br>
 33 
 34                 <label>Return Message: </label><br>
 35                 <input id="userMsg" type="text" disabled="disabled" style="width: 50%;"><br><br>
 36             </p>
 37             <hr>
 38             <p>
 39                 <label>App: </label><br>
 40                 <input id="appText" type="text" style="width: 50%;" value="app message"><button id="appButton">Send</button><br><br>
 41 
 42                 <label>Return Message: </label><br>
 43                 <input id="appMsg" type="text" disabled="disabled" style="width: 50%;"><br><br>
 44             </p>
 45 
 46             <p>&nbsp;</p>
 47 
 48             <script type="text/javascript">
 49                 var stomp = null;
 50 
 51                 $(document).ready(function() {
 52 
 53                     $("#connect").click(function () {
 54 
 55                         var url = $("#url").val();
 56                         if (url == "") {
 57                             alert("Please enter url");
 58                             $("#url").focus();
 59                             return;
 60                         }
 61 
 62                         var socket = new SockJS(url);
 63                         stomp = Stomp.over(socket);
 64 
 65                         // Connect
 66                         stomp.connect({}, function (frame) {
 67                             // Subscribe broadcast
 68                             stomp.subscribe("/topic/broadcast", function (res) {
 69                                 $("#broadcastMsg").val(res.body);
 70                             });
 71 
 72                             // Subscribe
 73                             stomp.subscribe("/app/subscribe/1", function (res) {
 74                                 $("#subscribeMsg").val(res.body);
 75                             });
 76 
 77                             // User
 78                             stomp.subscribe("/user/queue/one", function (res) {
 79                                 $("#userMsg").val(res.body);
 80                             });
 81 
 82                             // App
 83                             stomp.subscribe("/topic/app", function (res) {
 84                                 $("#appMsg").val(res.body);
 85                             });
 86                             setConnect(true);
 87                         });
 88                     });
 89 
 90                     $("#disconnect").click(function () {
 91                         if (stomp != null) {
 92                             stomp.disconnect();
 93                             stomp = null;
 94                         }
 95                         setConnect(false);
 96                     });
 97 
 98                     // Send broadcast message
 99                     $("#broadcastButton").click(function () {
100                         if (stomp == null) {
101                             alert("Please connect to server");
102                             return;
103                         }
104                         var msg = $("#broadcastText").val();
105                         if (msg == '') {
106                             alert("Please enter broadcast text");
107                             $("#broadcastText").focus();
108                             return;
109                         }
110                         stomp.send("/app/broadcast", {}, JSON.stringify({"content": msg}))
111                     });
112 
113                     // Send user message
114                     $("#userButton").click(function () {
115                         if (stomp == null) {
116                             alert("Please connect to server");
117                             return;
118                         }
119                         var msg = $("#userText").val();
120                         if (msg == '') {
121                             alert("Please enter user text");
122                             $("#userText").focus();
123                             return;
124                         }
125                         stomp.send("/app/one", {}, JSON.stringify({"content": msg}))
126                     });
127 
128                     // Send app message
129                     $("#appButton").click(function () {
130                         if (stomp == null) {
131                             alert("Please connect to server");
132                             return;
133                         }
134                         var msg = $("#appText").val();
135                         if (msg == '') {
136                             alert("Please enter app text");
137                             $("#appText").focus();
138                             return;
139                         }
140                         stomp.send("/topic/app", {}, JSON.stringify({"content": msg}))
141                     });
142 
143                 });
144 
145                 // Set buttons
146                 function setConnect(connectStatus) {
147                     $("#connect").attr("disabled", connectStatus);
148                     $("#disconnect").attr("disabled", !connectStatus);
149                 }
150 
151             </script>
152         </body>
153         </html>


    2) 修改 src/main/java/com/example/controller/IndexController.java 檔案

 1         package com.example.controller;
 2 
 3         import org.springframework.stereotype.Controller;
 4         import org.springframework.web.bind.annotation.RequestMapping;
 5         import org.springframework.web.bind.annotation.ResponseBody;
 6 
 7         @Controller
 8         public class IndexController {
 9             @ResponseBody
10             @RequestMapping("/test")
11             public String test() {
12                 return "Test Page";
13             }
14 
15             @RequestMapping("/stomp/client")
16             public String stompClient() {
17                 return "stomp_client";
18             }
19 
20         }


    訪問 http://localhost:9090/stomp/client