前言
本文中搭建了一個簡易的多人聊天室,使用了WebSocket的基礎特性。
本文內容摘要:
初步理解WebSocket的前後端互動邏輯手把手使用 SpringBoot + WebSocket 搭建一個多人聊天室Demo程式碼原始碼及其解釋前端展示頁面此外,在下一篇文章中,我們將做到:
對該WebSocket聊天室進行分散式改造,同時部署多臺機器來作為叢集,支撐高併發。儲存使用者session,並且在叢集上實現session同步,比如實時展示當前線上的使用者!正文WebSocket多人線上聊天室本文工程原始碼:
https://github.com/qqxx6661/springboot-websocket-demo
新建工程我們新建一個SpringBoot2的專案工程,在預設依賴中,新增websocket依賴:
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-websocket</artifactId></dependency>
WebSocket 配置
我們先來設定websocket的配置,新建config資料夾,在裡面新建類WebSocketConfig
import org.springframework.context.annotation.Configuration;import org.springframework.messaging.simp.config.MessageBrokerRegistry;import org.springframework.web.socket.config.annotation.*;@Configuration@EnableWebSocketMessageBrokerpublic class WebSocketConfig implements WebSocketMessageBrokerConfigurer { @Override public void registerStompEndpoints(StompEndpointRegistry registry) { registry.addEndpoint("/ws").withSockJS(); } @Override public void configureMessageBroker(MessageBrokerRegistry registry) { registry.setApplicationDestinationPrefixes("/app"); registry.enableSimpleBroker("/topic"); }}
程式碼解釋:
@EnableWebSocketMessageBroker用於啟用我們的WebSocket伺服器。
我們實現了WebSocketMessageBrokerConfigurer介面,並實現了其中的方法。
在第一種方法中,我們註冊一個websocket端點,客戶端將使用它連線到我們的websocket伺服器。
withSockJS()是用來為不支援websocket的瀏覽器啟用後備選項,使用了SockJS。
方法名中的STOMP是來自Spring框架STOMP實現。 STOMP代表簡單文字導向的訊息傳遞協議。它是一種訊息傳遞協議,用於定義資料交換的格式和規則。為啥我們需要這個東西?因為WebSocket只是一種通訊協議。它沒有定義諸如以下內容:如何僅向訂閱特定主題的使用者傳送訊息,或者如何向特定使用者傳送訊息。我們需要STOMP來實現這些功能。
在configureMessageBroker方法中,我們配置一個訊息代理,用於將訊息從一個客戶端路由到另一個客戶端。
第一行定義了以“/app”開頭的訊息應該路由到訊息處理方法(之後會定義這個方法)。
第二行定義了以“/topic”開頭的訊息應該路由到訊息代理。訊息代理向訂閱特定主題的所有連線客戶端廣播訊息。
在上面的示例中,我們使用的是記憶體中的訊息代理。
之後也可以使用RabbitMQ或ActiveMQ等其他訊息代理。
建立 ChatMessage 實體ChatMessage用來在客戶端和服務端中互動
我們新建model資料夾,建立實體類ChatMessage。
public class ChatMessage { private MessageType type; private String content; private String sender; public enum MessageType { CHAT, JOIN, LEAVE } public MessageType getType() { return type; } public void setType(MessageType type) { this.type = type; } public String getContent() { return content; } public void setContent(String content) { this.content = content; } public String getSender() { return sender; } public void setSender(String sender) { this.sender = sender; }}
實體中,有三個欄位:
type:訊息型別content:訊息內容sender:傳送者型別有三種:
CHAT: 訊息JOIN:加入LEAVE:離開建立Controller來接收和傳送訊息建立controller資料夾,在controller資料夾新增類ChatController
import com.example.websocketdemo.model.ChatMessage;import org.springframework.messaging.handler.annotation.MessageMapping;import org.springframework.messaging.handler.annotation.Payload;import org.springframework.messaging.handler.annotation.SendTo;import org.springframework.messaging.simp.SimpMessageHeaderAccessor;import org.springframework.stereotype.Controller;@Controllerpublic class ChatController { @MessageMapping("/chat.sendMessage") @SendTo("/topic/public") public ChatMessage sendMessage(@Payload ChatMessage chatMessage) { return chatMessage; } @MessageMapping("/chat.addUser") @SendTo("/topic/public") public ChatMessage addUser(@Payload ChatMessage chatMessage, SimpMessageHeaderAccessor headerAccessor) { // Add username in web socket session headerAccessor.getSessionAttributes().put("username", chatMessage.getSender()); return chatMessage; }}
程式碼解釋:
我們在websocket配置中,從目的地以/app開頭的客戶端傳送的所有訊息都將路由到這些使用@MessageMapping註釋的訊息處理方法。
例如,具有目標/app/chat.sendMessage的訊息將路由到sendMessage()方法,並且具有目標/app/chat.addUser的訊息將路由到addUser()方法
新增WebSocket事件監聽完成了上述程式碼後,我們還需要對socket的連線和斷連事件進行監聽,這樣我們才能廣播使用者進來和出去等操作。
建立listener資料夾,新建WebSocketEventListener類
import com.example.websocketdemo.model.ChatMessage;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.beans.factory.annotation.Autowired;import org.springframework.context.event.EventListener;import org.springframework.messaging.simp.SimpMessageSendingOperations;import org.springframework.messaging.simp.stomp.StompHeaderAccessor;import org.springframework.stereotype.Component;import org.springframework.web.socket.messaging.SessionConnectedEvent;import org.springframework.web.socket.messaging.SessionDisconnectEvent;@Componentpublic class WebSocketEventListener { private static final Logger logger = LoggerFactory.getLogger(WebSocketEventListener.class); @Autowired private SimpMessageSendingOperations messagingTemplate; @EventListener public void handleWebSocketConnectListener(SessionConnectedEvent event) { logger.info("Received a new web socket connection"); } @EventListener public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) { StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage()); String username = (String) headerAccessor.getSessionAttributes().get("username"); if(username != null) { logger.info("User Disconnected : " + username); ChatMessage chatMessage = new ChatMessage(); chatMessage.setType(ChatMessage.MessageType.LEAVE); chatMessage.setSender(username); messagingTemplate.convertAndSend("/topic/public", chatMessage); } }}
程式碼解釋:
我們已經在ChatController中定義的addUser()方法中廣播了使用者加入事件。因此,我們不需要在SessionConnected事件中執行任何操作。
在SessionDisconnect事件中,編寫程式碼用來從websocket會話中提取使用者名稱,並向所有連線的客戶端廣播使用者離開事件。
建立前端聊天室頁面我們在src/main/resources檔案下建立前端檔案,結構類似這樣:
static └── css └── main.css └── js └── main.js └── index.html
1. HTML檔案 index.html
HTML檔案包含用於顯示聊天訊息的使用者介面。 它包括sockjs和stomp 兩個js庫。
SockJS是一個WebSocket客戶端,它嘗試使用本機WebSockets,併為不支援WebSocket的舊瀏覽器提供支援。 STOMP JS是javascript的stomp客戶端。
筆者在檔案裡使用了國內的CDN源
新增連線到websocket端點以及傳送和接收訊息所需的javascript。
'use strict';var usernamePage = document.querySelector('#username-page');var chatPage = document.querySelector('#chat-page');var usernameForm = document.querySelector('#usernameForm');var messageForm = document.querySelector('#messageForm');var messageInput = document.querySelector('#message');var messageArea = document.querySelector('#messageArea');var connectingElement = document.querySelector('.connecting');var stompClient = null;var username = null;var colors = [ '#2196F3', '#32c787', '#00BCD4', '#ff5652', '#ffc107', '#ff85af', '#FF9800', '#39bbb0'];function connect(event) { username = document.querySelector('#name').value.trim(); if(username) { usernamePage.classList.add('hidden'); chatPage.classList.remove('hidden'); var socket = new SockJS('/ws'); stompClient = Stomp.over(socket); stompClient.connect({}, onConnected, onError); } event.preventDefault();}function onConnected() { // Subscribe to the Public Topic stompClient.subscribe('/topic/public', onMessageReceived); // Tell your username to the server stompClient.send("/app/chat.addUser", {}, JSON.stringify({sender: username, type: 'JOIN'}) ) connectingElement.classList.add('hidden');}function onError(error) { connectingElement.textContent = 'Could not connect to WebSocket server. Please refresh this page to try again!'; connectingElement.style.color = 'red';}function sendMessage(event) { var messageContent = messageInput.value.trim(); if(messageContent && stompClient) { var chatMessage = { sender: username, content: messageInput.value, type: 'CHAT' }; stompClient.send("/app/chat.sendMessage", {}, JSON.stringify(chatMessage)); messageInput.value = ''; } event.preventDefault();}function onMessageReceived(payload) { var message = JSON.parse(payload.body); var messageElement = document.createElement('li'); if(message.type === 'JOIN') { messageElement.classList.add('event-message'); message.content = message.sender + ' joined!'; } else if (message.type === 'LEAVE') { messageElement.classList.add('event-message'); message.content = message.sender + ' left!'; } else { messageElement.classList.add('chat-message'); var avatarElement = document.createElement('i'); var avatarText = document.createTextNode(message.sender[0]); avatarElement.appendChild(avatarText); avatarElement.style['background-color'] = getAvatarColor(message.sender); messageElement.appendChild(avatarElement); var usernameElement = document.createElement('span'); var usernameText = document.createTextNode(message.sender); usernameElement.appendChild(usernameText); messageElement.appendChild(usernameElement); } var textElement = document.createElement('p'); var messageText = document.createTextNode(message.content); textElement.appendChild(messageText); messageElement.appendChild(textElement); messageArea.appendChild(messageElement); messageArea.scrollTop = messageArea.scrollHeight;}function getAvatarColor(messageSender) { var hash = 0; for (var i = 0; i < messageSender.length; i++) { hash = 31 * hash + messageSender.charCodeAt(i); } var index = Math.abs(hash % colors.length); return colors[index];}usernameForm.addEventListener('submit', connect, true)messageForm.addEventListener('submit', sendMessage, true)
程式碼解釋:
connect()函式使用SockJS和stomp客戶端連線到我們在Spring Boot中配置的/ws端點。
成功連線後,客戶端訂閱/topic/public,並透過向/app/chat.addUser目的地傳送訊息將該使用者的名稱告知伺服器。
stompClient.subscribe()函式採用一種回撥方法,只要訊息到達訂閱主題,就會呼叫該方法。
其它的程式碼用於在螢幕上顯示和格式化訊息。
3. CSS main.css
* { -webkit-box-sizing: border-box; -moz-box-sizing: border-box; box-sizing: border-box;}html,body { height: 100%; overflow: hidden;}body { margin: 0; padding: 0; font-weight: 400; font-family: "Helvetica Neue", Helvetica, Arial, sans-serif; font-size: 1rem; line-height: 1.58; color: #333; background-color: #f4f4f4; height: 100%;}body:before { height: 50%; width: 100%; position: absolute; top: 0; left: 0; background: #128ff2; content: ""; z-index: 0;}.clearfix:after { display: block; content: ""; clear: both;}.hidden { display: none;}.form-control { width: 100%; min-height: 38px; font-size: 15px; border: 1px solid #c8c8c8;}.form-group { margin-bottom: 15px;}input { padding-left: 10px; outline: none;}h1, h2, h3, h4, h5, h6 { margin-top: 20px; margin-bottom: 20px;}h1 { font-size: 1.7em;}a { color: #128ff2;}button { box-shadow: none; border: 1px solid transparent; font-size: 14px; outline: none; line-height: 100%; white-space: nowrap; vertical-align: middle; padding: 0.6rem 1rem; border-radius: 2px; transition: all 0.2s ease-in-out; cursor: pointer; min-height: 38px;}button.default { background-color: #e8e8e8; color: #333; box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12);}button.primary { background-color: #128ff2; box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12); color: #fff;}button.accent { background-color: #ff4743; box-shadow: 0 2px 2px 0 rgba(0, 0, 0, 0.12); color: #fff;}#username-page { text-align: center;}.username-page-container { background: #fff; box-shadow: 0 1px 11px rgba(0, 0, 0, 0.27); border-radius: 2px; width: 100%; max-width: 500px; display: inline-block; margin-top: 42px; vertical-align: middle; position: relative; padding: 35px 55px 35px; min-height: 250px; position: absolute; top: 50%; left: 0; right: 0; margin: 0 auto; margin-top: -160px;}.username-page-container .username-submit { margin-top: 10px;}#chat-page { position: relative; height: 100%;}.chat-container { max-width: 700px; margin-left: auto; margin-right: auto; background-color: #fff; box-shadow: 0 1px 11px rgba(0, 0, 0, 0.27); margin-top: 30px; height: calc(100% - 60px); max-height: 600px; position: relative;}#chat-page ul { list-style-type: none; background-color: #FFF; margin: 0; overflow: auto; overflow-y: scroll; padding: 0 20px 0px 20px; height: calc(100% - 150px);}#chat-page #messageForm { padding: 20px;}#chat-page ul li { line-height: 1.5rem; padding: 10px 20px; margin: 0; border-bottom: 1px solid #f4f4f4;}#chat-page ul li p { margin: 0;}#chat-page .event-message { width: 100%; text-align: center; clear: both;}#chat-page .event-message p { color: #777; font-size: 14px; word-wrap: break-word;}#chat-page .chat-message { padding-left: 68px; position: relative;}#chat-page .chat-message i { position: absolute; width: 42px; height: 42px; overflow: hidden; left: 10px; display: inline-block; vertical-align: middle; font-size: 18px; line-height: 42px; color: #fff; text-align: center; border-radius: 50%; font-style: normal; text-transform: uppercase;}#chat-page .chat-message span { color: #333; font-weight: 600;}#chat-page .chat-message p { color: #43464b;}#messageForm .input-group input { float: left; width: calc(100% - 85px);}#messageForm .input-group button { float: left; width: 80px; height: 38px; margin-left: 5px;}.chat-header { text-align: center; padding: 15px; border-bottom: 1px solid #ececec;}.chat-header h2 { margin: 0; font-weight: 500;}.connecting { padding-top: 5px; text-align: center; color: #777; position: absolute; top: 65px; width: 100%;}@media screen and (max-width: 730px) { .chat-container { margin-left: 10px; margin-right: 10px; margin-top: 10px; }}@media screen and (max-width: 480px) { .chat-container { height: calc(100% - 30px); } .username-page-container { width: auto; margin-left: 15px; margin-right: 15px; padding: 25px; } #chat-page ul { height: calc(100% - 120px); } #messageForm .input-group button { width: 65px; } #messageForm .input-group input { width: calc(100% - 70px); } .chat-header { padding: 10px; } .connecting { top: 60px; } .chat-header h2 { font-size: 1.1em; }}
整個專案結構如下:
image.png
啟動啟動SpringBoot專案
效果如下:
image.png
image.png
補充:使用RabbitMQ代替記憶體作為訊息代理新增依賴:
public void configureMessageBroker(MessageBrokerRegistry registry) { registry.setApplicationDestinationPrefixes("/app"); // Use this for enabling a Full featured broker like RabbitMQ registry.enableStompBrokerRelay("/topic") .setRelayHost("localhost") .setRelayPort(61613) .setClientLogin("guest") .setClientPasscode("guest");}
如此一來,便可以透過RabbitMq進行訊息的訂閱。