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

embedded/2024/9/24 6:22:22/

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:61613STOMP 协议端口。
  • rabbitmq:management:使用包含管理插件的 RabbitMQ 镜像。

在这里插入图片描述

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

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

docker ps

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

STOMP__50">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.ppmy.cn/embedded/97149.html

相关文章

【Kubernetes】k8s集群安全机制

目录 一.认证 1.k8s集群内的三种认证方式 2.k8s集群内的认证说明 2.1.需要被认证的访问类型 2.2.安全性说明 2.3.证书颁发的方式 2.4.kubeconfig 2.5.Service Account 2.6.Secret 与 SA 的关系 二.鉴权 1.鉴权的方式 2.RBAC的角色与角色绑定 2.1.RBAC的角色 2.2…

C语言实现排序之插入排序算法

一、插入排序算法 基本思想 插入排序的基本思想是将未排序的元素逐个插入到已排序的序列中。初始时&#xff0c;假设序列的第一个元素已经被排序。然后从第二个元素开始&#xff0c;将其插入到已排序的序列中的适当位置&#xff0c;使得已排序的序列仍然有序。 步骤 初始化&…

探索腾讯云对象存储COS在Java中的实现:以实际项目为例

文章目录 背景介绍项目结构及关键依赖代码实现解析详细解析小结 在现代的互联网应用中&#xff0c;存储和管理大量的文件和数据是不可避免的。无论是图片、视频还是文档等&#xff0c;这些数据都需要一个安全且高效的存储方案。腾讯云对象存储&#xff08;COS&#xff09;作为一…

监控设备上云的方式有哪些?

监控设备上云主要有以下几种方式&#xff0c;各方式的区别如下&#xff1a; - 直接写入云存储&#xff1a; - 方式&#xff1a;监控设备将视频流等数据直接写入云服务提供商的云存储中&#xff0c;如腾讯云的对象存储&#xff08;COS&#xff09;、阿里云的对象存储&#xff08…

基于UDP的TFTP文件传输

1. tftp协议概述 简单文件传输协议&#xff0c;适用于在网络上进行文件传输的一套标准协议&#xff0c;使用UDP传输 特点&#xff1a; 是应用层协议 基于UDP协议实现 数据传输模式 octet&#xff1a;二进制模式&#xff08;常用&#xff09; mail&#xff1a;已经不再支持 2. t…

【区块链+金融服务】供应链金融平台 | FISCO BCOS应用案例

释放数据要素价值&#xff0c;FISCO BCOS 2024 应用案例征集 四方精创基于 FISCO BCOS 设计了供应链金融平台&#xff0c;利用区块链多中心、不可篡改、不可抵赖、可追溯的特性&#xff0c; 在供应链生态企业间实现信用传递。 平台所取得成效包括&#xff1a;核心企业减少了供…

【Py/Java/C++三种语言详解】LeetCode743、网络延迟时间【单源最短路问题Djikstra算法】

可上 欧弟OJ系统 练习华子OD、大厂真题 绿色聊天软件戳 od1441了解算法冲刺训练&#xff08;备注【CSDN】否则不通过&#xff09; 文章目录 相关推荐阅读一、题目描述二、题目解析三、参考代码PythonJavaC 时空复杂度 华为OD算法/大厂面试高频题算法练习冲刺训练 相关推荐阅读 …

[C#]实现GRPC通讯的服务端和客户端实例

最近要做两个软件之间消息的通讯&#xff0c;学习了一下GRPC框架的通讯。根据官方资料做了一个实例。 官方资料请参考&#xff1a;Create a .NET Core gRPC client and server in ASP.NET Core | Microsoft Learn 开发平台&#xff1a;Visual Studio 2022 开发前提条件&#x…