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 開發環境的搭建,可以參考 “
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 和國際化
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> <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> </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