通過本文你將學習如何使用Spring Boot和WebSocket API開發一個簡單的群聊天應用。 WebSocket是HTML5開始提供的一種在單個TCP連接上進行全雙工通訊的協議。WebSocket使得客戶端和伺服器之間的數據交換變得更加簡單,允許伺服器主動向客戶端推送數據。在WebSocke ...
通過本文你將學習如何使用Spring Boot和WebSocket API開發一個簡單的群聊天應用。
WebSocket是HTML5開始提供的一種在單個TCP連接上進行全雙工通訊的協議。WebSocket使得客戶端和伺服器之間的數據交換變得更加簡單,允許伺服器主動向客戶端推送數據。在WebSocket API中,瀏覽器和伺服器只需要完成一次握手,兩者之間就直接可以創建持久性的連接,併進行雙向數據傳輸。
很多網站為實現推送技術,所用技術都是Ajax輪詢。輪詢指的是在特定的時間間隔(如每1秒),由瀏覽器對伺服器發出HTTP請求,然後伺服器返回最新的數據給瀏覽器。這種傳統的模式有很明顯的缺點,即瀏覽器需要不斷的向伺服器發出請求,而HTTP請求可能包含較長的頭部,其中真正有效的數據可能只是很小的一部分,這樣就會浪費很多資源。HTML5定義的WebSocket協議能更好的節省伺服器帶寬等資源,並能夠實時地進行你通訊。
詳情請看HTML5 WebSocket。
新建項目
打開IDEA,選擇Spring Initializer
填寫好相關信息:
依賴選擇Spring Web
和WebSocket
之後選擇Finish
即可。創建完畢後,項目目錄結構如下:
WebSocket配置
首先我們配置一下WebSocket端點和消息代理。在com.andy.chat
包下創建一個名為config
的包,然後在config
包新增一個帶有以下內容的類WebSocketConfig
:
package com.andy.chat.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.EnableWebSocketMessageBroker;
import org.springframework.web.socket.config.annotation.StompEndpointRegistry;
import org.springframework.web.socket.config.annotation.WebSocketMessageBrokerConfigurer;
@Configuration
@EnableWebSocketMessageBroker
public 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端點,客戶端將使用該端點連接到我們的WebSocket伺服器。端點配置中使用withSockJS()
方法,用於為不支持WebSocket
的瀏覽器啟用備用選項。
你可能註意到方法名中帶有STOMP。STOMP(Simple Text Orientated Messaging Protocol,簡單文本定向消息協議)允許STOMP客戶端與任意STOMP消息代理進行交互。
為什麼需要STOMP?因為WebSocket只是一種通信協議。 它沒有定義如何僅向訂閱了特定主題的用戶發送消息,或者如何向特定用戶發送消息,所以需要STOMP來實現這些功能。舉個例子,在課堂上舉手就好比WebSocket代表的通信協議,因為它定義了在講堂上如何和老師建立通信(舉手)。而具體你想問老師的內容:“李老師,為什麼這個函數不是連續的呢?”就相當於STOMP協議,因為它定義如何向特定用戶發送消息(李老師)。
在第二個方法中,我們配置了一個消息代理,用於將消息從一個客戶端路由到另一個客戶端。第一行定義了以/app
開頭為目標的消息應應路由到消息處理方法。第二行定義了以/topic
開頭為目標的消息應路由到消息代理。消息代理廣播消息到所有訂閱了特定主題的所有連接的客戶端。
上例中,我們使用了一個簡單的記憶體消息代理。 也可以使用任何其他功能齊全的消息代理,例如RabbitMQ或ActiveMQ。
Message模型
Message
模型用來表示客戶端與伺服器之間的消息。在com.andy.chat
新建一個包model
,在model
包中新建Message
類:
package com.andy.chat.model;
public class Message {
private MessageType messageType;
private String content;
public MessageType getMessageType() {
return messageType;
}
public void setMessageType(MessageType messageType) {
this.messageType = messageType;
}
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;
}
private String sender;
public enum MessageType {
JOIN,CHAT, LEAVE
}
}
收發消息的控制器
我們將在控制器中定義消息處理方法。在com.andy.chat
包下新建一個包controller
併在其中創建類ChatController
:
package com.andy.chat.controller;
import com.andy.chat.model.Message;
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;
@Controller
public class ChatController {
@MessageMapping("/sendMessage")
@SendTo("/topic/public")
public Message sendMessage(@Payload Message message) {
return message;
}
@MessageMapping("addUser")
@SendTo("/topic/public")
public Message addUser(@Payload Message message, SimpMessageHeaderAccessor headerAccessor) {
headerAccessor.getSessionAttributes().put("username", message.getSender());
return message;
}
}
正如在WebSocket配置一節所配置的,從客戶端發送的所有以/app
開頭的消息將被路由到這些消息處理方法,這些方法使用@MessageMapping
註解。
例如,以/app/sendMessage
為目標的消息被路由到sendMessage()
方法,以/app/addUser
為目標的消息被路由到addUser
方法。
添加WebSocket事件監聽器
我們將使用事件監聽器來監聽Socket連接和斷開事件,以便記錄這些事件,併在用戶加入或離開群聊時廣播它們。
package com.andy.chat.controller;
import com.andy.chat.model.Message;
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;
@Component
public class WebSocketEventListener {
private static final Logger logger = LoggerFactory.getLogger(WebSocketEventListener.class);
@Autowired
private SimpMessageSendingOperations messageSendingOperations;
@EventListener
public void handleWebSocketConnectListener(SessionConnectedEvent event) {
logger.info("新連接");
}
@EventListener
public void handleWebSocketDisconnectListener(SessionDisconnectEvent event) {
StompHeaderAccessor headerAccessor = StompHeaderAccessor.wrap(event.getMessage());
String username = (String) headerAccessor.getSessionAttributes().get("username");
if (username != null) {
logger.info("用戶{}取消連接", username);
Message message = new Message();
message.setMessageType(Message.MessageType.LEAVE);
message.setSender(username);
messageSendingOperations.convertAndSend("/topic/public", message);
}
}
}
在SessionDisconnect
事件中,從WebSocket
會話中提取用戶名,並將用戶離開事件廣播到所有連接的客戶端。
前端開發
在項目的static
下創建如下目錄結構:
HTML文件用來顯示用戶群聊信息。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>群聊</title>
<link rel="stylesheet" href="css/main.css">
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
</head>
<body>
<div id="username-page">
<div class="username-page-container">
<h1 class="title">請輸入您的用戶名</h1>
<form id="username-form">
<div class="form-group">
<input type="text" class="form-control" id="name" placeholder="用戶名" autocomplete="off">
</div>
<div class="form-group">
<button type="submit" class="username-submit accent">開始聊天</button>
</div>
</form>
</div>
</div>
<div id="chat-page" class="hidden">
<div class="chat-container">
<div class="chat-header">
<h2>我的群聊</h2>
</div>
<div class="connecting">
連接中.....
</div>
<ul id="message-area">
</ul>
<form id="message-form">
<div class="form-group">
<div class="input-group clearfix">
<input type="text" class="form-control" id="message" placeholder="輸入消息" autocomplete="off">
<button type="submit" class="primary">發送</button>
</div>
</div>
</form>
</div>
</div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/sockjs-client/1.1.4/sockjs.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/stomp.js/2.3.3/stomp.min.js"></script>
<script src="js/main.js"></script>
</body>
</html>
我們在其中引入了兩個javascript
庫——sockjs
和stomp
。
SockJS
是一個WebSocket
客戶端,它嘗試使用WebSocket
,併為不支持WebSocket
的舊版瀏覽器提供備用選項。 stomp
是javascript
的STOMP客戶端。
現在,我們來編寫用於連接WebSocket
端點和收發信息的javascript
代碼。將下列代碼添加到main.js
文件中:
'use strict';
let usernamePage = document.querySelector("#username-page");
let chatPage = document.querySelector("#chat-page");
let usernameForm = document.querySelector("#username-form");
let messageForm = document.querySelector("#message-form");
let messageInput = document.querySelector("#message");
let messageArea = document.querySelector("#message-area");
let connectingElement = document.querySelector(".connecting");
let username = null;
let stompClient = null;
let 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");
let socket = new SockJS('/ws');
stompClient = Stomp.over(socket);
stompClient.connect({}, onConnected, onError);
}
event.preventDefault();
}
function onConnected() {
// 訂閱Public主題
stompClient.subscribe("/topic/public", onMessageReceived);
stompClient.send("/app/addUser", {}, JSON.stringify({
sender: username,
messageType: 'JOIN'
}));
connectingElement.classList.add("hidden");
}
function onError(error) {
connectingElement.textContent = "連接不上WebSocket伺服器,請刷新頁面!";
connectingElement.style.color = "red";
}
function sendMessage(event) {
let messageContent = messageInput.value.trim();
if (messageContent && stompClient) {
let message = {
sender: username,
content: messageContent,
messageType: "CHAT"
};
stompClient.send("/app/sendMessage", {}, JSON.stringify(message));
messageInput.value = "";
}
event.preventDefault();
}
function onMessageReceived(payload) {
var message = JSON.parse(payload.body);
var messageElement = document.createElement('li');
if(message.messageType === 'JOIN') {
messageElement.classList.add('event-message');
message.content = message.sender + ' 加入!';
} else if (message.messageType === 'LEAVE') {
messageElement.classList.add('event-message');
message.content = message.sender + ' 離開!';
} 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
端點。
添加如下代碼到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 #message-form {
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;
}
#message-form .input-group input {
float: left;
width: calc(100% - 85px);
}
#message-form .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);
}
#message-form .input-group button {
width: 65px;
}
#message-form .input-group input {
width: calc(100% - 70px);
}
.chat-header {
padding: 10px;
}
.connecting {
top: 60px;
}
.chat-header h2 {
font-size: 1.1em;
}
}
啟動應用
若想啟動該應用,在命令行執行以下命令(或者在IDEA中啟動):
mvn spring-boot:run
應用將會在預設的8080埠啟動,在瀏覽器訪問http://localhost:8080即可。
使用Rabbit MQ
如果要使用RabbitMQ之類的功能全面的消息代理而不是簡單的記憶體消息代理,我們需要先安裝RabbitMQ,具體步驟可以參考下文:
然後配置STOMP
插件:
rabbitmq-plugins enable rabbitmq_stomp
在pom.xml文件中添加以下依賴項:
<!-- RabbitMQ Starter Dependency -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-amqp</artifactId>
</dependency>
<!-- Following additional dependency is required for Full Featured STOMP Broker Relay -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-reactor-netty</artifactId>
</dependency>
添加好以上依賴後,可以在WebSocketConfig.java
文件中啟用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");
}
最後,正常啟動應用即可。
參考
- https://www.callicoder.com/spring-boot-websocket-chat-example/