Spring boot webSocket從入門到放棄
在構建Spring boot專案時已經提供webSocket依賴的勾選。webSocket是TCP之上的一個非常薄的輕量級層 ,webSocket主要的應用場景離不開即時通訊與訊息推送,但只要應用程式需要在瀏覽器和伺服器之間來回傳送訊息,就可以使用webSocket來降低客戶端流量與伺服器的負載。
下面將基於Spring boot實現一個非常簡單的HelloWorld程式,用來熟悉專案構建邏輯。
1.匯入依賴
主要的webSocket依賴
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId> </dependency>
一些js庫依賴,這裡也使用maven方式匯入,官網 ofollow,noindex" target="_blank">https://www.webjars.org/
<dependency> <groupId>org.webjars</groupId> <artifactId>webjars-locator-core</artifactId> </dependency> <dependency> <groupId>org.webjars</groupId> <artifactId>sockjs-client</artifactId> <version>1.0.2</version> </dependency> <dependency> <groupId>org.webjars</groupId> <artifactId>stomp-websocket</artifactId> <version>2.3.3</version> </dependency> <dependency> <groupId>org.webjars</groupId> <artifactId>bootstrap</artifactId> <version>3.3.7</version> </dependency> <dependency> <groupId>org.webjars</groupId> <artifactId>jquery</artifactId> <version>3.1.0</version> </dependency>
Thymeleaf模板引擎,不用多說了吧
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-thymeleaf</artifactId> </dependency>
2.配置開啟
接下來開啟webSocket並配置一番
@Configuration @EnableWebSocketMessageBroker public class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/gs-guide-websocket").withSockJS(); } @Override public void configureMessageBroker(MessageBrokerRegistry config) { config.enableSimpleBroker("/topic"); config.setApplicationDestinationPrefixes("/app"); } }
這裡從上向下說一下,
1)@EnableWebSocketMessageBroker註解用於開啟使用STOMP協議來傳輸基於代理(MessageBroker)的訊息,這時候控制器(controller)開始支援@MessageMapping,就像是使用@requestMapping一樣。
2)registerStompEndpoints()方法只寫了一行程式碼:
registry.addEndpoint("/gs-guide-websocket").withSockJS();
一是用來註冊一個Stomp的節點(endpoint),也就是webSocket的服務端地址,客戶端在連結時使用到;
二是withSockJs()方法指定使用SockJS協議。SockJs是一個WebSocket的通訊js庫,Spring對這個js庫進行了後臺的自動支援,如果使用它不需要進行過多配置。
3)配置訊息代理(MessageBroker),該方法幹了兩件事,一是啟用一個簡單的message broker並配置一個或多個字首來過濾針對代理的目的地(例如以“/topic”為字首的目的地),該前戳限制了網頁客戶端設定本地地址時的前戳。 二是設定了一個客戶端訪問服務端地址的字首。比如我們設定@MessageMapping("/hello"),那客戶端要傳送訊息到伺服器上的地址是 /app/hello。
3.提供介面
@Controller public class GreetingController { @MessageMapping("/hello") @SendTo("/topic/greetings") public Greeting greeting(HelloMessage message) throws Exception { System.out.println("收到:" + message.toString() + "訊息"); return new Greeting("Hello, " + HtmlUtils.htmlEscape(message.getName()) + "!"); }
1)@MessageMapping和@RequestMapping功能類似,用於設定URL對映地址,瀏覽器向伺服器發起請求,需要通過該地址。
需要注意,這裡設定路徑為/hello,但是客戶端需要訪問/app/hello,原因前面已經講述。
2)@SendTo("/topic/greetings") 設定目的地,這裡的目的地是站在服務端的角度對客戶端而言。客戶端也需要設定相同的地址,而且必須使用/topic前戳,前面也已經講述。
本示例中如果伺服器接受到了訊息,就會對訂閱了@SendTo括號中的地址傳送訊息。
備註:HtmlUtils.htmlEscape()方法會將特殊字元轉換為HTML字元引用。
HelloMessage.java
public class HelloMessage { private String name; ... }
Greeting.java
public class Greeting { private String content; }
4.客戶端
在resources/static目錄下:
index.html
<!DOCTYPE html> <html> <head> <title>Hello WebSocket</title> <link href="/webjars/bootstrap/css/bootstrap.min.css" rel="stylesheet"> <link href="/main.css" rel="stylesheet"> <link rel="shortcut icon" href="/favicon.ico" /> <script src="/webjars/jquery/jquery.min.js"></script> <script src="/webjars/sockjs-client/sockjs.min.js"></script> <script src="/webjars/stomp-websocket/stomp.min.js"></script> <script src="/app.js"></script> </head> <body> <noscript><h2 style="color: #ff0000">Seems your browser doesn't support Javascript! Websocket relies on Javascript being enabled. Please enable Javascript and reload this page!</h2></noscript> <div id="main-content" class="container"> <div class="row"> <div class="col-md-6"> <form class="form-inline"> <div class="form-group"> <label for="connect">WebSocket connection:</label> <button id="connect" class="btn btn-default" type="submit">Connect</button> <button id="disconnect" class="btn btn-default" type="submit" disabled="disabled">Disconnect </button> </div> </form> </div> <div class="col-md-6"> <form class="form-inline"> <div class="form-group"> <label for="name">What is your name?</label> <input type="text" id="name" class="form-control" placeholder="Your name here..."> </div> <button id="send" class="btn btn-default" type="submit">Send</button> </form> </div> </div> <div class="row"> <div class="col-md-12"> <table id="conversation" class="table table-striped"> <thead> <tr> <th>Greetings</th> </tr> </thead> <tbody id="greetings"> </tbody> </table> </div> </div> </div> </body> </html>
main.css
body { background-color: #f5f5f5; } #main-content { max-width: 940px; padding: 2em 3em; margin: 0 auto 20px; background-color: #fff; border: 1px solid #e5e5e5; -webkit-border-radius: 5px; -moz-border-radius: 5px; border-radius: 5px; }
app.js
var stompClient = null; function setConnected(connected) { $("#connect").prop("disabled", connected); $("#disconnect").prop("disabled", !connected); if (connected) { $("#conversation").show(); } else { $("#conversation").hide(); } $("#greetings").html(""); } function connect() { var socket = new SockJS('/gs-guide-websocket'); stompClient = Stomp.over(socket); stompClient.connect({"id": "header"}, function (frame) { setConnected(true); console.log('Connected: ' + frame); stompClient.subscribe('/topic/greetings', function (greeting) { showGreeting(JSON.parse(greeting.body).content); }); }); } function disconnect() { if (stompClient !== null) { stompClient.disconnect(); } setConnected(false); console.log("Disconnected"); } function sendName() { stompClient.send("/app/hello", {}, JSON.stringify({'name': $("#name").val()})); } function showGreeting(message) { $("#greetings").append("<tr><td>" + message + "</td></tr>"); } $(function () { $("form").on('submit', function (e) { e.preventDefault(); }); $( "#connect" ).click(function() { connect(); }); $( "#disconnect" ).click(function() { disconnect(); }); $( "#send" ).click(function() { sendName(); }); });
這部分最值得說的就是JS部分了,比較簡單主要就是3個點選事件,包括連線、斷開、傳送訊息。
當我們點選了連線後,會新建SockjS物件,並設定服務端的連線點(/gs-guide-websocket),這裡的連線點由服務端提供。
subscribe()方法的第一個引數是註冊客戶端地址,注意前戳必須是/topic開頭,因為在前面服務端已經配置了目的地前戳。與@SendTo中的地址對應。
客戶端傳送訊息只需要呼叫send()方法,還方法的第一個引數是服務端@MessageMapping地址並且加了指定的/app前戳,第二個引數為header頭部資訊,第三個是傳送的訊息內容。
5.執行程式
成功實現Hello World程式,下面將通過搭建一個一對一聊天伺服器,深入學習更多的socket知識。
6.傳送訊息
在前面我們寫了一個自動回覆的小例子,用到了@MessageMapping("/hello")和@SendTo("/topic/greetings")兩個註解,其實我們還可以使用程式設計的方式傳送訊息。
其實很簡單,直接引用該訊息模板
@Autowired private SimpMessagingTemplate messagingTemplate;
訊息模板內建了一系列方法,比如
void convertAndSendToUser(String user, String destination, Object payload) void convertAndSend(D destination, Object payload)
這倆方法會包裝為訊息並將其傳送到給定的目的地。
7.STOMP監聽
監聽webSocket伺服器的連線只需要實現ApplicationListener<>介面。程式碼如下:
@Component public class STOMPConnectEventListener implements ApplicationListener<SessionConnectEvent> { @Override public void onApplicationEvent(SessionConnectEvent event) { StompHeaderAccessor sha = StompHeaderAccessor.wrap(event.getMessage()); //判斷客戶端的連線狀態 switch (sha.getCommand()) { case CONNECT: System.out.println("上線"); break; case DISCONNECT: System.out.println("下線"); break; case SUBSCRIBE: System.out.println("訂閱"); break; case SEND: System.out.println("傳送"); break; case UNSUBSCRIBE: System.out.println("取消訂閱"); break; default: break; } } }
監聽的所有狀態被封裝在一個列舉類中,其實還有很多,這裡不再一一列舉。
有了上面這些知識,我們就可以基於此開發一對一聊天伺服器。
8.Session存取
webSocket提供的是一個socket框架,並不會幫我們管理session,我們需要自己去編寫session管理類,進行session的讀寫。程式碼如下:
@Component public class SocketSessionMap { private final static ConcurrentMap<String, String> sessionMap = new ConcurrentHashMap<>(); /** * 註冊Session * @param userId * @param sessionId */ public synchronized void registerSession(String userId, String sessionId) { sessionMap.put(userId,sessionId); } /** * 移除Session * @param userId * @param sessionId */ public synchronized void removeSession(String userId, String sessionId) { sessionMap.remove(userId); } /** * 獲取使用者的SessionId * @param userId * @return */ public String getUserSessionId(String userId){ return sessionMap.get(userId); } /** * 獲取所有Session集合 * @return */ public Map<String, String> queryAllSession(){ return sessionMap; } /** * 獲取集合的大小 */ public int onlineCount(){ return sessionMap.size(); } }
9.監聽註冊
接著,對STOMP監聽類進行擴充套件。
@Component public class STOMPConnectEventListener implements ApplicationListener<SessionConnectEvent> { @Autowired SocketSessionMap socketSessionMap; @Override public void onApplicationEvent(SessionConnectEvent event) { StompHeaderAccessor sha = StompHeaderAccessor.wrap(event.getMessage()); String userId = sha.getFirstNativeHeader("id"); String sessionId = sha.getSessionId(); switch (sha.getCommand()) { case CONNECT: System.out.println("上線:" + userId + "" + sessionId); socketSessionMap.registerSession(userId, sessionId); break; default: break; } } }
服務端通過 sha.getFirstNativeHeader("id")
讀取到客戶端的ID,這個值需要網頁客戶端手動在header頭部資訊中設定。
當服務端監聽到客戶端連線時,會將使用者SessionId註冊到Map中。
10.監聽下線
這裡我們使用更可靠的請求下線方式,程式碼如下:
@MessageMapping("/chatOut") public void sayHello(String userId) { String sessionId = socketSessionMap.getUserSessionId(userId); System.out.println("下線:" + userId + "" + sessionId); socketSessionMap.removeSession(userId,sessionId); }
當收到下線請求時,移除SessionId。
關於Session,也可以設定一個最大值,超時自動移除。
11.一對一訊息處理
在一對一伺服器中,主要處理的就是一對一的訊息傳送。大致邏輯是接收客戶端訊息,分析訊息結構,通過SessionMap判斷對方是否線上,然後傳送相應內容。程式碼如下:
@MessageMapping("/chat") public void sayHello(Message user) { System.out.println(user.getId()+"-->"+user.getPid()+":"+user.getContent()); String userPid = String.valueOf(user.getPid()); String userId = String.valueOf(user.getId()); String sendTo = "/topic/chat/"+userPid; String content = user.getId()+":"+user.getContent(); if (socketSessionMap.getUserSessionId(userPid)!=null){ messagingTemplate.convertAndSend(sendTo, HtmlUtils.htmlEscape(content)); }else { sendTo = "/topic/chat/"+userId; content = "對方已下線"; messagingTemplate.convertAndSend(sendTo, HtmlUtils.htmlEscape(content)); } }
值得一體的是,關於使用者ID的處理,這裡使用的是自定義客戶端地址,不同的地址表示不同的使用者。最後通過convertAndSend()方法傳送,這種方式比較可靠方便。
Message.java
public class Message { private int id; //使用者ID private String content;//傳送內容 private int pid;//傳送到使用者
12.聊天網頁
服務端使用FreeMarker模板引擎返回html網頁,程式碼如下:
@RequestMapping("/chat/{id}") public String chat_page(@PathVariable int id, ModelMap model) { model.addAttribute("id", id); int count = socketSessionMap.onlineCount(); model.addAttribute("count", count); return "chat"; }
通過RESTful形式的URl註冊ID。
chat.html
<!DOCTYPE html > <html xmlns:th="http://www.thymeleaf.org"> <head> <title>Hello WebSocket</title> <link href="/webjars/bootstrap/css/bootstrap.min.css" rel="stylesheet"> <link href="/main.css" rel="stylesheet"> <link rel="shortcut icon" href="/favicon.ico" /> <script src="/webjars/jquery/jquery.min.js"></script> <script src="/webjars/sockjs-client/sockjs.min.js"></script> <script src="/webjars/stomp-websocket/stomp.min.js"></script> <script src="/chat.js"></script> </head> <body> <noscript><h2 style="color: #ff0000">Seems your browser doesn't support Javascript! Websocket relies on Javascript being enabled. Please enable Javascript and reload this page!</h2></noscript> <div id="main-content" class="container"> <div class="row"> <div class="col-md-6"> <form class="form-inline"> <div class="form-group"> <label for="connect">WebSocket connection:</label> <button id="connect" class="btn btn-default" type="submit">Connect</button> <button id="disconnect" class="btn btn-default" type="submit" disabled="disabled">Disconnect </button> </div> </form> </div> <div class="col-md-6"> <form class="form-inline"> <div class="form-group"> <label th:text="'Online:'+${count}"> </label> <input type="text" id="id" class="form-control" th:value="${id}"required> <input type="text" id="content" class="form-control" placeholder="Your name here..."required> <input type="text" id="pid" class="form-control" placeholder="Your PID"required> </div> <button id="send" class="btn btn-default" type="submit">Send</button> </form> </div> </div> <div class="row"> <div class="col-md-12"> <table id="conversation" class="table table-striped"> <thead> <tr> <th>Greetings</th> </tr> </thead> <tbody id="greetings"> </tbody> </table> </div> </div> </div> </body> </html>
chat.js
var stompClient = null; function setConnected(connected) { $("#connect").prop("disabled", connected); $("#disconnect").prop("disabled", !connected); if (connected) { $("#conversation").show(); } else { $("#conversation").hide(); } $("#greetings").html(""); } function connect() { var socket = new SockJS('/gs-guide-websocket'); stompClient = Stomp.over(socket); stompClient.connect({"id": $("#id").val()}, function (frame) { //客戶端ID setConnected(true); console.log('Connected: ' + frame); stompClient.subscribe('/topic/chat/' + $("#id").val(), function (greeting) { //表明客戶端地址 showGreeting(greeting.body); }, {"id": "Host_" + $("#id").val()}); }); } function disconnect() { if (stompClient !== null) { stompClient.send("/app/chatOut", {},$("#id").val()); stompClient.disconnect(); } setConnected(false); console.log("Disconnected"); } function sendName() { stompClient.send("/app/chat", {}, JSON.stringify({ 'content': $("#content").val(), 'id': $("#id").val(), 'pid': $("#pid").val() })); showGreeting("我:" + $("#content").val()) } function showGreeting(message) { $("#greetings").append("<tr><td>" + message + "</td></tr>"); } $(function () { $("form").on('submit', function (e) { e.preventDefault(); }); $("#connect").click(function () { connect(); }); $("#disconnect").click(function () { disconnect(); }); $("#send").click(function () { sendName(); }); });
網頁客戶端的主要邏輯在chat.js中。非常有必要的是在stompClient.connect()方法的第一個引數中傳入header頭資訊,該頭部資訊必須設定id欄位的值,因為服務端會讀取該ID值,該值最終會取自URL中的引數。
其次是在使用者斷開連結前,會向服務端傳送斷開通知。
stompClient.send("/app/chatOut", {},$("#id").val());
13.程式演示
開啟兩個網頁,URl分別為http://localhost:8080/chat/100和http://localhost:8080/chat/101,點選連線。
檢視控制檯輸出:
接下來演示使用者101向用戶100傳送訊息:
檢視使用者100收到資訊:
再次檢視控制輸出:
訊息成功傳送!
當用戶斷開連結時,控制檯輸出為:
關於Spring boot webSocket就先到這裡,更多請持續關注我的部落格!