Spring Boot 和 WebSocket 构建一个聊天室应用
原文链接:Building a chat application with Spring Boot and WebSocket - 原文作者:Ramesh Fadatare
本文采用意译的方式
本文,我们将学习如何使用 WebSocket API
和 Spring Boot
,并在最后构建个简单的群聊应用。
以下是我们将在教程中构建的聊天应用的截图 -
WebSocket 是一个通信协议,在服务端和客户端之间建立双向沟通渠道。
WebSocket
工作原理是首先和服务端建立一个常规的 HTTP
链接,然后通过发送一个 Upgrade
头来升级为双向 WebSocket
连接。
现在大多数的现代浏览器都支持 WebSocket
,对于那些不支持的浏览器,有相关的库等技术作为后备,比如 comet 和 long-polling。
嗯,现在我们知道 WebSocket
是什么了,并且了解了它怎么工作。那么,我们来实现聊天程序。
开发环境
本小节为译者加
本案例在以下开发环境进行复现:
- macOs Monterey 12.4 (Apple M1)
- IntelliJ IDEA 2021.2.2(Ultimate Edition)
- java --version (17.0.7)
- maven version 3.9.2
- Google Chrome 版本 119.0.6045.123(正式版本)(arm64)
浮现效果如下👇
👌,我们下面进入案例环节。
假设我们通过 $ spring init --name=websocket-demo -dependencies=websocket websocket-demo
创建了一个 websocket-demo
项目。
WebSocket 配置
首先,我们配置 websocket
断点和信息代理(message broker)
。我们在 com.example.websocketdemo
中创建一个新的包 config
,然后在该包内创建 WebSocketConfig
类。
package com.example.websocketdemo.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.messaging.simp.config.MessageBrokerRegistry;
import org.springframework.web.socket.config.annotation.*;
@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
服务。
请注意 withSocketJS()
和端点配置的使用。SockJS 用于为不支持 websocket
服务的后备选项。
你可能会留意到方法名中的 STOMP 单词。这个方法来自 Spring
框架 STOMP
实现。STOMP
全称 Simple Text Oriented Messaging Protocol
(简单文本导向信息协议)。它是一个信息协议,定义了数据交流的格式和规则。
为什么我们需要 STOMP 呢? 嗯~ WebSocket
只是一个通信协议。它并没有定义一些事情,比如 - 怎么给订阅特定主题的用户发送消息,或者说怎么给特定的用户发送消息。所以,我们需要 STOMP
来实现这些功能。
在第二个方法中,我们配置了一个信息代理,用于将信息从一个客户端路由到另一个客户端。
第一行代码中,我们定义目的地以 /app
开头的消息应路由到消息处理方法(我们后面很快会定义这些方法)。
第二行代码中,我们定义目的地以 /topic
开头的消息路由到消息代理。信息代理将向所有连接并订阅特定主题的客户端广播信息。
在上面这个例子中,我们开启了一个简单的基于内存的信息代理。但是,我们可以免费使用任意全功能信息代理,比如 RabbitMQ 和 ActiveMQ。
创建 ChatMessage model
ChatMessage
模型是用于在客户端和服务端交流的信息载体。我们在 com.example.websocketdemo
包中创建一个新的包 model
,然后创建名为 ChatMessage
的类:
package com.example.websocketdemo.model;
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;
}
}
创建 Controller 用来发送和接收信息
我们将在 controller
里面定义处理的方法。这些方法的职责是从另外客户端接收信息,然后将其广播出去。
我们创建一个新包 controller
,然后常见 ChatController
类:
package com.example.websocketdemo.controller;
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;
@Controller
public 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
连接和断开,以便我们可以记录这些事件并在用户加入或者离开聊天室时候对他们广播。
package com.example.websocketdemo.controller;
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;
@Component
public 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 session
中提取用户名字,然后给所有的连接客户端广播用户离开事件。
创建前端
在 srx/main/resources
文件夹中,创建下面的文件:
static
└── css
└── main.css
└── js
└── main.js
└── index.html
src/main/resources/static
文件夹是 Spring Boot
中默认的静态文件存放的位置。
1. 创建 HTML - index.html
HTML
文件包含用户界面,展示聊天信息。它应该可以包含 sockjs
和 stomp
这两个 javascript
库。
SockJS
是一个 WebSocket
客户端,尝试使用本地的 WebSockets
,并且为不支持 WebSocket
老浏览器提供备选。STOMP JS
是 javascript
版的 stomp
客户端。
下面是 index.html
完整的代码:
<!DOCTYPE html>
<html>
<head>
<meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0">
<title>Spring Boot WebSocket Chat Application</title>
<link rel="stylesheet" href="/css/main.css" />
</head>
<body>
<noscript>
<h2>Sorry! Your browser doesn't support Javascript</h2>
</noscript>
<div id="username-page">
<div class="username-page-container">
<h1 class="title">Type your username</h1>
<form id="usernameForm" name="usernameForm">
<div class="form-group">
<input type="text" id="name" placeholder="Username" autocomplete="off" class="form-control" />
</div>
<div class="form-group">
<button type="submit" class="accent username-submit">Start Chatting</button>
</div>
</form>
</div>
</div>
<div id="chat-page" class="hidden">
<div class="chat-container">
<div class="chat-header">
<h2>Spring WebSocket Chat Demo</h2>
</div>
<div class="connecting">
Connecting...
</div>
<ul id="messageArea">
</ul>
<form id="messageForm" name="messageForm">
<div class="form-group">
<div class="input-group clearfix">
<input type="text" id="message" placeholder="Type a message..." autocomplete="off" class="form-control"/>
<button type="submit" class="primary">Send</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>
2. JavaScript - main.js
现在,我们使用 javascript
来连接 websocket
端点并发送&接收信息。首先,在 main.js
文件内添加下面的代码,然后,我们将探讨其中的关键方法 -
'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
最后,我们在 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;
}
}
译者加:样式无需多说了吧。样式只是让页面看起来更加清爽,我们可以不加。
运行应用
然后我们可以在控制台上运行下面的命令行开启服务 mvn spring-boot:run
。
当然,我们可以通过
IntelliJ IDEA
开启
应用将会默认开启在 Spring Boot
默认的端口号 8080
上。我们直接在浏览器上打开 http://localhost:8080
即可。
使用 ReabbitMQ 作为信息中间件
读者自行验证
如果我们想使用全信息的消息中间件,比如 RabbitMQ
来代替简单的内存消息中间件,那么我们需要在 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");
}
总结
自此,我们使用 Spring Boot
和 WebSocket
从头开始构建了一个成熟的聊天应用程序。
转载自:https://juejin.cn/post/7310156414252351488