SpringBoot + RabbitMQ + WebSocket + STOMP 协议 + Vue 实现简单的实时在线聊天案例

1. 什么是WebSocket?

WebSocket是一种在单个TCP连接上进行全双工通信的协议。它允许客户端和服务器之间的实时、双向数据传输。与传统的HTTP请求/响应模型相比,WebSocket更加高效,因为它在初次握手后,连接保持打开状态,可以不断传输数据。

WebSocket的基本工作原理

  1. 握手阶段:客户端通过HTTP请求发起WebSocket连接请求,服务器响应并同意协议升级。
  2. 数据传输阶段:握手完成后,客户端和服务器可以通过这个持久连接进行双向数据传输。
  3. 连接关闭:客户端或服务器可以随时关闭连接。

2. 环境准备

2.1 拉取 RabbitMQ 镜像

使用以下命令从 Docker Hub 拉取 RabbitMQ 镜像:

docker pull bitnami/rabbitmq

在这里插入图片描述

2.2. 启动容器

使用以下命令从 Docker Hub 拉取 RabbitMQ 镜像并运行容器,同时设置远程访问的用户名和密码。

docker run -d --name rabbitmq -p 5672:5672 -p 15672:15672 -p 61613:61613  bitnami/rabbitmq:latest

此命令做了以下事情:

  • -d:以后台模式运行容器。
  • --name rabbitmq:为容器指定名称为 rabbitmq
  • -p 5672:5672:将容器的 5672 端口映射到主机的 5672 端口,用于 AMQP 协议。
  • -p 15672:15672:将容器的 15672 端口映射到主机的 15672 端口,用于 RabbitMQ 管理插件(Web UI)。
  • p 61613:61613:STOMP 协议端口。
  • rabbitmq:management:使用包含管理插件的 RabbitMQ 镜像。

在这里插入图片描述

2.3 验证 RabbitMQ 容器是否启动成功

运行以下命令查看 RabbitMQ 容器的状态:

docker ps

你应该能够看到名为 rabbitmq 的容器正在运行。

2.4 添加新管理员用户并启用 STOMP 插件

执行以下命令以进入运行中的容器,并添加新的管理员用户以及启用 STOMP 插件:

# 进入 RabbitMQ 容器
docker exec -it rabbitmq bash# 添加新的管理员用户
rabbitmqctl add_user admin 123456
rabbitmqctl set_user_tags admin administrator
rabbitmqctl set_permissions -p / admin ".*" ".*" ".*"# 启用 STOMP 插件
rabbitmq-plugins enable rabbitmq_stomp# 退出容器
exit

在这里插入图片描述

2.5 访问 RabbitMQ 管理界面

打开浏览器,访问 http://192.168.186.77:15672,切换IP为你自己的IP,使用你在运行容器时设置的用户名和密码登录。

在这里插入图片描述

登录成功页面:

在这里插入图片描述

3. 项目代码

3.1 项目结构

在这里插入图片描述

3.2 pom.xml

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd"><modelVersion>4.0.0</modelVersion><parent><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-parent</artifactId><version>3.3.2</version><relativePath/> <!-- lookup parent from repository --></parent><groupId>org.example</groupId><artifactId>websocket</artifactId><version>0.0.1-SNAPSHOT</version><properties><java.version>17</java.version></properties><dependencies><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-web</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-websocket</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-webflux</artifactId></dependency><dependency><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId><optional>true</optional></dependency><dependency><groupId>mysql</groupId><artifactId>mysql-connector-java</artifactId><version>8.0.33</version></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-data-jpa</artifactId></dependency><dependency><groupId>org.springframework.boot</groupId><artifactId>spring-boot-starter-amqp</artifactId></dependency><dependency><groupId>com.fasterxml.jackson.core</groupId><artifactId>jackson-databind</artifactId></dependency><dependency><groupId>javax.xml.bind</groupId><artifactId>jaxb-api</artifactId><version>2.3.1</version></dependency><dependency><groupId>org.glassfish.jaxb</groupId><artifactId>jaxb-runtime</artifactId><version>2.3.1</version></dependency><dependency><groupId>javax.activation</groupId><artifactId>javax.activation-api</artifactId><version>1.2.0</version></dependency></dependencies><build><plugins><plugin><groupId>org.springframework.boot</groupId><artifactId>spring-boot-maven-plugin</artifactId><configuration><excludes><exclude><groupId>org.projectlombok</groupId><artifactId>lombok</artifactId></exclude></excludes></configuration></plugin></plugins></build></project>

3.3 application.yml

spring:datasource:url: jdbc:mysql://localhost:3306/websocket_demousername: rootpassword: 123456driver-class-name: com.mysql.cj.jdbc.Driverjpa:hibernate:ddl-auto: updateshow-sql: trueopen-in-view: falserabbitmq:host: 192.168.186.77port: 5672username: adminpassword: 123456
stomp:port: 61613

3.4 WebsocketApplication.java

package org.example;import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;@SpringBootApplication
public class WebsocketApplication {public static void main(String[] args) {SpringApplication.run(WebsocketApplication.class, args);}}

3.5 User.java

package org.example.model;import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.*;
import lombok.Getter;
import lombok.Setter;import java.util.HashSet;
import java.util.Set;@Entity
@Getter
@Setter
public class User {@Id@GeneratedValue(strategy = GenerationType.IDENTITY)private Long id;@Column(unique = true)private String username;private String password;private String name;private String imageUrl;@ManyToMany@JoinTable(name = "friends",joinColumns = @JoinColumn(name = "user_id"),inverseJoinColumns = @JoinColumn(name = "friend_id"))@JsonIgnoreprivate Set<User> friends = new HashSet<>();
}

3.6 ChatMessage.java

package org.example.model;
import lombok.Data;@Data
public class ChatMessage {public enum MessageType {CHAT,JOIN,LEAVE}private MessageType type;private String content;private String sender;private String senderName;private String recipient; // 用于一对一消息private long timestamp;
}

3.7 UserRepository.java

package org.example.repository;import org.example.model.User;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;@Repository
public interface UserRepository extends JpaRepository<User, Long> {User findByUsername(String username);
}

3.8 UserService.java

package org.example.service;import org.example.model.User;
import org.example.repository.UserRepository;
import org.hibernate.Hibernate;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;import java.util.Set;@Service
public class UserService {@Autowiredprivate UserRepository userRepository;@Transactionalpublic void register(User user) {userRepository.save(user);}@Transactional(readOnly = true)public User login(String username, String password) {User user = userRepository.findByUsername(username);if (user != null && user.getPassword().equals(password)) {return user;}return null;}@Transactionalpublic void addFriend(String userName, String friendName) {User user = userRepository.findByUsername(userName);User friend = userRepository.findByUsername(friendName);user.getFriends().add(friend);friend.getFriends().add(user); // 双向关系userRepository.save(friend); // 保存友谊关系userRepository.save(user);}@Transactional(readOnly = true)public Set<User> getFriends(String username) {User user = userRepository.findByUsername(username);Hibernate.initialize(user.getFriends());  // 手动初始化,防止懒加载return user.getFriends();}
}

3.9 RabbitMQService.java

package org.example.service;import org.example.listener.ChatMessageListener;
import org.example.model.ChatMessage;
import org.springframework.amqp.core.*;
import org.springframework.amqp.rabbit.annotation.RabbitListener;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.amqp.rabbit.listener.MessageListenerContainer;
import org.springframework.amqp.rabbit.listener.SimpleMessageListenerContainer;
import org.springframework.amqp.rabbit.listener.adapter.MessageListenerAdapter;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;@Service
public class RabbitMQService {// RabbitAdmin 用于管理 AMQP 资源,如队列、交换机和绑定private final RabbitAdmin rabbitAdmin;// 定义 TopicExchange,支持基于路由键模式的消息路由private final TopicExchange exchange;// ConnectionFactory 用于配置和管理与 RabbitMQ 的连接private final ConnectionFactory connectionFactory;// 消息监听器,用于处理接收到的消息private final ChatMessageListener chatMessageListener;// 消息转换器,将消息转换为 JSON 格式和从 JSON 格式转换private final Jackson2JsonMessageConverter messageConverter;// 使用构造函数注入所需的依赖项@Autowiredpublic RabbitMQService(RabbitAdmin rabbitAdmin, TopicExchange exchange,ConnectionFactory connectionFactory,ChatMessageListener chatMessageListener,Jackson2JsonMessageConverter messageConverter) {this.rabbitAdmin = rabbitAdmin;this.exchange = exchange;this.connectionFactory = connectionFactory;this.chatMessageListener = chatMessageListener;this.messageConverter = messageConverter;}/*** 动态创建私有队列。* @param channelName 队列名称的一部分,通常是私聊双方的标识。*/public void createPrivateQueue(String channelName) {// 构建队列的完整名称,例如 "private-queue.admin-guest"String queueName = "private-queue." + channelName;// 创建一个非持久化(不在磁盘上存储)的队列Queue queue = new Queue(queueName, false);// 声明队列,使其在 RabbitMQ 中存在rabbitAdmin.declareQueue(queue);// 将队列绑定到指定的交换机,并使用指定的路由键Binding binding = BindingBuilder.bind(queue).to(exchange).with(channelName);rabbitAdmin.declareBinding(binding);// 创建监听器容器,以便监听此私有队列中的消息MessageListenerContainer container = createMessageListenerContainer(queueName);container.start();  // 启动监听器容器,开始监听消息}/*** 创建监听器容器,用于监听指定队列的消息。* @param queueName 要监听的队列的名称。* @return 配置好的 MessageListenerContainer 实例。*/private MessageListenerContainer createMessageListenerContainer(String queueName) {// 创建一个简单的消息监听器容器SimpleMessageListenerContainer container = new SimpleMessageListenerContainer();// 设置 RabbitMQ 的连接工厂container.setConnectionFactory(connectionFactory);// 设置要监听的队列名称container.setQueueNames(queueName);// 使用 MessageListenerAdapter 将消息传递给 ChatMessageListenerMessageListenerAdapter adapter = new MessageListenerAdapter(chatMessageListener, "receiveMessage");// 设置消息转换器为 JSON 转换器adapter.setMessageConverter(messageConverter);// 将适配器设置为消息监听器container.setMessageListener(adapter);return container;  // 返回配置好的容器}/*** 使用 @RabbitListener 注解监听 "public-queue" 队列中的消息。* 当队列中有消息时,会自动调用此方法。* @param chatMessage 接收到的聊天消息。*/@RabbitListener(queues = "public-queue")public void receivePublicMessage(ChatMessage chatMessage) {// 调用消息监听器的 receiveMessage 方法处理消息chatMessageListener.receiveMessage(chatMessage);}
}

3.10 ChatMessageListener.java

package org.example.listener;import org.example.model.ChatMessage;
import org.springframework.messaging.simp.SimpMessagingTemplate;
import org.springframework.stereotype.Component;@Component
public class ChatMessageListener {// SimpMessagingTemplate 是一个 Spring 提供的工具类,用于在 WebSocket 上发送消息private final SimpMessagingTemplate messagingTemplate;// 构造函数注入 SimpMessagingTemplate,用于后续发送消息public ChatMessageListener(SimpMessagingTemplate messagingTemplate) {this.messagingTemplate = messagingTemplate;}/*** 接收消息的方法,这个方法会被 RabbitMQ 的消息监听器调用。* @param chatMessage 从队列中接收到的 ChatMessage 对象。*/public void receiveMessage(ChatMessage chatMessage) {// 根据消息的接收者生成唯一的目的地频道名称String destination = chatMessage.getRecipient() != null? "/queue/private-" + chatMessage.getRecipient() + "-" + chatMessage.getSender()  // 私人消息的通道: "/topic/public";  // 公共消息的通道// 使用 SimpMessagingTemplate 将消息发送到生成的通道中messagingTemplate.convertAndSend(destination, chatMessage);}
}

3.11 UserController.java

package org.example.controller;import org.example.model.User;
import org.example.service.UserService;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.web.bind.annotation.*;import java.util.Set;@RestController
@RequestMapping("/api")
public class UserController {@Autowiredprivate UserService userService;@PostMapping("/register")public String register(@RequestBody User user) {userService.register(user);return "User registered successfully";}@PostMapping("/login")public User login(@RequestBody User user) {User existingUser = userService.login(user.getUsername(), user.getPassword());if (existingUser != null) {return existingUser;} else {throw new RuntimeException("Invalid username or password");}}@PostMapping("/addFriend")public String addFriend(@RequestParam String username, @RequestParam String friendname) {userService.addFriend(username, friendname);return "Friend added successfully";}@GetMapping("/friends/{username}")public Set<User> getFriends(@PathVariable String username) {return userService.getFriends(username);}
}

3.12 ChatController.java

package org.example.controller;import org.example.model.ChatMessage;
import org.example.service.RabbitMQService;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.messaging.handler.annotation.MessageMapping;
import org.springframework.messaging.handler.annotation.Payload;
import org.springframework.messaging.simp.SimpMessageHeaderAccessor;
import org.springframework.stereotype.Controller;import java.util.Objects;@Controller
public class ChatController {private final RabbitTemplate rabbitTemplate;private final RabbitMQService rabbitMQService;public ChatController(RabbitTemplate rabbitTemplate, RabbitMQService rabbitMQService) {this.rabbitTemplate = rabbitTemplate;this.rabbitMQService = rabbitMQService;}@MessageMapping("/public-channel")public void handleMessage(@Payload ChatMessage chatMessage,SimpMessageHeaderAccessor headerAccessor) {if (chatMessage.getType() == ChatMessage.MessageType.JOIN) {// 将用户名添加到WebSocket会话中Objects.requireNonNull(headerAccessor.getSessionAttributes()).put("username", chatMessage.getSender());} else if (chatMessage.getType() == ChatMessage.MessageType.LEAVE) {// 从WebSocket会话中移除用户名Objects.requireNonNull(headerAccessor.getSessionAttributes()).remove("username");}// 广播公共消息rabbitTemplate.convertAndSend("chat-exchange", "public", chatMessage);}@MessageMapping("/private-channel")public void handlePrivateMessage(@Payload ChatMessage chatMessage,SimpMessageHeaderAccessor headerAccessor) {if (chatMessage.getType() == ChatMessage.MessageType.JOIN) {// 将用户名添加到WebSocket会话中Objects.requireNonNull(headerAccessor.getSessionAttributes()).put("username", chatMessage.getSender());} else if (chatMessage.getType() == ChatMessage.MessageType.LEAVE) {// 从WebSocket会话中移除用户名Objects.requireNonNull(headerAccessor.getSessionAttributes()).remove("username");}String channel=chatMessage.getRecipient()+"-"+chatMessage.getSender();// 确保私人队列存在rabbitMQService.createPrivateQueue(channel);// 发送私人消息到私人频道rabbitTemplate.convertAndSend("chat-exchange",channel, chatMessage);}
}

3.13 WebSocketConfig.java

package org.example.config;import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.amqp.core.Binding;
import org.springframework.amqp.core.BindingBuilder;
import org.springframework.amqp.core.Queue;
import org.springframework.amqp.core.TopicExchange;
import org.springframework.amqp.rabbit.connection.ConnectionFactory;
import org.springframework.amqp.rabbit.core.RabbitAdmin;
import org.springframework.amqp.rabbit.core.RabbitTemplate;
import org.springframework.amqp.support.converter.Jackson2JsonMessageConverter;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
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 {// 从配置文件中注入 RabbitMQ 主机地址@Value("${spring.rabbitmq.host}")private String host;// 从配置文件中注入 RabbitMQ 用户名@Value("${spring.rabbitmq.username}")private String username;// 从配置文件中注入 RabbitMQ 密码@Value("${spring.rabbitmq.password}")private String password;// 从配置文件中注入 STOMP 端口@Value("${stomp.port}")private int port;/*** 配置消息代理,用于处理 STOMP 消息。* @param config 消息代理注册表。*/@Overridepublic void configureMessageBroker(MessageBrokerRegistry config) {// 配置 STOMP 代理中继config.enableStompBrokerRelay("/topic", "/queue").setRelayHost(host)  // 配置 RabbitMQ 主机地址.setRelayPort(port)  // 配置 STOMP 端口.setClientLogin(username)  // 配置 STOMP 客户端登录用户名.setClientPasscode(password)  // 配置 STOMP 客户端登录密码.setSystemLogin(username)  // 配置 STOMP 系统登录用户名.setSystemPasscode(password);  // 配置 STOMP 系统登录密码// 配置应用前缀,用于识别发送给应用程序的消息config.setApplicationDestinationPrefixes("/app");}/*** 注册 STOMP 端点并配置 SockJS 回退选项。* @param registry STOMP 端点注册表。*/@Overridepublic void registerStompEndpoints(StompEndpointRegistry registry) {// 注册一个 WebSocket 端点,并设置允许的跨域请求registry.addEndpoint("/ws").setAllowedOriginPatterns("*").withSockJS();}/*** 创建并配置一个公共队列的 Bean。* @return 公共队列的实例。*/@Beanpublic Queue publicQueue() {// 创建一个非持久化的队列return new Queue("public-queue", false);}/*** 创建并配置一个 TopicExchange 的 Bean。* @return TopicExchange 的实例。*/@Beanpublic TopicExchange exchange() {// 创建一个 TopicExchange,用于基于路由键模式的消息路由return new TopicExchange("chat-exchange");}/*** 绑定公共队列到交换机,并指定路由键。* @param publicQueue 要绑定的队列。* @param exchange 要绑定到的交换机。* @return 队列与交换机绑定的实例。*/@Beanpublic Binding bindingPublicQueue(Queue publicQueue, TopicExchange exchange) {// 使用 "public" 作为路由键将队列绑定到交换机return BindingBuilder.bind(publicQueue).to(exchange).with("public");}/*** 创建并配置 RabbitAdmin,用于管理 RabbitMQ 资源。* @param connectionFactory RabbitMQ 的连接工厂。* @return RabbitAdmin 的实例。*/@Beanpublic RabbitAdmin rabbitAdmin(ConnectionFactory connectionFactory) {// 使用给定的连接工厂创建 RabbitAdminreturn new RabbitAdmin(connectionFactory);}/*** 创建并配置 RabbitTemplate,用于发送和接收消息。* @param connectionFactory RabbitMQ 的连接工厂。* @param messageConverter 消息转换器,用于 JSON 消息的转换。* @return RabbitTemplate 的实例。*/@Beanpublic RabbitTemplate rabbitTemplate(ConnectionFactory connectionFactory, Jackson2JsonMessageConverter messageConverter) {RabbitTemplate template = new RabbitTemplate(connectionFactory);// 设置消息转换器为 Jackson2JsonMessageConvertertemplate.setMessageConverter(messageConverter);return template;}/*** 创建并配置 Jackson2JsonMessageConverter,用于将消息转换为 JSON 格式。* @param objectMapper 用于 JSON 转换的 ObjectMapper 实例。* @return Jackson2JsonMessageConverter 的实例。*/@Beanpublic Jackson2JsonMessageConverter jackson2JsonMessageConverter(ObjectMapper objectMapper) {// 使用给定的 ObjectMapper 创建 Jackson2JsonMessageConverterreturn new Jackson2JsonMessageConverter(objectMapper);}
}

说明:


  1. 消息代理(Message Broker)

    消息代理是一个中介,负责将消息从一个地方路由到另一个地方。在 WebSocket 中,它通常负责接收来自客户端的消息,并将它们发送到一个或多个目的地(如特定的客户端或订阅特定频道的所有客户端)。

  2. enableSimpleBroker("/topic")

    config.enableSimpleBroker("/topic");
    

    这行代码启用了一个简单的内存消息代理,并指定了它的目的地前缀为 /topic。这意味着所有以 /topic 开头的消息目的地都会被这个内存消息代理处理。

    简单消息代理

    • 简单消息代理是 Spring 内置的一种轻量级消息代理,适用于基本的消息传递需求。
    • 它通常用于开发和测试环境,在生产环境中,您可能会使用更复杂的消息代理(如 RabbitMQ 或 ActiveMQ)。

    /topic 前缀

    • 消息目的地的前缀。所有订阅以 /topic 开头的目的地的客户端都将接收发送到这些目的地的消息。
    • 例如,客户端订阅了 /topic/public,那么发送到 /topic/public 的消息将被这个客户端接收。
  3. setApplicationDestinationPrefixes("/app")

    config.setApplicationDestinationPrefixes("/app");
    

    这行代码指定了应用程序消息的发送路径前缀为 /app。这意味着所有以 /app 开头的消息路径都会被路由到带有 @MessageMapping 注解的方法中。

    应用程序目的地前缀

    • 用于区分应用程序内部的消息路径。
    • 当客户端发送消息到服务器时,消息路径会以 /app 开头,这样 Spring 框架可以将这些消息路由到适当的处理方法中。

    例如,客户端发送消息到 /app/public-channel,该消息将被路由到 ChatController 类中带有 @MessageMapping("/public-channel") 注解的方法。

  4. STOMP 端点

    STOMP 是一种简单的基于文本的协议,用于在 WebSocket 之上定义消息格式和路由规则。通过 STOMP,客户端和服务器之间可以进行更复杂的消息传递,如订阅、发送和接收消息等。

    registry.addEndpoint("/ws")

    registry.addEndpoint("/ws");
    

    这行代码定义了一个 WebSocket 端点,客户端将通过这个端点连接到 WebSocket 服务器。端点的路径为 /ws

    端点(Endpoint)

    • 端点是客户端连接 WebSocket 服务器的 URL 路径。在这个例子中,客户端会连接到 ws://<your-server>/ws
    • 客户端通过这个端点与服务器建立 WebSocket 连接,并使用 STOMP 协议进行通信。
  5. setAllowedOrigins("*")

    setAllowedOrigins("*");
    

    这行代码允许来自任何源的请求连接到这个 WebSocket 端点。* 表示允许所有域名进行跨域请求。

    跨域请求(CORS)

    • 跨域资源共享(CORS)是指浏览器允许来自不同域的请求访问资源。
    • 在生产环境中,通常会限制允许的域名,以提高安全性。这里使用 * 是为了方便开发和测试。
  6. withSockJS()

    withSockJS();
    

    这行代码启用了 SockJS 支持。SockJS 是一个 JavaScript 库,它提供了对 WebSocket 的回退支持,使得在 WebSocket 不可用的情况下,客户端仍然可以与服务器进行通信。

    SockJS

    • SockJS 提供了一组传输协议,如 XHR、iframe、JSONP 等,以确保在不同浏览器和网络环境下的兼容性。
    • 当原生 WebSocket 不可用时(如浏览器不支持或防火墙阻止),SockJS 会自动回退到其他传输方式,确保连接的可靠性。

3.14 index.html

<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Vue Chat Application</title><style>body {font-family: Arial, sans-serif;background-color: #f4f4f4;display: flex;justify-content: center;align-items: center;height: 100vh;margin: 0;}#app {width: 90%;max-width: 900px;background: #fff;box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);border-radius: 10px;overflow: hidden;display: flex;flex-direction: column;height: 100%;}#login-container {padding: 20px;display: flex;flex-direction: column;align-items: center;justify-content: center;background-color: #f8f8f8;}#login-container input {margin-bottom: 10px;padding: 10px;width: 80%;border: 1px solid #ccc;border-radius: 5px;}#login-container button {padding: 10px;width: 80%;border: none;border-radius: 5px;background-color: #007bff;color: white;font-size: 16px;cursor: pointer;}#chat-container {display: flex;flex: 1;overflow: hidden;}#friend-list {width: 220px;border-right: 1px solid #ccc;background-color: #f1f1f1;padding: 10px;overflow-y: auto;}#friend-list h3 {margin-top: 0;margin-bottom: 10px;font-size: 1.2em;}#friend-list div {padding: 12px;cursor: pointer;margin-bottom: 8px;border-radius: 5px;background-color: #e9e9e9;font-size: 1em;}#friend-list div:hover {background-color: #d8d8d8;}#friend-input-container {position: relative;margin-bottom: 15px;}#friend-input-container input {width: 100%;padding: 8px;padding-right: 50px; /* 为按钮预留空间 */border: 1px solid #ccc;border-radius: 5px;font-size: 1em;box-sizing: border-box;}#friend-input-container button {position: absolute;right: 0;top: 0;height: 100%;padding: 8px 12px;border: none;border-radius: 0 5px 5px 0;background-color: #28a745;color: white;cursor: pointer;font-size: 1em;}#chat-box {flex: 1;display: flex;flex-direction: column;padding: 10px;background-color: #fff;overflow-y: auto;}#chat-title {font-size: 20px;font-weight: bold;margin-bottom: 10px;}#chat-content {flex: 1;overflow-y: auto;padding-bottom: 10px;display: flex;flex-direction: column;}.message {display: inline-block;flex-direction: column;margin-bottom: 10px;max-width: 70%;min-width: 50px;border-radius: 10px;word-wrap: break-word;position: relative;padding: 10px 10px 20px 10px;vertical-align: top;font-size: 1em;}.message-username {font-weight: bold;margin-bottom: 5px;color: #555;}.message-content {font-size: 14px;}.message-info {font-size: 12px;color: #888;text-align: right;margin-top: 5px;position: absolute;right: 10px;bottom: 5px;line-height: 1;}.received-message {align-self: flex-start;background-color: #f1f1f1;color: #333;}.sent-message {align-self: flex-end;background-color: #afff78;color: white;}#message-input-container {display: flex;padding: 10px;background-color: #f8f8f8;border-top: 1px solid #ccc;}#message-input {flex: 1;padding: 10px;border: 1px solid #ccc;border-radius: 5px;margin-right: 10px;}#send-button {padding: 10px 20px;border: none;border-radius: 5px;background-color: #007bff;color: white;cursor: pointer;}</style><script src="https://cdn.jsdelivr.net/npm/vue@2"></script><script src="https://cdn.jsdelivr.net/npm/sockjs-client@1/dist/sockjs.min.js"></script><script src="https://cdn.jsdelivr.net/npm/stompjs@2.3.3/lib/stomp.min.js"></script>
</head>
<body>
<div id="app"><div v-if="!isLoggedIn" id="login-container"><h2>Login</h2><input type="text" v-model="username" placeholder="Username"><input type="password" v-model="password" placeholder="Password"><button @click="login">Login</button></div><div v-else id="chat-container"><div id="friend-list"><h3>Friends</h3><div id="friend-input-container"><input type="text" v-model="newFriendUsername" placeholder="Add friend by username"><button @click="addFriend">Add</button></div><div @click="subscribeToPublicChannel">Public Channel</div><div v-for="friend in friends" :key="friend.username" @click="selectPrivateChat(friend.username)">{{ friend.username }}</div></div><div id="chat-box"><div id="chat-title">{{ chatTitle }}</div><div id="chat-content"><div v-for="message in currentMessages" :key="message.timestamp" :class="['message', message.sender === username ? 'sent-message' : 'received-message']"><!-- 公共频道显示用户名 --><div v-if="currentChannel === 'public'" class="message-username"><strong>{{ message.sender }}</strong></div><div class="message-content">{{ message.content }}</div><div class="message-info">{{ formatTimestamp(message.timestamp) }}</div></div></div></div></div><div v-if="isLoggedIn" id="message-input-container"><input type="text" v-model="messageInput" id="message-input" placeholder="Enter your message"><button @click="sendMessage" id="send-button">Send</button></div>
</div><script>new Vue({el: '#app',data: {username: '',password: '',isLoggedIn: false,friends: [],publicMessages: [],privateChats: {},  // 存储所有私聊消息的对象messageInput: '',newFriendUsername: '', // 用于添加好友的用户名输入socket: null,stompClient: null,currentRecipient: null,currentSubscription: {}, // 存储所有频道的订阅对象currentChannel: 'public' // 'public' or 'private'},computed: {currentMessages() {if (this.currentChannel === 'public') {return this.publicMessages;} else if (this.currentRecipient && this.privateChats[this.currentRecipient]) {return this.privateChats[this.currentRecipient];} else {return [];}},chatTitle() {return this.currentChannel === 'public'? 'Public Channel': `Private Chat with ${this.currentRecipient}`;}},methods: {// 登录成功后建立WebSocket连接async login() {const response = await fetch('/api/login', {method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify({ username: this.username, password: this.password })});if (response.ok) {const user = await response.json();this.username = user.username;this.isLoggedIn = true;this.loadFriends();this.connect(); // 登录成功后立即连接WebSocket} else {alert('Invalid username or password');}},async addFriend() {if (this.newFriendUsername) {try {const response = await fetch('/api/addFriend?username='+this.username+"&friendname="+this.newFriendUsername, {method: 'POST',headers: {'Content-Type': 'application/json'}});if (response.ok) {alert("Friend added successfully!");this.loadFriends(); // 添加成功后重新加载好友列表this.newFriendUsername = ''; // 清空输入框} else {alert("Failed to add friend.");}} catch (error) {console.error("Error adding friend:", error);}} else {alert("Please enter a username.");}},connect() {this.socket = new SockJS('/ws');this.stompClient = Stomp.over(this.socket);this.stompClient.connect({}, () => {console.log('WebSocket connected');// 不在这里立即订阅频道,只是建立连接});this.stompClient.onclose = () => {console.log('WebSocket closed');};this.stompClient.onerror = (error) => {console.log('WebSocket error:', error);};},async loadFriends() {const response = await fetch(`/api/friends/${this.username}`, {method: 'GET',headers: {'Content-Type': 'application/json'}});if (response.ok) {this.friends = await response.json();}},sendSystemMessage(content) {const systemMessage = {sender: 'System',recipient: null,content: `${this.username} ${content}`,type: 'CHAT',timestamp: new Date().getTime()};const destination = `/app/public-channel`;this.stompClient.send(destination, {}, JSON.stringify(systemMessage));},// 当用户点击公共频道时,建立公共频道的订阅subscribeToPublicChannel() {if (!this.currentSubscription.public) {this.currentSubscription.public = this.stompClient.subscribe('/topic/public', (message) => {const chatMessage = JSON.parse(message.body);this.publicMessages.push(chatMessage);this.scrollToBottom();});// 向公共频道发送加入消息this.sendSystemMessage('has joined the public channel.');}this.currentChannel = 'public';this.currentRecipient = null;},// 当用户点击私人聊天时,建立相应的私人频道订阅selectPrivateChat(friendUsername) {if (this.currentRecipient === friendUsername && this.currentChannel === 'private') {return;}// 当用户从公共频道切换到私人聊天时,发送离开公共频道的系统消息if (this.currentChannel === 'public') {this.sendSystemMessage('has left the public channel.');}this.currentRecipient = friendUsername;this.currentChannel = 'private';if (!this.privateChats[friendUsername]) {this.$set(this.privateChats, friendUsername, []);}if (!this.currentSubscription[friendUsername]) {this.currentSubscription[friendUsername] = this.stompClient.subscribe(`/queue/private-${this.username}-${friendUsername}`, (message) => {const chatMessage = JSON.parse(message.body);this.privateChats[friendUsername].push(chatMessage);this.scrollToBottom();});}},sendPublicMessage() {const chatMessage = {sender: this.username,recipient: null,content: this.messageInput,type: 'CHAT',timestamp: new Date().getTime()};const destination = `/app/public-channel`;this.stompClient.send(destination, {}, JSON.stringify(chatMessage));this.messageInput = '';},sendPrivateMessage() {const chatMessage = {sender: this.username,recipient: this.currentRecipient,content: this.messageInput,type: 'CHAT',timestamp: new Date().getTime()};const destination = `/app/private-channel`;this.stompClient.send(destination, {}, JSON.stringify(chatMessage));this.privateChats[this.currentRecipient].push(chatMessage);this.scrollToBottom();this.messageInput = '';},sendMessage() {if (this.currentChannel === 'public') {this.sendPublicMessage();} else {this.sendPrivateMessage();}},scrollToBottom() {this.$nextTick(() => {const chatContent = this.$el.querySelector("#chat-content");chatContent.scrollTop = chatContent.scrollHeight;});},formatTimestamp(timestamp) {const date = new Date(timestamp);const hours = date.getHours().toString().padStart(2, '0');const minutes = date.getMinutes().toString().padStart(2, '0');return `${hours}:${minutes}`;}}});
</script>
</body>
</html>

4. 测试验证

4.1 前提准备

在这里插入图片描述

说明:手动创建三个用户:user1,user2,user3 进行模拟一对一对话和公共对话。

4.2 用户登录

在这里插入图片描述

说明:将用户user1,user2,user3分别登录不同的窗口,user1先登录,添加好友后再登录user2和user3。

4.3 登录后的界面

在这里插入图片描述

4.4 添加好友

在这里插入图片描述

说明:输入用户名user2点Add按钮,即可完成添加,下图是添加两个用户的结果。

在这里插入图片描述

说明:添加完后可以登录user2和user3了。

4.5 公共频道

在这里插入图片描述

说明:登录成功的时候,鼠标点击一下Public Channel加入公共通道。

4.6 私聊频道

4.6.1 user1和user2

在这里插入图片描述

4.6.2 user1和user3

在这里插入图片描述

4.6.3 MQ队列

在这里插入图片描述

4.6.4 MQ交换机

在这里插入图片描述

4.6.5 MQ连接

在这里插入图片描述

5. 总结

5.1. 系统架构

前端(Vue.js):通过 WebSocket 连接到 Spring Boot 提供的WebSocket 端点。使用 STOMP 协议与后端进行消息传递,支持订阅和发送消息功能。用户登录后,可选择加入公共频道或私人聊天,发送和接收实时消息。
后端(Spring Boot):配置 WebSocket 端点,允许客户端使用 STOMP 协议进行连接。配置 RabbitMQ 作为消息代理,用于处理和分发消息。处理来自客户端的消息,将其发布到相应的 RabbitMQ 队列,并通过 STOMP 通知相关订阅者。监听 RabbitMQ 队列中的消息,将其通过 WebSocket 推送给订阅该队列的客户端。

5.2 工作流程

1.客户端连接
用户通过 WebSocket 端点连接到服务器。
连接建立后,客户端可以订阅公共频道或私人聊天的消息队列。
2. 消息发布
用户在前端界面中输入消息并发送。
消息通过 WebSocket 传递到服务器端,并被发布到对应的 RabbitMQ 队列(如公共频道或私人队列)。
3. 消息分发
RabbitMQ 将消息路由到正确的队列。
服务器端监听器监听这些队列中的消息,并通过 WebSocket 将消息推送给已订阅该队列的客户端。
4. 实时更新
订阅的客户端接收到消息,并实时更新前端界面,显示新消息。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.rhkb.cn/news/395955.html

如若内容造成侵权/违法违规/事实不符,请联系长河编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

前端(HTML + CSS)小兔鲜儿项目(仿)

前言 这是一个简单的商城网站&#xff0c;代码部分为HTML CSS 和少量JS代码 项目总览 一、头部区域 头部的 购物车 和 手机 用的是 文字图标&#xff0c;所以效果可以和文字一样 购物车右上角用的是绝对定位 logo用的是 h1 标签&#xff0c;用来提高网站搜索排名 二、banne…

Golang | Leetcode Golang题解之第331题验证二叉树的前序序列化

题目&#xff1a; 题解&#xff1a; func isValidSerialization(preorder string) bool {n : len(preorder)slots : 1for i : 0; i < n; {if slots 0 {return false}if preorder[i] , {i} else if preorder[i] # {slots--i} else {// 读一个数字for i < n &&…

各种高端链游 区块链游戏 休闲小游戏DAPP开发

【西游闯关】-高端区块链3D手游 【我朝有马】-高端区块链3D手游 【弹弹岛2】-高端区块链3D手游

FPGA第4篇,中国FPGA芯片市场,发展分析与报告

前言 FPGA&#xff08;Field-Programmable Gate Array&#xff09;&#xff0c;即现场可编程门阵列&#xff0c;是一种可由用户配置的集成电路&#xff0c;具有高度的灵活性和强大的计算能力&#xff0c;被广泛应用于通信、航空航天、汽车电子、消费电子、数据中心、人工智能等…

Java毕业设计-基于SSM框架的舞蹈网站系统项目实战(附源码+论文)

大家好&#xff01;我是程序猿老A&#xff0c;感谢您阅读本文&#xff0c;欢迎一键三连哦。 &#x1f49e;当前专栏&#xff1a;Java毕业设计 精彩专栏推荐&#x1f447;&#x1f3fb;&#x1f447;&#x1f3fb;&#x1f447;&#x1f3fb; &#x1f380; Python毕业设计 &…

【mysql 第四篇章】bin log 的作用是啥呢?

一、redo Log 介绍 redo log 是一种偏向物理性质的重做日志&#xff0c;因为他里面记录类似的这样的东西&#xff0c;“对那个数据也中的什么记录&#xff0c;做了个什么修改”。它是 InnoDB 存储引擎特有的东西。 二、bin Log 日志 bin log 叫做归档日志&#xff0c;它里面…

STM32-USART时序与寄存器状态分析

一、时序分析 在UART&#xff08;通用异步收发传输&#xff09;通信中&#xff0c;信号线上的状态分为两种&#xff1a;逻辑1&#xff08;高电平&#xff09;和逻辑0&#xff08;低电平&#xff09;。在空闲状态下&#xff0c;数据线应保持逻辑高电平。UART协议中的各个信号位具…

使用frp内网穿透将个人主机上的MySQL发布到公网上,再通过python管理MySQL

目录 1.frp内网穿透部署 1.frp服务器 1.开放端口 2.上传软件包 3.解压 4.配置文件 2.frp客户端 1.上传软件包 2.配置文件 3.启动测试 1.浏览器查看服务器上连接的客户端数量 2.启动测试 2.MySQL安装 3.python3的安装使用 4.python管理MySQL 1.pip 2.pandas 3.p…

uniapp2【搬代码】

我们预期想要的效果: 步骤; 1.创建项目&#xff1a;填写项目名称 2.重新选择项目保存的目录 3.点击确定 4.将图标倒入static中 5.在pages中创建vue如图所示 6.vue创建成功后&#xff0c;在pages.json中进行配置tabBar "tabBar": {"color": "#999&q…

基于Jeecgboot3.6.3的vue3版本的流程中仿钉钉流程的鼠标拖动功能支持

因为这个项目license问题无法开源&#xff0c;更多技术支持与服务请加入我的知识星球。 1、因为原先仿钉钉流程里不能进行鼠标拖动来查看流程&#xff0c;所以根据作者提供的信息进行修改&#xff0c;在hooks下增加下面文件useDraggableScroll.ts import { ref, onMounted, on…

网络编程day1

一、思维导图 网络基础

泛微E-office 10 schema_mysql接口敏感信息泄露漏洞复现 [附POC]

文章目录 泛微E-office 10 schema_mysql接口敏感信息泄露漏洞复现 [附POC]0x01 前言0x02 漏洞描述0x03 影响版本0x04 漏洞环境0x05 漏洞复现1.访问漏洞环境2.构造POC3.复现泛微E-office 10 schema_mysql接口敏感信息泄露漏洞复现 [附POC] 0x01 前言 免责声明:请勿利用文章内…

WPF自定义控件的应用(DynamicResource的使用方法)

1 DynamicResource的使用方法 可以在字典文件 的抬头区写入数&#xff1a; <SolidColorBrush x:Key"PrimaryBackgroundColor" Color"#FFABAdB3"/><SolidColorBrush x:Key"TextBox.MouseOver.Border" Color"#FF7EB4EA"/>&l…

服务器CPU天梯图2024年8月,含EYPC/至强及E3/E5

原文地址&#xff08;高清无水印原图/持续更新/含榜单出处链接&#xff09;&#xff1a; >>>服务器CPU天梯图<<< 本文提供的服务器CPU天梯图数据均采集自各大专业网站&#xff0c;榜单图片末尾会标准其来源&#xff08;挂太多链接有概率会被ban&#xff0c;…

datawind可视化查询-along表达式计算占比表达式

常见表达式 表计算表达式仅能作为指标应用along的维度必须存在于查询面板中&#xff0c;否则报错 例如计算埋点的占比 停止充电时长分箱 case when [个人].[停止充电时长(小时)] >12 then (12,13] when [个人].[停止充电时长(小时)] >11 then (11,12] when [个人].[停止…

ARM 汇编语言基础

目录 汇编指令代码框架 汇编指令语法格式 数据处理指令 数据搬移指令 mov 示例 立即数的本质 立即数的特点 立即数的使用 算术运算指令 指令格式 add 普通的加法指令 adc 带进位的加法指令 跳转指令 Load/Store指令 状态寄存器指令 基础概念 C 语言与汇编指令的关…

Acrel-1000DP分布式光伏监控系统在光伏并网系统的实际应用分析-安科瑞 蒋静

摘要&#xff1a;为实现“双碳目标”即中国明确提出的2030年“碳达峰”与2060年“碳中和”目标。推动节能减排、实现经济可持续发展&#xff0c;我国采取了一系列方案和行动。其中就包括能源绿色低碳转型行动&#xff1a;大力发展新能源&#xff0c;‌推动煤电节能降碳&#xf…

让对话AI帮助你做程序架构设计,以及解决你的疑问

我想问下对话AI,本文采取的是chatgpt免费版 我问&#xff1a; 你说程序的设计&#xff0c;前后端分离的BS架构。比如工人基础档案1000条记录&#xff0c;工程项目基础档案10条记录&#xff0c;其他相关这两个基础档案的具体功能&#xff0c;比如打卡记录&#xff0c;宿舍记录&…

Java-super关键字

目录 1.super关键字 基本介绍 基本语法 案例演示 2.super使用细节 细节1 细节2 细节3 super和this的比较 1.super关键字 基本介绍 super代表父类的引用&#xff0c;用于访问父类的属性&#xff0c;方法&#xff0c;构造器。 基本语法 不能用super去访问父类的pri…

多模态大模型intern-vl 1.5 论文解读:How Far Are We to GPT-4V?

论文&#xff1a;https://arxiv.org/pdf/2404.16821 目录 1 介绍 3.1 整体架构 3.2 强大的视觉编码器 InternViT-6B-448px-V1.2 InternViT-6B-448px-V1.5 3.3 动态高分辨率 动态宽高比匹配 图像分割与缩略图 InternVL 1.5&#xff0c;这是一款开源的多模态大语言模型&…